diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 5929987f916594da1109eee2082c154620edf660..ba3015dbbac51fb88f9b207b57708280885733de 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1071,6 +1071,134 @@ "created_at": "2026-01-14T14:02:04Z", "repoId": 987670088, "pullRequestNo": 1870 + }, + { + "name": "huaiyuWangh", + "id": 34158348, + "comment_id": 3785195950, + "created_at": "2026-01-22T15:58:33Z", + "repoId": 987670088, + "pullRequestNo": 1943 + }, + { + "name": "akitaonrails", + "id": 2840, + "comment_id": 3786408984, + "created_at": "2026-01-22T19:57:59Z", + "repoId": 987670088, + "pullRequestNo": 1945 + }, + { + "name": "mcowger", + "id": 1929548, + "comment_id": 3787591535, + "created_at": "2026-01-23T00:44:49Z", + "repoId": 987670088, + "pullRequestNo": 1950 + }, + { + "name": "jerilynzheng", + "id": 15837981, + "comment_id": 3788071777, + "created_at": "2026-01-23T04:00:52Z", + "repoId": 987670088, + "pullRequestNo": 1951 + }, + { + "name": "AnyCPU", + "id": 2059051, + "comment_id": 3791901071, + "created_at": "2026-01-23T19:14:55Z", + "repoId": 987670088, + "pullRequestNo": 1967 + }, + { + "name": "billycao", + "id": 543122, + "comment_id": 3793624061, + "created_at": "2026-01-24T03:06:50Z", + "repoId": 987670088, + "pullRequestNo": 1971 + }, + { + "name": "gdamjan", + "id": 81654, + "comment_id": 3795660594, + "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 + }, + { + "name": "zhiquanchi", + "id": 29973289, + "comment_id": 3845838711, + "created_at": "2026-02-04T07:39:06Z", + "repoId": 987670088, + "pullRequestNo": 2112 + }, + { + "name": "inquam", + "id": 1265038, + "comment_id": 3849304908, + "created_at": "2026-02-04T19:22:33Z", + "repoId": 987670088, + "pullRequestNo": 2124 + }, + { + "name": "nickgrim", + "id": 8376, + "comment_id": 3852565144, + "created_at": "2026-02-05T10:17:46Z", + "repoId": 987670088, + "pullRequestNo": 2131 } ] } \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index 09f0934027aaf35d7d90e3129a82a8e55b38166f..e0e998246f6533415b9c356ccae8d905e0afe1df 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,6 +3,10 @@ "area: ci": - "/^ci: /i" +"area: crush run": + - "/crush run/i" + - "/headless/i" + - "/non-interactive/i" "area: diff": - "/diff/i" "area: docs": diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39b5923298e2f7fa8d5452327a6e8b2a08f0df97..07481c3d96382c8f06af322bc5eb6c13e990d37a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,10 +15,10 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod - run: go mod tidy diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f24ccb5c189f5a591d652f3142683e7d94e2bd1e..2cd5823e2473abcbbd2e6adffbbed52118cb1251 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: outputs: should_run: ${{ steps.check.outputs.should_run }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - id: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7337e1dc58c039785a4b3f51a161cc3217d4a8a2..8c58e6bdf7bd1492665daf7b9ac966edec0da0d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,8 @@ jobs: with: go_version: "1.25" macos_sign_entitlements: "./.github/entitlements.plist" + # XXX: remove it after goreleaser 2.14. + goreleaser_version: nightly secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -25,7 +27,6 @@ jobs: fury_token: ${{ secrets.FURY_TOKEN }} nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} - npm_token: ${{ secrets.NPM_TOKEN }} snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} aur_key: ${{ secrets.AUR_KEY }} macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 5bc1f29d91969f32757f9ad78f7742e7e20b7f3e..967c9b47af65a6f912e95c22784fbc93aae4f275 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -11,10 +11,10 @@ jobs: update-schema: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod - run: go run . schema > ./schema.json diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3a90ea316c3d86f5b2f93224fd2b35eaa572e704..7291604a5f34c4e1565d5c1a454860c6d25892da 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -27,14 +27,14 @@ jobs: pull-requests: read security-events: write steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 - - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 grype: runs-on: ubuntu-latest @@ -43,16 +43,16 @@ jobs: actions: read contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1 + - uses: anchore/scan-action@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1 id: scan with: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: sarif_file: ${{ steps.scan.outputs.sarif }} @@ -62,7 +62,7 @@ jobs: security-events: write contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 @@ -73,7 +73,7 @@ jobs: - name: Run govulncheck run: | govulncheck -C . -format sarif ./... > results.sarif - - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: sarif_file: results.sarif @@ -83,7 +83,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000000000000000000000000000000000000..a5a45d8fdeeaf8f0c1374366e7c1d34839c1acc5 --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,34 @@ +name: snapshot + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: true + +jobs: + snapshot: + runs-on: + # Use our own large runners with more CPU and RAM for faster builds + group: releasers + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + version: "nightly" + distribution: goreleaser-pro + args: build --snapshot --clean + env: + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.gitignore b/.gitignore index 01510713f6c6886781775f35d27d95fa96d3ef2f..008dcff3153d850de53e4e792fb320355f0009ea 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,5 @@ Thumbs.db /tmp/ manpages/ -completions/ -!internal/tui/components/completions/ +completions/crush.*sh .prettierignore diff --git a/.goreleaser.yml b/.goreleaser.yml index c79a0c9fd96f1d9bc76c9f09f60649f7fc6f7018..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -116,7 +116,7 @@ aur_sources: export CGO_CXXFLAGS="${CXXFLAGS}" export CGO_LDFLAGS="${LDFLAGS}" export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw" - go build -ldflags="-w -s -buildid='' -linkmode=external -X main.version=v${pkgver}" . + go build -ldflags="-w -s -buildid='' -linkmode=external -X github.com/charmbracelet/crush/internal/version.Version=v${pkgver}" . ./crush completion bash >./completions/crush.bash ./crush completion zsh >./completions/crush.zsh ./crush completion fish >./completions/crush.fish @@ -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/CRUSH.md b/AGENTS.md similarity index 90% rename from CRUSH.md rename to AGENTS.md index b98b8813fde6109bd5fdbbc21b1c2f92dee602af..654f1cd0a7fe1cbb50a3026f86f31b68e04f8043 100644 --- a/CRUSH.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 @@ -70,3 +72,6 @@ func TestYourFunction(t *testing.T) { - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc). - Try to keep commits to one line, not including your attribution. Only use multi-line commits when additional context is truly necessary. + +## Working on the TUI (UI) +Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file diff --git a/README.md b/README.md index 6a57c7934d0714cd4e0ae3f30fab108d03196b98..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

@@ -174,7 +174,7 @@ go install github.com/charmbracelet/crush@latest ## Getting Started The quickest way to get started is to grab an API key for your preferred -provider such as Anthropic, OpenAI, Groq, or OpenRouter and just start +provider such as Anthropic, OpenAI, Groq, OpenRouter, or Vercel AI Gateway and just start Crush. You'll be prompted to enter your API key. That said, you can also set environment variables for preferred providers. @@ -183,18 +183,21 @@ That said, you can also set environment variables for preferred providers. | --------------------------- | -------------------------------------------------- | | `ANTHROPIC_API_KEY` | Anthropic | | `OPENAI_API_KEY` | OpenAI | -| `OPENROUTER_API_KEY` | OpenRouter | +| `VERCEL_API_KEY` | Vercel AI Gateway | | `GEMINI_API_KEY` | Google Gemini | +| `SYNTHETIC_API_KEY` | Synthetic | +| `ZAI_API_KEY` | Z.ai | +| `HF_TOKEN` | Hugging Face Inference | | `CEREBRAS_API_KEY` | Cerebras | -| `HF_TOKEN` | Huggingface Inference | +| `OPENROUTER_API_KEY` | OpenRouter | +| `GROQ_API_KEY` | Groq | | `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) | -| `GROQ_API_KEY` | Groq | -| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) | -| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) | -| `AWS_REGION` | Amazon Bedrock (Claude) | -| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) | -| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock | +| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) | +| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) | +| `AWS_REGION` | Amazon Bedrock (Claude) | +| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) | +| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock | | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | @@ -735,8 +738,8 @@ Or by setting the following in your config: } ``` -Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com) -convention which can be enabled via `export DO_NOT_TRACK=1`. +Crush also respects the `DO_NOT_TRACK` convention which can be enabled via +`export DO_NOT_TRACK=1`. ## Contributing diff --git a/Taskfile.yaml b/Taskfile.yaml index 0043f4f033e455a5800da2431848e620c37a0f5a..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: @@ -66,6 +72,11 @@ tasks: cmds: - gofumpt -w . + fmt:html: + desc: Run prettier on HTML/CSS/JS files + cmds: + - prettier --write internal/cmd/stats/index.html internal/cmd/stats/index.css internal/cmd/stats/index.js + dev: desc: Run with profiling enabled env: @@ -122,6 +133,10 @@ tasks: msg: Not on main branch - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" msg: "Git is dirty" + - sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' + msg: "Test build for this commit failed or not present" + - sh: 'gh run list --workflow snapshot.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' + msg: "Snapshot build for this commit failed or not present" cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" @@ -138,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 df3b5ed4d72c0798a8e7191bdf93eb31d3d50e94..dff5825ea0183abbd202e71dd9021973e1875fbb 100644 --- a/go.mod +++ b/go.mod @@ -5,21 +5,20 @@ 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/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 + 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 charm.land/x/vcr v0.1.1 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.11.0 - github.com/alecthomas/chroma/v2 v2.23.0 + github.com/alecthomas/chroma/v2 v2.23.1 github.com/atotto/clipboard v0.1.4 - github.com/aymanbagabas/go-nativeclipboard v0.1.2 github.com/aymanbagabas/go-udiff v0.3.1 - github.com/bmatcuk/doublestar/v4 v4.9.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.14.2 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 @@ -30,53 +29,54 @@ require ( github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff - github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b + github.com/charmbracelet/x/exp/strings v0.1.0 + 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 + github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 + github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d github.com/lucasb-eyer/go-colorful v1.3.0 + github.com/mattn/go-isatty v0.0.20 github.com/modelcontextprotocol/go-sdk v1.2.0 - github.com/muesli/termenv v0.16.0 - github.com/ncruces/go-sqlite3 v0.30.4 - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/ncruces/go-sqlite3 v0.30.5 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/posthog/posthog-go v1.9.0 + github.com/posthog/posthog-go v1.9.1 github.com/pressly/goose/v3 v3.26.0 github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/rivo/uniseg v0.4.7 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/zeebo/xxh3 v1.0.2 - golang.org/x/mod v0.32.0 + github.com/zeebo/xxh3 v1.1.0 golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.44.2 + modernc.org/sqlite v1.44.3 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) 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.7.4 // indirect @@ -94,31 +94,27 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 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 - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - 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 github.com/google/s2a-go v0.1.9 // indirect @@ -128,15 +124,14 @@ 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.0.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect @@ -165,23 +160,24 @@ 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 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/image v0.34.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect 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 c0416e3602f6e5486ca43b5ce7264584217b754f..2074da1337570ed873c889e366b3b6d06846f014 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/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek= -charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= +charm.land/catwalk v0.16.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao= +charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= +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= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE= charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow= @@ -12,8 +14,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= @@ -33,12 +35,12 @@ 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.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0EofhbyKhhk= -github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= @@ -78,20 +80,18 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM= -github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI= -github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +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= @@ -116,22 +116,24 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk= github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +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= @@ -142,16 +144,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= -github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= -github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= -github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= -github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= @@ -173,14 +171,16 @@ 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= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -213,19 +213,21 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -263,16 +265,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= -github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68= +github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE= +github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8= @@ -288,8 +286,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.9.0 h1:7tRfnaHqPNrBNTnSnFLQwJ5aVz6LOBngiwl15lD8bHU= -github.com/posthog/posthog-go v1.9.0/go.mod h1:0i1H2BlsK9mHvHGc9Kp6oenUlHUqPl45hWzRtR/2PVI= +github.com/posthog/posthog-go v1.9.1 h1:9bkcRnYSvcgMxL2s9QlCnd1DVnm2qWXxWu5o0HSF0xM= +github.com/posthog/posthog-go v1.9.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= @@ -319,10 +317,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -359,24 +353,24 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 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.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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.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= @@ -393,6 +387,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -483,8 +478,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= @@ -529,8 +524,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.44.2 h1:EdYqXeBpKFJjg8QYnw6E71MpANkoxyuYi+g68ugOL8g= -modernc.org/sqlite v1.44.2/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c916cfd886372ab86f6d1fbb0e8b7bde2c87dabb..7ccd503ad1f0dce0d922c35df4f91873523ecd9c 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,17 +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) - sessionLock.Unlock() + _, sessionErr := a.sessions.Save(ctx, updatedSession) if sessionErr != nil { return sessionErr } + currentSession = updatedSession return a.messages.Update(genCtx, *currentAssistant) }, StopWhen: []fantasy.StopCondition{ @@ -664,6 +682,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"}, + }, } } @@ -785,22 +806,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 } @@ -809,10 +830,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 } @@ -826,7 +847,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 } @@ -855,13 +876,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user } promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens - completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens + completionTokens := resp.TotalUsage.OutputTokens // Atomically update only title and usage fields to avoid overriding other // 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 } } @@ -894,8 +915,8 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, session.Cost += cost } - session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens - session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens + session.CompletionTokens = usage.OutputTokens + session.PromptTokens = usage.InputTokens + usage.CacheReadTokens } func (a *sessionAgent) Cancel(sessionID string) { @@ -904,25 +925,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) } } @@ -1082,7 +1103,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 bdf7990cf8a8aff509ed39d1167213b45ff92615..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] } @@ -112,11 +114,12 @@ func testEnv(t *testing.T) fakeEnv { require.NoError(t, err) q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) 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 223ed650c440f41020e1185146ae5fb99c8cde7c..d10477bc4904e3d81aa079b4425c6ae49de915d5 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" ) @@ -64,6 +66,7 @@ type coordinator struct { messages message.Service permissions permission.Service history history.Service + filetracker filetracker.Service lspClients *csync.Map[string, *lsp.Client] currentAgent SessionAgent @@ -79,6 +82,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{ @@ -87,6 +91,7 @@ func NewCoordinator( messages: messages, permissions: permissions, history: history, + filetracker: filetracker, lspClients: lspClients, agents: make(map[string]SessionAgent), } @@ -117,6 +122,11 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, return nil, err } + // refresh models before each run + if err := c.UpdateModels(ctx); err != nil { + return nil, fmt.Errorf("failed to update models: %w", err) + } + model := c.currentAgent.Model() maxTokens := model.CatwalkCfg.DefaultMaxTokens if model.ModelCfg.MaxTokens != 0 { @@ -142,7 +152,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 } @@ -167,18 +177,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() } } @@ -234,7 +244,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 != "" { @@ -280,6 +303,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 { @@ -399,20 +434,20 @@ 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 { - allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients)) + if c.lspClients.Len() > 0 { + allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) } var filteredTools []fantasy.AgentTool @@ -430,7 +465,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 } @@ -593,6 +628,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), @@ -750,6 +799,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..e4c1cd85eb1171226f48ff496ea238ff2121619d 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -21,13 +21,13 @@ 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" ) -//go:generate wget -O provider.json https://console.charm.land/api/v1/provider +//go:generate wget -O provider.json https://hyper.charm.land/api/v1/provider //go:embed provider.json var embedded []byte @@ -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 }) @@ -58,8 +61,7 @@ const ( // Name is the default name of this meta provider. Name = "hyper" // defaultBaseURL is the default proxy URL. - // TODO: change this to production URL when ready. - defaultBaseURL = "https://console.charm.land" + defaultBaseURL = "https://hyper.charm.land" ) // BaseURL returns the base URL, which is either $HYPER_URL or the default. @@ -250,10 +252,16 @@ func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. continue } } - if err := scanner.Err(); err != nil && - !errors.Is(err, context.Canceled) && - !errors.Is(err, context.DeadlineExceeded) { - yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) + if err := scanner.Err(); err != nil { + if sawFinish && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { + // If we already saw an explicit finish event, treat cancellation as a no-op. + } else { + _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) + return + } + } + if err := ctx.Err(); err != nil && !sawFinish { + _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) return } // flush any pending data diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 5558750e38e35024615b41b71243888a1a1ebd6c..4f2cd461e46eeea6bb18739535a03003cb075f26 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://hyper.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/diagnostics.go b/internal/agent/tools/diagnostics.go index 85e8b8d0f7d997f8db83f0d6176ce30c644b86f0..9af0da43c396d9fa8aa9776f4f7fb177af6b5806 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -137,11 +137,9 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1) - sourceInfo := "" + sourceInfo := source if diagnostic.Source != "" { - sourceInfo = diagnostic.Source - } else if source != "" { - sourceInfo = source + sourceInfo += " " + diagnostic.Source } codeInfo := "" 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/lsp_restart.go b/internal/agent/tools/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..5e5a8a90a11927079086fe407384f32ceecf10c5 --- /dev/null +++ b/internal/agent/tools/lsp_restart.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "maps" + "strings" + "sync" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" +) + +const LSPRestartToolName = "lsp_restart" + +//go:embed lsp_restart.md +var lspRestartDescription []byte + +type LSPRestartParams struct { + // Name is the optional name of a specific LSP client to restart. + // If empty, all LSP clients will be restarted. + Name string `json:"name,omitempty"` +} + +func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { + return fantasy.NewAgentTool( + LSPRestartToolName, + string(lspRestartDescription), + func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if lspClients.Len() == 0 { + return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil + } + + clientsToRestart := make(map[string]*lsp.Client) + if params.Name == "" { + maps.Insert(clientsToRestart, lspClients.Seq2()) + } else { + client, exists := lspClients.Get(params.Name) + if !exists { + return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil + } + clientsToRestart[params.Name] = client + } + + var restarted []string + var failed []string + var mu sync.Mutex + var wg sync.WaitGroup + for name, client := range clientsToRestart { + wg.Go(func() { + if err := client.Restart(); err != nil { + slog.Error("Failed to restart LSP client", "name", name, "error", err) + mu.Lock() + failed = append(failed, name) + mu.Unlock() + return + } + mu.Lock() + restarted = append(restarted, name) + mu.Unlock() + }) + } + + wg.Wait() + + var output string + if len(restarted) > 0 { + output = fmt.Sprintf("Successfully restarted %d LSP client(s): %s\n", len(restarted), strings.Join(restarted, ", ")) + } + if len(failed) > 0 { + output += fmt.Sprintf("Failed to restart %d LSP client(s): %s\n", len(failed), strings.Join(failed, ", ")) + return fantasy.NewTextErrorResponse(output), nil + } + + return fantasy.NewTextResponse(output), nil + }) +} diff --git a/internal/agent/tools/lsp_restart.md b/internal/agent/tools/lsp_restart.md new file mode 100644 index 0000000000000000000000000000000000000000..118ebd645391d9c73b01ff35a8a73094b6f766a3 --- /dev/null +++ b/internal/agent/tools/lsp_restart.md @@ -0,0 +1,25 @@ +Restart LSP (Language Server Protocol) clients. + + +- Restart all running LSP clients or a specific LSP client by name +- Useful when LSP servers become unresponsive or need to be reloaded +- Parameters: + - name (optional): Specific LSP client name to restart. If not provided, all clients will be restarted. + + + +- Gracefully shuts down all LSP clients +- Restarts them with their original configuration +- Reports success/failure for each client + + + +- Only restarts clients that were successfully started +- Does not modify LSP configurations +- Requires LSP clients to be already running + + + +- Use when LSP diagnostics are stale or unresponsive +- Call this tool if you notice LSP features not working properly + 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 0f98a8383124274d8aaae12b40146411ed969c8d..f0cabfa534a58401280fb5e9b973aa6f5a9d91c9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,6 +15,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent" @@ -22,6 +23,7 @@ import ( "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" @@ -31,8 +33,8 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/shell" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/update" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/ansi" @@ -40,11 +42,19 @@ import ( "github.com/charmbracelet/x/term" ) +// UpdateAvailableMsg is sent when a new version is available. +type UpdateAvailableMsg struct { + CurrentVersion string + LatestVersion string + IsDevelopment bool +} + type App struct { Sessions session.Service Messages message.Service History history.Service Permissions permission.Service + FileTracker filetracker.Service AgentCoordinator agent.Coordinator @@ -65,7 +75,7 @@ type App struct { // New initializes a new application instance. func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests @@ -79,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, @@ -93,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) @@ -124,17 +132,24 @@ 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 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) defer cancel() + if largeModel != "" || smallModel != "" { + if err := app.overrideModelsForNonInteractive(ctx, largeModel, smallModel); err != nil { + return fmt.Errorf("failed to override models: %w", err) + } + } + var ( spinner *format.Spinner stdoutTTY bool stderrTTY bool stdinTTY bool + progress bool ) if f, ok := output.(*os.File); ok { @@ -142,9 +157,10 @@ 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 { - t := styles.CurrentTheme() + if !hideSpinner && stderrTTY { + t := styles.DefaultStyles() // Detect background color to set the appropriate color for the // spinner's 'Generating...' text. Without this, that text would be @@ -168,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 } @@ -225,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) } @@ -237,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) @@ -248,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) @@ -274,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) } @@ -292,6 +313,96 @@ func (app *App) UpdateAgentModel(ctx context.Context) error { return app.AgentCoordinator.UpdateModels(ctx) } +// overrideModelsForNonInteractive parses the model strings and temporarily +// overrides the model configurations, then rebuilds the agent. +// Format: "model-name" (searches all providers) or "provider/model-name". +// Model matching is case-insensitive. +// If largeModel is provided but smallModel is not, the small model defaults to +// the provider's default small model. +func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error { + providers := app.config.Providers.Copy() + + largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel) + if err != nil { + return err + } + + var largeProviderID string + + // Override large model. + if largeModel != "" { + found, err := validateMatches(largeMatches, largeModel, "large") + if err != nil { + return err + } + largeProviderID = found.provider + slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID) + app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{ + Provider: found.provider, + Model: found.modelID, + } + } + + // Override small model. + switch { + case smallModel != "": + found, err := validateMatches(smallMatches, smallModel, "small") + if err != nil { + return err + } + slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID) + app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{ + Provider: found.provider, + Model: found.modelID, + } + + case largeModel != "": + // No small model specified, but large model was - use provider's default. + smallCfg := app.GetDefaultSmallModel(largeProviderID) + app.config.Models[config.SelectedModelTypeSmall] = smallCfg + } + + return app.AgentCoordinator.UpdateModels(ctx) +} + +// GetDefaultSmallModel returns the default small model for the given +// provider. Falls back to the large model if no default is found. +func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel { + cfg := app.config + largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] + + // Find the provider in the known providers list to get its default small model. + knownProviders, _ := config.Providers(cfg) + var knownProvider *catwalk.Provider + for _, p := range knownProviders { + if string(p.ID) == providerID { + knownProvider = &p + break + } + } + + // For unknown/local providers, use the large model as small. + if knownProvider == nil { + slog.Warn("Using large model as small model for unknown provider", "provider", providerID, "model", largeModelCfg.Model) + return largeModelCfg + } + + defaultSmallModelID := knownProvider.DefaultSmallModelID + model := cfg.GetModel(providerID, defaultSmallModelID) + if model == nil { + slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model) + return largeModelCfg + } + + slog.Info("Using provider default small model", "provider", providerID, "model", defaultSmallModelID) + return config.SelectedModel{ + Provider: providerID, + Model: defaultSmallModelID, + MaxTokens: model.DefaultMaxTokens, + ReasoningEffort: model.DefaultReasoningEffort, + } +} + func (app *App) setupEvents() { ctx, cancel := context.WithCancel(app.globalCtx) app.eventsCtx = ctx @@ -323,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 } } @@ -356,6 +467,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Messages, app.Permissions, app.History, + app.FileTracker, app.LSPClients, ) if err != nil { @@ -400,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. @@ -452,7 +564,7 @@ func (app *App) checkForUpdates(ctx context.Context) { if err != nil || !info.Available() { return } - app.events <- pubsub.UpdateAvailableMsg{ + app.events <- UpdateAvailableMsg{ CurrentVersion: info.Current, LatestVersion: info.Latest, IsDevelopment: info.IsDevelopment(), 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.go b/internal/app/provider.go new file mode 100644 index 0000000000000000000000000000000000000000..570edadf9e1647eeeeab32107d3da3a1d3494935 --- /dev/null +++ b/internal/app/provider.go @@ -0,0 +1,95 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/config" + xstrings "github.com/charmbracelet/x/exp/strings" +) + +// parseModelStr parses a model string into provider filter and model ID. +// Format: "model-name" or "provider/model-name" or "synthetic/moonshot/kimi-k2". +// This function only checks if the first component is a valid provider name; if not, +// it treats the entire string as a model ID (which may contain slashes). +func parseModelStr(providers map[string]config.ProviderConfig, modelStr string) (providerFilter, modelID string) { + parts := strings.Split(modelStr, "/") + if len(parts) == 1 { + return "", parts[0] + } + // Check if the first part is a valid provider name + if _, ok := providers[parts[0]]; ok { + return parts[0], strings.Join(parts[1:], "/") + } + + // First part is not a valid provider, treat entire string as model ID + return "", modelStr +} + +// modelMatch represents a found model. +type modelMatch struct { + provider string + modelID string +} + +func findModels(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch, error) { + largeProviderFilter, largeModelID := parseModelStr(providers, largeModel) + smallProviderFilter, smallModelID := parseModelStr(providers, smallModel) + + // Validate provider filters exist. + for _, pf := range []struct { + filter, label string + }{ + {largeProviderFilter, "large"}, + {smallProviderFilter, "small"}, + } { + if pf.filter != "" { + if _, ok := providers[pf.filter]; !ok { + return nil, nil, fmt.Errorf("%s model: provider %q not found in configuration. Use 'crush models' to list available models", pf.label, pf.filter) + } + } + } + + // Find matching models in a single pass. + var largeMatches, smallMatches []modelMatch + for name, provider := range providers { + if provider.Disable { + continue + } + for _, m := range provider.Models { + if filter(largeModelID, largeProviderFilter, m.ID, name) { + largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID}) + } + if filter(smallModelID, smallProviderFilter, m.ID, name) { + smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID}) + } + } + } + + return largeMatches, smallMatches, nil +} + +func filter(modelFilter, providerFilter, model, provider string) bool { + return modelFilter != "" && model == modelFilter && + (providerFilter == "" || provider == providerFilter) +} + +// Validate and return a single match. +func validateMatches(matches []modelMatch, modelID, label string) (modelMatch, error) { + switch { + case len(matches) == 0: + return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID) + case len(matches) > 1: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.provider + } + return modelMatch{}, fmt.Errorf( + "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format", + label, + modelID, + xstrings.EnglishJoin(names, true), + ) + } + return matches[0], nil +} diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8430211e0067810523a713a07a343ac546248830 --- /dev/null +++ b/internal/app/provider_test.go @@ -0,0 +1,210 @@ +package app + +import ( + "testing" + + "charm.land/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/stretchr/testify/require" +) + +func TestParseModelStr(t *testing.T) { + tests := []struct { + name string + modelStr string + expectedFilter string + expectedModelID string + setupProviders func() map[string]config.ProviderConfig + }{ + { + name: "simple model with no slashes", + modelStr: "gpt-4o", + expectedFilter: "", + expectedModelID: "gpt-4o", + setupProviders: setupMockProviders, + }, + { + name: "valid provider and model", + modelStr: "openai/gpt-4o", + expectedFilter: "openai", + expectedModelID: "gpt-4o", + setupProviders: setupMockProviders, + }, + { + name: "model with multiple slashes and first part is invalid provider", + modelStr: "moonshot/kimi-k2", + expectedFilter: "", + expectedModelID: "moonshot/kimi-k2", + setupProviders: setupMockProviders, + }, + { + name: "full path with valid provider and model with slashes", + modelStr: "synthetic/moonshot/kimi-k2", + expectedFilter: "synthetic", + expectedModelID: "moonshot/kimi-k2", + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "empty model string", + modelStr: "", + expectedFilter: "", + expectedModelID: "", + setupProviders: setupMockProviders, + }, + { + name: "model with trailing slash but valid provider", + modelStr: "openai/", + expectedFilter: "openai", + expectedModelID: "", + setupProviders: setupMockProviders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providers := tt.setupProviders() + filter, modelID := parseModelStr(providers, tt.modelStr) + + require.Equal(t, tt.expectedFilter, filter, "provider filter mismatch") + require.Equal(t, tt.expectedModelID, modelID, "model ID mismatch") + }) + } +} + +func setupMockProviders() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "openai": { + ID: "openai", + Name: "OpenAI", + Models: []catwalk.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}, + }, + "anthropic": { + ID: "anthropic", + Name: "Anthropic", + Models: []catwalk.Model{{ID: "claude-3-sonnet"}, {ID: "claude-3-opus"}}, + }, + } +} + +func setupMockProvidersWithSlashes() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "synthetic": { + ID: "synthetic", + Name: "Synthetic", + Models: []catwalk.Model{ + {ID: "moonshot/kimi-k2"}, + {ID: "deepseek/deepseek-chat"}, + }, + }, + "openai": { + ID: "openai", + Name: "OpenAI", + Models: []catwalk.Model{{ID: "gpt-4o"}}, + }, + } +} + +func TestFindModels(t *testing.T) { + tests := []struct { + name string + modelStr string + expectedProvider string + expectedModelID string + expectError bool + errorContains string + setupProviders func() map[string]config.ProviderConfig + }{ + { + name: "simple model found in one provider", + modelStr: "gpt-4o", + expectedProvider: "openai", + expectedModelID: "gpt-4o", + expectError: false, + setupProviders: setupMockProviders, + }, + { + name: "model with slashes in ID", + modelStr: "moonshot/kimi-k2", + expectedProvider: "synthetic", + expectedModelID: "moonshot/kimi-k2", + expectError: false, + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "provider and model with slashes in ID", + modelStr: "synthetic/moonshot/kimi-k2", + expectedProvider: "synthetic", + expectedModelID: "moonshot/kimi-k2", + expectError: false, + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "model not found", + modelStr: "nonexistent-model", + expectError: true, + errorContains: "not found", + setupProviders: setupMockProviders, + }, + { + name: "invalid provider specified", + modelStr: "nonexistent-provider/gpt-4o", + expectError: true, + errorContains: "provider", + setupProviders: setupMockProviders, + }, + { + name: "model found in multiple providers without provider filter", + modelStr: "shared-model", + expectError: true, + errorContains: "multiple providers", + setupProviders: func() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "openai": { + ID: "openai", + Models: []catwalk.Model{{ID: "shared-model"}}, + }, + "anthropic": { + ID: "anthropic", + Models: []catwalk.Model{{ID: "shared-model"}}, + }, + } + }, + }, + { + name: "empty model string", + modelStr: "", + expectError: true, + errorContains: "not found", + setupProviders: setupMockProviders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providers := tt.setupProviders() + + // Use findModels with the model as "large" and empty "small". + matches, _, err := findModels(providers, tt.modelStr, "") + if err != nil { + if tt.expectError { + require.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + } + return + } + + // Validate the matches. + match, err := validateMatches(matches, tt.modelStr, "large") + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedProvider, match.provider) + require.Equal(t, tt.expectedModelID, match.modelID) + } + }) + } +} diff --git a/internal/cmd/models.go b/internal/cmd/models.go new file mode 100644 index 0000000000000000000000000000000000000000..e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d --- /dev/null +++ b/internal/cmd/models.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + "sort" + "strings" + + "charm.land/catwalk/pkg/catwalk" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/config" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +var modelsCmd = &cobra.Command{ + Use: "models", + Short: "List all available models from configured providers", + Long: `List all available models from configured providers. Shows provider name and model IDs.`, + Example: `# List all available models +crush models + +# Search models +crush models gpt5`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := ResolveCwd(cmd) + if err != nil { + return err + } + + dataDir, _ := cmd.Flags().GetString("data-dir") + debug, _ := cmd.Flags().GetBool("debug") + + cfg, err := config.Init(cwd, dataDir, debug) + if err != nil { + return err + } + + if !cfg.IsConfigured() { + return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") + } + + term := strings.ToLower(strings.Join(args, " ")) + filter := func(p config.ProviderConfig, m catwalk.Model) bool { + for _, s := range []string{p.ID, p.Name, m.ID, m.Name} { + if term == "" || strings.Contains(strings.ToLower(s), term) { + return true + } + } + return false + } + + var providerIDs []string + providerModels := make(map[string][]string) + + for providerID, provider := range cfg.Providers.Seq2() { + if provider.Disable { + continue + } + var found bool + for _, model := range provider.Models { + if !filter(provider, model) { + continue + } + providerModels[providerID] = append(providerModels[providerID], model.ID) + found = true + } + if !found { + continue + } + slices.Sort(providerModels[providerID]) + providerIDs = append(providerIDs, providerID) + } + sort.Strings(providerIDs) + + if len(providerIDs) == 0 && len(args) == 0 { + return fmt.Errorf("no enabled providers found") + } + if len(providerIDs) == 0 { + return fmt.Errorf("no enabled providers found matching %q", term) + } + + if !isatty.IsTerminal(os.Stdout.Fd()) { + for _, providerID := range providerIDs { + for _, modelID := range providerModels[providerID] { + fmt.Println(providerID + "/" + modelID) + } + } + return nil + } + + t := tree.New() + for _, providerID := range providerIDs { + providerNode := tree.Root(providerID) + for _, modelID := range providerModels[providerID] { + providerNode.Child(modelID) + } + t.Child(providerNode) + } + + cmd.Println(t) + return nil + }, +} + +func init() { + rootCmd.AddCommand(modelsCmd) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index edb2512171348b0c9a1156683ecb398d73657ccf..c6dac2e7801779e359c939e7d595323a8ac22e49 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -20,8 +20,8 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/projects" - "github.com/charmbracelet/crush/internal/stringext" - "github.com/charmbracelet/crush/internal/tui" + "github.com/charmbracelet/crush/internal/ui/common" + ui "github.com/charmbracelet/crush/internal/ui/model" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" uv "github.com/charmbracelet/ultraviolet" @@ -46,6 +46,7 @@ func init() { logsCmd, schemaCmd, loginCmd, + statsCmd, ) } @@ -86,14 +87,16 @@ crush -y // Set up the TUI. var env uv.Environ = os.Environ() - ui := tui.New(app) - ui.QueryVersion = shouldQueryTerminalVersion(env) + + com := common.DefaultCommon(app) + model := ui.New(com) program := tea.NewProgram( - ui, + model, tea.WithEnvironment(env), tea.WithContext(cmd.Context()), - tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state + tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state + ) go app.Subscribe(program) if _, err := program.Run(); err != nil { @@ -164,12 +167,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. @@ -286,13 +296,3 @@ func createDotCrushDir(dir string) error { return nil } - -func shouldQueryTerminalVersion(env uv.Environ) bool { - termType := env.Getenv("TERM") - termProg, okTermProg := env.LookupEnv("TERM_PROGRAM") - _, okSSHTTY := env.LookupEnv("SSH_TTY") - return (!okTermProg && !okSSHTTY) || - (!strings.Contains(termProg, "Apple") && !okSSHTTY) || - // Terminals that do support XTVERSION. - stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm") -} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 6ebe5d79593bab6170e958ebdf26240d34327445..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,15 @@ 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") // Cancel on SIGINT or SIGTERM. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) @@ -47,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) @@ -62,7 +73,7 @@ crush run --quiet "Generate a README for this project" event.SetNonInteractive(true) event.AppInitialized() - return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet) + return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose) }, PostRun: func(cmd *cobra.Command, args []string) { event.AppExited() @@ -71,4 +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.go b/internal/cmd/stats.go new file mode 100644 index 0000000000000000000000000000000000000000..5dc971d1229350f35f93d5cf772239fa83e9206e --- /dev/null +++ b/internal/cmd/stats.go @@ -0,0 +1,384 @@ +package cmd + +import ( + "bytes" + "context" + "database/sql" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/db" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +//go:embed stats/index.html +var statsTemplate string + +//go:embed stats/index.css +var statsCSS string + +//go:embed stats/index.js +var statsJS string + +//go:embed stats/header.svg +var headerSVG string + +//go:embed stats/heartbit.svg +var heartbitSVG string + +//go:embed stats/footer.svg +var footerSVG string + +var statsCmd = &cobra.Command{ + Use: "stats", + Short: "Show usage statistics", + Long: "Generate and display usage statistics including token usage, costs, and activity patterns", + RunE: runStats, +} + +// Day names for day of week statistics. +var dayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + +// Stats holds all the statistics data. +type Stats struct { + GeneratedAt time.Time `json:"generated_at"` + Total TotalStats `json:"total"` + UsageByDay []DailyUsage `json:"usage_by_day"` + UsageByModel []ModelUsage `json:"usage_by_model"` + UsageByHour []HourlyUsage `json:"usage_by_hour"` + UsageByDayOfWeek []DayOfWeekUsage `json:"usage_by_day_of_week"` + RecentActivity []DailyActivity `json:"recent_activity"` + AvgResponseTimeMs float64 `json:"avg_response_time_ms"` + ToolUsage []ToolUsage `json:"tool_usage"` + HourDayHeatmap []HourDayHeatmapPt `json:"hour_day_heatmap"` +} + +type TotalStats struct { + TotalSessions int64 `json:"total_sessions"` + TotalPromptTokens int64 `json:"total_prompt_tokens"` + TotalCompletionTokens int64 `json:"total_completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` + TotalMessages int64 `json:"total_messages"` + AvgTokensPerSession float64 `json:"avg_tokens_per_session"` + AvgMessagesPerSession float64 `json:"avg_messages_per_session"` +} + +type DailyUsage struct { + Day string `json:"day"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` + SessionCount int64 `json:"session_count"` +} + +type ModelUsage struct { + Model string `json:"model"` + Provider string `json:"provider"` + MessageCount int64 `json:"message_count"` +} + +type HourlyUsage struct { + Hour int `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +type DayOfWeekUsage struct { + DayOfWeek int `json:"day_of_week"` + DayName string `json:"day_name"` + SessionCount int64 `json:"session_count"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` +} + +type DailyActivity struct { + Day string `json:"day"` + SessionCount int64 `json:"session_count"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` +} + +type ToolUsage struct { + ToolName string `json:"tool_name"` + CallCount int64 `json:"call_count"` +} + +type HourDayHeatmapPt struct { + DayOfWeek int `json:"day_of_week"` + Hour int `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func runStats(cmd *cobra.Command, _ []string) error { + dataDir, _ := cmd.Flags().GetString("data-dir") + ctx := cmd.Context() + + if dataDir == "" { + cfg, err := config.Init("", "", false) + if err != nil { + return fmt.Errorf("failed to initialize config: %w", err) + } + dataDir = cfg.Options.DataDirectory + } + + conn, err := db.Connect(ctx, dataDir) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close() + + stats, err := gatherStats(ctx, conn) + if err != nil { + return fmt.Errorf("failed to gather stats: %w", err) + } + + if stats.Total.TotalSessions == 0 { + return fmt.Errorf("no data available: no sessions found in database") + } + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + username := currentUser.Username + project, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + project = strings.Replace(project, currentUser.HomeDir, "~", 1) + + htmlPath := filepath.Join(dataDir, "stats/index.html") + if err := generateHTML(stats, project, username, htmlPath); err != nil { + return fmt.Errorf("failed to generate HTML: %w", err) + } + + fmt.Printf("Stats generated: %s\n", htmlPath) + + if err := browser.OpenFile(htmlPath); err != nil { + fmt.Printf("Could not open browser: %v\n", err) + fmt.Println("Please open the file manually.") + } + + return nil +} + +func gatherStats(ctx context.Context, conn *sql.DB) (*Stats, error) { + queries := db.New(conn) + + stats := &Stats{ + GeneratedAt: time.Now(), + } + + // Total stats. + total, err := queries.GetTotalStats(ctx) + if err != nil { + return nil, fmt.Errorf("get total stats: %w", err) + } + stats.Total = TotalStats{ + TotalSessions: total.TotalSessions, + TotalPromptTokens: toInt64(total.TotalPromptTokens), + TotalCompletionTokens: toInt64(total.TotalCompletionTokens), + TotalTokens: toInt64(total.TotalPromptTokens) + toInt64(total.TotalCompletionTokens), + TotalCost: toFloat64(total.TotalCost), + TotalMessages: toInt64(total.TotalMessages), + AvgTokensPerSession: toFloat64(total.AvgTokensPerSession), + AvgMessagesPerSession: toFloat64(total.AvgMessagesPerSession), + } + + // Usage by day. + dailyUsage, err := queries.GetUsageByDay(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by day: %w", err) + } + for _, d := range dailyUsage { + prompt := nullFloat64ToInt64(d.PromptTokens) + completion := nullFloat64ToInt64(d.CompletionTokens) + stats.UsageByDay = append(stats.UsageByDay, DailyUsage{ + Day: fmt.Sprintf("%v", d.Day), + PromptTokens: prompt, + CompletionTokens: completion, + TotalTokens: prompt + completion, + Cost: d.Cost.Float64, + SessionCount: d.SessionCount, + }) + } + + // Usage by model. + modelUsage, err := queries.GetUsageByModel(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by model: %w", err) + } + for _, m := range modelUsage { + stats.UsageByModel = append(stats.UsageByModel, ModelUsage{ + Model: m.Model, + Provider: m.Provider, + MessageCount: m.MessageCount, + }) + } + + // Usage by hour. + hourlyUsage, err := queries.GetUsageByHour(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by hour: %w", err) + } + for _, h := range hourlyUsage { + stats.UsageByHour = append(stats.UsageByHour, HourlyUsage{ + Hour: int(h.Hour), + SessionCount: h.SessionCount, + }) + } + + // Usage by day of week. + dowUsage, err := queries.GetUsageByDayOfWeek(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by day of week: %w", err) + } + for _, d := range dowUsage { + stats.UsageByDayOfWeek = append(stats.UsageByDayOfWeek, DayOfWeekUsage{ + DayOfWeek: int(d.DayOfWeek), + DayName: dayNames[int(d.DayOfWeek)], + SessionCount: d.SessionCount, + PromptTokens: nullFloat64ToInt64(d.PromptTokens), + CompletionTokens: nullFloat64ToInt64(d.CompletionTokens), + }) + } + + // Recent activity (last 30 days). + recent, err := queries.GetRecentActivity(ctx) + if err != nil { + return nil, fmt.Errorf("get recent activity: %w", err) + } + for _, r := range recent { + stats.RecentActivity = append(stats.RecentActivity, DailyActivity{ + Day: fmt.Sprintf("%v", r.Day), + SessionCount: r.SessionCount, + TotalTokens: nullFloat64ToInt64(r.TotalTokens), + Cost: r.Cost.Float64, + }) + } + + // Average response time. + avgResp, err := queries.GetAverageResponseTime(ctx) + if err != nil { + return nil, fmt.Errorf("get average response time: %w", err) + } + stats.AvgResponseTimeMs = toFloat64(avgResp) * 1000 + + // Tool usage. + toolUsage, err := queries.GetToolUsage(ctx) + if err != nil { + return nil, fmt.Errorf("get tool usage: %w", err) + } + for _, t := range toolUsage { + if name, ok := t.ToolName.(string); ok && name != "" { + stats.ToolUsage = append(stats.ToolUsage, ToolUsage{ + ToolName: name, + CallCount: t.CallCount, + }) + } + } + + // Hour/day heatmap. + heatmap, err := queries.GetHourDayHeatmap(ctx) + if err != nil { + return nil, fmt.Errorf("get hour day heatmap: %w", err) + } + for _, h := range heatmap { + stats.HourDayHeatmap = append(stats.HourDayHeatmap, HourDayHeatmapPt{ + DayOfWeek: int(h.DayOfWeek), + Hour: int(h.Hour), + SessionCount: h.SessionCount, + }) + } + + return stats, nil +} + +func toInt64(v any) int64 { + switch val := v.(type) { + case int64: + return val + case float64: + return int64(val) + case int: + return int64(val) + default: + return 0 + } +} + +func toFloat64(v any) float64 { + switch val := v.(type) { + case float64: + return val + case int64: + return float64(val) + case int: + return float64(val) + default: + return 0 + } +} + +func nullFloat64ToInt64(n sql.NullFloat64) int64 { + if n.Valid { + return int64(n.Float64) + } + return 0 +} + +func generateHTML(stats *Stats, projName, username, path string) error { + statsJSON, err := json.Marshal(stats) + if err != nil { + return err + } + + tmpl, err := template.New("stats").Parse(statsTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + data := struct { + StatsJSON template.JS + CSS template.CSS + JS template.JS + Header template.HTML + Heartbit template.HTML + Footer template.HTML + GeneratedAt string + ProjectName string + Username string + }{ + StatsJSON: template.JS(statsJSON), + CSS: template.CSS(statsCSS), + JS: template.JS(statsJS), + Header: template.HTML(headerSVG), + Heartbit: template.HTML(heartbitSVG), + Footer: template.HTML(footerSVG), + GeneratedAt: stats.GeneratedAt.Format("2006-01-02"), + ProjectName: projName, + Username: username, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + // Ensure parent directory exists. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/internal/cmd/stats/AGENTS.md b/internal/cmd/stats/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b2b557e2d074fc7940a79cd0c07b3685753a8ab1 --- /dev/null +++ b/internal/cmd/stats/AGENTS.md @@ -0,0 +1,3 @@ +# General Guidelines + +- always format CSS, HTML, and JS files with `prettier` diff --git a/internal/cmd/stats/footer.svg b/internal/cmd/stats/footer.svg new file mode 100644 index 0000000000000000000000000000000000000000..06b4c85ac3e672e531981a1d7e01aaee58552233 --- /dev/null +++ b/internal/cmd/stats/footer.svg @@ -0,0 +1,838 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +KLUv/QBYlC0DOi4boTogRUTOAwAMAAwArAHAAMAALMA6vrpQuiak2EE606A//CAh0Acdrm3bBlrg +Xdd2Skn9A4kJGABwToA0XQkTCksKPgRDkQzJ0Dw/n3pzj+j4ob8UfVp+5md6k5tbLdHxK0FOoiM6 +muD4UdEM+z59yE/Q/DiYkh/5iR84/tH/zT//pSd7KX59i76T+6MiuD/Zyc5J9TRLMvzG74/bjx01 +yVH8nm815GJajqHnYSmCIRh+4Ac56EEV/cyPf/1TP0qCP/TkBv3nP+9PspM9/L2nJhl+Pi1VteTm +/kITJFFTNVOziyd4huM5lufm3n8oaG4UTdHzTNUUTdGNoqd5niZaoiVJjmG4QxREQY9+3zmqpig5 +bvEMzxD8vO8nP/l5mmiKjhtMSzT8vVS356kpgt/3URVVURXFU9yniIok+P0uirv8wi/0oeebDP0J +9q/+nXa0m730KD95ycvv46Afe+egF8Gvc7Lj2df9R3bPxZD8+Og7Gfb+/0d2Ugw7npE8JEkuerB7 +kfzE7oslunnv3fcfdrFEe9rVzn333v/Qi3400/79///b4yd/if70q59//fPfHz/yKz8z/dSPgx0E +R1MFNw8/MRzNtIte/GIpmuIpomIqqn6PIzmWaEiK5HjuD/2i2VEP7hEl0TL1IZmWKUii+0P/d/qT +n6mJmqZJmqIJmt/splqipVmSZVh+pS95mZInSH6Sk+l4jpscxREc/8jHVDxFUhRFUPxiF9UQDc2Q +DMUQDH/YQ7X8wI9+Z/iB6d/oN8MPqj4dvejTju5S5OH3fd9jOYK+q2S6RTL8OsnF/UOPfh1UP/V/ +23+pR0k/9u3RXZJjuNGTND85jlscQ1IcN6iCKYiCJ3iS4AiK6Yf6b5dfP705fv57n24TTDlahh// +XU1R89xmCX7fc3NzUy3VMi3T0xzFMgS/L3u6PVmO4yjuMiTDMRTDMPzCH/qwhzxMQRQ0wTIEQfAD +P7g9yEH1Sz/0Oz/zK7/xE7/wA7/v+1//+Kf+9KP//OYn//jFH37wf/9///lXfepRb/rSl570og89 +6Pfnv++ee+7Vjvazn93sZSf72MUe9q///rvvvfOu8pSf3OQlJznJRy7y8OOfe97//yo4Rc1Nmz3j +5NfxDAS9+Pv/RpGHZP86LpLf90WS/CNJei52PBNH8ZMdFHv4fZKLHd+0adp2xzRtGee9Zz4TO56J +YPfk5yPJwU/0X8czkgQ/773vXxc573gWht+HXwR9+L/Qix3P+D7Ys2nasm9LNjc7uU3TNE/zNFEz +NVNTNVWTn/zspz/9+c9/fud3guIpnuM5nuRZnuVpnuZ5nueJnumZnuqpnhzlaEe3Rz360Y9+6IeC +IiqiI0qiJFqiJWqiJnqiKIqiKZqiKqqiPO1pT33q05/+9AvTMBVTMR3TTaZkWqZlaqZmeqZoiqZp +mqZqqqZc7WpXverVr37qF6qhKqqjOqqkSqqlWqqmeqqniqqomqqpqqroaZbkGG4xBL/vOw+3mqKn +WZIjuEcxBL/vOwdVNVVR9VRNtVRJdVRFNVRB9VO/6tWuclVN0xRNz9RMy5RMx1RMwxRMv/Sn26c9 +5amKpiiKnqiJliiJjqiIhiiIfuhHPdpRjqpneqLneZpneZLneIpneILnd/7Tn/3kp2qmJmqepmnu +0iTN0Zvd5KZapiVanqVZliVZjqVYhiVYfuUvfdlLXqpkSqLkSZpkSZLkSIpkSILkR35ye7KTnFTH +dETHczTHciTHcRTHcATHb/yjH/vIR1VMRVQ8RVMsRVIcRVEMRVD8xC96sYtcVMM0RMMzNMMtsPmW +bE+WJmmWZkmOpmiOOwRBcIuhaIbmDk0xDM0QBL9vboEt2bxzzz8O8pCPnOQmP3nKVe7718E+drKb +/exp39x3/3nQi570pT896lXP///gD7/4yW/+86eff/77PvALP/Erv/NDP/VTPw9+8ANDUARJsARP +EAVTcPfQhz8EwzAkwzI8QzRUQy568RM/ERRDcRRJ0RRRMRX52Mc/fiM4hqM4jiM5muM5pqM6crKT +nvzkR4LkDsmxJE3yJFEyJVNSJXnZS1/+8iu/EizDUizHkizJsizN0izPEi3RMi3VUi252U1vfvOb +n/mZoLkF9sFWmT7j2V6EGStTtS8Whj+K7FHDrati5xU2s74dDrCTLVqZbXUyFRyL5Vd8trUb62Rz +q4LdVatmN6K6hdl3U8Mui5lZmH1bAS6GVsHta2YtAW6damV6blNuS2Znlvu+7FnPZT2XrZrdwJo5 +rl1VmsEbGuViFNkig2N3BVuyVqpmBWhZqjR749jzxrEna9Gx2czmtq7ZVFvTA3TaN+XWM7t50e4b +o14DdOT4bQXA6WuOVy2qyBZZO7OvupBdtlNkCxbsptjZkymyRZ1qE1CaTNUCwJ3jOtWCFDtewRY5 +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 +51l64rdL/f3se2aspnrGn/rbL9PeLC35sb8z3uTfaKOvqb899TfiePJv9PTf5e9+Kc3RJhHNq3uT +0xxt+Cyped9bJjVHm9L9blR1/KTmpe+mtpb81tyczb3zcvVTzfqp62PzNde0a87mbe7mqHOuye7n +vp3+OBnnvf95Onxv1+TeiNPen3YxKeKZWRtySu+/0rdD5jQzDsCn15Ru+PmovvGr7z9jykvDhJrS +OxVxLg3dNP/vdpPbmJdbPfW80ZH1ny2ZN9qwWrbrd9dppuuut2bk3NZP3Y2ev/JUT92u3LmauBN3 +918fo/N/d/L+Pu9txv/HT+Zdiee+6NbW6I02ep/Quw5PW/35LKV3aWr1Rhts7MTe1e2ImZp8p+bt +Xbyp7R79ny+563maWWui/X9icvdzTX6ffJHt0xLTb3My+fL59D4h5uEe2m40GWbuYWIyvDUMQLUb +sust45v/v3YydE+2++rZ6MmRvz3frnPN/NJtI45ta9f3sTc+urJnOnqataXWLm61XT1Ojppozr/Z +qdESa6P3pzMe7tue2u4mTWxVt022z0U7S0yqjTiemt34Zohp1d3sMLF24d5b31lnau6Ute2H2nbG +mVwPLb3WOSb729nuU55cf/iq62bL6bXRxg67mZc5sS17sm206c5025r6tvtyuxZTXy1mPnWb+Oq8 +zRhTbqONlvurbubl/z9rytLfdN4208NtxMnlTbpdmJx2G21SO/G29napuvJ7Xtpa7O1L3NuoU53c +vbqM/Ydm/Mm3UYiMQiTpoCBBA3o2acnzWXed0UwzcSa/T7vb6dmT2ibO5uV+M+PE2Z74yp3VEu/s +P3E22pimPm83+1215OfZvLdpx2aLnM5fX7PL58fv9v9vTH6dbN3X/x8x+Z32b2qq++Wf55m9p8vO +30t+5/a765781M72NpuTX6km7vGaIW8bbqNt+nNMtpf7jLdm/wm1bSs5EXU7IdYi26fZY+rs6n7P +68eE2Gnx+jF702Gp+rv1mfkmzS5F/WX9/19Lhtn1+ciNmvouGz2lV5ksG20609c5evalWesmTUuW +vQmzESfP0V3NLC9xdjVarh/j7rX3udmmRZzi87vrbORe09RPXX+omG1mmMzcxnfJS399t//fMqE2 +2mRy9HLn79M040uoXd4bnpAt2RJxStuys+49db0l2nQ/+uWrY1cXn3Zj13EzFiN6lAsby4+rsRAb +680REcexWi2R89CRC61PFRcvmdWbX5/KRa7/R7TZ2sdFxPEtR0e0acfS8satRcTZwu3txfLkZazV +On1NxsJkRJvKZExGxKnE8kZE1OlLfMoXldP/DNHuMTvPljVxLYcVG7kOi5EXfxEFoHKRF9HDaIPJ +i5oWEeexF8tTEW3qWrEU63aV2bkRPZ3EDf6pYRoaHh7i4eEhIhoaGv79/f35+fn5+a3f/f3f3Rki +jqGh4Rm+4T53sjeyX9oh7jce+uEhoiGia+edn/fp+fn5+eXfbV79Zj5vppkl5rUm5k82xzSuxTz9 +z180rjwu1Ctt49a9T3qshzi8x7XHy6V8nZivK6+auw5tuvYK0/ZeXmXllbY+eOjpbGWe4Sa/ljVh +7Rdi6aMNImddXdqhjV0t6um/syrmoae4GRU5U+/4LZnVHjXV/gw98wvTu7E/s7K7rzdb/7930yPO +p1dmfTPzWrq7ptevW95vtmF7amoh2hRbfvPq/5bes2on2mxhLZ7ep2dxltud4Tmjo7MjzvMjDn+l +Lv+jJTu24ydxGz1paLOld3yHOHzHtdel186Y7I5TV9ohTv/+Iw7nuyNObWd3vXXdH+I4nz8fryPO +amniQ5ys7uwsbbTJRnfU+Xw0n401t7nTNqppI44fv3qxZ9rSVa/etGhTvKlfqreZZ6a1eq75t19s +6etW/fSUq58TcTgLbxVveb8WEW3qj//KmX+Pkf/rVF2vMY3fC2391BON2VKZU1MX+m5al99ts/u/ +FZridV8jDqPy/q4jjta66/P6W2nvKtob4682pr2/IU419malaqvjf31u9zPu/+8nLD1k5v90iDhZ +7ovFluX+eOmreJb1x7mK1qmF6632ylnLiziVmfz/bkmrsjFpJdqxo9lWt7sm96t7HvP1eVr0sKWv +m1miZ4vVEHvN3zQdrRlNVesvaaU2576ZKeLU77a2d2s1vqJimmVtGudffsr9ddxMxFlLWb3Y3lis +ncqnija1p/mM6KpmV7i2V753uaZmV2+p3z0VNV25d7Wy2X3a1ZZq7j2q2SGOo5c/5l8nZ8K7vNxF +xMlKrzz111uzLPTaTq/m4z818/ps1bXtlF6K/ImciTjl7cXX6dxqSb3cvPF+rbowd78VMSnaeOo6 +TF0zNa3V0mzcvTdDHdds+9ukyCSkUjfZJit/22TltpasTo97Efs/EzNZvbrqtmL2c6PdpgohRTFR +jDKETHg89Ps34+3Uqmdml35mlonL+b+Gto6+9Wpnfdb//HjGi7h3znac62kffR370Pg207+mfdBR +p/mQBt3OxNnP/DQ66tTqTqhAGtDzbOzwnm/Tc7sf/vM9N+LsL/v/+3On5nZ/s9TcfIht//3slb51 +YsZPXEV3Te3MjThrvP9tnazcaNPazt7x+o1HGtDzpqp3ZrjVqobbj7h9uJ1pu9FGe7W7cjMp9iLO +c3OKqPN2cl7P5a7uiCMCg8vlckEoG0w+CECFQ4hCWVFhCYGysqFQNph8QGExYVHx4MGkUAJgEmID +WkFJAAAmKwckSLCCQgCT59mggehEg4TkG2SQoJyoZOisqGg4oEHGZqJILmgAAFhhMXmgoVGGNGhw +NL67E6x6o05IvdU5I95a2b+iDRp1pVaXoKyMgg+daCAKzzt6UL9zj92hjlHorO+z/ozMfOjxzk46 +HtKgeahz6OGOnd9zHctDG/jpbCjT8Oihp9LP9zYe3Y7nvT+P9/f8oSefQr9+3u8MEXXs+VNEHf37 +8z/D66ChGt/t4/b2YGo+36edPjTES8TEQ1TEB/ETHfF4FXkvz3XvMeVFnYeRbx9W7wdVV/X53TT0 +5f1NbX4ONaUu6mAuevxz9bL1uUzk2z5FxEWdvH1s123x720Tl1PbNjHv2eJt3qLOhjKkAT3t70++ +6uq7mu3Zoo79uvY3o+crurfbK+p8d6fltdZzdEWd64f5knsnq/Y2/1pfGz+7ytysqMN7y4nb+mus +qOOqqru8ijqqaqq3uJqqroo6nYqpqaqqmWqqp4o6v8j5HCqmZipqn/p7ni7q6vOmiHZs2qfeaJqn +epqmn29qp6jzr2/Ke4+met+e/u/puvqd9veJOubPtrdzY6sn6mDzt3miDitv8z58jZ2oc7zOzpur +zLmvfGy7vaynuLmJOqidr+qZq6yJOpppmaiXmRgz0zMzM1MzUSfRLS2TMRsTdfYOMzHx0jIv01Kv +91It/+/yLlHHLL8t3ZIfPy01/3H/N8t3Xvx8RJ3zp9v9HVEn29O1n83PEXWsn+XudX6OWx/Zl60R +dR7ex46REXVWfRF1VDcfRtRJfTZZWxF1GGlAz5OqiDqJaEMZinD7eLtXt7eIU227x4vFvIue4ly0 +Rc+iDZYvFu9W4haarnLSRZzVrebSWvTUuy7aXG7dlW/l1RVv7e2izhWv32Yd89gx/z8Xn7u5k225 +T5+Ny5Wx9m7rdvU23aZbt7VN3H/t/NP75o//v8+Dumz4iqbI7696x9z9jjaP/OxpmqX5l7iod7a8 +izh+6nh/aa9oebdY5227erdoU32Fm1qoublc54q6qb+IU8pbubi4W27p9hZxbG170aaybW3rXhGn +LvW1X/sVPcX8HVe0UYaqqZdUVe20vPn0FG20eBm1unl5eVtZq1W1eKOpqKijuVllu99X1PpnRNVt +11Qz08tS1DxbzfLUrEwt/21HdMxE/ScVbTi1C1MxtVpbMRG1UVERB7VPq/kUcVa7TPnbNlerTc00 +y7R+TRFHa/e0UE9RB3Xjn+nfm/vr6enpyYpo2frJqbao+vmenZna2Ynr2PyZnJzp/u23ZsnJ+Zg4 +czkXOTkTZ+pEnM7O9NSpfPm61vmNx8uezZ7XnJ552f53uvm5mmqYn4izrNion57p6dmZOjs5c5M3 +XTM3eXNz8znzYdbSTJiJWbmsjYZZjImJNpWYnZ+YmYlYmmhT3ZpZyZiZurzfnJeZmZWZmZnl71np +3VqZlapPfW0m6tQXpoYn0VPe3NHUTV6ur7Jta6ujNuo6rq/nVvumdcRL+4go1O7jFqblU056/Cxm +zk7yRt0hDejZNKIjOiIu5qMlZiUGlIColDCL94mcc8wUEQAAAACzEQgAgCgoDkkGgxKt8hQABU9a +MjYsKB4uOEBKFo5EagxjgAHGIMQMZNAIIEIAlwPUj3R21ON+OoolweHHyxEec6PUXuHG3tm8FdzV +26/Dtr2iBDx65sB//To0b06XgPsn6vi0awiJ9c1TGfpNu24gg8GTTWQ2surHzQ8iOkVMRQ8bqc3T +dz23gbqUKb8Do4CprHXpqUS1sxNMxk+nDv2Kx0qUZ3JtEyH5MJyirueANVPNJ8uECmYbTQH4bQxe +wOgDhxfHWveLy5GRmr44vxTfqiUCt5VJVHopJlXVsc6W01qnKlfFWVT8AR9BSMtzKKHWSj2dvVF1 +NKyz89JH4IYLL8nSLYH3pNK+RYY56JNiRDxOnixB8yrPM5QUi+gVnkRCtTo+PX9DETY9CZC0XUiC +kreIZASFtoCr4J32RGKBI9lLDZ63TipQQHzBpq3plEnAOG1XcQ9MIQERItkIgxJDQlR3d2B55Kb/ +t18APJYV9Xx/NwyPpMOxTRRNl9NGgqGbkSPTA9XZeL10N8qDqn3YynJmIh8KwNQ2LKpYNRqklgbG +ia6KVcbeSbhen6LibqjfAdEW1bvFFc2ivYZg4NXmQGpiTyHtmU+MZkCUr0V0AgF/HunA+j0I6ruJ +jbKcB0censPyjpTD5Gy1fIzvSHOag/aNfXzEX0s+Sn7JUeCb1BQjvDCLJ2dDWPeD0o43x0Q7BfOZ +Bb6BRJ1CVs2n/J47TZLO4cP9qBNU/b6nj9joYwaxZ6N1NNIIkQ6x0mbFYwTjfgtDI5mUxJ5ikKqU +H1+UeGgoK38LK0nlvUFUcZh4lEpqmgNj/qWYtfkSn+htRkgJ73dzIyWzfIWvzMmLkCz745HiPGG3 +CXaYrTAvu4or9YUKZUJ9MaHly3wgSHyvxMeRpWAEluVAOexZrznE+N/VYcE/ElnxkRaWNaXHv4Kx +6mQHG6+rNKEU9qKXWzPRsB6NClLlwbA4kaw40Uk/Ls7xR+pLT3IFwEDdwC7mwD8SYCK1wGG+BQAH +NZRDVgyUlta3Ba53xA6hnzUJawnEAP7R2UM3iueapHx3K6Rdk+Jen8tqDJvk0KRJBz3pLBFeptTB +sUvS1W0fFhXgH+P8o31sPo0nG9NNDtQbKrC4GhrIYJM1FU9xHxtIC2trwHZxI0BluZVnkzRIJCyk +hw1wtBeUfyRDgFGQrNToI812UDMGj9D5mWQK5ZhJkw0eYf6/fTCCQZqQEoyMZs3SpuRN0WOdds8t +OZm9/K9o3y1vhlmB73pubqYNxk84aqjyEhQzeGaniQcJlJ1RcTQsda4xnpvRQBj5MJqPXSkmokqK +plZ2kQolm2XEzGnuAdfNr+703/Bpc4JZFgLcIlNiKDviKjsfWo76j8gHVolJ1m8oDCfEXpHka8E5 +ZCntq7tt7ep2abAE1WyZUb0o+YlQB0xIcNEzyci4VLdTQ4MqMHRv+iaIYoWiatIcKRlY2XO9kRdN +D8ccUtUQ3j1dLCO0XJM0vwF0TSRZ1Qq3c9gewS4m2oHctRRfBgZJyVqBbDRiNtjOFTVkRCkbV5J5 +c1pLGeFvfg6RLJlhyY4F5nLADUXSzQXr8H3Q9VCkRn/7Hr/E4+7S2iZZNjj7sA7ByQKBICKKzSLI +DGilpggEwMisO4JxEP+JWEpNj7gqLcvDDAuH/5mOr9lB4qlRvhH+tcko73QDQDdm8JUayhaoVCKk +OPuP4k9pYvcBjeK2azfFpCWz12o3Ew+CO0UoJlQ6cBI+Jqk+PCW1N1ro4kt7wpTzM48Aussc/9jg +nJRerE+uNdXyCRtlkvJdVRA+BcENsD2wo8jwynAlzGJ4bEpvSecKRvLdm8VOI0Y3IN3MBDPZujjm +uw9u9yBueVQWUYfcxtK3453kNFlDpTRETuhmPt5sGfxxgojOXK+v3Kt/EilqbGltFRDhV8IBvD4h +ukzWlD8vN8+iUppEgG+SObkpfOcmg3/PuhdyECl9CxM0ylhcDgcKR3MSAfwJgjPDLIDrUkSh7IWT +B4QFmpgQ/mTAiNT+lrIS1TsJT9REXnn3cjoOQpTrET8fujOFT0bYnYmP8SfVcNBjZgDIYumUdfK9 +ehPteW/zgydSoh2mzBI+QImGfy/M50RK8+dvEKn4u6VR2knuQuk16fJt4F6a+NpZ2kzhA4So+HfC +fJhIPBiBUQsCkdq3pVfuBtma7nSSQAcx1i4cDM8jsR1Lc7yPr0IocSZNWSFmoE6rHCUuW8rEOglr +jQn3ht2Lb3AAK27Hlom0uckyiAZHF47cpGPdCaqzJQuAu9R6BGPJyffqTWT75wj34k2J5p6wVIc7 +eSUeExFBCRCyIYtwjodSb0FxQT7/J2nUDPmSDxxonEHEbWMpiOskWyxM7EDUiR0bACW4m7ITZYS8 +WJwNOJScv0TrU0u5xkmAtumYhT/Hz+V3qNFVOwVebHzlqC/O2teUNMJBbpJVbQoQlWj6Y6kqA0NQ +MrdNRjbdh38VB1AihtIEKml2t+p/bp88vkFoGhMRNp0N5Ibah6WvF5CT08JvZTK49+S5cHb+IcW+ +uG1y2yw7Z1vti+xj7fdV+GuTHZ6b1xCgssPSBMzQSel/Tlzt8bm+FCrTL+BKS2V7RPw90Uw0MA7o +3vhIThCzsLcLNEXwB066QmqSGP/iCjLf7F8GXGppZMbwJwlZfWLT3BQ5yFmgYvyCoyWBgwpCBTxw +niQ28Bfc0qYAJS5LmYCnThrIzIlSqipJaQzLAklvIhDgyZzsUOckZCExV4sKAZqy3VI17ORbITWa +VPl6wJ9K+DZQ+OIYS3Rvhq26JtqfGPzmQpnQl75anAQCVqvCLpeE6m5poHaSB4aTxMMeIgIQdks/ ++ScUsIsEqLiqWZv6YQizJtuKoTCy31tQ9/gtlqZpLE6WEmabCeVeskyA72c+JwxA3SbR4sWCFffH +r232ZHsZOF7AjAFCr7ikQXhRIZ+37PC84MQvZs9C1pPeodwvMlyUsQ3+8+gTwwV3jLutCKswusxk +fyqVgWfXC5m+E2PB3k/AhjDuknLA39kEiPB7ujfZ3pha/TtQnrROfriTlH1R3XsTbggpbNMMgzjq +x5kcgMQEZXt/lyfb29Tb8zitBxMd9nm0pfxK98XtEJrYgQyE3upywb2Sv0CU9IiDO4Pt1TgSnBHW +WBlINSX75blpG8nVQqeE6WEnbERfv1ESGrDGuNnvCpq9dXzjomaip9A74M7nPlFIjQNA+k6Nd5jl +XJzEAQqMTjAsQQa5UgoTnsqdwahwJfJTBhPFASnrj4oJl2oUlg4tbN5CqgGEX995kx1s3elPYh9g +FY++ancnocDlYJIHeyJSB2iPiUWczGS3wGMmtyUmJfz+2kwIVzcJgpAo4AIyXVgkp9RXxYMmTVY3 +HaZpN2z0fvWJ6Eis9EL3XWlMdB8F2L2mkOS7KXYvusrZlw6rYjZh+yYZVer5uwCW61xNLcUx1P4b +Tk61LoTrIXoi03CyVieVpiNGxn4/hpjicwQ/obGhUOaT4mp20QICZPEsu+Zc4PPdZBveZFDdZbDt +i5WAkNh2uzoyMGFMhHhS4jUMvA45oa8PHyosKPbrk1Cj54QYfds35FOMP+KnJ35pDSaEB8pP9bEL +hb9x8Mpikl0NGIG9u1FA+ECYzIftsSyd/cI4bU3zbVoE76t3iYxvbFAdrA7BXZiOE/pZAfKlw4NB +gp3ZIgzlkqBH5VOeXrcgQMHQC3dCgxJo2kRYYRWe/CkPzZEwOBxMvrq2mjwGz9QqkV3uMTGMKRQD +ky4u5OosqOrCDbOuBZAw/3H+WMonLLI+RcjGE4dPOn9BmNFZOLSjCbODwzs6ocsjlQSsFSEPZOdF +MgXSJKWhO2hYwZoLs1BM06xs6T5lDxDIOsIP/mpM0WBeo28h/n2yoWZCVGx4viuz6mXDQFGo0U3o +e2HMW1ZEZ1/Od8kaknKZAlrhCOHWzHzXzoyM4vosG+km2npbW4dKPg7WAD7E0He2tzPosCeJhcYZ +C5xSZWLi6y5LhSDsRn+u2XfqmIlLY3WaJ5qFjd76K1Jf2l4tXfiFNXV1riEzCpu+ExLt7UoX8ggE +v26GqbDKDNi9d5hsOYKC4/duWVH8kUsBm48/diazKaMT0WzZy3NX5eE/MXNym+1vTpBKd5r0B5c7 +W7pvwKq/xEw4cKJQ3vXUIEjP+pbZYQWIpOW9QDJ+vV/knZRN7Q2CHswBhu2rR1/WfuBSHBqN16H0 +U5xrgRGA1s9cYJecPKv6pVevINWmU+1mXsNzV5hWdjI0cwM4l1YNPbgE5FLhECtS20WoBmYJJEjw +IAyjG6MuKLwJzB0usC9rcyt8MY46WrpDeeU2hHC8EKE7mJfiOodw0Il3koGLS3ipoEZOHCVztQO6 +cDe+4PQvVva6V/C9LFPtdxYDJRAktl+5kDN19j4kEJlrVTKGAgCB7YUaxndaCx62Ccq0MSRKsVkI +G38uSVUSz/hxtTv83QguBSkR2s3y6x5pkVYSq93rUDIHVY5cIGSN+we07YgkrFrDb3aMFp9Cw1Ez +MxnbZaHhg9RpUFhhby6YSeWZgJonBEkVgkIKZWkiQdux+vTao+q0WRtL3VykVp/lWXWfiL0R/K7/ +QEtD7p29NU5AmruG7X1wQNGTYSRzuVS20is5uMSwNVJQmcDO5jSTL6ug0bBf9FxI8/HDgP1AGlR/ +3oaZIqZidGYBqrkH7QKqPAEh0btl+jzYn90DNTNXbL/TrnM3LuN75vwnbZecsrI9ueIsT4soYiU8 +SOLJb+rqQNlSXUk2wL+pnNBKI9oeZqvQ4PCRGKASBLx2XnllXcfz3gj5xP6T1MP864iq6oVJXBRO +QJ6ottHfojwdDe+rEMG+oOwBfu/HfPVJ4kVCHMw3GBxc58YLy4KXE0b30kqY6XRHODMV/SQFEYcU +fS7G8hQiKhmzCggkdCa16V4jV6c3MO/R/LiKXHXxWB2LrOzBGgyLmy1i4g3/4WKkiJRdb+KCbEXC +ULXJEpyhzdWZMjXTrgZ6XkqVuXvi1RUFtqmSOyXfy1LUg0Dxk3AixLgcnboAsFGfMUW5+Ge71Qqj +CIxyRHQjzzwvCGDxoIGoqhPm5Snz6lKcR3QPVCScOOuOCGGcGOVWrGiEbvv91oA/DZG6H5+fzK8p +73VWsM7BxPZxPxS8Jge6y7Yk/uZ9Ap1lWqGQQN/aYKfVJnEkGWY5Ogigprj21xYFlBDMf7RIvoa7 +gJ9C/c7/ZkAwBfsxt097xkRDDWAjzDRCOmrAQbcIwkQDQjvaMC6fo1vInmvrAQSbcVbOB7UUvrxQ +VLpw1DkxHkz6gGshKZ2ReZDnORXtQiRsVOPA4sTSxBCcClHZmSywa1LgYcGlqn5dfbJGZZRbW5Gr +S7eLv82A/I8B+amJP1ZtgwWbgTwRWon9cixlZduMB1dyMFOp78HjnRlYoJ4rrPKWHIvfoeWuEN97 +uxAY2nAlhYYN3a2/bS7xWszHxiuFokZ/AcYa/q/FBTZ9lV97+eGXzMrVAaQ5P82uWmUVUH9+rMmS +fYXbNeImKnrXAI8vJlyZcRKRx06HeTiNrgcRfjHxyIcDsYjAQ2ai1T5xAxC0xRr0doc28FQZIKUv +qbSDxA4qkZN0b72WspZFztwdwmY5A3jihYiqBLiR/6O06HfwePLNjr4305R81pgRVLymLGV2vRsZ +ZK9sCzXXAFfQg4TSHQTbKkRmLjEX6aQg0iX6oah6bjwfCGw9es7YiTsZ8vd+cSqEC1LhIEiFNFb1 +C0VWWc0HR8Z+ZOh9h8h+HVjDfQupJu0wvltV9yRasERIChQZh4I7kjgAb2PPdf5jGZviRRDbFwED +kYbkDxxbnCw/NbE9mJU+nxKcx+hmo8xLhj1iroh8nVjw4wCxsr8kKrUp6hVmPgQUsFu3IO8uZFKb +dwrQCM0tW5ZjKzdtUcIC4mn1RaN5wQR6/JtcfOawcHs0rH9RHjWZvIf7/ssjMuosxLh7BeU1Ljo0 +ayEQYlpgpDJkYBRiFLd9UwT6lEIMXSalvRADlQtTwcAnjzvpZRnivACLEBN6KUwSeMtQ7QP1DTHf +6a/dJiK2RNcexBjRJLkImEPMnxJNWbsrxCiLYsX7pHchBj5LfZk/7CEmYbT+hvWlDrrlZ1G10q0l +Ey6xduHAwPVbmzYQE6kNSAAxglmgKHwlKrWFwmrjdjD7VtHdUhuIgXpb/VgZiBnx8GECP5geJq9y +1WH+JzAO8E/ZuH6JTGQ5jP8WWZBii+4w28VetILDCCV51TYHc9ybdEh1UQPIruEw6nVIxo8Jtzd1 +kVSjsrKA1fbIGLVJxN4wO0LiwIKTdcN05lh4YHcGDyPLQaE+kDbujEgW7te431hP8w3TGzfHYXqj +hhmV53xzwy03FJF6Sh4apqorsNrkEMO8ZGmY4XmJ68JpLNS4iLu7IPnlmm/kdEQSJO4iNqQMwauL +DzU0SsP4tqZKuSHiTnpTqfwCMH0nTSOWGaall1a2UlEa5lAkSDC7zl56uoqqQCRwKfy/7/zELZxZ +wjYxWEPjd2NQZid362NrKFzmNLkwzOZE6ZQ780oadBJTvzFM0YQmC0s7MuO/T3HKs7CKgKtgzlSs +b35d73T97bjdn5io2aiAzpWCgSwCrjdMG18jFkgpXMHex2wt4mveS3bQtlh7CM3wvz666VrhZ0MQ +xBY+aBf2dNoB8YUm2A0DFdLRMHdebahe2S8OuA07VUC8TxdajE5105Kdo3nNgrmQef0f1MtJwK+P +54y8XMrmDkbOWKLdOh9HgXoDlZ1coAjIgCy2vk7vNdPC2gLVGMdLLAU4YpEGN3cX/MzUhM2sV238 +i/PhlzCFdsVGEMJOCwNdTY2UpOwA5Wg1I0Hw1eG5sKbg38SbpvMdmPtxniClv1KAI5ryxX3PMUQK +syNf8o6UgbWlXsMS9wWb7QyZAZIiYMl21oXhjC4IirxoMS2Eg455PIHheZlUb8QeNAhi4rxusXXM +8JKl2y/g0FELhkkOLLVAedtDPPli9ldvHQLfdxjQEFHX3L9T/+DhuElaK2B8RHLFwag2kNnNmvi5 +ZoOeRCrXG8AxB6NH5G6AVqnkWNd3vI3qNYiZq965t1WwDPxvKGQOavUvJEWND21EA62F6V44kwWg +RyVv4VlHR6gnRkomlADHGSSSFq08EAOTdYOd7+v+y6HS9YH7x6SpTDm+2cAPOp11kKbd5DZG/2BP +eaij3cKE8kTTAwI6UnWZANYWfJGci/PHnEKjXrO9qDIiFGC/+NdqCAr0eY8p16XOjc0hn78cAB9v +o9WJJUdNGmuR1PbL8ucAK06mQpb/erwha5/j8OHcytqC2iuvcl5o+wE9UiOWeIX3Mqopl+t8e8gX +lKDo36HbYK42Z/mF3iKjvzmcJVQQuFwwcxTesTOpadvdnJA7Bkypvx+sfypd5FkM5egODm5xnWfl +mGBwx8xjTIQP19GTAWTlZMUopHyfELsKBI1cMd7EBltgGxe4wBUUl1AczfGMIr6nBqGAmAOJzT4U +wxHVx4M42uRY6RAfK+1PXpNGvB4Ru2APkuD3iwuGW2zrwiYSaELLYhQdd4GMttj41vjXtxnNqhWZ +ePl0dNztyasfYgYYaAgS+8JtR630ylXS/JBq1IrEonySWqFc8Ryer+s49VQT+8lIhru4N97c6JA0 +WwCUbu9cUI7HNhErhwXRTdX+FlH0adGuQKva73h24Nb8pmJnznlqrkRLzzuIybCN1Ao9Q41Sojwj +Me0imi3Ga6EkurJI12Kp+81U+HR+LhLJKNNyjUEdbblWLZ6Bgxvykkeyei0VMCl8SqMYPHhAQL9y +VBsNALKSZGanDEd1Mn47O91PNaFUzROwW1hWkxhQ6beB9wIl1x25tP3PdHO1aBexoG6zZwCgqYho +GIv3HsOiGU8aSkQY62JlX0n6lfHNVArnkgCaREXIGuxw7+aBKE+IA28ez7rSyLJl4D+Li0qquqPR +OitSxzLaScmaG7+OPj/vJFCmhEDmokt5a05ZRGlukOGI8SYQRJMLXa5XyOJTZB6Cdp0hHAVTHkAQ +tk5eUHqD1t8D2zU04P75hADIkNTpMgSK0kZwmO5oEjQ8W58Q5ahB2NsmldAeyEgm/2Xx5WE5IErp +g7f8gwSeTCcOjKno5oqQNonpaCsG4m2/7tGCrRmBUcYkXgtGJlDCf5ncXgn0O7ogOEGdUiQXEAOp +lYoUOevIgTOjvv5KK6bqj7cJIohzBD15VeXs79hUrUejZ/b/oAGn8mKhMqvUyJIT8rqIDIsUZpNm +pQWA7kCNlGmFBvtZSS0HWs/yvsPLPhhLlrc/jNwHdntU3DXSgaWMS1P2vDJVIyzV0Gk2ycN/kvOl +VPXw0Pv1KElwFBDTUl3WUl8N6dlrpoqA+eRzb7AjCrcGEClE9xTXsfgBIjBvpRoRhQGiLuGLhZpB +gLB7LQEFiFPVoLfi2Y13HiB8/5DtHeX9QlR7ldjEA4TJSm4C4koM6ACIHAt4/2F6SbAizvwPvhqo +n2Jt/EO2Jcl/O+0fNi6sEkfDfTd8lQvaoYFRF06a1lGT8ljTDGzMi4GiS8/dy9/SkSaVbgX4w/6h +f2jcQAJEHyQoIKAdQDhvJRDdr/DnCjh/o6G9QKy3Hej/QEhsEgnFjjsQVns7ohKIu8sDCFRBxp/b +/0OngTDWDIQGIrdln8nbtXV97iQMkJVhCgYiYZyN5HahFojClbqKQEj39VK/EL7O2U4ORKge5Vj6 +EO1AkIbSlmYcp1SXoQMRIOhmcfLGvCM7Xzq76QAOhK+8bKGYSfSuk2sNBGNb0T5BtCNFc1qaK8QV +PGe6B68IouGS9vpAGHuKLgeIDw5Z1V84EJhVbPWYFo5cuWFlHuu7+OUpOQPxZE8NQeA1y6kGGiER +CxSdt7KUBRDM04l4RaJSBAi/XQZTXxH5ybZXEV7zIYmKkDQS1uTBJIurjiRKAH+loNmuBepEGocj +3bLb+UaSRGgJqUoSvW3W6FpKceOWK6w6JGhrs5Kw5ORdEEt35Mzf+jm7TzhegrzLHyUjTfFkF/YD +RmvgE2vcOLvW6zEbwXkxsugxiBbmp6Avhcyu2UDt1TcPCJhxy9e1wvOFucv9a7LAB4U86U1Jedl8 +SeFPWrzSmuZQP2TwKE/WUkmY1nFLvlLjZwQz+Hsr7j77cx5dW3AzEOE0FGlQQZPvWGizA5xvPGzy +kBB2X/cnYZeFFkgZzEw1pAjwGU1+IG2eCvifjD37i4jx2SLgWQcF8EiVj3GIPI2Knxcpg2YnA8uy +ei6DQgGBw2fYoYWTWHZR2B4TJibbO6hw67E3Olr2/I4RQU760IJqngyteNbu5xhdPbOkDiARTTHI +0HuLHo3V/LK5tgcA6xTo3iHsW7oHtJtsuYwHklCMFvBWUaD13GAcS7NnUmL/MCw6N0MRU7Tno+gn +WRRxNCdmsg5OsJHlBYReax77JWUqMl+qMDu/LDqOcfA8xOMHcqhA2zAwJn9uadFR5vEl6CXhSAE4 +IUSgNU/8GG47qQO/iMqUOHEMsjLySPVDabsrX2yR5VbWY3kopzcIP5eTfEj1kcnoHZOHCMstBigD +C5lxvFzgWicIxrRJXY6IowkNREUkomUAfOpASN45TbZpl6zLqCC6RpQM/IA2UzMFIlBC3WIcDMjU +6wCfhwE4tk6QeVvl8Tb/FJYAvvS45mmKI3je9eV8BtDhbR7FlYYrCKaihlNKH+NZwh7gWzQZ/vmh +DqPeNBFkT7w9qCftv1doJPCMaDEYW7cYhBGf/HmD3suzjaO0hoM4JrJcjxJ7GGyqpStFM/JGFy41 +b6KfqKQn0EWK/FREZ/EpIoGjlnBgk0uCypHIiKt5NMWD0lXLNd8J6GT9BLim7H+hMzsTZQPJDXVH +cy/xE8/uhV1EbxQap3UqVkjuyYhbHbSWOmoVboumpkk9nGHee6SELKorIcw1anD7fq4Ih3gvkJ2T +O9F+Ccpn2aUmoxEu0joSiX8nXOE8tMShUerJmGyCR8LxZ7eKvF+0ik7wOmUbxajoJMXp3W30DdY1 +pJAGr6mrOryX8B41ZQz2c8APXP86LRSrNxpgJ04uBn1YWklailH5oIvr3wTmJhKcwLqciDZmHGVq +4DWFjR4tpqg7P/VhO9Hs7N1g0HmW932TSKZflnEnjdQK7GwKHo0v6jHJZaZSioHO8ISg2FX2d85J +X29mq86AT07VIgA5L6wpY73HNifEWOZJ8I71uAzVGD0ygDjBOXjY2MYPmWueCun0EVenv6p6YF/o +/evCKHekWzr2nFjFdyeRlZKhmvkNna5kVROscLHktOeYWVDTRqKigNI+3Ei0mKwTIPisMvDTlkg7 +oYyCPBXk3uQAf+biC56sWaCaw+dVv4LLJo6OyNtiYCKR6ZdmteCD/OxQpKNWrX2fncPmTTjLh9OE +O8FGrrNW2BaMstgMCh8qg1f+L67H3qRfyEXtRsRBK7sdCVfXVoxIKneQ1RaZaqblxl3oFB3p/bEM +NDFMU75m8PrywtuMRZLojgc8X6wg5RNRqMLtrzSgX3oRk4epIenUZHct1gKuDCUnuR+SzIFnbd5B +tOqPQNdMY0Baoz0qe4K3gSI9d/Ww1H/tQ/j8J4KGhYd2Y+pqp04m4IZuyo3pdgLuVrAphRXU2wxX +/1Oglpuqys+ZtsTnGCBvXXtRqAOYqB57b9aJJik5R6+2rsiGYlF6+b2aoGDA+FF3i3aAvCNaRAqQ +8ZKyB6ijVHn/AHTUaKLcZiGF/y8o+38k+Q39n78a5CddfUBIRMucBSl1jj0rpzGBgi4gT1VFG+vK +g16lC0YBeZDBukIMjerhTx53dqJLtzWxgcmRyACMR0WWSY4pzQO3x56S7irvSUIK4cr8j1leoD1n +CHOM6slgVwNZ6SvENTAc7udzgjtchnw9fGNA2eCgXYeuIfKhowkDbPqChJABLzLq7oxa/oNn45pK +rAFkJCtaTfvi83HJyoVqRE6eZURPAtWd0h5hACHeP3B9pAXbVOGl2TJkzlhAayiqRYtIp7RvqwLU +PLXTGpQ4Ex4gtjkxk8N9BuwST6MAP/7ee8R8ESgoXYCUdMHHhhgFmbBmRaPpkU/K5/5mXykbGBW/ +NpryOYrWsQGg7oxl2FxY9yMig8RezzW1YE45lThfl5BCSlgq/2rTsbEE8pHQAhSF7KYV0pZJMLiC +j9I+H5/hINkvCxXArQX90sIGxrjlZTKxcp8oQnnELuSxH8W7gyMSoFoGJWw5N8Z7zyzVBasg3T49 +3jnn7Ei9HojSXmMGxDSedkLJ/zuwTfG6nhaE4eH7x9yvwh90oz1vYZU94iWiSqeiI3r3CXHWqPfr +6GU366m0DZfQ4TQOkz2kbTRCENCsidN/M16Wjx0VuIuL3GFZYIoCUkS71R//lhtkZCdtlzmkFHQq +k8gMrPzMjOg4jObAiI2Cw3fvMkV0hj6WdGjT2hBB5AQFi9LjO2gFUgg1xpI1vvYcevmapv2fF5hy +aagztlvkg6jo03Bfm+Yj8dEqsoyqfzvcUAyO2eCMtS9TYOgDPp+N2jHaWNQ+NF+xBcuFauOPA8YZ ++hQ9WwS+xnZ+hqitnLirX1k1hJMlCpEe/xJTTZs5PNKfGw9BP2mMO/HFMt9UuDlGtSFpsZlqCHCv +Kxv8LwuvA3u1hnCywiA+2J3uAcXV1OV0Tz5MvzBalUh2jcZCteikULpeFDJckNMjuIFGbOZX3O8Z +eT7O8yWxcdZyJWyGJ5s2ATs33ilKHonBPARo2RwpAI6ylifPudjDx7BLzviKBgUcP6pbcymnCTg8 +D9llc1iAZv0HoTDbruNTAH5oHqfOiCrWtiXjdbhiGsPCQ6WpjlX1RuUqHOw1lQTnDn56VCOIufDx +QpgKCchiLLg1TvlNqNg9MNTiipyUinhjPYMpGoVlJKX2uooIOG1tckd6MzTdo67luE2p2wygGc+s +MFR6hVYH2wwuhisDEDiCPbCkXZmEZ8ZVoSwI8BSzy9EgxDrnEIG9gsMbGx4BnNfiOYM+euzvQtRH +FwB5mTIiYIFCnKrW07ESYMpxmir93JPTPOggEbCFGvGAOf3HsvcZp8Zci6GK0It8wd2x3RnB87la +v5JZ1gGPIs96P6tFZt4HIVk1XDUU4dBQCQVJqRK6aLjEGQxYCs3OSWTD8W/qJsVNbWmwWrojUR1u +Ne/422XlAnutUoldHJ8rBTVslShvdIVmaT2yTu06DsIpWAlUyPnnZK2CG8yNmpx+BmXzRJppd8CH +RplZwmTaA1GeiUbxPjiesZS5zAmKF/XOZHCGyt770c6JoolFs/Vm7v8vyvgxepeKqLgIjhxCSFwy +tMakzHI2iKeY01aJDDc1/MIu6IS9CcaTn+VRLTfx6eCNJk8Hqx2XcM+9uCRXIkku+RUSEO2ZeVO3 +IJchWsUMbxhef30Ey4SSFVsoAkuopDCjZgo1kPwvKqCoNmSRhJmQOsFq4nEMBAqFj4n1wMicb8BX +fRflqz0apnRkIuzihcaHxXnCfkyYGXTkJY5lIO3yYkyc60Jq5EfVxMYGcVhrSP8P6XWiAzH8UmMi +CrPJBmtSR/8Fr7D0HDKJse6zCgw4Qh2e0BBbyMlIcz09xrACB0AK4SkM9aQatlWBrp1fOPUw/CQU +S8Jm1Kk1+FzHBcpr5+syPWWSO3+/WMqbb/Nw/Jeqi1fQ0tZPJC0GnNAXGxPU2xXidKf4EtMR8w// +tjNRvc91IIKMwGmbCTkcFvkuhRo/wpV4mKS3FbWzJM6M9aYlWaC9i4QVjgF2Rkq2meVnkuGHJCOR +3m9/mzmA9gmUQzC0LYftCCbnWcTlQ011Fin9WcWKuI+DuFEpmj3Pyk4OKRVirQ9oN4vOQQlA9Nxz +umn0yH6UarC8E7CZYEYyP4FcRT/nZOHGonukXzOh1BJNHx16npfEITPz4md81fRx4SUcCWCWVZqi +uN4jl0lo5UuNJZi6epDT7X1eWlkTTXrQOYlsCWIpK1E9KSZqHvIlh2hejzXVjBUONq76P7p1acgn +3ZxH+87Jt+QTXeAeilNBTpNmnQfbOjjOi/YVlqZtbP0J4tBzBemKoBcJ/w41FU4dsHa+mYh6FyxE +JnGSiaHvchUpeeJ26aaa0Y+070fBc7hXUfAyPpVwtt9It6NnT0qqzXOBEgQdN5Zyr70DGEemlFea ++4X1p9ZnvgpdH4lpXOWJ5HcueO65pmxjTt7Ekb9NreFW5QtNUH08kwRgrsmryzpao1nkFEBw5CtE +NMJ6Kn1n8uLA9XOaGXb7/mamoqAZot/l3+vutVpWTBgninkB7V7viKyrc9AidEQAVQTMVxTRwpkP +ExdLHc+NXaG24+FwSUZCX1n9ynXob48WD1wUZNGpnPdo1P0F2dAxRbuL//3KnjPwG+0bgWh/BGVN +/o9tw+MgQuZ6PAiPYg28csULsKE1Ewkjr1qR0qbjuR1LOvITggQpKu1CVYYCTgxEcfbRbn2oQQZR +RO6lL8qb5gU9r1rvwPmdsZk2qrRXyfBJuvNQHnSdszKfi3spG6pf9ijMMmDKWI9WtTNRz79A39K2 ++jNoUWE32p8Zqh9pX1RLkvyO+YrtPCWESS9e/vR4N2xWjb0QBbcTDCquLEt+uaJ+eDcDQAn8oR4e +dXW/wQ6aKLUd9r6z0400/tyShE0s/BZA3LzYOS0x8tGx3A0i96SIrh9H/VG3ARWNkWExQCnB/dZ0 +8VUasIoqTtVGpSfL3SKI12nR+PR6+AjhvkCS7glVeog9HzS+g7BPjq5tXb/tdY+icAS5JqY9fWBZ +Tu3vaYE+e4MJpEwu3oOJtCfJYkWRuK7ZG1SFWAUg+2hZTvguxRWYh/B7vahxDuErSAATEXfM792s +BdiSH9wgk8LJAL2hiXeSHF46W9fqdKUgKvW/VaLS0x2mkvwCDkI/Uan1GNQQqdW5unNCcyarYHmv +2MPwsCQErN7rf4lgSv5QAwIzuco4epqkXBulFBCqtxmrT4RAXSl5bUs8bImZ0sEeispxukQppysU +ZDx+vbQRJOfo9xzkkSNMCMmw4AMVU2B2yDMZYB4cu9FFB/T8hug3F8KBCsT5KexDnZX7wVgk4rFu +z79cpJSSOiLBMufg1Mk7oHNyPnABhF0ZnbBUhvY0j878jYWu9Ot44AL4q4Cui6TyjjY/0iBX7hhx +KkcdWKO96xLxchP2cxyskMu0ghhds26IrnAMXGgcpANWQHSLDMh1GAJSXyAzH3qL9TELjNAWq3X4 +bSdWk2eL1enOEIrky4vVmlj70y/W7rbjkDawpI+H2/TH3vBkAs31G84TEqNsam/BG6ZZBFlho0/e +l9IW1j2Sjbok0C0sWpl9UQhKnjKIgcuO132C5yQb2usuRAFPO/a8AKbQ2HtAfpx1ZsUW8sZh4fo9 +nKvI1rvySiDE6v6/fqtG+OtjfanHoSylXFgdwZpigK7GcDRsmMYF47mXiiY2Wy2POkDidHQVyz8M +SMrgSi5GdFGwBkkiasFYwE3pfuYgLcLhzJYs4mc70Y5VwXf3gh+PzaB+DRrkXwU82Ysfj2jlumsw +uokEdKfMZevI0YfLbHZLtEIGcNyx+2vEBJkZSlhd1cCEup9FsQAtG8IzDTJW5VLddrsUIQ4wGkqW +E+SggHpnA3Lb4hLXrz3B8d3e/Ocac3DeE/oP8zCbBbcQ5YaBb2wTbiSN3kSGjxwEmxzYwlRLrFJA +hjGg6AMkQwWEawTQFHgTp1lqvZtWQZIct0+z1a87TWYK2XXUGWfplLGZUSphAyt+Em3Sa7ygVk5x +VT+UnOvHHVKhV7nAOgfjKwc0m+BNT1s+PpCPW+Xp0dBdhyuXAu/h4DlnBtCKq3SAFv7Ym1Ms+KiY +KyqD5gwn2DBFkkg/KshUkkxm/mC3xNd7Z6bM2+W7M325G5KVMDdMveSiFJAgvjJT4jKJvfnFoY6D +YkP0iJaYywQlm8InMPrby96ZDDzB5pRgU0gvE2rvlMzA6PxNG3YjtgIlXGSi7zRUqxlk4oSIScng +cJPSP7w4+L5jpk4bY+Fvgmjllw4Lssu+iFY+uMxcM7j461IyEgOlnbyLwQBKjGEJCJQ/KynVDap2 +Ua4uBhANVMQ8vNNQMMVtQV0NUZqHraz4u0IVe6cQaYQqGOfa69/R9kcHQCHm54T1rPfODFHupLoe +4I8Od08xCNF1wc+aDtPqAkPIDN4lu4gF3PFy160zaETHsoNX3dZgX7sothJAJIAAQVDcqHGyK7rD +nhB0Br6xanKFJ493hKeNUo3PixO3ZIqM3f6wpJecWJDYjYwdhuecjilDEcrFln+u4EGl1BM7R/V6 +Q3KsBTtKED8Pmy2pMBAvbyBhuRW+mNuAVIXozyqgV5x/joVweoilN+Y2ABqbNuNMrAdeBdQlrrZO +mmcRyEOuGOBE7W7Fqm8fIxycA5S+WXeRUVhNQcUCBIsMFQfYyUEGsSUyzSuGbOYgg9gWWcNsHCma +QcbYEZnmWYQGWcSOyMqRhdCBjKZGbNrdfer9/7ai00WJcsMYrEYnXteOH72BipfdUnKkl5uWMQGW +MolSxWsc7iyCMdGqTBql4m0gdjmNCSaUqZQqXsgudAkyJuMu065VvBYiksOYemKZgrKKlxxjggll +KlXxwn32Ehi9LpiWvQGfJZJbUqAr3v4oQXGHy50nIRWlwal4cSwk6Fk+CakoDa7ixUs1sFr5pJ4o +6eyKt/60HXbi1WWyFa9E3jiWTwoVpYZb8Yb4jLOJ11QokoyOJkoHUvGuABZX8urMzRPvrcLNIvMZ +foSfvkwA9+kQg5XOCcEaje2WHeVDvxIP2RUHnFBW3089++TNltbngsXZY65MajZkY1alMV9Xh538 +JC6ra2TTXCC+vH+Y0fKihEyDvKNujzxZM+1fugF8K79RfAW8GHiN5BH0lMnAgn85iyURs02DZx+8 +NeWp7VqU4xNu84krPrxh0wRbR905op/SYKF0IEjfdbFktif2kthZEnqLRymWTEo6S3aP9DLHkn7c +enskzJKssK3gWDKiglmSde1dUCwpUX+W5F68PUrXNbOk9saSvWdJJ1wAhIdyyi55FhfXt6xcjkml +iknCQcdkSDvCr5g8UOCYFN0msi8mHc49JrepiSQ1hH7kmJy2SpAsJpkjHJPR70hrIiBlo4RpddqO +irN0SS/HSDoOsv77ftIbXizKuQiCZcuUiHo57Q/jU4KD88eL0QV601DHKSa0gWRZxSGdY+7KJcLB +ZFiAoghXTowMShg7vPLi15SyVaTy7UKF4MGSZgpL9JlvLtCBNNQHKyw33QgwyQ6CFy1oitCgo2hX +5TIP/hF9oDs4VUfSSsp0KkiBNFZHOnVmliaxB1bVbvpM/1O/wR/oswWBX1aDH5MQ408kjVw4VhHr +JIguezGt6KDZP5YVP570BrFUKmP7zz9i6yU6LO0qKnxNA614JHV8PXkAqFVB28c5Ev7rqwS+qzZG +VmcQPv9k2qFdhMfiKBI8nX4oW9urPCydw/D1yhVLL9Z7FsFSwhwGdHZiWUlmOCGE2A9lY6JIGH0p +JAcjgnN3X55Ug5lltlx5AAgdtHgHihNFOoHQzee3OxABVjSzK0VtLFLCeQ5kL1gdgz8BLVRxGlcH +x0RFCOQEuSDgHnKErSXWyjOmkU9unjougPqHIjMlgRiB/6IsbwyaiO8pixAaBDHyFgbhSHgNqeam +wblDByacCYG0pkwRoQgOAcBwDi5t8CpTOCfyX3zXubYOvRGqolpeMVUWcRe6bsFEa5bEiWA0lpvk +MEi5hCIRbQT3KQ4cXyEvOEu+j2PmBvJ4KwkDF87+92SHrqMNtuTd+WpVvn+g9PoTzktupc/WGIVH +o8xjTFOaYaYEkCE2OEgwiG4t9g+7yY30Mh1IcukeXrxMhDcfKuNQAr/SICi4QI/YV4cLJGX38CeK +hUSlvidIOjsW/VuLWC95/5Ng6XwIrKYsWmIZ0iXHc+Fw7TREbGcZCJcfly1FFgVmk7wSpXKIcinF +f+UeNGHsg1JYv1eKYvOqbfUeasphxTUReLF0hUcZgS4mKawUR9xuzpaEg8+pUSXPki2WD6y5TQzM +GZiqYFxa2esgYrmBYksYIlKUFNvhkoL+G0pEz56K1NbmHKoMwhBXk8thKu5z9jBINt1aw0tdt0Rf +ssneyEI6hTBzMuMpChYQdMfIR6SI1i2DymDecWSju+R+SDViChfzDqPnwKBlGY9i5zIYC3Rtcy8J +xdbhynUrctD5hByHW5lmOZagJPEOueFKgpQRduRwWHrfl5fI/huzLf2/p6KW87c3Cm2l+vYX5hWb +r+zLDermtv0HFkqWIu4bU0O0+RAcOOGB3MWNUrqd042wFPcXzEeEDH3ckm/3Fb5cn/mtSW8yOQyg +OAQkQtAucASSAUl8TpX8EIeExZ06626TBRYCnhO7SRd+0lvys6Zq0sDvjBpDLITzt/aehKub8uV/ +RLV/iGCDxxPmfzik+w33j/COOMvmPhrWdu3Vhv8bSfzO8L3wUgUjZV8T/hEzup1+wEUMaFV7Dwl7 +ltPVrZPOih4YTILDgvd2JtQ/sPJEA5o2QeoXyg0lgl+pjegeQIoJKGOcXtIY97uEiTOzs5Uo6wh1 +LdkvRBb7KDK5X5xUhCBlUWD2aEHzdeD6QFzi5D0gfrH5UQPYbO21sD9jsbYis9XvBFo32tCjgIwu +X9D5UcgWhts7lk8r0dDdHYS32kGm21/EcW93N6cpjS+9dBAB + + + + \ No newline at end of file diff --git a/internal/cmd/stats/header.svg b/internal/cmd/stats/header.svg new file mode 100644 index 0000000000000000000000000000000000000000..b97ba95adea196efa0e62202d8755e91f09c1f76 --- /dev/null +++ b/internal/cmd/stats/header.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + +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 +Kx6dLJ7lQADAwxrZ2AUZDAGPUSMZDYVcHJzJS0hDH05OBCtMXkImDgcwERYITFgQZhgVgnDZ2IYP +CY7GNosjouA0GxIczQRHRMFhAwmO5kM3CGy8DNCBEwgIaLHwgeBAxWYfQAaATUUIVcIW1aAxoaBg +gg74DJCPjpGOxAAHNnxjfVo6GQYYkJqO50LFwIA0lgMdCTBqYzFg41aD5WhWVTSuwXI0HioDHFCH +gKDBwMQDgWYoNCpcrJh0NiI0R2MLQE0icRiVkeEBA8bKp2UEITqcjWngeDi5Ec2C5Wg0Li9xHJkF +y9FsLBSQuE7EREfEgiSFAgWEFDvQHlAoggRHM0GlFMt8Ki5sJix2AwRFin08nFyB3o1B3Aa5mFHV +ZLeWJV7GvOslXcV1uSx55a7XRf313f5/y6Woalot7UrVP11+iiqdMptz/7Pkq3Nv6b0vvF2c+yth +uqme6q02ZV+WjGpnqia+tSip1MtQHx/N0lb/5f6Zda/9VNNfaqL9f0qn675t6pWnm9J6U25NXjRD +Xuup9+x76m4q7UrTb+XlzYzYy/6PudCU31e/NnbyYknC713O+bdtaKa4mvGXvaSLvRcy7jpONbXt +6q1L5urv1mfmtnoPFbN3U03R+pZUs9G8/11SW9u3eX23/3+3bJ8fv9v/v1f2Pmd2o+aZNa9V5jSz +06X63pn8/27LWVNdWS95GfsPzfjXp2eq6RzdFVniPbtH/2cJf/XmIi/3mxnj0sz03mb8/+bFm0pl +VYqSHJP97cwSF262rl36me0ux8RXXTezx9WYaspbxjf/VVKkiouHmJ7HfL2/NFkNsVd1qdTNvJzr +TknRri7EyJSkGJmalkmd3L1qq09JUR76/eNC31fVpefn7ojcaw9RSVFeZEqKFpdGhrrOUbU5982y +bTXm+qsr57Lj4z+1114ofbLd7+uvPUdrRtNT0/vyvFyr1//8qLzOHrOTbamUnIi6/cv2TVUvVyMj +IvZ6VVd+z+TFi/v5yG29bldiY+vE///Hha7ul8/Lk3N1auPuva2T9lcz/6/y6/83LlVltlTm/z9b +qWqKEBVdn///eTE/9qGhL05rRvb/z1+vvn1r6bxc1RTpv6W/rc/l/F9DM/tdjLd2lRR5Lyqn/5lZ +ty+a5tJVU/SLprxnZuzLsZ/1PM3MNhcfLq5jYzVFyssvQXQpSlLcO2fP5a/XmMb/v7yWlRS9HZ62 +7i2LcvWdz///fjGrKbJjM7O7xVFjq7r9/xsua6US2bVcRKpeIl4U6CIyJQCQyNRFZKoEowy4hC5K +LMqUmBkvIj737Rq6reFrW9r9m/cbbuaVYElEESXggOASutiUYJSCi8hUBinKYAShPlLkeNH3Vcig +oOQMKE4BWjCqTTYM4yF0kRgD0sTkTJajMQcGLhKFR1aj0WHdUkIgH8PAJIPBw0SENsDJRGREcFkF +gMvGUCrcicvGICxO44zEZWPTQgFxIbB4WsFsCH26tsfYdxq9Iut+StFZvjPfqWt3ah/q6oz+M/17 +1SNudYWi6woFfVkb1yPU9OxeS+N9XX1+5cvXNfYo23mzb3WyR4eojC95u+pE3fb43c6u2fa4Otuj +20Z3727njI79UKVI5qcmbWk61WV7E6sU5F30u8Rej6ikqNkVJ13EUynMKKH2qYmXPa/Z7tCzz/HM +WMqd2s9VitSHasqfOPm5k727J1NdaCpt3Lp9mNj8aXmnpOhZLZHzUMpTk9/f2ZkU2fZqO54ii9jS +7/QwGRmX/e66ERVbsuWNSkLpJm5Mx0tGlxwZlR3dVz7uY6PlP1r29qNk6XgmXTpmrna8k/oylz7m +Ix+mpT/qQkzFNDWUNlPXnV2qKVOzL/dSajVX/P5fTcQ8TM13R0dXU3SGuvx+MvRVc8zL49x/z4Xn +aoo23fzX1qV+Pm6n+nN2rpMW3dxcTSgZOn9/q0nS82/vPEc3X29vNZlLaf7dk3lndzd/7z2/tMg9 +ebeSzHl3W00t7dLex5UtOfdqa6tJSs7OVhNvb3Y23+LtXanXbWNjq2kZsSdN0+u9/tyUlmZfW6sp +09faWk0oSYn/vPxw+/oZn9V0uPjOnU9i58narHub1bT+JV763PyS+zKryaX093NpV7evXF6+dmVl +NbXEjHqqvHrrl5Wky3hPzTh/dS8ZWU3ryos8qaYerz8+VGlXJ688PlbTpRsbqylZkZXEyGh8xvZL +UdJlyL+/Xu/+fdV0rH99jM7Gb9zsybreq6U0/t5G4/7s7VWTdI/LmXlX9bLt19mR13Edq+8y591d +NUnat6+rpl3ZksSrquqrWcqV/Lm3kKXexVw1nS5e6dft2qfj9rJVRlxclXaZ8u2tmlJKQ1/+d1+a +e7n6qXfL36dpqybddcbMx11b28ZN3P/lr7cQV3v16ePzHd66t3Mvu2V0dVXT2lpbzfWvSuJltpv5 +l2iWv7i1VU3Sr1ue+n/qO3r2pRniutb3d9y1rPyrfVHSla593OZodr5+dVVNLvXqXrrpC1fVxHn2 +/n+7uH1ZY6vearWWAirDQwEGg8EokMqJBgGwWGiJQCEWlxZQSCUCqZxoALmcRKAAnLSoqBAIw4WT +0IOJiRCIACeSRAANLCglHg4JJRoRPiBEKAkgUQYiYhUMVB+MkpLnuCgBfVgqTCGWlAccFx2IqASj +BnAh5HLCAAMjMpWCgYIR4cTEd7eaQCKciKw1sshUMKKL1KKbiCyyzJ76YCgp0d1WkTW19cwN+3X3 +7vR3EeU09dt582+X71r/zvvdf3PVUy3fc08N2dscex23195dd+6itWZH39O9U5Le3S69+2V77kNB +VdTttLw+1HtGt9d2S7S+TsSPqIffq37XZ5pL7/gznW0Vj+8u9Y8XdfrEe5WipFuTzpTtOqfcdo3K +3srHx4qNypLquJ3hqmnfYX6ee+++qZ3dvd1+mtrperu3Uyj5vJnmdm7niriKbhclKx6yuW/qdSd1 +V7/l/ea33aFs2yE6nq0fCttp9BNbqhQl2Un3vMp/pme3/K7r/t2u7XbXjnYKpdEu7/A+faWd2pna +qdq56nxy2qWdQkE7tPsO9tnZ2fHZ9akUhcL7zG+9b/74/NHbav4h3+prNEPudl7d/J7R+r5C8WTP +qO8Mz/X/U9cu7quJc//79xKR+68/DTNN3f+Xjz/i9z40zOVETv3N04+S3dTQc/H87/ws1VPfcz33 +99k2+ZX0+amYtvtn/W67n2v/f2rsvG37+8YS/vu+6nxGp6amUeorlP3I2TZ9NdEU+X2f+RXKO/Z7 +v0KJ9H5N8XPRuO9N/aPv20M/M32VPPtVh/P7OXtXv5qid85+VClKsvd3bT5DSXUiXzvtP819j16Z +f/3MLLJlrzqRrjvfm0ae9lFmqnKnPeO618gx/z5aVUXzs9M+w2c/U3xXz/b2/c0/w1eS6kTHyfib +n7fwI/9L01S0f71DRWM+Q/3z01zs/0de9/cK5T7it77jW2Zv9gol2jRzZ1N7je45vS357KcoTbef +9/1UipLO1n3xjLM3c/HWNtU5dc3Z9iw99U5fdS9VJ8r8/OXLftRlw/TXla5Qole93r5UX21fjw49 +D/tyG3v/X6FE3ajRXzc+H2qEm5h+rDq2/ajcp3ppmdl4C9WNW/tM1aPrTO/jjC+oRDDq9qmmpA4p +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 + + + + diff --git a/internal/cmd/stats/heartbit.svg b/internal/cmd/stats/heartbit.svg new file mode 100644 index 0000000000000000000000000000000000000000..daef4b0b57f392d02699d5e7371671633ffd57f2 --- /dev/null +++ b/internal/cmd/stats/heartbit.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css new file mode 100644 index 0000000000000000000000000000000000000000..0216f9f79bd6bd16f77a5fd0ec14e9c142815436 --- /dev/null +++ b/internal/cmd/stats/index.css @@ -0,0 +1,270 @@ +:root { + /* Dark mode colors - charmtone dark palette */ + --bg: #201f26; + --bg-secondary: #2d2c35; + --text: #fffaf1; + --text-muted: #858392; + + /* Charmtone colors (global - same in both light and dark modes) */ + --charple: #6b50ff; + --cherry: #ff388b; + --julep: #00ffb2; + --urchin: #c337e0; + --butter: #fffaf1; + --squid: #858392; + --pepper: #201f26; + --iron: #4d4c57; + --tuna: #ff6daa; + --uni: #ff937d; + --coral: #ff577d; + --violet: #c259ff; + --malibu: #00a4ff; + --hazy: #8b75ff; +} + +/* Light mode colors - charmtone light palette */ +@media (prefers-color-scheme: light) { + :root { + --bg: #f0f0f0; + --bg-secondary: #fbfbfb; + --text: #201f26; + --text-muted: #4d4c57; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + padding: 2rem 1rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +.header-wrapper { + max-width: 1200px; + margin: 0 auto 2rem; +} + +.header-wrapper a { + display: block; + text-decoration: none; +} + +.header-content { + display: flex; + align-items: center; + width: 100%; +} + +.header-svg { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + height: 70px; + display: flex; + align-items: center; +} + +.header-svg svg { + height: 70px; + width: auto; + min-width: 1300px; + display: block; + pointer-events: none; +} + +.heartbit-svg { + flex-shrink: 0; + width: 70px; + flex-basis: 70px; + margin-left: 1rem; +} + +.heartbit-svg svg { + width: 100%; + height: auto; + display: block; +} + +.header-info { + margin-bottom: 2rem; + font-size: 0.875rem; + color: var(--hazy); + font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; +} + +.stats-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 2rem; + width: 100%; +} + +.stat-card { + background: var(--bg-secondary); + border-radius: 12px; + padding: 1.5rem; + flex: 1 1 150px; + max-width: calc((100% - 5rem) / 6); +} + +@media (prefers-color-scheme: light) { + .stat-card { + background: var(--butter); + } +} + +@media (max-width: 1024px) { + .stat-card { + max-width: calc((100% - 2rem) / 3); + } +} + +@media (max-width: 600px) { + .stat-card { + max-width: calc((100% - 1rem) / 2); + } +} + +.stat-card h3 { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.stat-card .value { + font-size: 2rem; + font-weight: 700; + color: var(--butter); + white-space: nowrap; +} + +@media (prefers-color-scheme: light) { + .stat-card .value { + color: var(--pepper); + } +} + +.charts-grid { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 2rem; + width: 100%; +} + +.chart-card { + background: var(--bg-secondary); + border-radius: 12px; + padding: 1.5rem; + width: 100%; + box-sizing: border-box; +} + +@media (prefers-color-scheme: light) { + .chart-card { + background: var(--butter); + } +} + +.chart-card.full-width { + width: 100%; +} + +.chart-row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + width: 100%; +} + +.chart-row .chart-card { + flex: 1 1 300px; + max-width: calc((100% - 1.5rem) / 2); +} + +.chart-card h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text); +} + +.chart-container { + position: relative; + height: 300px; +} + +.chart-container.tall { + height: 400px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +th, +td { + text-align: left; + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--text-muted); + font-weight: 500; + font-size: 0.875rem; +} + +td { + font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; +} + +.model-tag { + background: var(--bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.footer-container { + max-width: 1200px; + margin: 2rem auto 0; +} + +.footer-container svg { + width: 100%; + height: auto; + display: block; +} + +/* Override charm brand colors in footer */ +.footer-container .st2 { + fill: #fffaf1 !important; +} + +@media (prefers-color-scheme: light) { + + /* Override charm brand colors in footer */ + .footer-container .st2 { + fill: #644ced !important; + } +} diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b2822b132c6af919874523678a42ec32d3a76475 --- /dev/null +++ b/internal/cmd/stats/index.html @@ -0,0 +1,136 @@ + + + + + + Crush Usage Statistics + + + + + + + + +
+
+ +
+
{{.Header}}
+
{{.Heartbit}}
+
+
+
+ +
+ Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}. +
+ +
+
+

Total Sessions

+
+
+
+

Total Messages

+
+
+
+

Total Tokens

+
+
+
+

Total Cost

+
+
+
+

Tokens/Session

+
+
+
+

Response Time

+
+
+
+ +
+
+

Activity Heatmap

+
+ +
+
+ +
+

Activity (Last 30 Days)

+
+ +
+
+ +
+

Tool Usage

+
+ +
+
+ +
+
+

Messages by Provider

+
+ +
+
+ +
+

Token Distribution

+
+ +
+
+
+ +
+

Usage by Model

+
+ +
+
+ +
+

Daily Usage History

+
+ + + + + + + + + + + + +
DateSessionsPrompt TokensCompletion TokensTotal TokensCost
+
+
+
+
+ + + + + + diff --git a/internal/cmd/stats/index.js b/internal/cmd/stats/index.js new file mode 100644 index 0000000000000000000000000000000000000000..3007322881e458312a31702f735c1c3257bcfb6d --- /dev/null +++ b/internal/cmd/stats/index.js @@ -0,0 +1,356 @@ +// Get all charmtone colors once from computed styles +const rootStyles = getComputedStyle(document.documentElement); +const colors = { + charple: rootStyles.getPropertyValue("--charple").trim(), + cherry: rootStyles.getPropertyValue("--cherry").trim(), + julep: rootStyles.getPropertyValue("--julep").trim(), + urchin: rootStyles.getPropertyValue("--urchin").trim(), + butter: rootStyles.getPropertyValue("--butter").trim(), + squid: rootStyles.getPropertyValue("--squid").trim(), + pepper: rootStyles.getPropertyValue("--pepper").trim(), + tuna: rootStyles.getPropertyValue("--tuna").trim(), + uni: rootStyles.getPropertyValue("--uni").trim(), + coral: rootStyles.getPropertyValue("--coral").trim(), + violet: rootStyles.getPropertyValue("--violet").trim(), + malibu: rootStyles.getPropertyValue("--malibu").trim(), +}; + +const easeDuration = 500; +const easeType = "easeOutQuart"; + +// Helper functions +function formatNumber(n) { + return new Intl.NumberFormat().format(Math.round(n)); +} + +function formatCompact(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return Math.round(n).toString(); +} + +function formatCost(n) { + return "$" + n.toFixed(2); +} + +function formatTime(ms) { + if (ms < 1000) return Math.round(ms) + "ms"; + return (ms / 1000).toFixed(1) + "s"; +} + +const charpleColor = { r: 107, g: 80, b: 255 }; +const tunaColor = { r: 255, g: 109, b: 170 }; + +function interpolateColor(ratio, alpha = 1) { + const r = Math.round(charpleColor.r + (tunaColor.r - charpleColor.r) * ratio); + const g = Math.round(charpleColor.g + (tunaColor.g - charpleColor.g) * ratio); + const b = Math.round(charpleColor.b + (tunaColor.b - charpleColor.b) * ratio); + if (alpha < 1) { + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgb(${r}, ${g}, ${b})`; +} + +function getTopItemsWithOthers(items, countKey, labelKey, topN = 10) { + const topItems = items.slice(0, topN); + const otherItems = items.slice(topN); + const otherCount = otherItems.reduce((sum, item) => sum + item[countKey], 0); + const displayItems = [...topItems]; + if (otherItems.length > 0) { + const otherItem = { [countKey]: otherCount, [labelKey]: "others" }; + displayItems.push(otherItem); + } + return displayItems; +} + +// Populate summary cards +document.getElementById("total-sessions").textContent = formatNumber( + stats.total.total_sessions, +); +document.getElementById("total-messages").textContent = formatCompact( + stats.total.total_messages, +); +document.getElementById("total-tokens").textContent = formatCompact( + stats.total.total_tokens, +); +document.getElementById("total-cost").textContent = formatCost( + stats.total.total_cost, +); +document.getElementById("avg-tokens").innerHTML = + ' ' + + formatCompact(stats.total.avg_tokens_per_session); +document.getElementById("avg-response").innerHTML = + ' ' + formatTime(stats.avg_response_time_ms); + +// Chart defaults +Chart.defaults.color = colors.squid; +Chart.defaults.borderColor = colors.squid; + +if (stats.recent_activity?.length > 0) { + new Chart(document.getElementById("recentActivityChart"), { + type: "bar", + data: { + labels: stats.recent_activity.map((d) => d.day), + datasets: [ + { + label: "Sessions", + data: stats.recent_activity.map((d) => d.session_count), + backgroundColor: colors.charple, + borderRadius: 4, + yAxisID: "y", + }, + { + label: "Tokens (K)", + data: stats.recent_activity.map((d) => d.total_tokens / 1000), + backgroundColor: colors.julep, + borderRadius: 4, + yAxisID: "y1", + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 800, easing: easeType }, + interaction: { mode: "index", intersect: false }, + scales: { + y: { position: "left", title: { display: true, text: "Sessions" } }, + y1: { + position: "right", + title: { display: true, text: "Tokens (K)" }, + grid: { drawOnChartArea: false }, + }, + }, + }, + }); +} + +// Heatmap (Hour × Day of Week) - Bubble Chart +const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +let maxCount = + stats.hour_day_heatmap?.length > 0 + ? Math.max(...stats.hour_day_heatmap.map((h) => h.session_count)) + : 0; +if (maxCount === 0) maxCount = 1; +const scaleFactor = 20 / Math.sqrt(maxCount); + +if (stats.hour_day_heatmap?.length > 0) { + new Chart(document.getElementById("heatmapChart"), { + type: "bubble", + data: { + datasets: [ + { + label: "Sessions", + data: stats.hour_day_heatmap + .filter((h) => h.session_count > 0) + .map((h) => ({ + x: h.hour, + y: h.day_of_week, + r: Math.sqrt(h.session_count) * scaleFactor, + count: h.session_count, + })), + backgroundColor: (ctx) => { + const count = + ctx.raw?.count || ctx.dataset.data[ctx.dataIndex]?.count || 0; + const ratio = count / maxCount; + return interpolateColor(ratio); + }, + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + min: 0, + max: 23, + grid: { display: false }, + title: { display: true, text: "Hour of Day" }, + ticks: { + stepSize: 1, + callback: (v) => (Number.isInteger(v) ? v : ""), + }, + }, + y: { + min: 0, + max: 6, + reverse: true, + grid: { display: false }, + title: { display: true, text: "Day of Week" }, + ticks: { stepSize: 1, callback: (v) => dayLabels[v] || "" }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => + dayLabels[ctx.raw.y] + + " " + + ctx.raw.x + + ":00 - " + + ctx.raw.count + + " sessions", + }, + }, + }, + }, + }); +} + +if (stats.tool_usage?.length > 0) { + const displayTools = getTopItemsWithOthers( + stats.tool_usage, + "call_count", + "tool_name", + ); + const maxValue = Math.max(...displayTools.map((t) => t.call_count)); + new Chart(document.getElementById("toolChart"), { + type: "bar", + data: { + labels: displayTools.map((t) => t.tool_name), + datasets: [ + { + label: "Calls", + data: displayTools.map((t) => t.call_count), + backgroundColor: (ctx) => { + const value = ctx.raw; + const ratio = value / maxValue; + return interpolateColor(ratio); + }, + borderRadius: 4, + }, + ], + }, + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { legend: { display: false } }, + }, + }); +} + +// Token Distribution Pie +new Chart(document.getElementById("tokenPieChart"), { + type: "doughnut", + data: { + labels: ["Prompt Tokens", "Completion Tokens"], + datasets: [ + { + data: [ + stats.total.total_prompt_tokens, + stats.total.total_completion_tokens, + ], + backgroundColor: [colors.charple, colors.julep], + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { + legend: { position: "bottom" }, + }, + }, +}); + +// Model Usage Chart (horizontal bar) +if (stats.usage_by_model?.length > 0) { + const displayModels = getTopItemsWithOthers( + stats.usage_by_model, + "message_count", + "model", + ); + const maxModelValue = Math.max(...displayModels.map((m) => m.message_count)); + new Chart(document.getElementById("modelChart"), { + type: "bar", + data: { + labels: displayModels.map((m) => + m.provider ? `${m.model} (${m.provider})` : m.model, + ), + datasets: [ + { + label: "Messages", + data: displayModels.map((m) => m.message_count), + backgroundColor: (ctx) => { + const value = ctx.raw; + const ratio = value / maxModelValue; + return interpolateColor(ratio); + }, + borderRadius: 4, + }, + ], + }, + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { legend: { display: false } }, + }, + }); +} + +if (stats.usage_by_model?.length > 0) { + const providerData = stats.usage_by_model.reduce((acc, m) => { + acc[m.provider] = (acc[m.provider] || 0) + m.message_count; + return acc; + }, {}); + const providerColors = [ + colors.malibu, + colors.charple, + colors.violet, + colors.tuna, + colors.coral, + colors.uni, + ]; + new Chart(document.getElementById("providerPieChart"), { + type: "doughnut", + data: { + labels: Object.keys(providerData), + datasets: [ + { + data: Object.values(providerData), + backgroundColor: Object.keys(providerData).map( + (_, i) => providerColors[i % providerColors.length], + ), + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { + legend: { position: "bottom" }, + }, + }, + }); +} + +// Daily Usage Table +const tableBody = document.querySelector("#daily-table tbody"); +if (stats.usage_by_day?.length > 0) { + const fragment = document.createDocumentFragment(); + stats.usage_by_day.slice(0, 30).forEach((d) => { + const row = document.createElement("tr"); + row.innerHTML = `${d.day}${d.session_count}${formatNumber( + d.prompt_tokens, + )}${formatNumber( + d.completion_tokens, + )}${formatNumber(d.total_tokens)}${formatCost( + d.cost, + )}`; + fragment.appendChild(row); + }); + tableBody.appendChild(fragment); +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..b3fd3915182fa293aefc1fe60ec54e5b369fa591 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,237 @@ +package commands + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" +) + +var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) + +const ( + userCommandPrefix = "user:" + projectCommandPrefix = "project:" +) + +// Argument represents a command argument with its metadata. +type Argument struct { + ID string + Title string + Description string + Required bool +} + +// MCPPrompt represents a custom command loaded from an MCP server. +type MCPPrompt struct { + ID string + Title string + Description string + PromptID string + ClientID string + Arguments []Argument +} + +// CustomCommand represents a user-defined custom command loaded from markdown files. +type CustomCommand struct { + ID string + Name string + Content string + Arguments []Argument +} + +type commandSource struct { + path string + prefix string +} + +// LoadCustomCommands loads custom commands from multiple sources including +// XDG config directory, home directory, and project directory. +func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) { + return loadAll(buildCommandSources(cfg)) +} + +// LoadMCPPrompts loads custom commands from available MCP servers. +func LoadMCPPrompts() ([]MCPPrompt, error) { + var commands []MCPPrompt + for mcpName, prompts := range mcp.Prompts() { + for _, prompt := range prompts { + key := mcpName + ":" + prompt.Name + var args []Argument + for _, arg := range prompt.Arguments { + title := arg.Title + if title == "" { + title = arg.Name + } + args = append(args, Argument{ + ID: arg.Name, + Title: title, + Description: arg.Description, + Required: arg.Required, + }) + } + commands = append(commands, MCPPrompt{ + ID: key, + Title: prompt.Title, + Description: prompt.Description, + PromptID: prompt.Name, + ClientID: mcpName, + Arguments: args, + }) + } + } + return commands, nil +} + +func buildCommandSources(cfg *config.Config) []commandSource { + var sources []commandSource + + // XDG config directory + if dir := getXDGCommandsDir(); dir != "" { + sources = append(sources, commandSource{ + path: dir, + prefix: userCommandPrefix, + }) + } + + // Home directory + if home := home.Dir(); home != "" { + sources = append(sources, commandSource{ + path: filepath.Join(home, ".crush", "commands"), + prefix: userCommandPrefix, + }) + } + + // Project directory + sources = append(sources, commandSource{ + path: filepath.Join(cfg.Options.DataDirectory, "commands"), + prefix: projectCommandPrefix, + }) + + return sources +} + +func loadAll(sources []commandSource) ([]CustomCommand, error) { + var commands []CustomCommand + + for _, source := range sources { + if cmds, err := loadFromSource(source); err == nil { + commands = append(commands, cmds...) + } + } + + return commands, nil +} + +func loadFromSource(source commandSource) ([]CustomCommand, error) { + if err := ensureDir(source.path); err != nil { + return nil, err + } + + var commands []CustomCommand + + err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { + return err + } + + cmd, err := loadCommand(path, source.path, source.prefix) + if err != nil { + return nil // Skip invalid files + } + + commands = append(commands, cmd) + return nil + }) + + return commands, err +} + +func loadCommand(path, baseDir, prefix string) (CustomCommand, error) { + content, err := os.ReadFile(path) + if err != nil { + return CustomCommand{}, err + } + + id := buildCommandID(path, baseDir, prefix) + + return CustomCommand{ + ID: id, + Name: id, + Content: string(content), + Arguments: extractArgNames(string(content)), + }, nil +} + +func extractArgNames(content string) []Argument { + matches := namedArgPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + var args []Argument + + for _, match := range matches { + arg := match[1] + if !seen[arg] { + seen[arg] = true + // for normal custom commands, all args are required + args = append(args, Argument{ID: arg, Title: arg, Required: true}) + } + } + + return args +} + +func buildCommandID(path, baseDir, prefix string) string { + relPath, _ := filepath.Rel(baseDir, path) + parts := strings.Split(relPath, string(filepath.Separator)) + + // Remove .md extension from last part + if len(parts) > 0 { + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) + } + + return prefix + strings.Join(parts, ":") +} + +func getXDGCommandsDir() string { + xdgHome := os.Getenv("XDG_CONFIG_HOME") + if xdgHome == "" { + if home := home.Dir(); home != "" { + xdgHome = filepath.Join(home, ".config") + } + } + if xdgHome != "" { + return filepath.Join(xdgHome, "crush", "commands") + } + return "" +} + +func ensureDir(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0o755) + } + return nil +} + +func isMarkdownFile(name string) bool { + return strings.HasSuffix(strings.ToLower(name), ".md") +} + +func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) { + // TODO: we should pass the context down + result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args) + if err != nil { + return "", err + } + return strings.Join(result, " "), nil +} 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 2c414e3e9e35d6f232e00762f50aca1066aca321..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" @@ -53,6 +53,11 @@ var defaultContextPaths = []string{ type SelectedModelType string +// String returns the string representation of the [SelectedModelType]. +func (s SelectedModelType) String() string { + return string(s) +} + const ( SelectedModelTypeLarge SelectedModelType = "large" SelectedModelTypeSmall SelectedModelType = "small" @@ -182,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 { @@ -198,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. @@ -252,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 @@ -310,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 } } @@ -341,7 +350,7 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls,omitzero"` + Ls ToolLs `json:"ls,omitempty"` } type ToolLs struct { @@ -359,8 +368,9 @@ type Config struct { // We currently only support large/small as values here. Models map[SelectedModelType]SelectedModel `json:"models,omitempty" jsonschema:"description=Model configurations for different model types,example={\"large\":{\"model\":\"gpt-4o\",\"provider\":\"openai\"}}"` + // Recently used models stored in the data directory config. - RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"description=Recently used models sorted by most recent first"` + RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"-"` // The providers that are configured Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty" jsonschema:"description=AI provider configurations"` @@ -373,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:"-"` @@ -690,6 +700,7 @@ func allToolNames() []string { "multiedit", "lsp_diagnostics", "lsp_references", + "lsp_restart", "fetch", "agentic_fetch", "glob", @@ -831,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 9e972739eb6e53878fb4b0b6d2301cdaeb2f2a4d..9ac0928d0c8970161d0cf427f7447f5c7390fbe1 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 { @@ -787,3 +792,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 8924475ef9c652ea1962e4f032a0e62e560bce7a..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" @@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) @@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) 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 7fa2e6528743dcb5485c0de9b4a3f2b46eb39376..739c2087e1c1e125875d5006c86f85de37fed3be 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -48,18 +48,51 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil { return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err) } + if q.getAverageResponseTimeStmt, err = db.PrepareContext(ctx, getAverageResponseTime); err != nil { + return nil, fmt.Errorf("error preparing query GetAverageResponseTime: %w", err) + } if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil { return nil, fmt.Errorf("error preparing query GetFile: %w", err) } 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) + } if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil { return nil, fmt.Errorf("error preparing query GetMessage: %w", err) } + if q.getRecentActivityStmt, err = db.PrepareContext(ctx, getRecentActivity); err != nil { + return nil, fmt.Errorf("error preparing query GetRecentActivity: %w", err) + } if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil { return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err) } + if q.getToolUsageStmt, err = db.PrepareContext(ctx, getToolUsage); err != nil { + return nil, fmt.Errorf("error preparing query GetToolUsage: %w", err) + } + if q.getTotalStatsStmt, err = db.PrepareContext(ctx, getTotalStats); err != nil { + return nil, fmt.Errorf("error preparing query GetTotalStats: %w", err) + } + if q.getUsageByDayStmt, err = db.PrepareContext(ctx, getUsageByDay); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByDay: %w", err) + } + if q.getUsageByDayOfWeekStmt, err = db.PrepareContext(ctx, getUsageByDayOfWeek); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByDayOfWeek: %w", err) + } + if q.getUsageByHourStmt, err = db.PrepareContext(ctx, getUsageByHour); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByHour: %w", err) + } + 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) } @@ -78,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) } @@ -132,6 +171,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr) } } + if q.getAverageResponseTimeStmt != nil { + if cerr := q.getAverageResponseTimeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAverageResponseTimeStmt: %w", cerr) + } + } if q.getFileStmt != nil { if cerr := q.getFileStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getFileStmt: %w", cerr) @@ -142,16 +186,66 @@ 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) + } + } if q.getMessageStmt != nil { if cerr := q.getMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getMessageStmt: %w", cerr) } } + if q.getRecentActivityStmt != nil { + if cerr := q.getRecentActivityStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getRecentActivityStmt: %w", cerr) + } + } if q.getSessionByIDStmt != nil { if cerr := q.getSessionByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr) } } + if q.getToolUsageStmt != nil { + if cerr := q.getToolUsageStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getToolUsageStmt: %w", cerr) + } + } + if q.getTotalStatsStmt != nil { + if cerr := q.getTotalStatsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTotalStatsStmt: %w", cerr) + } + } + if q.getUsageByDayStmt != nil { + if cerr := q.getUsageByDayStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByDayStmt: %w", cerr) + } + } + if q.getUsageByDayOfWeekStmt != nil { + if cerr := q.getUsageByDayOfWeekStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByDayOfWeekStmt: %w", cerr) + } + } + if q.getUsageByHourStmt != nil { + if cerr := q.getUsageByHourStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByHourStmt: %w", cerr) + } + } + if q.getUsageByModelStmt != nil { + if cerr := q.getUsageByModelStmt.Close(); cerr != nil { + 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) @@ -182,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) @@ -244,16 +348,29 @@ type Queries struct { deleteSessionStmt *sql.Stmt deleteSessionFilesStmt *sql.Stmt deleteSessionMessagesStmt *sql.Stmt + getAverageResponseTimeStmt *sql.Stmt getFileStmt *sql.Stmt getFileByPathAndSessionStmt *sql.Stmt + getFileReadStmt *sql.Stmt + getHourDayHeatmapStmt *sql.Stmt getMessageStmt *sql.Stmt + getRecentActivityStmt *sql.Stmt getSessionByIDStmt *sql.Stmt + getToolUsageStmt *sql.Stmt + getTotalStatsStmt *sql.Stmt + getUsageByDayStmt *sql.Stmt + 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 @@ -271,16 +388,29 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { deleteSessionStmt: q.deleteSessionStmt, deleteSessionFilesStmt: q.deleteSessionFilesStmt, deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, + getAverageResponseTimeStmt: q.getAverageResponseTimeStmt, getFileStmt: q.getFileStmt, getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getFileReadStmt: q.getFileReadStmt, + getHourDayHeatmapStmt: q.getHourDayHeatmapStmt, getMessageStmt: q.getMessageStmt, + getRecentActivityStmt: q.getRecentActivityStmt, getSessionByIDStmt: q.getSessionByIDStmt, + getToolUsageStmt: q.getToolUsageStmt, + getTotalStatsStmt: q.getTotalStatsStmt, + getUsageByDayStmt: q.getUsageByDayStmt, + 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 dfa6d722535b4265f3f54331d1904523a648f562..c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -17,16 +17,29 @@ type Querier interface { DeleteSession(ctx context.Context, id string) error DeleteSessionFiles(ctx context.Context, sessionID string) error DeleteSessionMessages(ctx context.Context, sessionID string) error + 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) GetSessionByID(ctx context.Context, id string) (Session, error) + GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) + GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) + GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) + 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/db/sql/stats.sql b/internal/db/sql/stats.sql new file mode 100644 index 0000000000000000000000000000000000000000..02f2c33425b299870827b2d05f458106b82b599c --- /dev/null +++ b/internal/db/sql/stats.sql @@ -0,0 +1,93 @@ +-- name: GetUsageByDay :many +SELECT + date(created_at, 'unixepoch') as day, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as cost, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY date(created_at, 'unixepoch') +ORDER BY day DESC; + +-- name: GetUsageByModel :many +SELECT + COALESCE(model, 'unknown') as model, + COALESCE(provider, 'unknown') as provider, + COUNT(*) as message_count +FROM messages +WHERE role = 'assistant' +GROUP BY model, provider +ORDER BY message_count DESC; + +-- name: GetUsageByHour :many +SELECT + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY hour +ORDER BY hour; + +-- name: GetUsageByDayOfWeek :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + COUNT(*) as session_count, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week +ORDER BY day_of_week; + +-- name: GetTotalStats :one +SELECT + COUNT(*) as total_sessions, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(message_count), 0) as total_messages, + COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session, + COALESCE(AVG(message_count), 0) as avg_messages_per_session +FROM sessions +WHERE parent_session_id IS NULL; + +-- name: GetRecentActivity :many +SELECT + date(created_at, 'unixepoch') as day, + COUNT(*) as session_count, + SUM(prompt_tokens + completion_tokens) as total_tokens, + SUM(cost) as cost +FROM sessions +WHERE parent_session_id IS NULL + AND created_at >= strftime('%s', 'now', '-30 days') +GROUP BY date(created_at, 'unixepoch') +ORDER BY day ASC; + +-- name: GetAverageResponseTime :one +SELECT + CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds +FROM messages +WHERE role = 'assistant' + AND finished_at IS NOT NULL + AND finished_at > created_at; + +-- name: GetToolUsage :many +SELECT + json_extract(value, '$.data.name') as tool_name, + COUNT(*) as call_count +FROM messages, json_each(parts) +WHERE json_extract(value, '$.type') = 'tool_call' + AND json_extract(value, '$.data.name') IS NOT NULL +GROUP BY tool_name +ORDER BY call_count DESC; + +-- name: GetHourDayHeatmap :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week, hour +ORDER BY day_of_week, hour; diff --git a/internal/db/stats.sql.go b/internal/db/stats.sql.go new file mode 100644 index 0000000000000000000000000000000000000000..119dd410a07c7e47a2f7369c0ee2fdca8c19b7c3 --- /dev/null +++ b/internal/db/stats.sql.go @@ -0,0 +1,367 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: stats.sql + +package db + +import ( + "context" + "database/sql" +) + +const getAverageResponseTime = `-- name: GetAverageResponseTime :one +SELECT + CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds +FROM messages +WHERE role = 'assistant' + AND finished_at IS NOT NULL + AND finished_at > created_at +` + +func (q *Queries) GetAverageResponseTime(ctx context.Context) (int64, error) { + row := q.queryRow(ctx, q.getAverageResponseTimeStmt, getAverageResponseTime) + var avg_response_seconds int64 + err := row.Scan(&avg_response_seconds) + return avg_response_seconds, err +} + +const getHourDayHeatmap = `-- name: GetHourDayHeatmap :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week, hour +ORDER BY day_of_week, hour +` + +type GetHourDayHeatmapRow struct { + DayOfWeek int64 `json:"day_of_week"` + Hour int64 `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) { + rows, err := q.query(ctx, q.getHourDayHeatmapStmt, getHourDayHeatmap) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetHourDayHeatmapRow{} + for rows.Next() { + var i GetHourDayHeatmapRow + if err := rows.Scan(&i.DayOfWeek, &i.Hour, &i.SessionCount); 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 getRecentActivity = `-- name: GetRecentActivity :many +SELECT + date(created_at, 'unixepoch') as day, + COUNT(*) as session_count, + SUM(prompt_tokens + completion_tokens) as total_tokens, + SUM(cost) as cost +FROM sessions +WHERE parent_session_id IS NULL + AND created_at >= strftime('%s', 'now', '-30 days') +GROUP BY date(created_at, 'unixepoch') +ORDER BY day ASC +` + +type GetRecentActivityRow struct { + Day interface{} `json:"day"` + SessionCount int64 `json:"session_count"` + TotalTokens sql.NullFloat64 `json:"total_tokens"` + Cost sql.NullFloat64 `json:"cost"` +} + +func (q *Queries) GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) { + rows, err := q.query(ctx, q.getRecentActivityStmt, getRecentActivity) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRecentActivityRow{} + for rows.Next() { + var i GetRecentActivityRow + if err := rows.Scan( + &i.Day, + &i.SessionCount, + &i.TotalTokens, + &i.Cost, + ); 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 getToolUsage = `-- name: GetToolUsage :many +SELECT + json_extract(value, '$.data.name') as tool_name, + COUNT(*) as call_count +FROM messages, json_each(parts) +WHERE json_extract(value, '$.type') = 'tool_call' + AND json_extract(value, '$.data.name') IS NOT NULL +GROUP BY tool_name +ORDER BY call_count DESC +` + +type GetToolUsageRow struct { + ToolName interface{} `json:"tool_name"` + CallCount int64 `json:"call_count"` +} + +func (q *Queries) GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) { + rows, err := q.query(ctx, q.getToolUsageStmt, getToolUsage) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetToolUsageRow{} + for rows.Next() { + var i GetToolUsageRow + if err := rows.Scan(&i.ToolName, &i.CallCount); 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 getTotalStats = `-- name: GetTotalStats :one +SELECT + COUNT(*) as total_sessions, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(message_count), 0) as total_messages, + COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session, + COALESCE(AVG(message_count), 0) as avg_messages_per_session +FROM sessions +WHERE parent_session_id IS NULL +` + +type GetTotalStatsRow struct { + TotalSessions int64 `json:"total_sessions"` + TotalPromptTokens interface{} `json:"total_prompt_tokens"` + TotalCompletionTokens interface{} `json:"total_completion_tokens"` + TotalCost interface{} `json:"total_cost"` + TotalMessages interface{} `json:"total_messages"` + AvgTokensPerSession interface{} `json:"avg_tokens_per_session"` + AvgMessagesPerSession interface{} `json:"avg_messages_per_session"` +} + +func (q *Queries) GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) { + row := q.queryRow(ctx, q.getTotalStatsStmt, getTotalStats) + var i GetTotalStatsRow + err := row.Scan( + &i.TotalSessions, + &i.TotalPromptTokens, + &i.TotalCompletionTokens, + &i.TotalCost, + &i.TotalMessages, + &i.AvgTokensPerSession, + &i.AvgMessagesPerSession, + ) + return i, err +} + +const getUsageByDay = `-- name: GetUsageByDay :many +SELECT + date(created_at, 'unixepoch') as day, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as cost, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY date(created_at, 'unixepoch') +ORDER BY day DESC +` + +type GetUsageByDayRow struct { + Day interface{} `json:"day"` + PromptTokens sql.NullFloat64 `json:"prompt_tokens"` + CompletionTokens sql.NullFloat64 `json:"completion_tokens"` + Cost sql.NullFloat64 `json:"cost"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) { + rows, err := q.query(ctx, q.getUsageByDayStmt, getUsageByDay) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByDayRow{} + for rows.Next() { + var i GetUsageByDayRow + if err := rows.Scan( + &i.Day, + &i.PromptTokens, + &i.CompletionTokens, + &i.Cost, + &i.SessionCount, + ); 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 getUsageByDayOfWeek = `-- name: GetUsageByDayOfWeek :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + COUNT(*) as session_count, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week +ORDER BY day_of_week +` + +type GetUsageByDayOfWeekRow struct { + DayOfWeek int64 `json:"day_of_week"` + SessionCount int64 `json:"session_count"` + PromptTokens sql.NullFloat64 `json:"prompt_tokens"` + CompletionTokens sql.NullFloat64 `json:"completion_tokens"` +} + +func (q *Queries) GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) { + rows, err := q.query(ctx, q.getUsageByDayOfWeekStmt, getUsageByDayOfWeek) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByDayOfWeekRow{} + for rows.Next() { + var i GetUsageByDayOfWeekRow + if err := rows.Scan( + &i.DayOfWeek, + &i.SessionCount, + &i.PromptTokens, + &i.CompletionTokens, + ); 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 getUsageByHour = `-- name: GetUsageByHour :many +SELECT + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY hour +ORDER BY hour +` + +type GetUsageByHourRow struct { + Hour int64 `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) { + rows, err := q.query(ctx, q.getUsageByHourStmt, getUsageByHour) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByHourRow{} + for rows.Next() { + var i GetUsageByHourRow + if err := rows.Scan(&i.Hour, &i.SessionCount); 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 getUsageByModel = `-- name: GetUsageByModel :many +SELECT + COALESCE(model, 'unknown') as model, + COALESCE(provider, 'unknown') as provider, + COUNT(*) as message_count +FROM messages +WHERE role = 'assistant' +GROUP BY model, provider +ORDER BY message_count DESC +` + +type GetUsageByModelRow struct { + Model string `json:"model"` + Provider string `json:"provider"` + MessageCount int64 `json:"message_count"` +} + +func (q *Queries) GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) { + rows, err := q.query(ctx, q.getUsageByModelStmt, getUsageByModel) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByModelRow{} + for rows.Next() { + var i GetUsageByModelRow + if err := rows.Scan(&i.Model, &i.Provider, &i.MessageCount); 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 +} diff --git a/internal/env/env.go b/internal/env/env.go index fa7546f8ebd28ed0c44528f7d9b8ae4b03db8b89..2fe19cc20bdd8771cff98da3dd24d7b25c3cba1e 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -2,7 +2,6 @@ package env import ( "os" - "testing" ) type Env interface { @@ -18,17 +17,10 @@ func (o *osEnv) Get(key string) string { } func (o *osEnv) Env() []string { - env := os.Environ() - if len(env) == 0 { - return nil - } - return env + return os.Environ() } func New() Env { - if testing.Testing() { - return NewFromMap(nil) - } return &osEnv{} } @@ -46,9 +38,6 @@ func (m *mapEnv) Get(key string) string { // Env implements Env. func (m *mapEnv) Env() []string { - if len(m.m) == 0 { - return nil - } env := make([]string, 0, len(m.m)) for k, v := range m.m { env = append(env, k+"="+v) diff --git a/internal/env/env_test.go b/internal/env/env_test.go index a86b8f4140a57d564797f260b24262472c9ad058..e8e19cf8b6356e19e4f61cb3bfb2eef7b93ad9fc 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -90,13 +90,15 @@ func TestMapEnv_Env(t *testing.T) { t.Run("empty map", func(t *testing.T) { env := NewFromMap(map[string]string{}) envVars := env.Env() - require.Nil(t, envVars) + require.NotNil(t, envVars) + require.Len(t, envVars, 0) }) t.Run("nil map", func(t *testing.T) { env := NewFromMap(nil) envVars := env.Env() - require.Nil(t, envVars) + require.NotNil(t, envVars) + require.Len(t, envVars, 0) }) } 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/format/spinner.go b/internal/format/spinner.go index 53d48dbb2831df8b6145f762884ee506c2f4ce0a..0a66ce8d886065aecada4999cabafea2f051a2b4 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -7,7 +7,7 @@ import ( "os" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/x/ansi" ) @@ -22,8 +22,8 @@ type model struct { anim *anim.Anim } -func (m model) Init() tea.Cmd { return m.anim.Init() } -func (m model) View() tea.View { return tea.NewView(m.anim.View()) } +func (m model) Init() tea.Cmd { return m.anim.Start() } +func (m model) View() tea.View { return tea.NewView(m.anim.Render()) } // Update implements tea.Model. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -34,10 +34,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancel() return m, tea.Quit } + case anim.StepMsg: + cmd := m.anim.Animate(msg) + return m, cmd } - mm, cmd := m.anim.Update(msg) - m.anim = mm.(*anim.Anim) - return m, cmd + return m, nil } // NewSpinner creates a new spinner with the given message 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 79220cc1f315fec30a1bee2aa0dcd106bc311a02..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,12 +36,19 @@ 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 // Configuration for this LSP client config config.LSPConfig + // Original context and resolver for recreating the client + ctx context.Context + resolver config.VariableResolver + // Diagnostic change callback onDiagnosticsChanged func(name string, count int) @@ -59,58 +68,22 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) { - // Convert working directory to file URI - workDir, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("failed to get working directory: %w", err) - } - - rootURI := string(protocol.URIFromPath(workDir)) - - command, err := resolver.ResolveValue(config.Command) - if err != nil { - return nil, fmt.Errorf("invalid lsp command: %w", err) - } - - // Create powernap client config - clientConfig := powernap.ClientConfig{ - Command: home.Long(command), - Args: config.Args, - RootURI: rootURI, - Environment: func() map[string]string { - env := make(map[string]string) - maps.Copy(env, config.Env) - return env - }(), - Settings: config.Options, - InitOptions: config.InitOptions, - WorkspaceFolders: []protocol.WorkspaceFolder{ - { - URI: rootURI, - Name: filepath.Base(workDir), - }, - }, - } - - // Create the powernap client - powernapClient, err := powernap.NewClient(clientConfig) - if err != nil { - return nil, fmt.Errorf("failed to create lsp client: %w", err) - } - +func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) { client := &Client{ - client: powernapClient, name: name, - fileTypes: config.FileTypes, + fileTypes: cfg.FileTypes, diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](), openFiles: csync.NewMap[string, *OpenFileInfo](), - config: config, + config: cfg, + ctx: ctx, + resolver: resolver, } - - // Initialize server state client.serverState.Store(StateStarting) + if err := client.createPowernapClient(); err != nil { + return nil, err + } + return client, nil } @@ -140,13 +113,7 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol Capabilities: protocolCaps, } - c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) - c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) - c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) - c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) - c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { - HandleDiagnostics(c, params) - }) + c.registerHandlers() return result, nil } @@ -163,6 +130,103 @@ func (c *Client) Close(ctx context.Context) error { return c.client.Exit() } +// createPowernapClient creates a new powernap client with the current configuration. +func (c *Client) createPowernapClient() error { + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + rootURI := string(protocol.URIFromPath(workDir)) + c.workDir = workDir + + command, err := c.resolver.ResolveValue(c.config.Command) + if err != nil { + return fmt.Errorf("invalid lsp command: %w", err) + } + + clientConfig := powernap.ClientConfig{ + Command: home.Long(command), + Args: c.config.Args, + RootURI: rootURI, + Environment: maps.Clone(c.config.Env), + Settings: c.config.Options, + InitOptions: c.config.InitOptions, + WorkspaceFolders: []protocol.WorkspaceFolder{ + { + URI: rootURI, + Name: filepath.Base(workDir), + }, + }, + } + + powernapClient, err := powernap.NewClient(clientConfig) + if err != nil { + return fmt.Errorf("failed to create lsp client: %w", err) + } + + c.client = powernapClient + return nil +} + +// registerHandlers registers the standard LSP notification and request handlers. +func (c *Client) registerHandlers() { + c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) + c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) + c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) + c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) + c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { + HandleDiagnostics(c, params) + }) +} + +// Restart closes the current LSP client and creates a new one with the same configuration. +func (c *Client) Restart() error { + var openFiles []string + for uri := range c.openFiles.Seq2() { + openFiles = append(openFiles, string(uri)) + } + + closeCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second) + defer cancel() + + if err := c.Close(closeCtx); err != nil { + slog.Warn("Error closing client during restart", "name", c.name, "error", err) + } + + c.diagCountsCache = DiagnosticCounts{} + c.diagCountsVersion = 0 + + if err := c.createPowernapClient(); err != nil { + return err + } + + initCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() + + c.SetServerState(StateStarting) + + if err := c.client.Initialize(initCtx, false); err != nil { + c.SetServerState(StateError) + return fmt.Errorf("failed to initialize lsp client: %w", err) + } + + c.registerHandlers() + + if err := c.WaitForServerReady(initCtx); err != nil { + slog.Error("Server failed to become ready after restart", "name", c.name, "error", err) + c.SetServerState(StateError) + return err + } + + for _, uri := range openFiles { + if err := c.OpenFile(initCtx, uri); err != nil { + slog.Warn("Failed to reopen file after restart", "file", uri, "error", err) + } + } + return nil +} + // ServerState represents the state of an LSP server type ServerState int @@ -247,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 } @@ -288,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 } @@ -345,7 +423,7 @@ func (c *Client) CloseAllFiles(ctx context.Context) { slog.Debug("Closing file", "file", uri) } if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil { - slog.Warn("Error closing rile", "uri", uri, "error", err) + slog.Warn("Error closing file", "uri", uri, "error", err) continue } c.openFiles.Del(uri) @@ -499,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/attachment.go b/internal/message/attachment.go index b04863f39cc5b266662395344d5227cfa12f4188..c3c04aaea237e9ad060a8687c123a82643edba24 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -15,7 +15,7 @@ type Attachment struct { func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } -// ContainsTextAttachment returns true if any of the attachments is a text attachments. +// ContainsTextAttachment returns true if any of the attachments is a text attachment. func ContainsTextAttachment(attachments []Attachment) bool { return slices.ContainsFunc(attachments, func(a Attachment) bool { return a.IsText() 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 a09d0acbf590e840541a7d5e057fb89513cc0618..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 } @@ -63,7 +65,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes Reason: "stop", }) } - partsJSON, err := marshallParts(params.Parts) + partsJSON, err := marshalParts(params.Parts) if err != nil { return Message{}, err } @@ -110,7 +112,7 @@ func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) e } func (s *service) Update(ctx context.Context, message Message) error { - parts, err := marshallParts(message.Parts) + parts, err := marshalParts(message.Parts) if err != nil { return err } @@ -157,8 +159,38 @@ 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 := unmarshallParts([]byte(item.Parts)) + parts, err := unmarshalParts([]byte(item.Parts)) if err != nil { return Message{}, err } @@ -192,7 +224,7 @@ type partWrapper struct { Data ContentPart `json:"data"` } -func marshallParts(parts []ContentPart) ([]byte, error) { +func marshalParts(parts []ContentPart) ([]byte, error) { wrappedParts := make([]partWrapper, len(parts)) for i, part := range parts { @@ -225,7 +257,7 @@ func marshallParts(parts []ContentPart) ([]byte, error) { return json.Marshal(wrappedParts) } -func unmarshallParts(data []byte) ([]ContentPart, error) { +func unmarshalParts(data []byte) ([]ContentPart, error) { temp := []json.RawMessage{} if err := json.Unmarshal(data, &temp); err != nil { diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 2209e26ea924535598982c5158900b5a93dc0a21..fc47b7dc93869a1b0a39d30ddb0e408ce479429f 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -152,6 +152,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe s.autoApproveSessionsMu.RUnlock() if autoApprove { + s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ + ToolCallID: opts.ToolCallID, + Granted: true, + }) return true, nil } @@ -183,6 +187,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe for _, p := range s.sessionPermissions { if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { s.sessionPermissionsMu.RUnlock() + s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ + ToolCallID: opts.ToolCallID, + Granted: true, + }) return true, nil } } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index ed14cbfed6c8fd44355501e16457e0dd92a494bc..2faf7f89b7c982950bfc69801a7901526e37eec4 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -20,13 +20,11 @@ func NewBroker[T any]() *Broker[T] { } func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] { - b := &Broker[T]{ + return &Broker[T]{ subs: make(map[chan Event[T]]struct{}), done: make(chan struct{}), - subCount: 0, maxEvents: maxEvents, } - return b } func (b *Broker[T]) Shutdown() { diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 016cc10c9f8a51039ce9eeda6210f5f59bdc1e6c..827158d52fd671aeda828c0383fce98850e27fc7 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -26,10 +26,3 @@ type ( Publish(EventType, T) } ) - -// UpdateAvailableMsg is sent when a new version is available. -type UpdateAvailableMsg struct { - CurrentVersion string - LatestVersion string - IsDevelopment bool -} diff --git a/internal/session/session.go b/internal/session/session.go index 3792cc1d576cdd7ebd0dbf0b64670c746718da9c..0ef6cfe22bebbf35df48f0db1fbe00c6d128251b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -61,7 +61,8 @@ type Service interface { type service struct { *pubsub.Broker[Session] - q db.Querier + db *sql.DB + q *db.Queries } func (s *service) Create(ctx context.Context, title string) (Session, error) { @@ -107,14 +108,32 @@ func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string } func (s *service) Delete(ctx context.Context, id string) error { - session, err := s.Get(ctx, id) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return err + return fmt.Errorf("beginning transaction: %w", err) } - err = s.q.DeleteSession(ctx, session.ID) + defer tx.Rollback() //nolint:errcheck + + qtx := s.q.WithTx(tx) + + dbSession, err := qtx.GetSessionByID(ctx, id) if err != nil { return err } + if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session messages: %w", err) + } + if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session files: %w", err) + } + if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + + session := s.fromDBItem(dbSession) s.Publish(pubsub.DeletedEvent, session) event.SessionDeleted() return nil @@ -184,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, @@ -223,11 +242,12 @@ func unmarshalTodos(data string) ([]Todo, error) { return todos, nil } -func NewService(q db.Querier) Service { +func NewService(q *db.Queries, conn *sql.DB) Service { broker := pubsub.NewBroker[Session]() return &service{ - broker, - q, + Broker: broker, + db: conn, + q: q, } } diff --git a/internal/shell/shell.go b/internal/shell/shell.go index e5a54f01c403ae1b8de681616c5d693bc842ac14..ced8da26ed4e837b08e66152e9aafb2cc029c0d1 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -227,7 +227,7 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand for _, blockFunc := range s.blockFuncs { if blockFunc(args) { - return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " ")) + return fmt.Errorf("command is not allowed for security reasons: %q", args[0]) } } diff --git a/internal/stringext/string.go b/internal/stringext/string.go index 9383ce1d78b8f0a776fc533526ee961d0123d734..8be28ccc2096c3d54b9f3106ed30d584503acdf4 100644 --- a/internal/stringext/string.go +++ b/internal/stringext/string.go @@ -11,11 +11,12 @@ func Capitalize(text string) string { return cases.Title(language.English, cases.Compact).String(text) } -func ContainsAny(str string, args ...string) bool { - for _, arg := range args { - if strings.Contains(str, arg) { - return true - } - } - return false +// 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/chat.go b/internal/tui/components/chat/chat.go deleted file mode 100644 index 036c8262d2b0d8419bf89b64afd922767b6be12a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/chat.go +++ /dev/null @@ -1,782 +0,0 @@ -package chat - -import ( - "context" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat/messages" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type SendMsg struct { - Text string - Attachments []message.Attachment -} - -type SessionSelectedMsg = session.Session - -type SessionClearedMsg struct{} - -type SelectionCopyMsg struct { - clickCount int - endSelection bool - x, y int -} - -const ( - NotFound = -1 -) - -// MessageListCmp represents a component that displays a list of chat messages -// with support for real-time updates and session management. -type MessageListCmp interface { - util.Model - layout.Sizeable - layout.Focusable - layout.Help - - SetSession(session.Session) tea.Cmd - GoToBottom() tea.Cmd - GetSelectedText() string - CopySelectedText(bool) tea.Cmd -} - -// messageListCmp implements MessageListCmp, providing a virtualized list -// of chat messages with support for tool calls, real-time updates, and -// session switching. -type messageListCmp struct { - app *app.App - width, height int - session session.Session - listCmp list.List[list.Item] - previousSelected string // Last selected item index for restoring focus - - lastUserMessageTime int64 - defaultListKeyMap list.KeyMap - - // Click tracking for double/triple click detection - lastClickTime time.Time - lastClickX int - lastClickY int - clickCount int -} - -// New creates a new message list component with custom keybindings -// and reverse ordering (newest messages at bottom). -func New(app *app.App) MessageListCmp { - defaultListKeyMap := list.DefaultKeyMap() - listCmp := list.New( - []list.Item{}, - list.WithGap(1), - list.WithDirectionBackward(), - list.WithFocus(false), - list.WithKeyMap(defaultListKeyMap), - list.WithEnableMouse(), - ) - return &messageListCmp{ - app: app, - listCmp: listCmp, - previousSelected: "", - defaultListKeyMap: defaultListKeyMap, - } -} - -// Init initializes the component. -func (m *messageListCmp) Init() tea.Cmd { - return m.listCmp.Init() -} - -// Update handles incoming messages and updates the component state. -func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyPressMsg: - if m.listCmp.IsFocused() && m.listCmp.HasSelection() { - switch { - case key.Matches(msg, messages.CopyKey): - cmds = append(cmds, m.CopySelectedText(true)) - return m, tea.Batch(cmds...) - case key.Matches(msg, messages.ClearSelectionKey): - cmds = append(cmds, m.SelectionClear()) - return m, tea.Batch(cmds...) - } - } - case tea.MouseClickMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - return m, nil // Ignore clicks outside the component - } - if msg.Button == tea.MouseLeft { - cmds = append(cmds, m.handleMouseClick(x, y)) - return m, tea.Batch(cmds...) - } - return m, tea.Batch(cmds...) - case tea.MouseMotionMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - if y < 0 { - cmds = append(cmds, m.listCmp.MoveUp(1)) - return m, tea.Batch(cmds...) - } - if y >= m.height-1 { - cmds = append(cmds, m.listCmp.MoveDown(1)) - return m, tea.Batch(cmds...) - } - return m, nil // Ignore clicks outside the component - } - if msg.Button == tea.MouseLeft { - m.listCmp.EndSelection(x, y) - } - return m, tea.Batch(cmds...) - case tea.MouseReleaseMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if msg.Button == tea.MouseLeft { - clickCount := m.clickCount - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { - return SelectionCopyMsg{ - clickCount: clickCount, - endSelection: false, - } - }) - - cmds = append(cmds, tick) - return m, tea.Batch(cmds...) - } - tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { - return SelectionCopyMsg{ - clickCount: clickCount, - endSelection: true, - x: x, - y: y, - } - }) - cmds = append(cmds, tick) - return m, tea.Batch(cmds...) - } - return m, nil - case SelectionCopyMsg: - if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold { - // If the click count matches and within threshold, copy selected text - if msg.endSelection { - m.listCmp.EndSelection(msg.x, msg.y) - } - m.listCmp.SelectionStop() - cmds = append(cmds, m.CopySelectedText(true)) - return m, tea.Batch(cmds...) - } - case pubsub.Event[permission.PermissionNotification]: - cmds = append(cmds, m.handlePermissionRequest(msg.Payload)) - return m, tea.Batch(cmds...) - case SessionSelectedMsg: - if msg.ID != m.session.ID { - cmds = append(cmds, m.SetSession(msg)) - } - return m, tea.Batch(cmds...) - case SessionClearedMsg: - m.session = session.Session{} - cmds = append(cmds, m.listCmp.SetItems([]list.Item{})) - return m, tea.Batch(cmds...) - - case pubsub.Event[message.Message]: - cmds = append(cmds, m.handleMessageEvent(msg)) - return m, tea.Batch(cmds...) - - case tea.MouseWheelMsg: - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -// View renders the message list or an initial screen if empty. -func (m *messageListCmp) View() string { - t := styles.CurrentTheme() - return t.S().Base. - Padding(1, 1, 0, 1). - Width(m.width). - Height(m.height). - Render(m.listCmp.View()) -} - -func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd { - items := m.listCmp.Items() - if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound { - toolCall := items[toolCallIndex].(messages.ToolCallCmp) - toolCall.SetPermissionRequested() - if permission.Granted { - toolCall.SetPermissionGranted() - } - m.listCmp.UpdateItem(toolCall.ID(), toolCall) - } - return nil -} - -// handleChildSession handles messages from child sessions (agent tools). -func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd { - var cmds []tea.Cmd - if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { - return nil - } - - // Check if this is an agent tool session and parse it - childSessionID := event.Payload.SessionID - parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID) - if !ok { - return nil - } - items := m.listCmp.Items() - toolCallInx := NotFound - var toolCall messages.ToolCallCmp - for i := len(items) - 1; i >= 0; i-- { - if msg, ok := items[i].(messages.ToolCallCmp); ok { - if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID { - toolCallInx = i - toolCall = msg - } - } - } - if toolCallInx == NotFound { - return nil - } - nestedToolCalls := toolCall.GetNestedToolCalls() - for _, tc := range event.Payload.ToolCalls() { - found := false - for existingInx, existingTC := range nestedToolCalls { - if existingTC.GetToolCall().ID == tc.ID { - nestedToolCalls[existingInx].SetToolCall(tc) - found = true - break - } - } - if !found { - nestedCall := messages.NewToolCallCmp( - event.Payload.ID, - tc, - m.app.Permissions, - messages.WithToolCallNested(true), - ) - cmds = append(cmds, nestedCall.Init()) - nestedToolCalls = append( - nestedToolCalls, - nestedCall, - ) - } - } - for _, tr := range event.Payload.ToolResults() { - for nestedInx, nestedTC := range nestedToolCalls { - if nestedTC.GetToolCall().ID == tr.ToolCallID { - nestedToolCalls[nestedInx].SetToolResult(tr) - break - } - } - } - - toolCall.SetNestedToolCalls(nestedToolCalls) - m.listCmp.UpdateItem( - toolCall.ID(), - toolCall, - ) - return tea.Batch(cmds...) -} - -// handleMessageEvent processes different types of message events (created/updated). -func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd { - switch event.Type { - case pubsub.CreatedEvent: - if event.Payload.SessionID != m.session.ID { - return m.handleChildSession(event) - } - if m.messageExists(event.Payload.ID) { - return nil - } - return m.handleNewMessage(event.Payload) - case pubsub.DeletedEvent: - if event.Payload.SessionID != m.session.ID { - return nil - } - return m.handleDeleteMessage(event.Payload) - case pubsub.UpdatedEvent: - if event.Payload.SessionID != m.session.ID { - return m.handleChildSession(event) - } - switch event.Payload.Role { - case message.Assistant: - return m.handleUpdateAssistantMessage(event.Payload) - case message.Tool: - return m.handleToolMessage(event.Payload) - } - } - return nil -} - -// messageExists checks if a message with the given ID already exists in the list. -func (m *messageListCmp) messageExists(messageID string) bool { - items := m.listCmp.Items() - // Search backwards as new messages are more likely to be at the end - for i := len(items) - 1; i >= 0; i-- { - if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID { - return true - } - } - return false -} - -// handleDeleteMessage removes a message from the list. -func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd { - items := m.listCmp.Items() - for i := len(items) - 1; i >= 0; i-- { - if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID { - m.listCmp.DeleteItem(items[i].ID()) - return nil - } - } - return nil -} - -// handleNewMessage routes new messages to appropriate handlers based on role. -func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd { - switch msg.Role { - case message.User: - return m.handleNewUserMessage(msg) - case message.Assistant: - return m.handleNewAssistantMessage(msg) - case message.Tool: - return m.handleToolMessage(msg) - } - return nil -} - -// handleNewUserMessage adds a new user message to the list and updates the timestamp. -func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd { - m.lastUserMessageTime = msg.CreatedAt - return m.listCmp.AppendItem(messages.NewMessageCmp(msg)) -} - -// handleToolMessage updates existing tool calls with their results. -func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { - items := m.listCmp.Items() - for _, tr := range msg.ToolResults() { - if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound { - toolCall := items[toolCallIndex].(messages.ToolCallCmp) - toolCall.SetToolResult(tr) - m.listCmp.UpdateItem(toolCall.ID(), toolCall) - } - } - return nil -} - -// findToolCallByID searches for a tool call with the specified ID. -// Returns the index if found, NotFound otherwise. -func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int { - // Search backwards as tool calls are more likely to be recent - for i := len(items) - 1; i >= 0; i-- { - if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID { - return i - } - } - return NotFound -} - -// handleUpdateAssistantMessage processes updates to assistant messages, -// managing both message content and associated tool calls. -func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd { - var cmds []tea.Cmd - items := m.listCmp.Items() - - // Find existing assistant message and tool calls for this message - assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID) - - // Handle assistant message content - if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil { - cmds = append(cmds, cmd) - } - - // Handle tool calls - if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil { - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// findAssistantMessageAndToolCalls locates the assistant message and its tool calls. -func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) { - assistantIndex := NotFound - toolCalls := make(map[int]messages.ToolCallCmp) - - // Search backwards as messages are more likely to be at the end - for i := len(items) - 1; i >= 0; i-- { - item := items[i] - if asMsg, ok := item.(messages.MessageCmp); ok { - if asMsg.GetMessage().ID == messageID { - assistantIndex = i - } - } else if tc, ok := item.(messages.ToolCallCmp); ok { - if tc.ParentMessageID() == messageID { - toolCalls[i] = tc - } - } - } - - return assistantIndex, toolCalls -} - -// updateAssistantMessageContent updates or removes the assistant message based on content. -func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd { - if assistantIndex == NotFound { - return nil - } - - shouldShowMessage := m.shouldShowAssistantMessage(msg) - hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == "" - - var cmd tea.Cmd - if shouldShowMessage { - items := m.listCmp.Items() - uiMsg := items[assistantIndex].(messages.MessageCmp) - uiMsg.SetMessage(msg) - m.listCmp.UpdateItem( - items[assistantIndex].ID(), - uiMsg, - ) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - m.listCmp.AppendItem( - messages.NewAssistantSection( - msg, - time.Unix(m.lastUserMessageTime, 0), - ), - ) - } - } else if hasToolCallsOnly { - items := m.listCmp.Items() - m.listCmp.DeleteItem(items[assistantIndex].ID()) - } - - return cmd -} - -// shouldShowAssistantMessage determines if an assistant message should be displayed. -func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool { - return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking() -} - -// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones. -func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { - var cmds []tea.Cmd - - for _, tc := range msg.ToolCalls() { - if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil { - cmds = append(cmds, cmd) - } - } - - return tea.Batch(cmds...) -} - -// updateOrAddToolCall updates an existing tool call or adds a new one. -func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { - // Try to find existing tool call - for _, existingTC := range existingToolCalls { - if tc.ID == existingTC.GetToolCall().ID { - existingTC.SetToolCall(tc) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { - existingTC.SetCancelled() - } - m.listCmp.UpdateItem(tc.ID, existingTC) - return nil - } - } - - // Add new tool call if not found - return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions)) -} - -// handleNewAssistantMessage processes new assistant messages and their tool calls. -func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd { - var cmds []tea.Cmd - - // Add assistant message if it should be displayed - if m.shouldShowAssistantMessage(msg) { - cmd := m.listCmp.AppendItem( - messages.NewMessageCmp( - msg, - ), - ) - cmds = append(cmds, cmd) - } - - // Add tool calls - for _, tc := range msg.ToolCalls() { - cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions)) - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// SetSession loads and displays messages for a new session. -func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { - if m.session.ID == session.ID { - return nil - } - - m.session = session - sessionMessages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - - if len(sessionMessages) == 0 { - return m.listCmp.SetItems([]list.Item{}) - } - - // Initialize with first message timestamp - m.lastUserMessageTime = sessionMessages[0].CreatedAt - - // Build tool result map for efficient lookup - toolResultMap := m.buildToolResultMap(sessionMessages) - - // Convert messages to UI components - uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap) - - return m.listCmp.SetItems(uiMessages) -} - -// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup. -func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult { - toolResultMap := make(map[string]message.ToolResult) - for _, msg := range messages { - for _, tr := range msg.ToolResults() { - toolResultMap[tr.ToolCallID] = tr - } - } - return toolResultMap -} - -// convertMessagesToUI converts database messages to UI components. -func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item { - uiMessages := make([]list.Item, 0) - - for _, msg := range sessionMessages { - switch msg.Role { - case message.User: - m.lastUserMessageTime = msg.CreatedAt - uiMessages = append(uiMessages, messages.NewMessageCmp(msg)) - case message.Assistant: - uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0))) - } - } - } - - return uiMessages -} - -// convertAssistantMessage converts an assistant message and its tool calls to UI components. -func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item { - var uiMessages []list.Item - - // Add assistant message if it should be displayed - if m.shouldShowAssistantMessage(msg) { - uiMessages = append( - uiMessages, - messages.NewMessageCmp( - msg, - ), - ) - } - - // Add tool calls with their results and status - for _, tc := range msg.ToolCalls() { - options := m.buildToolCallOptions(tc, msg, toolResultMap) - uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...)) - // If this tool call is the agent tool or agentic fetch, fetch nested tool calls - if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { - agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID) - nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID) - nestedToolResultMap := m.buildToolResultMap(nestedMessages) - nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap) - nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages)) - for _, nestedMsg := range nestedUIMessages { - if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok { - toolCall.SetIsNested(true) - nestedToolCalls = append(nestedToolCalls, toolCall) - } - } - uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls) - } - } - - return uiMessages -} - -// buildToolCallOptions creates options for tool call components based on results and status. -func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption { - var options []messages.ToolCallOption - - // Add tool result if available - if tr, ok := toolResultMap[tc.ID]; ok { - options = append(options, messages.WithToolCallResult(tr)) - } - - // Add cancelled status if applicable - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { - options = append(options, messages.WithToolCallCancelled()) - } - - return options -} - -// GetSize returns the current width and height of the component. -func (m *messageListCmp) GetSize() (int, int) { - return m.width, m.height -} - -// SetSize updates the component dimensions and propagates to the list component. -func (m *messageListCmp) SetSize(width int, height int) tea.Cmd { - m.width = width - m.height = height - return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding -} - -// Blur implements MessageListCmp. -func (m *messageListCmp) Blur() tea.Cmd { - return m.listCmp.Blur() -} - -// Focus implements MessageListCmp. -func (m *messageListCmp) Focus() tea.Cmd { - return m.listCmp.Focus() -} - -// IsFocused implements MessageListCmp. -func (m *messageListCmp) IsFocused() bool { - return m.listCmp.IsFocused() -} - -func (m *messageListCmp) Bindings() []key.Binding { - return m.defaultListKeyMap.KeyBindings() -} - -func (m *messageListCmp) GoToBottom() tea.Cmd { - return m.listCmp.GoToBottom() -} - -const ( - doubleClickThreshold = 500 * time.Millisecond - clickTolerance = 2 // pixels -) - -// handleMouseClick handles mouse click events and detects double/triple clicks. -func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { - now := time.Now() - - // Check if this is a potential multi-click - 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 - - switch m.clickCount { - case 1: - // Single click - start selection - m.listCmp.StartSelection(x, y) - case 2: - // Double click - select word - m.listCmp.SelectWord(x, y) - case 3: - // Triple click - select paragraph - m.listCmp.SelectParagraph(x, y) - m.clickCount = 0 // Reset after triple click - } - - return nil -} - -// SelectionClear clears the current selection in the list component. -func (m *messageListCmp) SelectionClear() tea.Cmd { - m.listCmp.SelectionClear() - m.previousSelected = "" - m.lastClickX, m.lastClickY = 0, 0 - m.lastClickTime = time.Time{} - m.clickCount = 0 - return nil -} - -// HasSelection checks if there is a selection in the list component. -func (m *messageListCmp) HasSelection() bool { - return m.listCmp.HasSelection() -} - -// GetSelectedText returns the currently selected text from the list component. -func (m *messageListCmp) GetSelectedText() string { - return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding -} - -// CopySelectedText copies the currently selected text to the clipboard. When -// clear is true, it clears the selection after copying. -func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd { - if !m.listCmp.HasSelection() { - return nil - } - - selectedText := m.GetSelectedText() - if selectedText == "" { - return util.ReportInfo("No text selected") - } - - cmds := []tea.Cmd{ - // We use both OSC 52 and native clipboard for compatibility with different - // terminal emulators and environments. - tea.SetClipboard(selectedText), - func() tea.Msg { - _ = clipboard.WriteAll(selectedText) - return nil - }, - util.ReportInfo("Selected text copied to clipboard"), - } - if clear { - cmds = append(cmds, m.SelectionClear()) - } - - return tea.Sequence(cmds...) -} - -// abs returns the absolute value of an integer. -func abs(x int) int { - if x < 0 { - return -x - } - return x -} diff --git a/internal/tui/components/chat/editor/clipboard.go b/internal/tui/components/chat/editor/clipboard.go deleted file mode 100644 index de4b95da3cab6069bf31f61b5fb9e2908f970c07..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard.go +++ /dev/null @@ -1,8 +0,0 @@ -package editor - -type clipboardFormat int - -const ( - clipboardFormatText clipboardFormat = iota - clipboardFormatImage -) diff --git a/internal/tui/components/chat/editor/clipboard_not_supported.go b/internal/tui/components/chat/editor/clipboard_not_supported.go deleted file mode 100644 index dfecc09dca05ca5d07dd1db109fe3178f6c357b8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_not_supported.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !(darwin || linux || windows) || arm || 386 || ios || android - -package editor - -func readClipboard(clipboardFormat) ([]byte, error) { - return nil, errClipboardPlatformUnsupported -} diff --git a/internal/tui/components/chat/editor/clipboard_supported.go b/internal/tui/components/chat/editor/clipboard_supported.go deleted file mode 100644 index 175a4b4ea4dfaea03916dc1012c313201f1846f8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_supported.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android - -package editor - -import "github.com/aymanbagabas/go-nativeclipboard" - -func readClipboard(f clipboardFormat) ([]byte, error) { - switch f { - case clipboardFormatText: - return nativeclipboard.Text.Read() - case clipboardFormatImage: - return nativeclipboard.Image.Read() - } - return nil, errClipboardUnknownFormat -} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go deleted file mode 100644 index 8f7c43c76a965539db3c3d6de4f46377c8a10a5c..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/editor.go +++ /dev/null @@ -1,763 +0,0 @@ -package editor - -import ( - "fmt" - "math/rand" - "net/http" - "os" - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" - "unicode" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - 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" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/editor" -) - -var ( - errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform") - errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format") -) - -type Editor interface { - util.Model - layout.Sizeable - layout.Focusable - layout.Help - layout.Positional - - SetSession(session session.Session) tea.Cmd - IsCompletionsOpen() bool - HasAttachments() bool - IsEmpty() bool - Cursor() *tea.Cursor -} - -type FileCompletionItem struct { - Path string // The file path -} - -type editorCmp struct { - width int - height int - x, y int - app *app.App - session session.Session - textarea textarea.Model - attachments []message.Attachment - deleteMode bool - readyPlaceholder string - workingPlaceholder string - - keyMap EditorKeyMap - - // File path completions - currentQuery string - completionsStartIndex int - isCompletionsOpen bool -} - -var DeleteKeyMaps = DeleteAttachmentKeyMaps{ - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), -} - -const maxFileResults = 25 - -type OpenEditorMsg struct { - Text string -} - -func (m *editorCmp) openEditor(value string) tea.Cmd { - tmpfile, err := os.CreateTemp("", "msg_*.md") - if err != nil { - return util.ReportError(err) - } - defer tmpfile.Close() //nolint:errcheck - if _, err := tmpfile.WriteString(value); err != nil { - return util.ReportError(err) - } - cmd, err := editor.Command( - "crush", - tmpfile.Name(), - editor.AtPosition( - m.textarea.Line()+1, - m.textarea.Column()+1, - ), - ) - if err != nil { - return util.ReportError(err) - } - return tea.ExecProcess(cmd, func(err error) tea.Msg { - if err != nil { - return util.ReportError(err) - } - content, err := os.ReadFile(tmpfile.Name()) - if err != nil { - return util.ReportError(err) - } - if len(content) == 0 { - return util.ReportWarn("Message is empty") - } - os.Remove(tmpfile.Name()) - return OpenEditorMsg{ - Text: strings.TrimSpace(string(content)), - } - }) -} - -func (m *editorCmp) Init() tea.Cmd { - return nil -} - -func (m *editorCmp) send() tea.Cmd { - value := m.textarea.Value() - value = strings.TrimSpace(value) - - switch value { - case "exit", "quit": - m.textarea.Reset() - return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()}) - } - - attachments := m.attachments - - if value == "" && !message.ContainsTextAttachment(attachments) { - return nil - } - - m.textarea.Reset() - m.attachments = nil - // Change the placeholder when sending a new message. - m.randomizePlaceholders() - - return tea.Batch( - util.CmdHandler(chat.SendMsg{ - Text: value, - Attachments: attachments, - }), - ) -} - -func (m *editorCmp) repositionCompletions() tea.Msg { - x, y := m.completionsPosition() - return completions.RepositionCompletionsMsg{X: x, Y: y} -} - -func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return m, m.repositionCompletions - case filepicker.FilePickedMsg: - m.attachments = append(m.attachments, msg.Attachment) - return m, nil - case completions.CompletionsOpenedMsg: - m.isCompletionsOpen = true - case completions.CompletionsClosedMsg: - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - case completions.SelectCompletionMsg: - if !m.isCompletionsOpen { - return m, nil - } - if item, ok := msg.Value.(FileCompletionItem); ok { - word := m.textarea.Word() - // If the selected item is a file, insert its path into the textarea - value := m.textarea.Value() - value = value[:m.completionsStartIndex] + // Remove the current query - item.Path + // Insert the file path - value[m.completionsStartIndex+len(word):] // Append the rest of the value - // XXX: This will always move the cursor to the end of the textarea. - m.textarea.SetValue(value) - m.textarea.MoveToEnd() - if !msg.Insert { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - } - absPath, _ := filepath.Abs(item.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(item.Path); err == nil && !info.ModTime().After(lastRead) { - return m, nil - } - } - 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), - MimeType: mimeOf(content), - Content: content, - }) - } - - case commands.OpenExternalEditorMsg: - if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { - return m, util.ReportWarn("Agent is working, please wait...") - } - return m, m.openEditor(m.textarea.Value()) - case OpenEditorMsg: - m.textarea.SetValue(msg.Text) - m.textarea.MoveToEnd() - case tea.PasteMsg: - // If pasted text has more than 2 newlines, treat it as a file attachment. - if strings.Count(msg.Content, "\n") > 2 { - content := []byte(msg.Content) - if len(content) > maxAttachmentSize { - return m, util.ReportWarn("Paste is too big (>5mb)") - } - name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) - mimeType := mimeOf(content) - attachment := message.Attachment{ - FileName: name, - FilePath: name, - MimeType: mimeType, - Content: content, - } - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - - // Try to parse as a file path. - content, path, err := filepathToFile(msg.Content) - if err != nil { - // Not a file path, just update the textarea normally. - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd - } - - if len(content) > maxAttachmentSize { - return m, util.ReportWarn("File is too big (>5mb)") - } - - mimeType := mimeOf(content) - attachment := message.Attachment{ - FilePath: path, - FileName: filepath.Base(path), - MimeType: mimeType, - Content: content, - } - if !attachment.IsText() && !attachment.IsImage() { - return m, util.ReportWarn("Invalid file content type: " + mimeType) - } - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - - case commands.ToggleYoloModeMsg: - m.setEditorPrompt() - return m, nil - case tea.KeyPressMsg: - cur := m.textarea.Cursor() - curIdx := m.textarea.Width()*cur.Y + cur.X - switch { - // Open command palette when "/" is pressed on empty prompt - case msg.String() == "/" && m.IsEmpty(): - return m, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: commands.NewCommandDialog(m.session.ID), - }) - // Completions - case msg.String() == "@" && !m.isCompletionsOpen && - // only show if beginning of prompt, or if previous char is a space or newline: - (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))): - m.isCompletionsOpen = true - m.currentQuery = "" - m.completionsStartIndex = curIdx - cmds = append(cmds, m.startCompletions) - case m.isCompletionsOpen && curIdx <= m.completionsStartIndex: - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { - m.deleteMode = true - return m, nil - } - if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { - m.deleteMode = false - m.attachments = nil - return m, nil - } - rune := msg.Code - if m.deleteMode && unicode.IsDigit(rune) { - num := int(rune - '0') - m.deleteMode = false - if num < 10 && len(m.attachments) > num { - if num == 0 { - m.attachments = m.attachments[num+1:] - } else { - m.attachments = slices.Delete(m.attachments, num, num+1) - } - return m, nil - } - } - if key.Matches(msg, m.keyMap.OpenEditor) { - if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { - return m, util.ReportWarn("Agent is working, please wait...") - } - return m, m.openEditor(m.textarea.Value()) - } - if key.Matches(msg, DeleteKeyMaps.Escape) { - m.deleteMode = false - return m, nil - } - if key.Matches(msg, m.keyMap.Newline) { - m.textarea.InsertRune('\n') - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - // Handle image paste from clipboard - if key.Matches(msg, m.keyMap.PasteImage) { - imageData, err := readClipboard(clipboardFormatImage) - - if err != nil || len(imageData) == 0 { - // If no image data found, try to get text data (could be file path) - var textData []byte - textData, err = readClipboard(clipboardFormatText) - if err != nil || len(textData) == 0 { - // If clipboard is empty, show a warning - return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.") - } - - // Check if the text data is a file path - textStr := string(textData) - // First, try to interpret as a file path (existing functionality) - path := strings.ReplaceAll(textStr, "\\ ", " ") - path, err = filepath.Abs(strings.TrimSpace(path)) - if err == nil { - isAllowedType := false - for _, ext := range filepicker.AllowedTypes { - if strings.HasSuffix(path, ext) { - isAllowedType = true - break - } - } - if isAllowedType { - tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) - if !tooBig { - content, err := os.ReadFile(path) - if err == nil { - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(path) - attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - } - } - } - - // If not a valid file path, show a warning - return m, util.ReportWarn("No image found in clipboard") - } else { - // We have image data from the clipboard - // Create a temporary file to store the clipboard image data - tempFile, err := os.CreateTemp("", "clipboard_image_crush_*") - if err != nil { - return m, util.ReportError(err) - } - defer tempFile.Close() - - // Write clipboard content to the temporary file - _, err = tempFile.Write(imageData) - if err != nil { - return m, util.ReportError(err) - } - - // Determine the file extension based on the image data - mimeBufferSize := min(512, len(imageData)) - mimeType := http.DetectContentType(imageData[:mimeBufferSize]) - - // Create an attachment from the temporary file - fileName := filepath.Base(tempFile.Name()) - attachment := message.Attachment{ - FilePath: tempFile.Name(), - FileName: fileName, - MimeType: mimeType, - Content: imageData, - } - - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - } - // Handle Enter key - if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) { - value := m.textarea.Value() - if strings.HasSuffix(value, "\\") { - // If the last character is a backslash, remove it and add a newline. - m.textarea.SetValue(strings.TrimSuffix(value, "\\")) - } else { - // Otherwise, send the message - return m, m.send() - } - } - } - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - if m.textarea.Focused() { - kp, ok := msg.(tea.KeyPressMsg) - if ok { - if kp.String() == "space" || m.textarea.Value() == "" { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } else { - word := m.textarea.Word() - if strings.HasPrefix(word, "@") { - // XXX: wont' work if editing in the middle of the field. - m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word) - m.currentQuery = word[1:] - x, y := m.completionsPosition() - x -= len(m.currentQuery) - m.isCompletionsOpen = true - cmds = append(cmds, - util.CmdHandler(completions.FilterCompletionsMsg{ - Query: m.currentQuery, - Reopen: m.isCompletionsOpen, - X: x, - Y: y, - }), - ) - } else if m.isCompletionsOpen { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - } - } - } - - return m, tea.Batch(cmds...) -} - -func (m *editorCmp) setEditorPrompt() { - if m.app.Permissions.SkipRequests() { - m.textarea.SetPromptFunc(4, yoloPromptFunc) - return - } - m.textarea.SetPromptFunc(4, normalPromptFunc) -} - -func (m *editorCmp) completionsPosition() (int, int) { - cur := m.textarea.Cursor() - if cur == nil { - return m.x, m.y + 1 // adjust for padding - } - x := cur.X + m.x - y := cur.Y + m.y + 1 // adjust for padding - return x, y -} - -func (m *editorCmp) Cursor() *tea.Cursor { - cursor := m.textarea.Cursor() - if cursor != nil { - cursor.X = cursor.X + m.x + 1 - cursor.Y = cursor.Y + m.y + 1 // adjust for padding - } - return cursor -} - -var readyPlaceholders = [...]string{ - "Ready!", - "Ready...", - "Ready?", - "Ready for instructions", -} - -var workingPlaceholders = [...]string{ - "Working!", - "Working...", - "Brrrrr...", - "Prrrrrrrr...", - "Processing...", - "Thinking...", -} - -func (m *editorCmp) randomizePlaceholders() { - m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] - m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] -} - -func (m *editorCmp) View() string { - t := styles.CurrentTheme() - // Update placeholder - if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder - } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.app.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" - } - if len(m.attachments) == 0 { - return t.S().Base.Padding(1).Render( - m.textarea.View(), - ) - } - return t.S().Base.Padding(0, 1, 1, 1).Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.attachmentsContent(), - m.textarea.View(), - ), - ) -} - -func (m *editorCmp) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - m.textarea.SetWidth(width - 2) // adjust for padding - m.textarea.SetHeight(height - 2) // adjust for padding - return nil -} - -func (m *editorCmp) GetSize() (int, int) { - return m.textarea.Width(), m.textarea.Height() -} - -func (m *editorCmp) attachmentsContent() string { - var styledAttachments []string - t := styles.CurrentTheme() - attachmentStyle := t.S().Base. - Padding(0, 1). - MarginRight(1). - Background(t.FgMuted). - Foreground(t.FgBase). - Render - iconStyle := t.S().Base. - Foreground(t.BgSubtle). - Background(t.Green). - Padding(0, 1). - Bold(true). - Render - rmStyle := t.S().Base. - Padding(0, 1). - Bold(true). - Background(t.Red). - Foreground(t.FgBase). - Render - for i, attachment := range m.attachments { - filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...") - icon := styles.ImageIcon - if attachment.IsText() { - icon = styles.TextIcon - } - if m.deleteMode { - styledAttachments = append( - styledAttachments, - rmStyle(fmt.Sprintf("%d", i)), - attachmentStyle(filename), - ) - continue - } - styledAttachments = append( - styledAttachments, - iconStyle(icon), - attachmentStyle(filename), - ) - } - return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) -} - -func (m *editorCmp) SetPosition(x, y int) tea.Cmd { - m.x = x - m.y = y - return nil -} - -func (m *editorCmp) startCompletions() tea.Msg { - ls := m.app.Config().Options.TUI.Completions - depth, limit := ls.Limits() - files, _, _ := fsext.ListDirectory(".", nil, depth, limit) - slices.Sort(files) - completionItems := make([]completions.Completion, 0, len(files)) - for _, file := range files { - file = strings.TrimPrefix(file, "./") - completionItems = append(completionItems, completions.Completion{ - Title: file, - Value: FileCompletionItem{ - Path: file, - }, - }) - } - - x, y := m.completionsPosition() - return completions.OpenCompletionsMsg{ - Completions: completionItems, - X: x, - Y: y, - MaxResults: maxFileResults, - } -} - -// Blur implements Container. -func (c *editorCmp) Blur() tea.Cmd { - c.textarea.Blur() - return nil -} - -// Focus implements Container. -func (c *editorCmp) Focus() tea.Cmd { - return c.textarea.Focus() -} - -// IsFocused implements Container. -func (c *editorCmp) IsFocused() bool { - return c.textarea.Focused() -} - -// Bindings implements Container. -func (c *editorCmp) Bindings() []key.Binding { - return c.keyMap.KeyBindings() -} - -// TODO: most likely we do not need to have the session here -// we need to move some functionality to the page level -func (c *editorCmp) SetSession(session session.Session) tea.Cmd { - c.session = session - return nil -} - -func (c *editorCmp) IsCompletionsOpen() bool { - return c.isCompletionsOpen -} - -func (c *editorCmp) HasAttachments() bool { - return len(c.attachments) > 0 -} - -func (c *editorCmp) IsEmpty() bool { - return strings.TrimSpace(c.textarea.Value()) == "" -} - -func normalPromptFunc(info textarea.PromptInfo) string { - t := styles.CurrentTheme() - if info.LineNumber == 0 { - if info.Focused { - return " > " - } - return "::: " - } - if info.Focused { - return t.S().Base.Foreground(t.GreenDark).Render("::: ") - } - return t.S().Muted.Render("::: ") -} - -func yoloPromptFunc(info textarea.PromptInfo) string { - t := styles.CurrentTheme() - if info.LineNumber == 0 { - if info.Focused { - return fmt.Sprintf("%s ", t.YoloIconFocused) - } else { - return fmt.Sprintf("%s ", t.YoloIconBlurred) - } - } - if info.Focused { - return fmt.Sprintf("%s ", t.YoloDotsFocused) - } - return fmt.Sprintf("%s ", t.YoloDotsBlurred) -} - -func New(app *app.App) Editor { - t := styles.CurrentTheme() - ta := textarea.New() - ta.SetStyles(t.S().TextArea) - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - e := &editorCmp{ - // TODO: remove the app instance from here - app: app, - textarea: ta, - keyMap: DefaultEditorKeyMap(), - } - e.setEditorPrompt() - - e.randomizePlaceholders() - e.textarea.Placeholder = e.readyPlaceholder - - return e -} - -var maxAttachmentSize = 5 * 1024 * 1024 // 5MB - -var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) - -func (m *editorCmp) pasteIdx() int { - result := 0 - for _, at := range m.attachments { - found := pasteRE.FindStringSubmatch(at.FileName) - if len(found) == 0 { - continue - } - idx, err := strconv.Atoi(found[1]) - if err == nil { - result = max(result, idx) - } - } - return result + 1 -} - -func filepathToFile(name string) ([]byte, string, error) { - path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", ""))) - if err != nil { - return nil, "", err - } - content, err := os.ReadFile(path) - if err != nil { - return nil, "", err - } - return content, path, nil -} - -func mimeOf(content []byte) string { - mimeBufferSize := min(512, len(content)) - return http.DetectContentType(content[:mimeBufferSize]) -} diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go deleted file mode 100644 index c20df5cc1c071deab83754430543b9be2381127c..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/keys.go +++ /dev/null @@ -1,77 +0,0 @@ -package editor - -import ( - "charm.land/bubbles/v2/key" -) - -type EditorKeyMap struct { - AddFile key.Binding - SendMessage key.Binding - OpenEditor key.Binding - Newline key.Binding - PasteImage key.Binding -} - -func DefaultEditorKeyMap() EditorKeyMap { - return EditorKeyMap{ - AddFile: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "add file"), - ), - SendMessage: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "send"), - ), - OpenEditor: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "open editor"), - ), - Newline: key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - // "ctrl+j" is a common keybinding for newline in many editors. If - // the terminal supports "shift+enter", we substitute the help text - // to reflect that. - key.WithHelp("ctrl+j", "newline"), - ), - PasteImage: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste image from clipboard"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k EditorKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.AddFile, - k.SendMessage, - k.OpenEditor, - k.Newline, - k.PasteImage, - AttachmentsKeyMaps.AttachmentDeleteMode, - AttachmentsKeyMaps.DeleteAllAttachments, - AttachmentsKeyMaps.Escape, - } -} - -type DeleteAttachmentKeyMaps struct { - AttachmentDeleteMode key.Binding - Escape key.Binding - DeleteAllAttachments key.Binding -} - -// TODO: update this to use the new keymap concepts -var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), -} diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go deleted file mode 100644 index c8848440b1193fda9a7b5df4b31e03edeaf744c4..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/header/header.go +++ /dev/null @@ -1,160 +0,0 @@ -package header - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type Header interface { - util.Model - SetSession(session session.Session) tea.Cmd - SetWidth(width int) tea.Cmd - SetDetailsOpen(open bool) - ShowingDetails() bool -} - -type header struct { - width int - session session.Session - lspClients *csync.Map[string, *lsp.Client] - detailsOpen bool -} - -func New(lspClients *csync.Map[string, *lsp.Client]) Header { - return &header{ - lspClients: lspClients, - width: 0, - } -} - -func (h *header) Init() tea.Cmd { - return nil -} - -func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if h.session.ID == msg.Payload.ID { - h.session = msg.Payload - } - } - } - return h, nil -} - -func (h *header) View() string { - if h.session.ID == "" { - return "" - } - - const ( - gap = " " - diag = "╱" - minDiags = 3 - leftPadding = 1 - rightPadding = 1 - ) - - t := styles.CurrentTheme() - - var b strings.Builder - - b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charm™")) - b.WriteString(gap) - b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)) - b.WriteString(gap) - - availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags - details := h.details(availDetailWidth) - - remainingWidth := h.width - - lipgloss.Width(b.String()) - - lipgloss.Width(details) - - leftPadding - - rightPadding - - if remainingWidth > 0 { - b.WriteString(t.S().Base.Foreground(t.Primary).Render( - strings.Repeat(diag, max(minDiags, remainingWidth)), - )) - b.WriteString(gap) - } - - b.WriteString(details) - - return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()) -} - -func (h *header) details(availWidth int) string { - s := styles.CurrentTheme().S() - - var parts []string - - errorCount := 0 - for l := range h.lspClients.Seq() { - errorCount += l.GetDiagnosticCounts().Error - } - - if errorCount > 0 { - parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount))) - } - - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100 - formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) - parts = append(parts, formattedPercentage) - - const keystroke = "ctrl+d" - if h.detailsOpen { - parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close")) - } else { - parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open ")) - } - - dot := s.Subtle.Render(" • ") - metadata := strings.Join(parts, dot) - metadata = dot + metadata - - // Truncate cwd if necessary, and insert it at the beginning. - const dirTrimLimit = 4 - cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit) - cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…") - cwd = s.Muted.Render(cwd) - - return cwd + metadata -} - -func (h *header) SetDetailsOpen(open bool) { - h.detailsOpen = open -} - -// SetSession implements Header. -func (h *header) SetSession(session session.Session) tea.Cmd { - h.session = session - return nil -} - -// SetWidth implements Header. -func (h *header) SetWidth(width int) tea.Cmd { - h.width = width - return nil -} - -// ShowingDetails implements Header. -func (h *header) ShowingDetails() bool { - return h.detailsOpen -} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go deleted file mode 100644 index b4db149946fe0a1f67c957eeb04da2966e1f5f28..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/messages.go +++ /dev/null @@ -1,461 +0,0 @@ -package messages - -import ( - "fmt" - "path/filepath" - "strings" - "time" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "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" - - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -// CopyKey is the key binding for copying message content to the clipboard. -var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy")) - -// ClearSelectionKey is the key binding for clearing the current selection in the chat interface. -var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection")) - -// MessageCmp defines the interface for message components in the chat interface. -// It combines standard UI model interfaces with message-specific functionality. -type MessageCmp interface { - util.Model // Basic Bubble util.Model interface - layout.Sizeable // Width/height management - layout.Focusable // Focus state management - GetMessage() message.Message // Access to underlying message data - SetMessage(msg message.Message) // Update the message content - Spinning() bool // Animation state for loading messages - ID() string -} - -// messageCmp implements the MessageCmp interface for displaying chat messages. -// It handles rendering of user and assistant messages with proper styling, -// animations, and state management. -type messageCmp struct { - width int // Component width for text wrapping - focused bool // Focus state for border styling - - // Core message data and state - message message.Message // The underlying message content - spinning bool // Whether to show loading animation - anim *anim.Anim // Animation component for loading states - - // Thinking viewport for displaying reasoning content - thinkingViewport viewport.Model -} - -var focusedMessageBorder = lipgloss.Border{ - Left: "▌", -} - -// NewMessageCmp creates a new message component with the given message and options -func NewMessageCmp(msg message.Message) MessageCmp { - t := styles.CurrentTheme() - - thinkingViewport := viewport.New() - thinkingViewport.SetHeight(1) - thinkingViewport.KeyMap = viewport.KeyMap{} - - m := &messageCmp{ - message: msg, - anim: anim.New(anim.Settings{ - Size: 15, - GradColorA: t.Primary, - GradColorB: t.Secondary, - CycleColors: true, - }), - thinkingViewport: thinkingViewport, - } - return m -} - -// Init initializes the message component and starts animations if needed. -// Returns a command to start the animation for spinning messages. -func (m *messageCmp) Init() tea.Cmd { - m.spinning = m.shouldSpin() - return m.anim.Init() -} - -// Update handles incoming messages and updates the component state. -// Manages animation updates for spinning messages and stops animation when appropriate. -func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case anim.StepMsg: - m.spinning = m.shouldSpin() - if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u.(*anim.Anim) - return m, cmd - } - case tea.KeyPressMsg: - if key.Matches(msg, CopyKey) { - return m, tea.Sequence( - tea.SetClipboard(m.message.Content().Text), - func() tea.Msg { - _ = clipboard.WriteAll(m.message.Content().Text) - return nil - }, - util.ReportInfo("Message copied to clipboard"), - ) - } - } - return m, nil -} - -// View renders the message component based on its current state. -// Returns different views for spinning, user, and assistant messages. -func (m *messageCmp) View() string { - if m.spinning && m.message.ReasoningContent().Thinking == "" { - if m.message.IsSummaryMessage { - m.anim.SetLabel("Summarizing") - } - return m.style().PaddingLeft(1).Render(m.anim.View()) - } - if m.message.ID != "" { - // this is a user or assistant message - switch m.message.Role { - case message.User: - return m.renderUserMessage() - default: - return m.renderAssistantMessage() - } - } - return m.style().Render("No message content") -} - -// GetMessage returns the underlying message data -func (m *messageCmp) GetMessage() message.Message { - return m.message -} - -func (m *messageCmp) SetMessage(msg message.Message) { - m.message = msg -} - -// textWidth calculates the available width for text content, -// accounting for borders and padding -func (m *messageCmp) textWidth() int { - return m.width - 2 // take into account the border and/or padding -} - -// style returns the lipgloss style for the message component. -// Applies different border colors and styles based on message role and focus state. -func (msg *messageCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - borderStyle := lipgloss.NormalBorder() - if msg.focused { - borderStyle = focusedMessageBorder - } - - style := t.S().Text - if msg.message.Role == message.User { - style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary) - } else { - if msg.focused { - style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark) - } else { - style = style.PaddingLeft(2) - } - } - return style -} - -// renderAssistantMessage renders assistant messages with optional footer information. -// Shows model name, response time, and finish reason when the message is complete. -func (m *messageCmp) renderAssistantMessage() string { - t := styles.CurrentTheme() - parts := []string{} - content := strings.TrimSpace(m.message.Content().String()) - thinking := m.message.IsThinking() - thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking) - finished := m.message.IsFinished() - finishedData := m.message.FinishPart() - - if thinking || thinkingContent != "" { - m.anim.SetLabel("Thinking") - thinkingContent = m.renderThinkingContent() - } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn { - // Don't render empty assistant messages with EndTurn - return "" - } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { - content = "*Canceled*" - } else if finished && content == "" && finishedData.Reason == message.FinishReasonError { - errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...") - title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated)) - details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details) - errorContent := fmt.Sprintf("%s\n\n%s", title, details) - return m.style().Render(errorContent) - } - - if thinkingContent != "" { - parts = append(parts, thinkingContent) - } - - if content != "" { - if thinkingContent != "" { - parts = append(parts, "") - } - parts = append(parts, m.toMarkdown(content)) - } - - joined := lipgloss.JoinVertical(lipgloss.Left, parts...) - return m.style().Render(joined) -} - -// renderUserMessage renders user messages with file attachments. It displays -// message content and any attached files with appropriate icons. -func (m *messageCmp) renderUserMessage() string { - t := styles.CurrentTheme() - var parts []string - - if s := m.message.Content().String(); s != "" { - parts = append(parts, m.toMarkdown(s)) - } - - attachmentStyle := t.S().Base. - Padding(0, 1). - MarginRight(1). - Background(t.FgMuted). - Foreground(t.FgBase). - Render - iconStyle := t.S().Base. - Foreground(t.BgSubtle). - Background(t.Green). - Padding(0, 1). - Bold(true). - Render - - attachments := make([]string, len(m.message.BinaryContent())) - for i, attachment := range m.message.BinaryContent() { - const maxFilenameWidth = 10 - filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...") - icon := styles.ImageIcon - if strings.HasPrefix(attachment.MIMEType, "text/") { - icon = styles.TextIcon - } - attachments[i] = lipgloss.JoinHorizontal( - lipgloss.Left, - iconStyle(icon), - attachmentStyle(filename), - ) - } - - if len(attachments) > 0 { - parts = append(parts, strings.Join(attachments, "")) - } - - joined := lipgloss.JoinVertical(lipgloss.Left, parts...) - return m.style().Render(joined) -} - -// toMarkdown converts text content to rendered markdown using the configured renderer -func (m *messageCmp) toMarkdown(content string) string { - r := styles.GetMarkdownRenderer(m.textWidth()) - rendered, _ := r.Render(content) - return strings.TrimSuffix(rendered, "\n") -} - -func (m *messageCmp) renderThinkingContent() string { - t := styles.CurrentTheme() - reasoningContent := m.message.ReasoningContent() - if strings.TrimSpace(reasoningContent.Thinking) == "" { - return "" - } - - width := m.textWidth() - 2 - width = min(width, 120) - - renderer := styles.GetPlainMarkdownRenderer(width - 1) - rendered, err := renderer.Render(reasoningContent.Thinking) - if err != nil { - lines := strings.Split(reasoningContent.Thinking, "\n") - var content strings.Builder - lineStyle := t.S().Subtle.Background(t.BgBaseLighter) - for i, line := range lines { - if line == "" { - continue - } - content.WriteString(lineStyle.Width(width).Render(line)) - if i < len(lines)-1 { - content.WriteString("\n") - } - } - rendered = content.String() - } - - fullContent := strings.TrimSpace(rendered) - height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10) - m.thinkingViewport.SetHeight(height) - m.thinkingViewport.SetWidth(m.textWidth()) - m.thinkingViewport.SetContent(fullContent) - m.thinkingViewport.GotoBottom() - finishReason := m.message.FinishPart() - var footer string - if reasoningContent.StartedAt > 0 { - duration := m.message.ThinkingDuration() - if reasoningContent.FinishedAt > 0 { - m.anim.SetLabel("") - opts := core.StatusOpts{ - Title: "Thought for", - Description: duration.String(), - } - if duration.String() != "0s" { - footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1)) - } - } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled { - footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*")) - } else { - footer = m.anim.View() - } - } - lineStyle := t.S().Subtle.Background(t.BgBaseLighter) - result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View()) - if footer != "" { - result += "\n\n" + footer - } - return result -} - -// shouldSpin determines whether the message should show a loading animation. -// Only assistant messages without content that aren't finished should spin. -func (m *messageCmp) shouldSpin() bool { - if m.message.Role != message.Assistant { - return false - } - - if m.message.IsFinished() { - return false - } - - if strings.TrimSpace(m.message.Content().Text) != "" { - return false - } - if len(m.message.ToolCalls()) > 0 { - return false - } - return true -} - -// Blur removes focus from the message component -func (m *messageCmp) Blur() tea.Cmd { - m.focused = false - return nil -} - -// Focus sets focus on the message component -func (m *messageCmp) Focus() tea.Cmd { - m.focused = true - return nil -} - -// IsFocused returns whether the message component is currently focused -func (m *messageCmp) IsFocused() bool { - return m.focused -} - -// Size management methods - -// GetSize returns the current dimensions of the message component -func (m *messageCmp) GetSize() (int, int) { - return m.width, 0 -} - -// SetSize updates the width of the message component for text wrapping -func (m *messageCmp) SetSize(width int, height int) tea.Cmd { - m.width = ordered.Clamp(width, 1, 120) - m.thinkingViewport.SetWidth(m.width - 4) - return nil -} - -// Spinning returns whether the message is currently showing a loading animation -func (m *messageCmp) Spinning() bool { - return m.spinning -} - -type AssistantSection interface { - list.Item - layout.Sizeable -} -type assistantSectionModel struct { - width int - id string - message message.Message - lastUserMessageTime time.Time -} - -// ID implements AssistantSection. -func (m *assistantSectionModel) ID() string { - return m.id -} - -func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection { - return &assistantSectionModel{ - width: 0, - id: uuid.NewString(), - message: message, - lastUserMessageTime: lastUserMessageTime, - } -} - -func (m *assistantSectionModel) Init() tea.Cmd { - return nil -} - -func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { - return m, nil -} - -func (m *assistantSectionModel) View() string { - t := styles.CurrentTheme() - finishData := m.message.FinishPart() - finishTime := time.Unix(finishData.Time, 0) - duration := finishTime.Sub(m.lastUserMessageTime) - infoMsg := t.S().Subtle.Render(duration.String()) - icon := t.S().Subtle.Render(styles.ModelIcon) - model := config.Get().GetModel(m.message.Provider, m.message.Model) - if model == nil { - // This means the model is not configured anymore - model = &catwalk.Model{ - Name: "Unknown Model", - } - } - modelFormatted := t.S().Muted.Render(model.Name) - assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) - return t.S().Base.PaddingLeft(2).Render( - core.Section(assistant, m.width-2), - ) -} - -func (m *assistantSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *assistantSectionModel) IsSectionHeader() bool { - return true -} - -func (m *messageCmp) ID() string { - return m.message.ID -} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go deleted file mode 100644 index 5fbd8a653c0b0374029bf13b31721d8ad5150948..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/renderer.go +++ /dev/null @@ -1,1403 +0,0 @@ -package messages - -import ( - "cmp" - "encoding/json" - "fmt" - "strings" - "time" - - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/tree" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/ansiext" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/tui/components/chat/todos" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/highlight" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -// responseContextHeight limits the number of lines displayed in tool output -const responseContextHeight = 10 - -// renderer defines the interface for tool-specific rendering implementations -type renderer interface { - // Render returns the complete (already styled) tool‑call view, not - // including the outer border. - Render(v *toolCallCmp) string -} - -// rendererFactory creates new renderer instances -type rendererFactory func() renderer - -// renderRegistry manages the mapping of tool names to their renderers -type renderRegistry map[string]rendererFactory - -// register adds a new renderer factory to the registry -func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f } - -// lookup retrieves a renderer for the given tool name, falling back to generic renderer -func (rr renderRegistry) lookup(name string) renderer { - if f, ok := rr[name]; ok { - return f() - } - return genericRenderer{} // sensible fallback -} - -// registry holds all registered tool renderers -var registry = renderRegistry{} - -// baseRenderer provides common functionality for all tool renderers -type baseRenderer struct{} - -func (br baseRenderer) Render(v *toolCallCmp) string { - if v.result.Data != "" { - if strings.HasPrefix(v.result.MIMEType, "image/") { - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content) - }) - } - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderMediaContent(v, v.result.MIMEType, v.result.Content) - }) - } - - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// paramBuilder helps construct parameter lists for tool headers -type paramBuilder struct { - args []string -} - -// newParamBuilder creates a new parameter builder -func newParamBuilder() *paramBuilder { - return ¶mBuilder{args: make([]string, 0)} -} - -// addMain adds the main parameter (first argument) -func (pb *paramBuilder) addMain(value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, value) - } - return pb -} - -// addKeyValue adds a key-value pair parameter -func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, key, value) - } - return pb -} - -// addFlag adds a boolean flag parameter -func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder { - if value { - pb.args = append(pb.args, key, "true") - } - return pb -} - -// build returns the final parameter list -func (pb *paramBuilder) build() []string { - return pb.args -} - -// renderWithParams provides a common rendering pattern for tools with parameters -func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string { - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := br.makeHeader(v, toolName, width, args...) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := contentRenderer() - return joinHeaderBody(header, body) -} - -// unmarshalParams safely unmarshal JSON parameters -func (br baseRenderer) unmarshalParams(input string, target any) error { - return json.Unmarshal([]byte(input), target) -} - -// makeHeader builds the tool call header with status icon and parameters for a nested tool call. -func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...) -} - -// makeHeader builds ": param (key=value)" and truncates as needed. -func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string { - if v.isNested { - return br.makeNestedHeader(v, tool, width, params...) - } - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - tool = t.S().Base.Foreground(t.Blue).Render(tool) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...) -} - -// renderError provides consistent error rendering -func (br baseRenderer) renderError(v *toolCallCmp, message string) string { - t := styles.CurrentTheme() - header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "") - errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space - return joinHeaderBody(header, errorTag+" "+message) -} - -// Register tool renderers -func init() { - registry.register(tools.BashToolName, func() renderer { return bashRenderer{} }) - registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} }) - registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} }) - registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} }) - registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} }) - registry.register(tools.EditToolName, func() renderer { return editRenderer{} }) - registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} }) - registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} }) - registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} }) - registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} }) - registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} }) - registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} }) - registry.register(tools.GlobToolName, func() renderer { return globRenderer{} }) - registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} }) - registry.register(tools.LSToolName, func() renderer { return lsRenderer{} }) - registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} }) - registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} }) - registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} }) - registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} }) -} - -// ----------------------------------------------------------------------------- -// Generic renderer -// ----------------------------------------------------------------------------- - -// genericRenderer handles unknown tool types with basic parameter display -type genericRenderer struct { - baseRenderer -} - -func (gr genericRenderer) Render(v *toolCallCmp) string { - if v.result.Data != "" { - if strings.HasPrefix(v.result.MIMEType, "image/") { - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content) - }) - } - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderMediaContent(v, v.result.MIMEType, v.result.Content) - }) - } - - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Bash renderer -// ----------------------------------------------------------------------------- - -// bashRenderer handles bash command execution display -type bashRenderer struct { - baseRenderer -} - -// Render displays the bash command with sanitized newlines and plain output -func (br bashRenderer) Render(v *toolCallCmp) string { - var params tools.BashParams - if err := br.unmarshalParams(v.call.Input, ¶ms); err != nil { - return br.renderError(v, "Invalid bash parameters") - } - - cmd := strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") - args := newParamBuilder(). - addMain(cmd). - addFlag("background", params.RunInBackground). - build() - if v.call.Finished { - var meta tools.BashResponseMetadata - _ = br.unmarshalParams(v.result.Metadata, &meta) - if meta.Background { - description := cmp.Or(meta.Description, params.Command) - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - content := "Command: " + params.Command + "\n" + v.result.Content - body := renderPlainContent(v, content) - return joinHeaderBody(header, body) - } - } - - return br.renderWithParams(v, "Bash", args, func() string { - var meta tools.BashResponseMetadata - if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - // for backwards compatibility with older tool calls. - if meta.Output == "" && v.result.Content != tools.BashNoOutput { - meta.Output = v.result.Content - } - - if meta.Output == "" { - return "" - } - return renderPlainContent(v, meta.Output) - }) -} - -// ----------------------------------------------------------------------------- -// Bash Output renderer -// ----------------------------------------------------------------------------- - -func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - - jobPart := t.S().Base.Foreground(t.Blue).Render("Job") - subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")") - pidPart := t.S().Muted.Render(pid) - descPart := "" - if description != "" { - descPart = " " + t.S().Subtle.Render(description) - } - - // Build the complete header - prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart) - fullHeader := prefix + descPart - - // Truncate if needed - if lipgloss.Width(fullHeader) > width { - availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space - if availableWidth < 10 { - // Not enough space for description, just show prefix - return prefix - } - descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…")) - fullHeader = prefix + descPart - } - - return fullHeader -} - -// bashOutputRenderer handles bash output retrieval display -type bashOutputRenderer struct { - baseRenderer -} - -// Render displays the shell ID and output from a background shell -func (bor bashOutputRenderer) Render(v *toolCallCmp) string { - var params tools.JobOutputParams - if err := bor.unmarshalParams(v.call.Input, ¶ms); err != nil { - return bor.renderError(v, "Invalid job_output parameters") - } - - var meta tools.JobOutputResponseMetadata - var description string - if v.result.Metadata != "" { - if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.Description != "" { - description = meta.Description - } else { - description = meta.Command - } - } - } - - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := renderPlainContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// ----------------------------------------------------------------------------- -// Bash Kill renderer -// ----------------------------------------------------------------------------- - -// bashKillRenderer handles bash process termination display -type bashKillRenderer struct { - baseRenderer -} - -// Render displays the shell ID being terminated -func (bkr bashKillRenderer) Render(v *toolCallCmp) string { - var params tools.JobKillParams - if err := bkr.unmarshalParams(v.call.Input, ¶ms); err != nil { - return bkr.renderError(v, "Invalid job_kill parameters") - } - - var meta tools.JobKillResponseMetadata - var description string - if v.result.Metadata != "" { - if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.Description != "" { - description = meta.Description - } else { - description = meta.Command - } - } - } - - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := renderPlainContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// ----------------------------------------------------------------------------- -// View renderer -// ----------------------------------------------------------------------------- - -// viewRenderer handles file viewing with syntax highlighting and line numbers -type viewRenderer struct { - baseRenderer -} - -// Render displays file content with optional limit and offset parameters -func (vr viewRenderer) Render(v *toolCallCmp) string { - var params tools.ViewParams - if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil { - return vr.renderError(v, "Invalid view parameters") - } - - file := fsext.PrettyPath(params.FilePath) - args := newParamBuilder(). - addMain(file). - addKeyValue("limit", formatNonZero(params.Limit)). - addKeyValue("offset", formatNonZero(params.Offset)). - build() - - return vr.renderWithParams(v, "View", args, func() string { - if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") { - return renderImageContent(v, v.result.Data, v.result.MIMEType, "") - } - - var meta tools.ViewResponseMetadata - if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset) - }) -} - -// formatNonZero returns string representation of non-zero integers, empty string for zero -func formatNonZero(value int) string { - if value == 0 { - return "" - } - return fmt.Sprintf("%d", value) -} - -// ----------------------------------------------------------------------------- -// Edit renderer -// ----------------------------------------------------------------------------- - -// editRenderer handles file editing with diff visualization -type editRenderer struct { - baseRenderer -} - -// Render displays the edited file with a formatted diff of changes -func (er editRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.EditParams - var args []string - if err := er.unmarshalParams(v.call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - args = newParamBuilder().addMain(file).build() - } - - return er.renderWithParams(v, "Edit", args, func() string { - var meta tools.EditResponseMetadata - if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(v.textWidth() - 2) // -2 for padding - if v.textWidth() > 120 { - formatter = formatter.Split() - } - // add a message to the bottom if the content was truncated - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := t.S().Muted. - Background(t.BgBaseLighter). - PaddingLeft(2). - Width(v.textWidth() - 2). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - return formatted - }) -} - -// ----------------------------------------------------------------------------- -// Multi-Edit renderer -// ----------------------------------------------------------------------------- - -// multiEditRenderer handles multiple file edits with diff visualization -type multiEditRenderer struct { - baseRenderer -} - -// Render displays the multi-edited file with a formatted diff of changes -func (mer multiEditRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.MultiEditParams - var args []string - if err := mer.unmarshalParams(v.call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - editsCount := len(params.Edits) - args = newParamBuilder(). - addMain(file). - addKeyValue("edits", fmt.Sprintf("%d", editsCount)). - build() - } - - return mer.renderWithParams(v, "Multi-Edit", args, func() string { - var meta tools.MultiEditResponseMetadata - if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(v.textWidth() - 2) // -2 for padding - if v.textWidth() > 120 { - formatter = formatter.Split() - } - // add a message to the bottom if the content was truncated - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := t.S().Muted. - Background(t.BgBaseLighter). - PaddingLeft(2). - Width(v.textWidth() - 4). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - - // Add failed edits warning if any exist - if len(meta.EditsFailed) > 0 { - noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note") - noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits)) - note := t.S().Base. - Width(v.textWidth() - 2). - Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg))) - formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note) - } - - return formatted - }) -} - -// ----------------------------------------------------------------------------- -// Write renderer -// ----------------------------------------------------------------------------- - -// writeRenderer handles file writing with syntax-highlighted content preview -type writeRenderer struct { - baseRenderer -} - -// Render displays the file being written with syntax highlighting -func (wr writeRenderer) Render(v *toolCallCmp) string { - var params tools.WriteParams - var args []string - var file string - if err := wr.unmarshalParams(v.call.Input, ¶ms); err == nil { - file = fsext.PrettyPath(params.FilePath) - args = newParamBuilder().addMain(file).build() - } - - return wr.renderWithParams(v, "Write", args, func() string { - return renderCodeContent(v, file, params.Content, 0) - }) -} - -// ----------------------------------------------------------------------------- -// Fetch renderer -// ----------------------------------------------------------------------------- - -// simpleFetchRenderer handles URL fetching with format-specific content display -type simpleFetchRenderer struct { - baseRenderer -} - -// Render displays the fetched URL with format and timeout parameters -func (fr simpleFetchRenderer) Render(v *toolCallCmp) string { - var params tools.FetchParams - var args []string - if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - addKeyValue("format", params.Format). - addKeyValue("timeout", formatTimeout(params.Timeout)). - build() - } - - return fr.renderWithParams(v, "Fetch", args, func() string { - file := fr.getFileExtension(params.Format) - return renderCodeContent(v, file, v.result.Content, 0) - }) -} - -// getFileExtension returns appropriate file extension for syntax highlighting -func (fr simpleFetchRenderer) getFileExtension(format string) string { - switch format { - case "text": - return "fetch.txt" - case "html": - return "fetch.html" - default: - return "fetch.md" - } -} - -// ----------------------------------------------------------------------------- -// Agentic fetch renderer -// ----------------------------------------------------------------------------- - -// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls -type agenticFetchRenderer struct { - baseRenderer -} - -// Render displays the fetched URL or web search with prompt parameter and nested tool calls -func (fr agenticFetchRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.AgenticFetchParams - var args []string - if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { - if params.URL != "" { - args = newParamBuilder(). - addMain(params.URL). - build() - } - } - - prompt := params.Prompt - prompt = strings.ReplaceAll(prompt, "\n", " ") - - header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...) - if res, done := earlyState(header, v); v.cancelled && done { - return res - } - - taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt") - remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1) - remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1)) - prompt = t.S().Base.Width(remainingWidth).Render(prompt) - header = lipgloss.JoinVertical( - lipgloss.Left, - header, - "", - lipgloss.JoinHorizontal( - lipgloss.Left, - taskTag, - " ", - prompt, - ), - ) - childTools := tree.Root(header) - - for _, call := range v.nestedToolCalls { - call.SetSize(remainingWidth, 1) - childTools.Child(call.View()) - } - parts := []string{ - childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), - } - - if v.result.ToolCallID == "" { - v.spinning = true - parts = append(parts, "", v.anim.View()) - } else { - v.spinning = false - } - - header = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - - if v.result.ToolCallID == "" { - return header - } - body := renderMarkdownContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// formatTimeout converts timeout seconds to duration string -func formatTimeout(timeout int) string { - if timeout == 0 { - return "" - } - return (time.Duration(timeout) * time.Second).String() -} - -// ----------------------------------------------------------------------------- -// Web fetch renderer -// ----------------------------------------------------------------------------- - -// webFetchRenderer handles web page fetching with simplified URL display -type webFetchRenderer struct { - baseRenderer -} - -// Render displays a compact view of web_fetch with just the URL in a link style -func (wfr webFetchRenderer) Render(v *toolCallCmp) string { - var params tools.WebFetchParams - var args []string - if err := wfr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - build() - } - - return wfr.renderWithParams(v, "Fetch", args, func() string { - return renderMarkdownContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Web search renderer -// ----------------------------------------------------------------------------- - -// webSearchRenderer handles web search with query display -type webSearchRenderer struct { - baseRenderer -} - -// Render displays a compact view of web_search with just the query -func (wsr webSearchRenderer) Render(v *toolCallCmp) string { - var params tools.WebSearchParams - var args []string - if err := wsr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Query). - build() - } - - return wsr.renderWithParams(v, "Search", args, func() string { - return renderMarkdownContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Download renderer -// ----------------------------------------------------------------------------- - -// downloadRenderer handles file downloading with URL and file path display -type downloadRenderer struct { - baseRenderer -} - -// Render displays the download URL and destination file path with timeout parameter -func (dr downloadRenderer) Render(v *toolCallCmp) string { - var params tools.DownloadParams - var args []string - if err := dr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - addKeyValue("file_path", fsext.PrettyPath(params.FilePath)). - addKeyValue("timeout", formatTimeout(params.Timeout)). - build() - } - - return dr.renderWithParams(v, "Download", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Glob renderer -// ----------------------------------------------------------------------------- - -// globRenderer handles file pattern matching with path filtering -type globRenderer struct { - baseRenderer -} - -// Render displays the glob pattern with optional path parameter -func (gr globRenderer) Render(v *toolCallCmp) string { - var params tools.GlobParams - var args []string - if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - build() - } - - return gr.renderWithParams(v, "Glob", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Grep renderer -// ----------------------------------------------------------------------------- - -// grepRenderer handles content searching with pattern matching options -type grepRenderer struct { - baseRenderer -} - -// Render displays the search pattern with path, include, and literal text options -func (gr grepRenderer) Render(v *toolCallCmp) string { - var params tools.GrepParams - var args []string - if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - addKeyValue("include", params.Include). - addFlag("literal", params.LiteralText). - build() - } - - return gr.renderWithParams(v, "Grep", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// LS renderer -// ----------------------------------------------------------------------------- - -// lsRenderer handles directory listing with default path handling -type lsRenderer struct { - baseRenderer -} - -// Render displays the directory path, defaulting to current directory -func (lr lsRenderer) Render(v *toolCallCmp) string { - var params tools.LSParams - var args []string - if err := lr.unmarshalParams(v.call.Input, ¶ms); err == nil { - path := params.Path - if path == "" { - path = "." - } - path = fsext.PrettyPath(path) - - args = newParamBuilder().addMain(path).build() - } - - return lr.renderWithParams(v, "List", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Sourcegraph renderer -// ----------------------------------------------------------------------------- - -// sourcegraphRenderer handles code search with count and context options -type sourcegraphRenderer struct { - baseRenderer -} - -// Render displays the search query with optional count and context window parameters -func (sr sourcegraphRenderer) Render(v *toolCallCmp) string { - var params tools.SourcegraphParams - var args []string - if err := sr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Query). - addKeyValue("count", formatNonZero(params.Count)). - addKeyValue("context", formatNonZero(params.ContextWindow)). - build() - } - - return sr.renderWithParams(v, "Sourcegraph", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Diagnostics renderer -// ----------------------------------------------------------------------------- - -// diagnosticsRenderer handles project-wide diagnostic information -type diagnosticsRenderer struct { - baseRenderer -} - -// Render displays project diagnostics with plain content formatting -func (dr diagnosticsRenderer) Render(v *toolCallCmp) string { - args := newParamBuilder().addMain("project").build() - - return dr.renderWithParams(v, "Diagnostics", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Task renderer -// ----------------------------------------------------------------------------- - -// agentRenderer handles project-wide diagnostic information -type agentRenderer struct { - baseRenderer -} - -func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator { - if width == 0 { - width = 2 - } - if lPadding == 0 { - lPadding = 1 - } - return func(children tree.Children, index int) string { - line := strings.Repeat("─", width) - padding := strings.Repeat(" ", lPadding) - if children.Length()-1 == index { - return padding + "╰" + line - } - return padding + "├" + line - } -} - -// Render displays agent task parameters and result content -func (tr agentRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params agent.AgentParams - tr.unmarshalParams(v.call.Input, ¶ms) - - prompt := params.Prompt - prompt = strings.ReplaceAll(prompt, "\n", " ") - - header := tr.makeHeader(v, "Agent", v.textWidth()) - if res, done := earlyState(header, v); v.cancelled && done { - return res - } - taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task") - remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 - remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2) - prompt = t.S().Muted.Width(remainingWidth).Render(prompt) - header = lipgloss.JoinVertical( - lipgloss.Left, - header, - "", - lipgloss.JoinHorizontal( - lipgloss.Left, - taskTag, - " ", - prompt, - ), - ) - childTools := tree.Root(header) - - for _, call := range v.nestedToolCalls { - call.SetSize(remainingWidth, 1) - childTools.Child(call.View()) - } - parts := []string{ - childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), - } - - if v.result.ToolCallID == "" { - v.spinning = true - parts = append(parts, "", v.anim.View()) - } else { - v.spinning = false - } - - header = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - - if v.result.ToolCallID == "" { - return header - } - - body := renderMarkdownContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// renderParamList renders params, params[0] (params[1]=params[2] ....) -func renderParamList(nested bool, paramsWidth int, params ...string) string { - t := styles.CurrentTheme() - if len(params) == 0 { - return "" - } - mainParam := params[0] - if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth { - mainParam = ansi.Truncate(mainParam, paramsWidth, "…") - } - - if len(params) == 1 { - return t.S().Subtle.Render(mainParam) - } - otherParams := params[1:] - // create pairs of key/value - // if odd number of params, the last one is a key without value - if len(otherParams)%2 != 0 { - otherParams = append(otherParams, "") - } - parts := make([]string, 0, len(otherParams)/2) - for i := 0; i < len(otherParams); i += 2 { - key := otherParams[i] - value := otherParams[i+1] - if value == "" { - continue - } - parts = append(parts, fmt.Sprintf("%s=%s", key, value)) - } - - partsRendered := strings.Join(parts, ", ") - remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()" - if remainingWidth < 30 { - // No space for the params, just show the main - return t.S().Subtle.Render(mainParam) - } - - if len(parts) > 0 { - mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) - } - - return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…")) -} - -// earlyState returns immediately‑rendered error/cancelled/ongoing states. -func earlyState(header string, v *toolCallCmp) (string, bool) { - t := styles.CurrentTheme() - message := "" - switch { - case v.result.IsError: - message = v.renderToolError() - case v.cancelled: - message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.") - case v.result.ToolCallID == "": - if v.permissionRequested && !v.permissionGranted { - message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...") - } else { - message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...") - } - default: - return "", false - } - - message = t.S().Base.PaddingLeft(2).Render(message) - return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true -} - -func joinHeaderBody(header, body string) string { - t := styles.CurrentTheme() - if body == "" { - return header - } - body = t.S().Base.PaddingLeft(2).Render(body) - return lipgloss.JoinVertical(lipgloss.Left, header, "", body) -} - -func renderPlainContent(v *toolCallCmp, content string) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings - content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces - content = strings.TrimSpace(content) - lines := strings.Split(content, "\n") - - width := v.textWidth() - 2 - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - ln = ansiext.Escape(ln) - ln = " " + ln - if lipgloss.Width(ln) > width { - ln = v.fit(ln, width) - } - out = append(out, t.S().Muted. - Width(width). - Background(t.BgBaseLighter). - Render(ln)) - } - - if len(lines) > responseContextHeight { - out = append(out, t.S().Muted. - Background(t.BgBaseLighter). - Width(width). - Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return strings.Join(out, "\n") -} - -func renderMarkdownContent(v *toolCallCmp, content string) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) - - width := v.textWidth() - 2 - width = min(width, 120) - - renderer := styles.GetPlainMarkdownRenderer(width) - rendered, err := renderer.Render(content) - if err != nil { - return renderPlainContent(v, content) - } - - lines := strings.Split(rendered, "\n") - - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - out = append(out, ln) - } - - style := t.S().Muted.Background(t.BgBaseLighter) - if len(lines) > responseContextHeight { - out = append(out, style. - Width(width-2). - Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return style.Render(strings.Join(out, "\n")) -} - -func getDigits(n int) int { - if n == 0 { - return 1 - } - if n < 0 { - n = -n - } - - digits := 0 - for n > 0 { - n /= 10 - digits++ - } - - return digits -} - -func renderCodeContent(v *toolCallCmp, path, content string, offset int) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings - content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces - truncated := truncateHeight(content, responseContextHeight) - - lines := strings.Split(truncated, "\n") - for i, ln := range lines { - lines[i] = ansiext.Escape(ln) - } - - bg := t.BgBase - highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg) - lines = strings.Split(highlighted, "\n") - - if len(strings.Split(content, "\n")) > responseContextHeight { - lines = append(lines, t.S().Muted. - Background(bg). - Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight))) - } - - maxLineNumber := len(lines) + offset - maxDigits := getDigits(maxLineNumber) - numFmt := fmt.Sprintf("%%%dd", maxDigits) - const numPR, numPL, codePR, codePL = 1, 1, 1, 2 - w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding - for i, ln := range lines { - num := t.S().Base. - Foreground(t.FgMuted). - Background(t.BgBase). - PaddingRight(1). - PaddingLeft(1). - Render(fmt.Sprintf(numFmt, i+1+offset)) - lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, - num, - t.S().Base. - Width(w). - Background(bg). - PaddingRight(1). - PaddingLeft(2). - Render(v.fit(ln, w-codePL-codePR)), - ) - } - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -// renderImageContent renders image data with optional text content (for MCP tools). -func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string { - t := styles.CurrentTheme() - - dataSize := len(data) * 3 / 4 - sizeStr := formatSize(dataSize) - - loaded := t.S().Base.Foreground(t.Green).Render("Loaded") - arrow := t.S().Base.Foreground(t.GreenDark).Render("→") - typeStyled := t.S().Base.Render(mediaType) - sizeStyled := t.S().Subtle.Render(sizeStr) - - imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled) - if strings.TrimSpace(textContent) != "" { - textDisplay := renderPlainContent(v, textContent) - return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay) - } - - return imageDisplay -} - -// renderMediaContent renders non-image media content. -func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string { - t := styles.CurrentTheme() - - loaded := t.S().Base.Foreground(t.Green).Render("Loaded") - arrow := t.S().Base.Foreground(t.GreenDark).Render("→") - typeStyled := t.S().Base.Render(mediaType) - mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled) - - if strings.TrimSpace(textContent) != "" { - textDisplay := renderPlainContent(v, textContent) - return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay) - } - - return mediaDisplay -} - -// formatSize formats byte count as human-readable size. -func formatSize(bytes int) string { - if bytes < 1024 { - return fmt.Sprintf("%d B", bytes) - } - if bytes < 1024*1024 { - return fmt.Sprintf("%.1f KB", float64(bytes)/1024) - } - return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) -} - -func (v *toolCallCmp) renderToolError() string { - t := styles.CurrentTheme() - err := strings.ReplaceAll(v.result.Content, "\n", " ") - errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag)))) - return err -} - -func truncateHeight(s string, h int) string { - lines := strings.Split(s, "\n") - if len(lines) > h { - return strings.Join(lines[:h], "\n") - } - return s -} - -func prettifyToolName(name string) string { - switch name { - case agent.AgentToolName: - return "Agent" - case tools.BashToolName: - return "Bash" - case tools.JobOutputToolName: - return "Job: Output" - case tools.JobKillToolName: - return "Job: Kill" - case tools.DownloadToolName: - return "Download" - case tools.EditToolName: - return "Edit" - case tools.MultiEditToolName: - return "Multi-Edit" - case tools.FetchToolName: - return "Fetch" - case tools.AgenticFetchToolName: - return "Agentic Fetch" - case tools.WebFetchToolName: - return "Fetch" - case tools.WebSearchToolName: - return "Search" - case tools.GlobToolName: - return "Glob" - case tools.GrepToolName: - return "Grep" - case tools.LSToolName: - return "List" - case tools.SourcegraphToolName: - return "Sourcegraph" - case tools.TodosToolName: - return "To-Do" - case tools.ViewToolName: - return "View" - case tools.WriteToolName: - return "Write" - default: - return name - } -} - -// ----------------------------------------------------------------------------- -// Todos renderer -// ----------------------------------------------------------------------------- - -type todosRenderer struct { - baseRenderer -} - -func (tr todosRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.TodosParams - var meta tools.TodosResponseMetadata - var headerText string - var body string - - // Parse params for pending state (before result is available). - if err := tr.unmarshalParams(v.call.Input, ¶ms); err == nil { - completedCount := 0 - inProgressTask := "" - for _, todo := range params.Todos { - if todo.Status == "completed" { - completedCount++ - } - if todo.Status == "in_progress" { - if todo.ActiveForm != "" { - inProgressTask = todo.ActiveForm - } else { - inProgressTask = todo.Content - } - } - } - - // Default display from params (used when pending or no metadata). - ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos))) - headerText = ratio - if inProgressTask != "" { - headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask) - } - - // If we have metadata, use it for richer display. - if v.result.Metadata != "" { - if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.IsNew { - if meta.JustStarted != "" { - headerText = fmt.Sprintf("created %d todos, starting first", meta.Total) - } else { - headerText = fmt.Sprintf("created %d todos", meta.Total) - } - body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth()) - } else { - // Build header based on what changed. - hasCompleted := len(meta.JustCompleted) > 0 - hasStarted := meta.JustStarted != "" - allCompleted := meta.Completed == meta.Total - - ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total)) - if hasCompleted && hasStarted { - text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted))) - headerText = fmt.Sprintf("%s%s", ratio, text) - } else if hasCompleted { - text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted))) - if allCompleted { - text = t.S().Subtle.Render(" · completed all") - } - headerText = fmt.Sprintf("%s%s", ratio, text) - } else if hasStarted { - headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task")) - } else { - headerText = ratio - } - - // Build body with details. - if allCompleted { - // Show all todos when all are completed, like when created - body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth()) - } else if meta.JustStarted != "" { - body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") + - t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted) - } - } - } - } - } - - args := newParamBuilder().addMain(headerText).build() - - return tr.renderWithParams(v, "To-Do", args, func() string { - return body - }) -} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go deleted file mode 100644 index b8163f5a4c2a51f13ebd7ba2650bb7c3f33dac44..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/tool.go +++ /dev/null @@ -1,877 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/diff" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -// ToolCallCmp defines the interface for tool call components in the chat interface. -// It manages the display of tool execution including pending states, results, and errors. -type ToolCallCmp interface { - util.Model // Basic Bubble util.Model interface - layout.Sizeable // Width/height management - layout.Focusable // Focus state management - GetToolCall() message.ToolCall // Access to tool call data - GetToolResult() message.ToolResult // Access to tool result data - SetToolResult(message.ToolResult) // Update tool result - SetToolCall(message.ToolCall) // Update tool call - SetCancelled() // Mark as cancelled - ParentMessageID() string // Get parent message ID - Spinning() bool // Animation state for pending tools - GetNestedToolCalls() []ToolCallCmp // Get nested tool calls - SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls - SetIsNested(bool) // Set whether this tool call is nested - ID() string - SetPermissionRequested() // Mark permission request - SetPermissionGranted() // Mark permission granted -} - -// toolCallCmp implements the ToolCallCmp interface for displaying tool calls. -// It handles rendering of tool execution states including pending, completed, and error states. -type toolCallCmp struct { - width int // Component width for text wrapping - focused bool // Focus state for border styling - isNested bool // Whether this tool call is nested within another - - // Tool call data and state - parentMessageID string // ID of the message that initiated this tool call - call message.ToolCall // The tool call being executed - result message.ToolResult // The result of the tool execution - cancelled bool // Whether the tool call was cancelled - permissionRequested bool - permissionGranted bool - - // Animation state for pending tool calls - spinning bool // Whether to show loading animation - anim util.Model // Animation component for pending states - - nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display -} - -// ToolCallOption provides functional options for configuring tool call components -type ToolCallOption func(*toolCallCmp) - -// WithToolCallCancelled marks the tool call as cancelled -func WithToolCallCancelled() ToolCallOption { - return func(m *toolCallCmp) { - m.cancelled = true - } -} - -// WithToolCallResult sets the initial tool result -func WithToolCallResult(result message.ToolResult) ToolCallOption { - return func(m *toolCallCmp) { - m.result = result - } -} - -func WithToolCallNested(isNested bool) ToolCallOption { - return func(m *toolCallCmp) { - m.isNested = isNested - } -} - -func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption { - return func(m *toolCallCmp) { - m.nestedToolCalls = calls - } -} - -func WithToolPermissionRequested() ToolCallOption { - return func(m *toolCallCmp) { - m.permissionRequested = true - } -} - -func WithToolPermissionGranted() ToolCallOption { - return func(m *toolCallCmp) { - m.permissionGranted = true - } -} - -// NewToolCallCmp creates a new tool call component with the given parent message ID, -// tool call, and optional configuration -func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp { - m := &toolCallCmp{ - call: tc, - parentMessageID: parentMessageID, - } - for _, opt := range opts { - opt(m) - } - t := styles.CurrentTheme() - m.anim = anim.New(anim.Settings{ - Size: 15, - Label: "Working", - GradColorA: t.Primary, - GradColorB: t.Secondary, - LabelColor: t.FgBase, - CycleColors: true, - }) - if m.isNested { - m.anim = anim.New(anim.Settings{ - Size: 10, - GradColorA: t.Primary, - GradColorB: t.Secondary, - CycleColors: true, - }) - } - return m -} - -// Init initializes the tool call component and starts animations if needed. -// Returns a command to start the animation for pending tool calls. -func (m *toolCallCmp) Init() tea.Cmd { - m.spinning = m.shouldSpin() - return m.anim.Init() -} - -// Update handles incoming messages and updates the component state. -// Manages animation updates for pending tool calls. -func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case anim.StepMsg: - var cmds []tea.Cmd - for i, nested := range m.nestedToolCalls { - if nested.Spinning() { - u, cmd := nested.Update(msg) - m.nestedToolCalls[i] = u.(ToolCallCmp) - cmds = append(cmds, cmd) - } - } - if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - case tea.KeyPressMsg: - if key.Matches(msg, CopyKey) { - return m, m.copyTool() - } - } - return m, nil -} - -// View renders the tool call component based on its current state. -// Shows either a pending animation or the tool-specific rendered result. -func (m *toolCallCmp) View() string { - box := m.style() - - if !m.call.Finished && !m.cancelled { - return box.Render(m.renderPending()) - } - - r := registry.lookup(m.call.Name) - - if m.isNested { - return box.Render(r.Render(m)) - } - return box.Render(r.Render(m)) -} - -// State management methods - -// SetCancelled marks the tool call as cancelled -func (m *toolCallCmp) SetCancelled() { - m.cancelled = true -} - -func (m *toolCallCmp) copyTool() tea.Cmd { - content := m.formatToolForCopy() - return tea.Sequence( - tea.SetClipboard(content), - func() tea.Msg { - _ = clipboard.WriteAll(content) - return nil - }, - util.ReportInfo("Tool content copied to clipboard"), - ) -} - -func (m *toolCallCmp) formatToolForCopy() string { - var parts []string - - toolName := prettifyToolName(m.call.Name) - parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName)) - - if m.call.Input != "" { - params := m.formatParametersForCopy() - if params != "" { - parts = append(parts, "### Parameters:") - parts = append(parts, params) - } - } - - if m.result.ToolCallID != "" { - if m.result.IsError { - parts = append(parts, "### Error:") - parts = append(parts, m.result.Content) - } else { - parts = append(parts, "### Result:") - content := m.formatResultForCopy() - if content != "" { - parts = append(parts, content) - } - } - } else if m.cancelled { - parts = append(parts, "### Status:") - parts = append(parts, "Cancelled") - } else { - parts = append(parts, "### Status:") - parts = append(parts, "Pending...") - } - - return strings.Join(parts, "\n\n") -} - -func (m *toolCallCmp) formatParametersForCopy() string { - switch m.call.Name { - case tools.BashToolName: - var params tools.BashParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - cmd := strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") - return fmt.Sprintf("**Command:** %s", cmd) - } - case tools.ViewToolName: - var params tools.ViewParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) - if params.Limit > 0 { - parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit)) - } - if params.Offset > 0 { - parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset)) - } - return strings.Join(parts, "\n") - } - case tools.EditToolName: - var params tools.EditParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.MultiEditToolName: - var params tools.MultiEditParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) - parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits))) - return strings.Join(parts, "\n") - } - case tools.WriteToolName: - var params tools.WriteParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.FetchToolName: - var params tools.FetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - if params.Format != "" { - parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format)) - } - if params.Timeout > 0 { - parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout)) - } - return strings.Join(parts, "\n") - } - case tools.AgenticFetchToolName: - var params tools.AgenticFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - if params.URL != "" { - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - } - if params.Prompt != "" { - parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt)) - } - return strings.Join(parts, "\n") - } - case tools.WebFetchToolName: - var params tools.WebFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**URL:** %s", params.URL) - } - case tools.GrepToolName: - var params tools.GrepParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) - if params.Path != "" { - parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) - } - if params.Include != "" { - parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include)) - } - if params.LiteralText { - parts = append(parts, "**Literal:** true") - } - return strings.Join(parts, "\n") - } - case tools.GlobToolName: - var params tools.GlobParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) - if params.Path != "" { - parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) - } - return strings.Join(parts, "\n") - } - case tools.LSToolName: - var params tools.LSParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - path := params.Path - if path == "" { - path = "." - } - return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path)) - } - case tools.DownloadToolName: - var params tools.DownloadParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath))) - if params.Timeout > 0 { - parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String())) - } - return strings.Join(parts, "\n") - } - case tools.SourcegraphToolName: - var params tools.SourcegraphParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query)) - if params.Count > 0 { - parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count)) - } - if params.ContextWindow > 0 { - parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow)) - } - return strings.Join(parts, "\n") - } - case tools.DiagnosticsToolName: - return "**Project:** diagnostics" - case agent.AgentToolName: - var params agent.AgentParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**Task:**\n%s", params.Prompt) - } - } - - var params map[string]any - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - for key, value := range params { - displayKey := strings.ReplaceAll(key, "_", " ") - if len(displayKey) > 0 { - displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:] - } - parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value)) - } - return strings.Join(parts, "\n") - } - - return "" -} - -func (m *toolCallCmp) formatResultForCopy() string { - if m.result.Data != "" { - if strings.HasPrefix(m.result.MIMEType, "image/") { - return fmt.Sprintf("[Image: %s]", m.result.MIMEType) - } - return fmt.Sprintf("[Media: %s]", m.result.MIMEType) - } - - switch m.call.Name { - case tools.BashToolName: - return m.formatBashResultForCopy() - case tools.ViewToolName: - return m.formatViewResultForCopy() - case tools.EditToolName: - return m.formatEditResultForCopy() - case tools.MultiEditToolName: - return m.formatMultiEditResultForCopy() - case tools.WriteToolName: - return m.formatWriteResultForCopy() - case tools.FetchToolName: - return m.formatFetchResultForCopy() - case tools.AgenticFetchToolName: - return m.formatAgenticFetchResultForCopy() - case tools.WebFetchToolName: - return m.formatWebFetchResultForCopy() - case agent.AgentToolName: - return m.formatAgentResultForCopy() - case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName: - return fmt.Sprintf("```\n%s\n```", m.result.Content) - default: - return m.result.Content - } -} - -func (m *toolCallCmp) formatBashResultForCopy() string { - var meta tools.BashResponseMetadata - if m.result.Metadata != "" { - json.Unmarshal([]byte(m.result.Metadata), &meta) - } - - output := meta.Output - if output == "" && m.result.Content != tools.BashNoOutput { - output = m.result.Content - } - - if output == "" { - return "" - } - - return fmt.Sprintf("```bash\n%s\n```", output) -} - -func (m *toolCallCmp) formatViewResultForCopy() string { - var meta tools.ViewResponseMetadata - if m.result.Metadata != "" { - json.Unmarshal([]byte(m.result.Metadata), &meta) - } - - if meta.Content == "" { - return m.result.Content - } - - lang := "" - if meta.FilePath != "" { - ext := strings.ToLower(filepath.Ext(meta.FilePath)) - switch ext { - case ".go": - lang = "go" - case ".js", ".mjs": - lang = "javascript" - case ".ts": - lang = "typescript" - case ".py": - lang = "python" - case ".rs": - lang = "rust" - case ".java": - lang = "java" - case ".c": - lang = "c" - case ".cpp", ".cc", ".cxx": - lang = "cpp" - case ".sh", ".bash": - lang = "bash" - case ".json": - lang = "json" - case ".yaml", ".yml": - lang = "yaml" - case ".xml": - lang = "xml" - case ".html": - lang = "html" - case ".css": - lang = "css" - case ".md": - lang = "markdown" - } - } - - var result strings.Builder - if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) - } else { - result.WriteString("```\n") - } - result.WriteString(meta.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatEditResultForCopy() string { - var meta tools.EditResponseMetadata - if m.result.Metadata == "" { - return m.result.Content - } - - if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil { - return m.result.Content - } - - var params tools.EditParams - json.Unmarshal([]byte(m.call.Input), ¶ms) - - var result strings.Builder - - if meta.OldContent != "" || meta.NewContent != "" { - fileName := params.FilePath - if fileName != "" { - fileName = fsext.PrettyPath(fileName) - } - diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) - result.WriteString("```diff\n") - result.WriteString(diffContent) - result.WriteString("\n```") - } - - return result.String() -} - -func (m *toolCallCmp) formatMultiEditResultForCopy() string { - var meta tools.MultiEditResponseMetadata - if m.result.Metadata == "" { - return m.result.Content - } - - if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil { - return m.result.Content - } - - var params tools.MultiEditParams - json.Unmarshal([]byte(m.call.Input), ¶ms) - - var result strings.Builder - if meta.OldContent != "" || meta.NewContent != "" { - fileName := params.FilePath - if fileName != "" { - fileName = fsext.PrettyPath(fileName) - } - diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) - result.WriteString("```diff\n") - result.WriteString(diffContent) - result.WriteString("\n```") - } - - return result.String() -} - -func (m *toolCallCmp) formatWriteResultForCopy() string { - var params tools.WriteParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - lang := "" - if params.FilePath != "" { - ext := strings.ToLower(filepath.Ext(params.FilePath)) - switch ext { - case ".go": - lang = "go" - case ".js", ".mjs": - lang = "javascript" - case ".ts": - lang = "typescript" - case ".py": - lang = "python" - case ".rs": - lang = "rust" - case ".java": - lang = "java" - case ".c": - lang = "c" - case ".cpp", ".cc", ".cxx": - lang = "cpp" - case ".sh", ".bash": - lang = "bash" - case ".json": - lang = "json" - case ".yaml", ".yml": - lang = "yaml" - case ".xml": - lang = "xml" - case ".html": - lang = "html" - case ".css": - lang = "css" - case ".md": - lang = "markdown" - } - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath))) - if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) - } else { - result.WriteString("```\n") - } - result.WriteString(params.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatFetchResultForCopy() string { - var params tools.FetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) - } - if params.Format != "" { - result.WriteString(fmt.Sprintf("Format: %s\n", params.Format)) - } - if params.Timeout > 0 { - result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout)) - } - result.WriteString("\n") - - result.WriteString(m.result.Content) - - return result.String() -} - -func (m *toolCallCmp) formatAgenticFetchResultForCopy() string { - var params tools.AgenticFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) - } - if params.Prompt != "" { - result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt)) - } - - result.WriteString("```markdown\n") - result.WriteString(m.result.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatWebFetchResultForCopy() string { - var params tools.WebFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL)) - result.WriteString("```markdown\n") - result.WriteString(m.result.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatAgentResultForCopy() string { - var result strings.Builder - - if len(m.nestedToolCalls) > 0 { - result.WriteString("### Nested Tool Calls:\n") - for i, nestedCall := range m.nestedToolCalls { - nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy() - indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ") - result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent)) - if i < len(m.nestedToolCalls)-1 { - result.WriteString("\n") - } - } - - if m.result.Content != "" { - result.WriteString("\n### Final Result:\n") - } - } - - if m.result.Content != "" { - result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content)) - } - - return result.String() -} - -// SetToolCall updates the tool call data and stops spinning if finished -func (m *toolCallCmp) SetToolCall(call message.ToolCall) { - m.call = call - if m.call.Finished { - m.spinning = false - } -} - -// ParentMessageID returns the ID of the message that initiated this tool call -func (m *toolCallCmp) ParentMessageID() string { - return m.parentMessageID -} - -// SetToolResult updates the tool result and stops the spinning animation -func (m *toolCallCmp) SetToolResult(result message.ToolResult) { - m.result = result - m.spinning = false -} - -// GetToolCall returns the current tool call data -func (m *toolCallCmp) GetToolCall() message.ToolCall { - return m.call -} - -// GetToolResult returns the current tool result data -func (m *toolCallCmp) GetToolResult() message.ToolResult { - return m.result -} - -// GetNestedToolCalls returns the nested tool calls -func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp { - return m.nestedToolCalls -} - -// SetNestedToolCalls sets the nested tool calls -func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) { - m.nestedToolCalls = calls - for _, nested := range m.nestedToolCalls { - nested.SetSize(m.width, 0) - } -} - -// SetIsNested sets whether this tool call is nested within another -func (m *toolCallCmp) SetIsNested(isNested bool) { - m.isNested = isNested -} - -// Rendering methods - -// renderPending displays the tool name with a loading animation for pending tool calls -func (m *toolCallCmp) renderPending() string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if m.isNested { - tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) - } - tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) -} - -// style returns the lipgloss style for the tool call component. -// Applies muted colors and focus-dependent border styles. -func (m *toolCallCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - - if m.isNested { - return t.S().Muted - } - style := t.S().Muted.PaddingLeft(2) - - if m.focused { - style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark) - } - return style -} - -// textWidth calculates the available width for text content, -// accounting for borders and padding -func (m *toolCallCmp) textWidth() int { - if m.isNested { - return m.width - 6 - } - return m.width - 5 // take into account the border and PaddingLeft -} - -// fit truncates content to fit within the specified width with ellipsis -func (m *toolCallCmp) fit(content string, width int) string { - if lipgloss.Width(content) <= width { - return content - } - t := styles.CurrentTheme() - lineStyle := t.S().Muted - dots := lineStyle.Render("…") - return ansi.Truncate(content, width, dots) -} - -// Focus management methods - -// Blur removes focus from the tool call component -func (m *toolCallCmp) Blur() tea.Cmd { - m.focused = false - return nil -} - -// Focus sets focus on the tool call component -func (m *toolCallCmp) Focus() tea.Cmd { - m.focused = true - return nil -} - -// IsFocused returns whether the tool call component is currently focused -func (m *toolCallCmp) IsFocused() bool { - return m.focused -} - -// Size management methods - -// GetSize returns the current dimensions of the tool call component -func (m *toolCallCmp) GetSize() (int, int) { - return m.width, 0 -} - -// SetSize updates the width of the tool call component for text wrapping -func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd { - m.width = width - for _, nested := range m.nestedToolCalls { - nested.SetSize(width, height) - } - return nil -} - -// shouldSpin determines whether the tool call should show a loading animation. -// Returns true if the tool call is not finished or if the result doesn't match the call ID. -func (m *toolCallCmp) shouldSpin() bool { - return !m.call.Finished && !m.cancelled -} - -// Spinning returns whether the tool call is currently showing a loading animation -func (m *toolCallCmp) Spinning() bool { - if m.spinning { - return true - } - for _, nested := range m.nestedToolCalls { - if nested.Spinning() { - return true - } - } - return m.spinning -} - -func (m *toolCallCmp) ID() string { - return m.call.ID -} - -// SetPermissionRequested marks that a permission request was made for this tool call -func (m *toolCallCmp) SetPermissionRequested() { - m.permissionRequested = true -} - -// SetPermissionGranted marks that permission was granted for this tool call -func (m *toolCallCmp) SetPermissionGranted() { - m.permissionGranted = true -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go deleted file mode 100644 index 9b3d52dadb9a7677bdb5db4b3a8360e7385775ba..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ /dev/null @@ -1,613 +0,0 @@ -package sidebar - -import ( - "context" - "fmt" - "slices" - "strings" - - 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" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/files" - "github.com/charmbracelet/crush/internal/tui/components/logo" - lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" - "github.com/charmbracelet/crush/internal/tui/components/mcp" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -type FileHistory struct { - initialVersion history.File - latestVersion history.File -} - -const LogoHeightBreakpoint = 30 - -// Default maximum number of items to show in each section -const ( - DefaultMaxFilesShown = 10 - DefaultMaxLSPsShown = 8 - DefaultMaxMCPsShown = 8 - MinItemsPerSection = 2 // Minimum items to show per section -) - -type SessionFile struct { - History FileHistory - FilePath string - Additions int - Deletions int -} -type SessionFilesMsg struct { - Files []SessionFile -} - -type Sidebar interface { - util.Model - layout.Sizeable - SetSession(session session.Session) tea.Cmd - SetCompactMode(bool) -} - -type sidebarCmp struct { - width, height int - session session.Session - logo string - cwd string - lspClients *csync.Map[string, *lsp.Client] - compactMode bool - history history.Service - files *csync.Map[string, SessionFile] -} - -func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar { - return &sidebarCmp{ - lspClients: lspClients, - history: history, - compactMode: compact, - files: csync.NewMap[string, SessionFile](), - } -} - -func (m *sidebarCmp) Init() tea.Cmd { - return nil -} - -func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case SessionFilesMsg: - m.files = csync.NewMap[string, SessionFile]() - for _, file := range msg.Files { - m.files.Set(file.FilePath, file) - } - return m, nil - - case chat.SessionClearedMsg: - m.session = session.Session{} - case pubsub.Event[history.File]: - return m, m.handleFileHistoryEvent(msg) - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if m.session.ID == msg.Payload.ID { - m.session = msg.Payload - } - } - } - return m, nil -} - -func (m *sidebarCmp) View() string { - t := styles.CurrentTheme() - parts := []string{} - - style := t.S().Base. - Width(m.width). - Height(m.height). - Padding(1) - if m.compactMode { - style = style.PaddingTop(0) - } - - if !m.compactMode { - if m.height > LogoHeightBreakpoint { - parts = append(parts, m.logo) - } else { - // Use a smaller logo for smaller screens - parts = append(parts, - logo.SmallRender(m.width-style.GetHorizontalFrameSize()), - "") - } - } - - if !m.compactMode && m.session.ID != "" { - parts = append(parts, t.S().Muted.Render(m.session.Title), "") - } else if m.session.ID != "" { - parts = append(parts, t.S().Text.Render(m.session.Title), "") - } - - if !m.compactMode { - parts = append(parts, - m.cwd, - "", - ) - } - parts = append(parts, - m.currentModelBlock(), - ) - - // Check if we should use horizontal layout for sections - if m.compactMode && m.width > m.height { - // Horizontal layout for compact mode when width > height - sectionsContent := m.renderSectionsHorizontal() - if sectionsContent != "" { - parts = append(parts, "", sectionsContent) - } - } else { - // Vertical layout (default) - if m.session.ID != "" { - parts = append(parts, "", m.filesBlock()) - } - parts = append(parts, - "", - m.lspBlock(), - "", - m.mcpBlock(), - ) - } - - return style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), - ) -} - -func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd { - return func() tea.Msg { - file := event.Payload - found := false - for existing := range m.files.Seq() { - if existing.FilePath != file.Path { - continue - } - if existing.History.latestVersion.Version < file.Version { - existing.History.latestVersion = file - } else if file.Version == 0 { - existing.History.initialVersion = file - } else { - // If the version is not greater than the latest, we ignore it - continue - } - before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content) - after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content) - path := existing.History.initialVersion.Path - cwd := config.Get().WorkingDir() - path = strings.TrimPrefix(path, cwd) - _, additions, deletions := diff.GenerateDiff(before, after, path) - existing.Additions = additions - existing.Deletions = deletions - m.files.Set(file.Path, existing) - found = true - break - } - if found { - return nil - } - sf := SessionFile{ - History: FileHistory{ - initialVersion: file, - latestVersion: file, - }, - FilePath: file.Path, - Additions: 0, - Deletions: 0, - } - m.files.Set(file.Path, sf) - return nil - } -} - -func (m *sidebarCmp) loadSessionFiles() tea.Msg { - files, err := m.history.ListBySession(context.Background(), m.session.ID) - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: err.Error(), - } - } - - fileMap := make(map[string]FileHistory) - - for _, file := range files { - if existing, ok := fileMap[file.Path]; ok { - // Update the latest version - existing.latestVersion = file - fileMap[file.Path] = existing - } else { - // Add the initial version - fileMap[file.Path] = FileHistory{ - initialVersion: file, - latestVersion: file, - } - } - } - - sessionFiles := make([]SessionFile, 0, len(fileMap)) - for path, fh := range fileMap { - cwd := config.Get().WorkingDir() - path = strings.TrimPrefix(path, cwd) - before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content) - after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content) - _, additions, deletions := diff.GenerateDiff(before, after, path) - sessionFiles = append(sessionFiles, SessionFile{ - History: fh, - FilePath: path, - Additions: additions, - Deletions: deletions, - }) - } - - return SessionFilesMsg{ - Files: sessionFiles, - } -} - -func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { - m.logo = m.logoBlock() - m.cwd = cwd() - m.width = width - m.height = height - return nil -} - -func (m *sidebarCmp) GetSize() (int, int) { - return m.width, m.height -} - -func (m *sidebarCmp) logoBlock() string { - t := styles.CurrentTheme() - return logo.Render(version.Version, true, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: m.width - 2, - }) -} - -func (m *sidebarCmp) getMaxWidth() int { - return min(m.width-2, 58) // -2 for padding -} - -// calculateAvailableHeight estimates how much height is available for dynamic content -func (m *sidebarCmp) calculateAvailableHeight() int { - usedHeight := 0 - - if !m.compactMode { - if m.height > LogoHeightBreakpoint { - usedHeight += 7 // Approximate logo height - } else { - usedHeight += 2 // Smaller logo height - } - usedHeight += 1 // Empty line after logo - } - - if m.session.ID != "" { - usedHeight += 1 // Title line - usedHeight += 1 // Empty line after title - } - - if !m.compactMode { - usedHeight += 1 // CWD line - usedHeight += 1 // Empty line after CWD - } - - usedHeight += 2 // Model info - - usedHeight += 6 // 3 sections × 2 lines each (header + empty line) - - // Base padding - usedHeight += 2 // Top and bottom padding - - return max(0, m.height-usedHeight) -} - -// getDynamicLimits calculates how many items to show in each section based on available height -func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) { - availableHeight := m.calculateAvailableHeight() - - // If we have very little space, use minimum values - if availableHeight < 10 { - return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection - } - - // Distribute available height among the three sections - // Give priority to files, then LSPs, then MCPs - totalSections := 3 - heightPerSection := availableHeight / totalSections - - // Calculate limits for each section, ensuring minimums - maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection)) - maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection)) - maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection)) - - // If we have extra space, give it to files first - remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs) - if remainingHeight > 0 { - extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles) - maxFiles += extraForFiles - remainingHeight -= extraForFiles - - if remainingHeight > 0 { - extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs) - maxLSPs += extraForLSPs - remainingHeight -= extraForLSPs - - if remainingHeight > 0 { - maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs) - } - } - } - - return maxFiles, maxLSPs, maxMCPs -} - -// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally -func (m *sidebarCmp) renderSectionsHorizontal() string { - // Calculate available width for each section - totalWidth := m.width - 4 // Account for padding and spacing - sectionWidth := min(50, totalWidth/3) - - // Get the sections content with limited height - var filesContent, lspContent, mcpContent string - - filesContent = m.filesBlockCompact(sectionWidth) - lspContent = m.lspBlockCompact(sectionWidth) - mcpContent = m.mcpBlockCompact(sectionWidth) - - return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent) -} - -// filesBlockCompact renders the files block with limited width and height for horizontal layout -func (m *sidebarCmp) filesBlockCompact(maxWidth int) string { - // Convert map to slice and handle type conversion - sessionFiles := slices.Collect(m.files.Seq()) - fileSlice := make([]files.SessionFile, len(sessionFiles)) - for i, sf := range sessionFiles { - fileSlice[i] = files.SessionFile{ - History: files.FileHistory{ - InitialVersion: sf.History.initialVersion, - LatestVersion: sf.History.latestVersion, - }, - FilePath: sf.FilePath, - Additions: sf.Additions, - Deletions: sf.Deletions, - } - } - - // Limit items for horizontal layout - maxItems := min(5, len(fileSlice)) - availableHeight := m.height - 8 // Reserve space for header and other content - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return files.RenderFileBlock(fileSlice, files.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "Modified Files", - }, true) -} - -// lspBlockCompact renders the LSP block with limited width and height for horizontal layout -func (m *sidebarCmp) lspBlockCompact(maxWidth int) string { - // Limit items for horizontal layout - lspConfigs := config.Get().LSP.Sorted() - maxItems := min(5, len(lspConfigs)) - availableHeight := m.height - 8 - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "LSPs", - }, true) -} - -// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout -func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string { - // Limit items for horizontal layout - maxItems := min(5, len(config.Get().MCP.Sorted())) - availableHeight := m.height - 8 - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return mcp.RenderMCPBlock(mcp.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "MCPs", - }, true) -} - -func (m *sidebarCmp) filesBlock() string { - // Convert map to slice and handle type conversion - sessionFiles := slices.Collect(m.files.Seq()) - fileSlice := make([]files.SessionFile, len(sessionFiles)) - for i, sf := range sessionFiles { - fileSlice[i] = files.SessionFile{ - History: files.FileHistory{ - InitialVersion: sf.History.initialVersion, - LatestVersion: sf.History.latestVersion, - }, - FilePath: sf.FilePath, - Additions: sf.Additions, - Deletions: sf.Deletions, - } - } - - // Limit the number of files shown - maxFiles, _, _ := m.getDynamicLimits() - maxFiles = min(len(fileSlice), maxFiles) - - return files.RenderFileBlock(fileSlice, files.RenderOptions{ - MaxWidth: m.getMaxWidth(), - MaxItems: maxFiles, - ShowSection: true, - SectionName: core.Section("Modified Files", m.getMaxWidth()), - }, true) -} - -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(), - MaxItems: maxLSPs, - ShowSection: true, - SectionName: core.Section("LSPs", m.getMaxWidth()), - }, true) -} - -func (m *sidebarCmp) mcpBlock() string { - // Limit the number of MCPs shown - _, _, maxMCPs := m.getDynamicLimits() - mcps := config.Get().MCP.Sorted() - maxMCPs = min(len(mcps), maxMCPs) - - return mcp.RenderMCPBlock(mcp.RenderOptions{ - MaxWidth: m.getMaxWidth(), - MaxItems: maxMCPs, - ShowSection: true, - SectionName: core.Section("MCPs", m.getMaxWidth()), - }, true) -} - -func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { - t := styles.CurrentTheme() - // Format tokens in human-readable format (e.g., 110K, 1.2M) - var formattedTokens string - switch { - case tokens >= 1_000_000: - formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) - case tokens >= 1_000: - formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) - default: - formattedTokens = fmt.Sprintf("%d", tokens) - } - - // Remove .0 suffix if present - if strings.HasSuffix(formattedTokens, ".0K") { - formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) - } - if strings.HasSuffix(formattedTokens, ".0M") { - formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) - } - - percentage := (float64(tokens) / float64(contextWindow)) * 100 - - baseStyle := t.S().Base - - formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost)) - - formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens)) - formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage))) - formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) - if percentage > 80 { - // add the warning icon - formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens) - } - - return fmt.Sprintf("%s %s", formattedTokens, formattedCost) -} - -func (s *sidebarCmp) currentModelBlock() string { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - - selectedModel := cfg.Models[agentCfg.Model] - - model := config.Get().GetModelByType(agentCfg.Model) - modelProvider := config.Get().GetProviderForModel(agentCfg.Model) - - t := styles.CurrentTheme() - - modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon) - modelName := t.S().Text.Render(model.Name) - modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) - parts := []string{ - modelInfo, - } - if model.CanReason { - reasoningInfoStyle := t.S().Subtle.PaddingLeft(2) - switch modelProvider.Type { - case catwalk.TypeAnthropic: - 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: - reasoningEffort := model.DefaultReasoningEffort - if selectedModel.ReasoningEffort != "" { - reasoningEffort = selectedModel.ReasoningEffort - } - formatter := cases.Title(language.English, cases.NoLower) - parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)))) - } - } - if s.session.ID != "" { - parts = append( - parts, - " "+formatTokensAndCost( - s.session.CompletionTokens+s.session.PromptTokens, - model.ContextWindow, - s.session.Cost, - ), - ) - } - return lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) -} - -// SetSession implements Sidebar. -func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd { - m.session = session - return m.loadSessionFiles -} - -// SetCompactMode sets the compact mode for the sidebar. -func (m *sidebarCmp) SetCompactMode(compact bool) { - m.compactMode = compact -} - -func cwd() string { - cwd := config.Get().WorkingDir() - t := styles.CurrentTheme() - return t.S().Muted.Render(home.Short(cwd)) -} diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go deleted file mode 100644 index fc8fc373498feea584e75701010762ac66db7879..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/keys.go +++ /dev/null @@ -1,58 +0,0 @@ -package splash - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Yes, - No, - Tab, - LeftRight, - Back, - Copy key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y"), - key.WithHelp("y", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch"), - ), - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch"), - ), - Back: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "back"), - ), - Copy: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - ), - } -} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go deleted file mode 100644 index 517f6d0930c46cf3d2e9f656c22515de4e9785fd..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/splash.go +++ /dev/null @@ -1,874 +0,0 @@ -package splash - -import ( - "fmt" - "strings" - "time" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "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" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/logo" - lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" - "github.com/charmbracelet/crush/internal/tui/components/mcp" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" -) - -type Splash interface { - util.Model - layout.Sizeable - layout.Help - Cursor() *tea.Cursor - // SetOnboarding controls whether the splash shows model selection UI - SetOnboarding(bool) - // SetProjectInit controls whether the splash shows project initialization prompt - SetProjectInit(bool) - - // Showing API key input - IsShowingAPIKey() bool - - // IsAPIKeyValid returns whether the API key is valid - IsAPIKeyValid() bool - - // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow - IsShowingHyperOAuth2() bool - - // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow - IsShowingCopilotOAuth2() bool -} - -const ( - SplashScreenPaddingY = 1 // Padding Y for the splash screen - - LogoGap = 6 -) - -// OnboardingCompleteMsg is sent when onboarding is complete -type ( - OnboardingCompleteMsg struct{} - SubmitAPIKeyMsg struct{} -) - -type splashCmp struct { - width, height int - keyMap KeyMap - logoRendered string - - // State - isOnboarding bool - needsProjectInit bool - needsAPIKey bool - selectedNo bool - - listHeight int - modelList *models.ModelListComponent - apiKeyInput *models.APIKeyInput - selectedModel *models.ModelOption - isAPIKeyValid bool - apiKeyValue string - - // Hyper device flow state - hyperDeviceFlow *hyper.DeviceFlow - showHyperDeviceFlow bool - - // Copilot device flow state - copilotDeviceFlow *copilot.DeviceFlow - showCopilotDeviceFlow bool -} - -func New() Splash { - keyMap := DefaultKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false) - apiKeyInput := models.NewAPIKeyInput() - - return &splashCmp{ - width: 0, - height: 0, - keyMap: keyMap, - logoRendered: "", - modelList: modelList, - apiKeyInput: apiKeyInput, - selectedNo: false, - } -} - -func (s *splashCmp) SetOnboarding(onboarding bool) { - s.isOnboarding = onboarding -} - -func (s *splashCmp) SetProjectInit(needsInit bool) { - s.needsProjectInit = needsInit -} - -// GetSize implements SplashPage. -func (s *splashCmp) GetSize() (int, int) { - return s.width, s.height -} - -// Init implements SplashPage. -func (s *splashCmp) Init() tea.Cmd { - return tea.Batch( - s.modelList.Init(), - s.apiKeyInput.Init(), - ) -} - -// SetSize implements SplashPage. -func (s *splashCmp) SetSize(width int, height int) tea.Cmd { - wasSmallScreen := s.isSmallScreen() - rerenderLogo := width != s.width - s.height = height - s.width = width - if rerenderLogo || wasSmallScreen != s.isSmallScreen() { - s.logoRendered = s.logoBlock() - } - // remove padding, logo height, gap, title space - s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2 - listWidth := min(60, width) - s.apiKeyInput.SetWidth(width - 2) - return s.modelList.SetSize(listWidth, s.listHeight) -} - -// Update implements SplashPage. -func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return s, s.SetSize(msg.Width, msg.Height) - case hyper.DeviceFlowCompletedMsg: - s.showHyperDeviceFlow = false - return s, s.saveAPIKeyAndContinue(msg.Token, true) - case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg: - if s.hyperDeviceFlow != nil { - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - } - return s, nil - case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: - if s.copilotDeviceFlow != nil { - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - } - return s, nil - case copilot.DeviceFlowCompletedMsg: - s.showCopilotDeviceFlow = false - return s, s.saveAPIKeyAndContinue(msg.Token, true) - case models.APIKeyStateChangeMsg: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - if msg.State == models.APIKeyInputStateVerified { - return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return SubmitAPIKeyMsg{} - }) - } - return s, cmd - case SubmitAPIKeyMsg: - if s.isAPIKeyValid { - return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true) - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow: - return s, s.hyperDeviceFlow.CopyCode() - case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow: - return s, s.copilotDeviceFlow.CopyCode() - case key.Matches(msg, s.keyMap.Back): - switch { - case s.showHyperDeviceFlow: - s.hyperDeviceFlow = nil - s.showHyperDeviceFlow = false - return s, nil - case s.showCopilotDeviceFlow: - s.copilotDeviceFlow = nil - s.showCopilotDeviceFlow = false - return s, nil - case s.isAPIKeyValid: - return s, nil - case s.needsAPIKey: - s.needsAPIKey = false - s.selectedModel = nil - s.isAPIKeyValid = false - s.apiKeyValue = "" - s.apiKeyInput.Reset() - return s, nil - } - case key.Matches(msg, s.keyMap.Select): - switch { - case s.showHyperDeviceFlow: - return s, s.hyperDeviceFlow.CopyCodeAndOpenURL() - case s.showCopilotDeviceFlow: - return s, s.copilotDeviceFlow.CopyCodeAndOpenURL() - case s.isAPIKeyValid: - return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true) - case s.isOnboarding && !s.needsAPIKey: - selectedItem := s.modelList.SelectedModel() - if selectedItem == nil { - return s, nil - } - if s.isProviderConfigured(string(selectedItem.Provider.ID)) { - cmd := s.setPreferredModel(*selectedItem) - s.isOnboarding = false - return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) - } else { - switch selectedItem.Provider.ID { - case hyperp.Name: - s.selectedModel = selectedItem - s.showHyperDeviceFlow = true - s.hyperDeviceFlow = hyper.NewDeviceFlow() - s.hyperDeviceFlow.SetWidth(min(s.width-2, 60)) - return s, s.hyperDeviceFlow.Init() - case catwalk.InferenceProviderCopilot: - if token, ok := config.Get().ImportCopilot(); ok { - s.selectedModel = selectedItem - return s, s.saveAPIKeyAndContinue(token, true) - } - s.selectedModel = selectedItem - s.showCopilotDeviceFlow = true - s.copilotDeviceFlow = copilot.NewDeviceFlow() - s.copilotDeviceFlow.SetWidth(min(s.width-2, 60)) - return s, s.copilotDeviceFlow.Init() - } - // Provider not configured, show API key input - s.needsAPIKey = true - s.selectedModel = selectedItem - s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) - return s, nil - } - case s.needsAPIKey: - // Handle API key submission - s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value()) - if s.apiKeyValue == "" { - return s, nil - } - - provider, err := s.getProvider(s.selectedModel.Provider.ID) - if err != nil || provider == nil { - return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID)) - } - providerConfig := config.ProviderConfig{ - ID: string(s.selectedModel.Provider.ID), - Name: s.selectedModel.Provider.Name, - APIKey: s.apiKeyValue, - Type: provider.Type, - BaseURL: provider.APIEndpoint, - } - return s, tea.Sequence( - util.CmdHandler(models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateVerifying, - }), - func() tea.Msg { - start := time.Now() - err := providerConfig.TestConnection(config.Get().Resolver()) - // intentionally wait for at least 750ms to make sure the user sees the spinner - elapsed := time.Since(start) - if elapsed < 750*time.Millisecond { - time.Sleep(750*time.Millisecond - elapsed) - } - if err == nil { - s.isAPIKeyValid = true - return models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateVerified, - } - } - return models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateError, - } - }, - ) - case s.needsProjectInit: - return s, s.initializeProject() - } - case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = !s.selectedNo - return s, nil - } - case key.Matches(msg, s.keyMap.Yes): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.isOnboarding { - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = false - return s, s.initializeProject() - } - case key.Matches(msg, s.keyMap.No): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.isOnboarding { - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = true - return s, s.initializeProject() - } - default: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - case s.needsAPIKey: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - case s.isOnboarding: - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - } - case tea.PasteMsg: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - case s.needsAPIKey: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - case s.isOnboarding: - var cmd tea.Cmd - s.modelList, cmd = s.modelList.Update(msg) - return s, cmd - } - case spinner.TickMsg: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - default: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - } - return s, nil -} - -func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd { - if s.selectedModel == nil { - return nil - } - - cfg := config.Get() - err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey) - if err != nil { - return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) - } - - // Reset API key state and continue with model selection - s.needsAPIKey = false - cmd := s.setPreferredModel(*s.selectedModel) - s.isOnboarding = false - s.selectedModel = nil - s.isAPIKeyValid = false - - if close { - return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) - } - return cmd -} - -func (s *splashCmp) initializeProject() tea.Cmd { - s.needsProjectInit = false - - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - var cmds []tea.Cmd - - cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{})) - if !s.selectedNo { - initPrompt, err := agent.InitializePrompt(*config.Get()) - if err != nil { - return util.ReportError(err) - } - cmds = append(cmds, - util.CmdHandler(chat.SessionClearedMsg{}), - util.CmdHandler(chat.SendMsg{ - Text: initPrompt, - }), - ) - } - return tea.Sequence(cmds...) -} - -func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd { - cfg := config.Get() - model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID) - if model == nil { - return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID)) - } - - selectedModel := config.SelectedModel{ - Model: selectedItem.Model.ID, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: model.DefaultReasoningEffort, - MaxTokens: model.DefaultMaxTokens, - } - - err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel) - if err != nil { - return util.ReportError(err) - } - - // Now lets automatically setup the small model - knownProvider, err := s.getProvider(selectedItem.Provider.ID) - if err != nil { - return util.ReportError(err) - } - if knownProvider == nil { - // for local provider we just use the same model - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) - if err != nil { - return util.ReportError(err) - } - } else { - smallModel := knownProvider.DefaultSmallModelID - model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel) - // should never happen - if model == nil { - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) - if err != nil { - return util.ReportError(err) - } - return nil - } - smallSelectedModel := config.SelectedModel{ - Model: smallModel, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: model.DefaultReasoningEffort, - MaxTokens: model.DefaultMaxTokens, - } - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel) - if err != nil { - return util.ReportError(err) - } - } - cfg.SetupAgents() - return nil -} - -func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) { - cfg := config.Get() - providers, err := config.Providers(cfg) - if err != nil { - return nil, err - } - for _, p := range providers { - if p.ID == providerID { - return &p, nil - } - } - return nil, nil -} - -func (s *splashCmp) isProviderConfigured(providerID string) bool { - cfg := config.Get() - if _, ok := cfg.Providers.Get(providerID); ok { - return true - } - return false -} - -func (s *splashCmp) View() string { - t := styles.CurrentTheme() - var content string - - switch { - case s.showHyperDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - hyperView := s.hyperDeviceFlow.View() - hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"), - hyperView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - hyperSelector, - ) - case s.showCopilotDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - copilotView := s.copilotDeviceFlow.View() - copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"), - copilotView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - copilotSelector, - ) - case s.needsAPIKey: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) - apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - apiKeyView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - apiKeySelector, - ) - case s.isOnboarding: - modelListView := s.modelList.View() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."), - "", - modelListView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - modelSelector, - ) - case s.needsProjectInit: - titleStyle := t.S().Base.Foreground(t.FgBase) - pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2) - bodyStyle := t.S().Base.Foreground(t.FgMuted) - shortcutStyle := t.S().Base.Foreground(t.Success) - - initFile := config.Get().Options.InitializeAs - initText := lipgloss.JoinVertical( - lipgloss.Left, - titleStyle.Render("Would you like to initialize this project?"), - "", - pathStyle.Render(s.cwd()), - "", - bodyStyle.Render("When I initialize your codebase I examine the project and put the"), - bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)), - "", - bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."), - "", - bodyStyle.Render("Would you like to initialize now?"), - ) - - yesButton := core.SelectableButton(core.ButtonOpts{ - Text: "Yep!", - UnderlineIndex: 0, - Selected: !s.selectedNo, - }) - - noButton := core.SelectableButton(core.ButtonOpts{ - Text: "Nope", - UnderlineIndex: 0, - Selected: s.selectedNo, - }) - - buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - - initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - initText, - "", - buttons, - ), - ) - - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - "", - initContent, - ) - default: - parts := []string{ - s.logoRendered, - s.infoSection(), - } - content = lipgloss.JoinVertical(lipgloss.Left, parts...) - } - - return t.S().Base. - Width(s.width). - Height(s.height). - PaddingTop(SplashScreenPaddingY). - PaddingBottom(SplashScreenPaddingY). - Render(content) -} - -func (s *splashCmp) Cursor() *tea.Cursor { - switch { - case s.needsAPIKey: - cursor := s.apiKeyInput.Cursor() - if cursor != nil { - return s.moveCursor(cursor) - } - case s.isOnboarding: - cursor := s.modelList.Cursor() - if cursor != nil { - return s.moveCursor(cursor) - } - } - return nil -} - -func (s *splashCmp) isSmallScreen() bool { - // Consider a screen small if either the width is less than 40 or if the - // height is less than 20 - return s.width < 55 || s.height < 20 -} - -func (s *splashCmp) infoSection() string { - t := styles.CurrentTheme() - infoStyle := t.S().Base.PaddingLeft(2) - if s.isSmallScreen() { - infoStyle = infoStyle.MarginTop(1) - } - return infoStyle.Render( - lipgloss.JoinVertical( - lipgloss.Left, - s.cwdPart(), - "", - s.currentModelBlock(), - "", - lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), - "", - ), - ) -} - -func (s *splashCmp) logoBlock() string { - t := styles.CurrentTheme() - logoStyle := t.S().Base.Padding(0, 2).Width(s.width) - if s.isSmallScreen() { - // If the width is too small, render a smaller version of the logo - // NOTE: 20 is not correct because [splashCmp.height] is not the - // *actual* window height, instead, it is the height of the splash - // component and that depends on other variables like compact mode and - // the height of the editor. - return logoStyle.Render( - logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()), - ) - } - return logoStyle.Render( - logo.Render(version.Version, false, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: s.width - logoStyle.GetHorizontalFrameSize(), - }), - ) -} - -func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - if cursor == nil { - return nil - } - // Calculate the correct Y offset based on current state - logoHeight := lipgloss.Height(s.logoRendered) - if s.needsAPIKey { - infoSectionHeight := lipgloss.Height(s.infoSection()) - baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight - remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY - offset := baseOffset + remainingHeight - cursor.Y += offset - cursor.X += 1 - } else if s.isOnboarding { - offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2 - cursor.Y += offset - cursor.X += 1 - } - - return cursor -} - -func (s *splashCmp) logoGap() int { - if s.height > 35 { - return LogoGap - } - return 0 -} - -// Bindings implements SplashPage. -func (s *splashCmp) Bindings() []key.Binding { - switch { - case s.needsAPIKey: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Back, - } - case s.isOnboarding: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Next, - s.keyMap.Previous, - } - case s.needsProjectInit: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Yes, - s.keyMap.No, - s.keyMap.Tab, - s.keyMap.LeftRight, - } - default: - return []key.Binding{} - } -} - -func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width-2, 90) // 2 for left padding -} - -func (s *splashCmp) cwdPart() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() - return t.S().Muted.Width(maxWidth).Render(s.cwd()) -} - -func (s *splashCmp) cwd() string { - return home.Short(config.Get().WorkingDir()) -} - -func LSPList(maxWidth int) []string { - return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{ - MaxWidth: maxWidth, - ShowSection: false, - }) -} - -func (s *splashCmp) lspBlock() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() / 2 - section := t.S().Subtle.Render("LSPs") - lspList := append([]string{section, ""}, LSPList(maxWidth-1)...) - return t.S().Base.Width(maxWidth).PaddingRight(1).Render( - lipgloss.JoinVertical( - lipgloss.Left, - lspList..., - ), - ) -} - -func MCPList(maxWidth int) []string { - return mcp.RenderMCPList(mcp.RenderOptions{ - MaxWidth: maxWidth, - ShowSection: false, - }) -} - -func (s *splashCmp) mcpBlock() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() / 2 - section := t.S().Subtle.Render("MCPs") - mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...) - return t.S().Base.Width(maxWidth).PaddingRight(1).Render( - lipgloss.JoinVertical( - lipgloss.Left, - mcpList..., - ), - ) -} - -func (s *splashCmp) currentModelBlock() string { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model == nil { - return "" - } - t := styles.CurrentTheme() - modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon) - modelName := t.S().Text.Render(model.Name) - modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) - parts := []string{ - modelInfo, - } - - return lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) -} - -func (s *splashCmp) IsShowingAPIKey() bool { - return s.needsAPIKey -} - -func (s *splashCmp) IsAPIKeyValid() bool { - return s.isAPIKeyValid -} - -func (s *splashCmp) IsShowingHyperOAuth2() bool { - return s.showHyperDeviceFlow -} - -func (s *splashCmp) IsShowingCopilotOAuth2() bool { - return s.showCopilotDeviceFlow -} diff --git a/internal/tui/components/chat/todos/todos.go b/internal/tui/components/chat/todos/todos.go deleted file mode 100644 index 8973e4f4675df65c5ff7466665ddf18e74d2203e..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/todos/todos.go +++ /dev/null @@ -1,67 +0,0 @@ -package todos - -import ( - "slices" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -func sortTodos(todos []session.Todo) { - slices.SortStableFunc(todos, func(a, b session.Todo) int { - return statusOrder(a.Status) - statusOrder(b.Status) - }) -} - -func statusOrder(s session.TodoStatus) int { - switch s { - case session.TodoStatusCompleted: - return 0 - case session.TodoStatusInProgress: - return 1 - default: - return 2 - } -} - -func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string { - if len(todos) == 0 { - return "" - } - - sorted := make([]session.Todo, len(todos)) - copy(sorted, todos) - sortTodos(sorted) - - var lines []string - for _, todo := range sorted { - var prefix string - var textStyle lipgloss.Style - - switch todo.Status { - case session.TodoStatusCompleted: - prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " " - textStyle = t.S().Base.Foreground(t.FgBase) - case session.TodoStatusInProgress: - prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ") - textStyle = t.S().Base.Foreground(t.FgBase) - default: - prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " " - textStyle = t.S().Base.Foreground(t.FgBase) - } - - text := todo.Content - if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" { - text = todo.ActiveForm - } - line := prefix + textStyle.Render(text) - line = ansi.Truncate(line, width, "…") - - lines = append(lines, line) - } - - return strings.Join(lines, "\n") -} diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go deleted file mode 100644 index 31532952f6243a466c18d55230875346448c151a..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/completions.go +++ /dev/null @@ -1,308 +0,0 @@ -package completions - -import ( - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const maxCompletionsHeight = 10 - -type Completion struct { - Title string // The title of the completion item - Value any // The value of the completion item -} - -type OpenCompletionsMsg struct { - Completions []Completion - X int // X position for the completions popup - Y int // Y position for the completions popup - MaxResults int // Maximum number of results to render, 0 for no limit -} - -type FilterCompletionsMsg struct { - Query string // The query to filter completions - Reopen bool - X int // X position for the completions popup - Y int // Y position for the completions popup -} - -type RepositionCompletionsMsg struct { - X, Y int -} - -type CompletionsClosedMsg struct{} - -type CompletionsOpenedMsg struct{} - -type CloseCompletionsMsg struct{} - -type SelectCompletionMsg struct { - Value any // The value of the selected completion item - Insert bool -} - -type Completions interface { - util.Model - Open() bool - Query() string // Returns the current filter query - KeyMap() KeyMap - Position() (int, int) // Returns the X and Y position of the completions popup - Width() int - Height() int -} - -type listModel = list.FilterableList[list.CompletionItem[any]] - -type completionsCmp struct { - wWidth int // The window width - wHeight int // The window height - width int - lastWidth int - height int // Height of the completions component` - x, xorig int // X position for the completions popup - y int // Y position for the completions popup - open bool // Indicates if the completions are open - keyMap KeyMap - - list listModel - query string // The current filter query -} - -func New() Completions { - completionsKeyMap := DefaultKeyMap() - keyMap := list.DefaultKeyMap() - keyMap.Up.SetEnabled(false) - keyMap.Down.SetEnabled(false) - keyMap.HalfPageDown.SetEnabled(false) - keyMap.HalfPageUp.SetEnabled(false) - keyMap.Home.SetEnabled(false) - keyMap.End.SetEnabled(false) - keyMap.UpOneItem = completionsKeyMap.Up - keyMap.DownOneItem = completionsKeyMap.Down - - l := list.NewFilterableList( - []list.CompletionItem[any]{}, - list.WithFilterInputHidden(), - list.WithFilterListOptions( - list.WithDirectionBackward(), - list.WithKeyMap(keyMap), - ), - ) - return &completionsCmp{ - width: 0, - height: maxCompletionsHeight, - list: l, - query: "", - keyMap: completionsKeyMap, - } -} - -// Init implements Completions. -func (c *completionsCmp) Init() tea.Cmd { - return tea.Sequence( - c.list.Init(), - c.list.SetSize(c.width, c.height), - ) -} - -// Update implements Completions. -func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth, c.wHeight = msg.Width, msg.Height - return c, nil - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keyMap.Up): - u, cmd := c.list.Update(msg) - c.list = u.(listModel) - return c, cmd - - case key.Matches(msg, c.keyMap.Down): - d, cmd := c.list.Update(msg) - c.list = d.(listModel) - return c, cmd - case key.Matches(msg, c.keyMap.UpInsert): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.list.SetSelected(selectedItem.ID()) - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - Insert: true, - }) - case key.Matches(msg, c.keyMap.DownInsert): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.list.SetSelected(selectedItem.ID()) - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - Insert: true, - }) - case key.Matches(msg, c.keyMap.Select): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.open = false // Close completions after selection - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - }) - case key.Matches(msg, c.keyMap.Cancel): - return c, util.CmdHandler(CloseCompletionsMsg{}) - } - case RepositionCompletionsMsg: - c.x, c.y = msg.X, msg.Y - c.adjustPosition() - case CloseCompletionsMsg: - c.open = false - return c, util.CmdHandler(CompletionsClosedMsg{}) - case OpenCompletionsMsg: - c.open = true - c.query = "" - c.x, c.xorig = msg.X, msg.X - c.y = msg.Y - items := []list.CompletionItem[any]{} - t := styles.CurrentTheme() - for _, completion := range msg.Completions { - item := list.NewCompletionItem( - completion.Title, - completion.Value, - list.WithCompletionBackgroundColor(t.BgSubtle), - ) - items = append(items, item) - } - width := listWidth(items) - if len(items) == 0 { - width = listWidth(c.list.Items()) - } - if c.x+width >= c.wWidth { - c.x = c.wWidth - width - 1 - } - c.width = width - c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height - c.list.SetResultsSize(msg.MaxResults) - return c, tea.Batch( - c.list.SetItems(items), - c.list.SetSize(c.width, c.height), - util.CmdHandler(CompletionsOpenedMsg{}), - ) - case FilterCompletionsMsg: - if !c.open && !msg.Reopen { - return c, nil - } - if msg.Query == c.query { - // PERF: if same query, don't need to filter again - return c, nil - } - if len(c.list.Items()) == 0 && - len(msg.Query) > len(c.query) && - strings.HasPrefix(msg.Query, c.query) { - // PERF: if c.query didn't match anything, - // AND msg.Query is longer than c.query, - // AND msg.Query is prefixed with c.query - which means - // that the user typed more chars after a 0 match, - // it won't match anything, so return earlier. - return c, nil - } - c.query = msg.Query - var cmds []tea.Cmd - cmds = append(cmds, c.list.Filter(msg.Query)) - items := c.list.Items() - itemsLen := len(items) - c.xorig = msg.X - c.x, c.y = msg.X, msg.Y - c.adjustPosition() - cmds = append(cmds, c.list.SetSize(c.width, c.height)) - if itemsLen == 0 { - cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{})) - } else if msg.Reopen { - c.open = true - cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{})) - } - return c, tea.Batch(cmds...) - } - return c, nil -} - -func (c *completionsCmp) adjustPosition() { - items := c.list.Items() - itemsLen := len(items) - width := listWidth(items) - c.lastWidth = c.width - if c.x < 0 || width < c.lastWidth { - c.x = c.xorig - } else if c.x+width >= c.wWidth { - c.x = c.wWidth - width - 1 - } - c.width = width - c.height = max(min(maxCompletionsHeight, itemsLen), 1) -} - -// View implements Completions. -func (c *completionsCmp) View() string { - if !c.open || len(c.list.Items()) == 0 { - return "" - } - - t := styles.CurrentTheme() - style := t.S().Base. - Width(c.width). - Height(c.height). - Background(t.BgSubtle) - - return style.Render(c.list.View()) -} - -// listWidth returns the width of the last 10 items in the list, which is used -// to determine the width of the completions popup. -// Note this only works for [completionItemCmp] items. -func listWidth(items []list.CompletionItem[any]) int { - var width int - if len(items) == 0 { - return width - } - - for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- { - itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding - width = max(width, itemWidth) - } - - return width -} - -func (c *completionsCmp) Open() bool { - return c.open -} - -func (c *completionsCmp) Query() string { - return c.query -} - -func (c *completionsCmp) KeyMap() KeyMap { - return c.keyMap -} - -func (c *completionsCmp) Position() (int, int) { - return c.x, c.y - c.height -} - -func (c *completionsCmp) Width() int { - return c.width -} - -func (c *completionsCmp) Height() int { - return c.height -} diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go deleted file mode 100644 index 2b60664c26a6082fafd28626d471575b706c9890..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/core.go +++ /dev/null @@ -1,207 +0,0 @@ -package core - -import ( - "image/color" - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -type KeyMapHelp interface { - Help() help.KeyMap -} - -type simpleHelp struct { - shortList []key.Binding - fullList [][]key.Binding -} - -func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap { - return &simpleHelp{ - shortList: shortList, - fullList: fullList, - } -} - -// FullHelp implements help.KeyMap. -func (s *simpleHelp) FullHelp() [][]key.Binding { - return s.fullList -} - -// ShortHelp implements help.KeyMap. -func (s *simpleHelp) ShortHelp() []key.Binding { - return s.shortList -} - -func Section(text string, width int) string { - t := styles.CurrentTheme() - char := "─" - length := lipgloss.Width(text) + 1 - remainingWidth := width - length - lineStyle := t.S().Base.Foreground(t.Border) - if remainingWidth > 0 { - text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) - } - return text -} - -func SectionWithInfo(text string, width int, info string) string { - t := styles.CurrentTheme() - char := "─" - length := lipgloss.Width(text) + 1 - remainingWidth := width - length - - if info != "" { - remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info - } - lineStyle := t.S().Base.Foreground(t.Border) - if remainingWidth > 0 { - text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info - } - return text -} - -func Title(title string, width int) string { - t := styles.CurrentTheme() - char := "╱" - length := lipgloss.Width(title) + 1 - remainingWidth := width - length - titleStyle := t.S().Base.Foreground(t.Primary) - if remainingWidth > 0 { - lines := strings.Repeat(char, remainingWidth) - lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary) - title = titleStyle.Render(title) + " " + lines - } - return title -} - -type StatusOpts struct { - Icon string // if empty no icon will be shown - Title string - TitleColor color.Color - Description string - DescriptionColor color.Color - ExtraContent string // additional content to append after the description -} - -func Status(opts StatusOpts, width int) string { - t := styles.CurrentTheme() - icon := opts.Icon - title := opts.Title - titleColor := t.FgMuted - if opts.TitleColor != nil { - titleColor = opts.TitleColor - } - description := opts.Description - descriptionColor := t.FgSubtle - if opts.DescriptionColor != nil { - descriptionColor = opts.DescriptionColor - } - title = t.S().Base.Foreground(titleColor).Render(title) - if description != "" { - extraContentWidth := lipgloss.Width(opts.ExtraContent) - if extraContentWidth > 0 { - extraContentWidth += 1 - } - description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") - description = t.S().Base.Foreground(descriptionColor).Render(description) - } - - content := []string{} - if icon != "" { - content = append(content, icon) - } - content = append(content, title) - if description != "" { - content = append(content, description) - } - if opts.ExtraContent != "" { - content = append(content, opts.ExtraContent) - } - - return strings.Join(content, " ") -} - -type ButtonOpts struct { - Text string - UnderlineIndex int // Index of character to underline (0-based) - Selected bool // Whether this button is selected -} - -// SelectableButton creates a button with an underlined character and selection state -func SelectableButton(opts ButtonOpts) string { - t := styles.CurrentTheme() - - // Base style for the button - buttonStyle := t.S().Text - - // Apply selection styling - if opts.Selected { - buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary) - } else { - buttonStyle = buttonStyle.Background(t.BgSubtle) - } - - // Create the button text with underlined character - text := opts.Text - if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) { - before := text[:opts.UnderlineIndex] - underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1] - after := text[opts.UnderlineIndex+1:] - - message := buttonStyle.Render(before) + - buttonStyle.Underline(true).Render(underlined) + - buttonStyle.Render(after) - - return buttonStyle.Padding(0, 2).Render(message) - } - - // Fallback if no underline index specified - return buttonStyle.Padding(0, 2).Render(text) -} - -// SelectableButtons creates a horizontal row of selectable buttons -func SelectableButtons(buttons []ButtonOpts, spacing string) string { - if spacing == "" { - spacing = " " - } - - var parts []string - for i, button := range buttons { - parts = append(parts, SelectableButton(button)) - if i < len(buttons)-1 { - parts = append(parts, spacing) - } - } - - return lipgloss.JoinHorizontal(lipgloss.Left, parts...) -} - -// SelectableButtonsVertical creates a vertical row of selectable buttons -func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string { - var parts []string - for i, button := range buttons { - parts = append(parts, SelectableButton(button)) - if i < len(buttons)-1 { - for range spacing { - parts = append(parts, "") - } - } - } - - return lipgloss.JoinVertical(lipgloss.Center, parts...) -} - -func DiffFormatter() *diffview.DiffView { - t := styles.CurrentTheme() - formatDiff := diffview.New() - style := chroma.MustNewStyle("crush", styles.GetChromaTheme()) - diff := formatDiff.ChromaStyle(style).Style(t.S().Diff).TabWidth(4) - return diff -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go deleted file mode 100644 index 99358755d6070286aab00ac13aeb3d3da2b91e3d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/layout.go +++ /dev/null @@ -1,27 +0,0 @@ -package layout - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -// TODO: move this to core - -type Focusable interface { - Focus() tea.Cmd - Blur() tea.Cmd - IsFocused() bool -} - -type Sizeable interface { - SetSize(width, height int) tea.Cmd - GetSize() (int, int) -} - -type Help interface { - Bindings() []key.Binding -} - -type Positional interface { - SetPosition(x, y int) tea.Cmd -} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go deleted file mode 100644 index 6d14c5db4c5c343e16bbda7f0846a0fcbfa61b36..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status/status.go +++ /dev/null @@ -1,113 +0,0 @@ -package status - -import ( - "time" - - "charm.land/bubbles/v2/help" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type StatusCmp interface { - util.Model - ToggleFullHelp() - SetKeyMap(keyMap help.KeyMap) -} - -type statusCmp struct { - info util.InfoMsg - width int - messageTTL time.Duration - help help.Model - keyMap help.KeyMap -} - -// clearMessageCmd is a command that clears status messages after a timeout -func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd { - return tea.Tick(ttl, func(time.Time) tea.Msg { - return util.ClearStatusMsg{} - }) -} - -func (m *statusCmp) Init() tea.Cmd { - return nil -} - -func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.help.SetWidth(msg.Width - 2) - return m, nil - - // Handle status info - case util.InfoMsg: - m.info = msg - ttl := msg.TTL - if ttl == 0 { - ttl = m.messageTTL - } - return m, m.clearMessageCmd(ttl) - case util.ClearStatusMsg: - m.info = util.InfoMsg{} - } - return m, nil -} - -func (m *statusCmp) View() string { - t := styles.CurrentTheme() - status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap)) - if m.info.Msg != "" { - status = m.infoMsg() - } - return status -} - -func (m *statusCmp) infoMsg() string { - t := styles.CurrentTheme() - message := "" - infoType := "" - switch m.info.Type { - case util.InfoTypeError: - infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR") - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info) - case util.InfoTypeWarn: - infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING") - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info) - default: - note := "OKAY!" - if m.info.Type == util.InfoTypeUpdate { - note = "HEY!" - } - infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render(note) - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info) - } - return ansi.Truncate(infoType+message, m.width, "…") -} - -func (m *statusCmp) ToggleFullHelp() { - m.help.ShowAll = !m.help.ShowAll -} - -func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) { - m.keyMap = keyMap -} - -func NewStatusCmp() StatusCmp { - t := styles.CurrentTheme() - help := help.New() - help.Styles = t.S().Help - return &statusCmp{ - messageTTL: 5 * time.Second, - help: help, - } -} diff --git a/internal/tui/components/core/status_test.go b/internal/tui/components/core/status_test.go deleted file mode 100644 index c82fc5b2a3e735e1eafd385b74ae5a4877032bd9..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package core_test - -import ( - "fmt" - "image/color" - "testing" - - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/x/exp/golden" -) - -func TestStatus(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts core.StatusOpts - width int - }{ - { - name: "Default", - opts: core.StatusOpts{ - Title: "Status", - Description: "Everything is working fine", - }, - width: 80, - }, - { - name: "WithCustomIcon", - opts: core.StatusOpts{ - Icon: "✓", - Title: "Success", - Description: "Operation completed successfully", - }, - width: 80, - }, - { - name: "NoIcon", - opts: core.StatusOpts{ - Title: "Info", - Description: "This status has no icon", - }, - width: 80, - }, - { - name: "WithColors", - opts: core.StatusOpts{ - Icon: "⚠", - Title: "Warning", - TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow - Description: "This is a warning message", - DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red - }, - width: 80, - }, - { - name: "WithExtraContent", - opts: core.StatusOpts{ - Title: "Build", - Description: "Building project", - ExtraContent: "[2/5]", - }, - width: 80, - }, - { - name: "LongDescription", - opts: core.StatusOpts{ - Title: "Processing", - Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping", - }, - width: 60, - }, - { - name: "NarrowWidth", - opts: core.StatusOpts{ - Icon: "●", - Title: "Status", - Description: "Short message", - }, - width: 30, - }, - { - name: "VeryNarrowWidth", - opts: core.StatusOpts{ - Icon: "●", - Title: "Test", - Description: "This will be truncated", - }, - width: 20, - }, - { - name: "EmptyDescription", - opts: core.StatusOpts{ - Icon: "●", - Title: "Title Only", - }, - width: 80, - }, - { - name: "AllFieldsWithExtraContent", - opts: core.StatusOpts{ - Icon: "🚀", - Title: "Deployment", - TitleColor: color.RGBA{0, 0, 255, 255}, // Blue - Description: "Deploying to production environment", - DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray - ExtraContent: "v1.2.3", - }, - width: 80, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - output := core.Status(tt.opts, tt.width) - golden.RequireEqual(t, []byte(output)) - }) - } -} - -func TestStatusTruncation(t *testing.T) { - t.Parallel() - - opts := core.StatusOpts{ - Icon: "●", - Title: "Very Long Title", - Description: "This is an extremely long description that definitely needs to be truncated", - ExtraContent: "[extra]", - } - - // Test different widths to ensure truncation works correctly - widths := []int{20, 30, 40, 50, 60} - - for _, width := range widths { - t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) { - t.Parallel() - - output := core.Status(opts, width) - golden.RequireEqual(t, []byte(output)) - }) - } -} diff --git a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden deleted file mode 100644 index 89477e3738e6547ea26734e8a49df5d281d70c57..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/Default.golden b/internal/tui/components/core/testdata/TestStatus/Default.golden deleted file mode 100644 index 2151efd10b7aeb6500b55a0e61fbf5d4a6ef1638..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/Default.golden +++ /dev/null @@ -1 +0,0 @@ -Status Everything is working fine \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden deleted file mode 100644 index db4acad54383ecbc2cc50061ee5ba77491dc545d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden +++ /dev/null @@ -1 +0,0 @@ -● Title Only \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden deleted file mode 100644 index 13fc6c3335871aaa5513d370d078f8e350571abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden +++ /dev/null @@ -1 +0,0 @@ -Processing This is a very long description that should be … \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden deleted file mode 100644 index 0c5b8e93c35e302038e019d58682716b1b220ef7..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Status Short message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden b/internal/tui/components/core/testdata/TestStatus/NoIcon.golden deleted file mode 100644 index 09e14574c853264a4b18dfafcfac256b38045a02..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden +++ /dev/null @@ -1 +0,0 @@ -Info This status has no icon \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden deleted file mode 100644 index 9bb3917977486b8f862c74db4f43951a9c44a450..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Test This will be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithColors.golden b/internal/tui/components/core/testdata/TestStatus/WithColors.golden deleted file mode 100644 index 97eeb24db9a9803f4d8877296d38a9d878b50fed..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithColors.golden +++ /dev/null @@ -1 +0,0 @@ -⚠ Warning This is a warning message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden deleted file mode 100644 index 00cf9455b72e0fd3b8fc94e48b09053bb3fde60a..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden +++ /dev/null @@ -1 +0,0 @@ -✓ Success Operation completed successfully \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden deleted file mode 100644 index 292d1fa97f0400a7c411eff5a658af537fc8b69e..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -Build Building project [2/5] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden deleted file mode 100644 index 0df96289f5aa373f174aa9f833478d5c559abe53..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title  [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden deleted file mode 100644 index 56915d1966ab547740910398b101fd70371bb264..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title Thi… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden deleted file mode 100644 index 6b249b2f865698ebc73ed7787daad30ddf417945..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an ex… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden deleted file mode 100644 index 1862198d631f525c3080f7f811ade5a5738658b1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely lo… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden deleted file mode 100644 index 0f29e46d2660d1bf2584c730c50972e962c4dd32..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go deleted file mode 100644 index 690d29e6c380e46777b57982913132a24c56448f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/arguments.go +++ /dev/null @@ -1,245 +0,0 @@ -package commands - -import ( - "cmp" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/uicmd" -) - -const ( - argumentsDialogID dialogs.DialogID = "arguments" -) - -// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. -type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg - -// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. -type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg - -// CommandArgumentsDialog represents the commands dialog. -type CommandArgumentsDialog interface { - dialogs.DialogModel -} - -type commandArgumentsDialogCmp struct { - wWidth, wHeight int - width, height int - - inputs []textinput.Model - focused int - keys ArgumentsDialogKeyMap - arguments []Argument - help help.Model - - id string - title string - name string - description string - - onSubmit func(args map[string]string) tea.Cmd -} - -type Argument struct { - Name, Title, Description string - Required bool -} - -func NewCommandArgumentsDialog( - id, title, name, description string, - arguments []Argument, - onSubmit func(args map[string]string) tea.Cmd, -) CommandArgumentsDialog { - t := styles.CurrentTheme() - inputs := make([]textinput.Model, len(arguments)) - - for i, arg := range arguments { - ti := textinput.New() - ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title) - ti.SetWidth(40) - ti.SetVirtualCursor(false) - ti.Prompt = "" - - ti.SetStyles(t.S().TextInput) - // Only focus the first input initially - if i == 0 { - ti.Focus() - } else { - ti.Blur() - } - - inputs[i] = ti - } - - return &commandArgumentsDialogCmp{ - inputs: inputs, - keys: DefaultArgumentsDialogKeyMap(), - id: id, - name: name, - title: title, - description: description, - arguments: arguments, - width: 60, - help: help.New(), - onSubmit: onSubmit, - } -} - -// Init implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth = msg.Width - c.wHeight = msg.Height - c.width = min(90, c.wWidth) - c.height = min(15, c.wHeight) - for i := range c.inputs { - c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2)) - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keys.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - case key.Matches(msg, c.keys.Confirm): - if c.focused == len(c.inputs)-1 { - args := make(map[string]string) - for i, arg := range c.arguments { - value := c.inputs[i].Value() - args[arg.Name] = value - } - return c, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - c.onSubmit(args), - ) - } - // Otherwise, move to the next input - c.inputs[c.focused].Blur() - c.focused++ - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Next): - // Move to the next input - c.inputs[c.focused].Blur() - c.focused = (c.focused + 1) % len(c.inputs) - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Previous): - // Move to the previous input - c.inputs[c.focused].Blur() - c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs) - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - var cmd tea.Cmd - c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg) - return c, cmd - } - case tea.PasteMsg: - var cmd tea.Cmd - c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg) - return c, cmd - } - return c, nil -} - -// View implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) View() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - title := lipgloss.NewStyle(). - Foreground(t.Primary). - Bold(true). - Padding(0, 1). - Render(cmp.Or(c.title, c.name)) - - promptName := t.S().Text. - Padding(0, 1). - Render(c.description) - - inputFields := make([]string, len(c.inputs)) - for i, input := range c.inputs { - labelStyle := baseStyle.Padding(1, 1, 0, 1) - - if i == c.focused { - labelStyle = labelStyle.Foreground(t.FgBase).Bold(true) - } else { - labelStyle = labelStyle.Foreground(t.FgMuted) - } - - arg := c.arguments[i] - argName := cmp.Or(arg.Title, arg.Name) - if arg.Required { - argName += "*" - } - label := labelStyle.Render(argName + ":") - - field := t.S().Text. - Padding(0, 1). - Render(input.View()) - - inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) - } - - elements := []string{title, promptName} - elements = append(elements, inputFields...) - - c.help.ShowAll = false - helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys)) - elements = append(elements, "", helpText) - - content := lipgloss.JoinVertical(lipgloss.Left, elements...) - - return baseStyle.Padding(1, 1, 0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(c.width). - Render(content) -} - -func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor { - if len(c.inputs) == 0 { - return nil - } - cursor := c.inputs[c.focused].Cursor() - if cursor != nil { - cursor = c.moveCursor(cursor) - } - return cursor -} - -const ( - headerHeight = 3 - itemHeight = 3 - paddingHorizontal = 3 -) - -func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := c.Position() - offset := row + headerHeight + (1+c.focused)*itemHeight - cursor.Y += offset - cursor.X = cursor.X + col + paddingHorizontal - return cursor -} - -func (c *commandArgumentsDialogCmp) Position() (int, int) { - row := (c.wHeight / 2) - (c.height / 2) - col := (c.wWidth / 2) - (c.width / 2) - return row, col -} - -// ID implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID { - return argumentsDialogID -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go deleted file mode 100644 index 26fa554e5868409a53ce4aa4cf86d68f88084dd7..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/commands.go +++ /dev/null @@ -1,481 +0,0 @@ -package commands - -import ( - "fmt" - "os" - "slices" - "strings" - - "charm.land/bubbles/v2/help" - "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" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/uicmd" -) - -const ( - CommandsDialogID dialogs.DialogID = "commands" - - defaultWidth int = 70 -) - -type commandType = uicmd.CommandType - -const ( - SystemCommands = uicmd.SystemCommands - UserCommands = uicmd.UserCommands - MCPPrompts = uicmd.MCPPrompts -) - -type listModel = list.FilterableList[list.CompletionItem[Command]] - -// Command represents a command that can be executed -type ( - Command = uicmd.Command - CommandRunCustomMsg = uicmd.CommandRunCustomMsg - ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg -) - -// CommandsDialog represents the commands dialog. -type CommandsDialog interface { - dialogs.DialogModel -} - -type commandDialogCmp struct { - width int - wWidth int // Width of the terminal window - wHeight int // Height of the terminal window - - commandList listModel - keyMap CommandsDialogKeyMap - help help.Model - selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts - userCommands []Command // User-defined commands - mcpPrompts *csync.Slice[Command] // MCP prompts - sessionID string // Current session ID -} - -type ( - SwitchSessionsMsg struct{} - NewSessionsMsg struct{} - SwitchModelMsg struct{} - QuitMsg struct{} - OpenFilePickerMsg struct{} - ToggleHelpMsg struct{} - ToggleCompactModeMsg struct{} - ToggleThinkingMsg struct{} - OpenReasoningDialogMsg struct{} - OpenExternalEditorMsg struct{} - ToggleYoloModeMsg struct{} - CompactMsg struct { - SessionID string - } -) - -func NewCommandDialog(sessionID string) CommandsDialog { - keyMap := DefaultCommandsDialogKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - commandList := list.NewFilterableList( - []list.CompletionItem[Command]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - list.WithResizeByList(), - ), - ) - help := help.New() - help.Styles = t.S().Help - return &commandDialogCmp{ - commandList: commandList, - width: defaultWidth, - keyMap: DefaultCommandsDialogKeyMap(), - help: help, - selected: SystemCommands, - sessionID: sessionID, - mcpPrompts: csync.NewSlice[Command](), - } -} - -func (c *commandDialogCmp) Init() tea.Cmd { - commands, err := uicmd.LoadCustomCommands() - if err != nil { - return util.ReportError(err) - } - c.userCommands = commands - c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - return c.setCommandType(c.selected) -} - -func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth = msg.Width - c.wHeight = msg.Height - return c, tea.Batch( - c.setCommandType(c.selected), - c.commandList.SetSize(c.listWidth(), c.listHeight()), - ) - case pubsub.Event[mcp.Event]: - // Reload MCP prompts when MCP state changes - if msg.Type == pubsub.UpdatedEvent { - c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - // If we're currently viewing MCP prompts, refresh the list - if c.selected == MCPPrompts { - return c, c.setCommandType(MCPPrompts) - } - return c, nil - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keyMap.Select): - selectedItem := c.commandList.SelectedItem() - if selectedItem == nil { - return c, nil // No item selected, do nothing - } - command := (*selectedItem).Value() - return c, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - command.Handler(command), - ) - case key.Matches(msg, c.keyMap.Tab): - if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 { - return c, nil - } - return c, c.setCommandType(c.next()) - case key.Matches(msg, c.keyMap.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := c.commandList.Update(msg) - c.commandList = u.(listModel) - return c, cmd - } - } - return c, nil -} - -func (c *commandDialogCmp) next() commandType { - switch c.selected { - case SystemCommands: - if len(c.userCommands) > 0 { - return UserCommands - } - if c.mcpPrompts.Len() > 0 { - return MCPPrompts - } - fallthrough - case UserCommands: - if c.mcpPrompts.Len() > 0 { - return MCPPrompts - } - fallthrough - case MCPPrompts: - return SystemCommands - default: - return SystemCommands - } -} - -func (c *commandDialogCmp) View() string { - t := styles.CurrentTheme() - listView := c.commandList - radio := c.commandTypeRadio() - - header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio) - if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 { - header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4)) - } - content := lipgloss.JoinVertical( - lipgloss.Left, - header, - listView.View(), - "", - t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), - ) - return c.style().Render(content) -} - -func (c *commandDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := c.commandList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = c.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (c *commandDialogCmp) commandTypeRadio() string { - t := styles.CurrentTheme() - - fn := func(i commandType) string { - if i == c.selected { - return "◉ " + i.String() - } - return "○ " + i.String() - } - - parts := []string{ - fn(SystemCommands), - } - if len(c.userCommands) > 0 { - parts = append(parts, fn(UserCommands)) - } - if c.mcpPrompts.Len() > 0 { - parts = append(parts, fn(MCPPrompts)) - } - return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " ")) -} - -func (c *commandDialogCmp) listWidth() int { - return defaultWidth - 2 // 4 for padding -} - -func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd { - c.selected = commandType - - var commands []Command - switch c.selected { - case SystemCommands: - commands = c.defaultCommands() - case UserCommands: - commands = c.userCommands - case MCPPrompts: - commands = slices.Collect(c.mcpPrompts.Seq()) - } - - commandItems := []list.CompletionItem[Command]{} - for _, cmd := range commands { - opts := []list.CompletionItemOption{ - list.WithCompletionID(cmd.ID), - } - if cmd.Shortcut != "" { - opts = append( - opts, - list.WithCompletionShortcut(cmd.Shortcut), - ) - } - commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...)) - } - return c.commandList.SetItems(commandItems) -} - -func (c *commandDialogCmp) listHeight() int { - listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections - return min(listHeigh, c.wHeight/2) -} - -func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := c.Position() - offset := row + 3 - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -func (c *commandDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(c.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (c *commandDialogCmp) Position() (int, int) { - row := c.wHeight/4 - 2 // just a bit above the center - col := c.wWidth / 2 - col -= c.width / 2 - return row, col -} - -func (c *commandDialogCmp) defaultCommands() []Command { - commands := []Command{ - { - ID: "new_session", - Title: "New Session", - Description: "start a new session", - Shortcut: "ctrl+n", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(NewSessionsMsg{}) - }, - }, - { - ID: "switch_session", - Title: "Switch Session", - Description: "Switch to a different session", - Shortcut: "ctrl+s", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(SwitchSessionsMsg{}) - }, - }, - { - ID: "switch_model", - Title: "Switch Model", - Description: "Switch to a different model", - Shortcut: "ctrl+l", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(SwitchModelMsg{}) - }, - }, - } - - // Only show compact command if there's an active session - if c.sessionID != "" { - commands = append(commands, Command{ - ID: "Summarize", - Title: "Summarize Session", - Description: "Summarize the current session and create a new one with the summary", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(CompactMsg{ - SessionID: c.sessionID, - }) - }, - }) - } - - // Add reasoning toggle for models that support it - cfg := config.Get() - if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { - providerCfg := cfg.GetProviderForModel(agentCfg.Model) - model := cfg.GetModelByType(agentCfg.Model) - if providerCfg != nil && model != nil && model.CanReason { - selectedModel := cfg.Models[agentCfg.Model] - - // Anthropic/Bedrock models: thinking toggle - if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) || providerCfg.Type == catwalk.TypeBedrock { - status := "Enable" - if selectedModel.Think { - status = "Disable" - } - commands = append(commands, Command{ - ID: "toggle_thinking", - Title: status + " Thinking Mode", - Description: "Toggle model thinking for reasoning-capable models", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleThinkingMsg{}) - }, - }) - } - - // OpenAI models: reasoning effort dialog - if len(model.ReasoningLevels) > 0 { - commands = append(commands, Command{ - ID: "select_reasoning_effort", - Title: "Select Reasoning Effort", - Description: "Choose reasoning effort level (low/medium/high)", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenReasoningDialogMsg{}) - }, - }) - } - } - } - // Only show toggle compact mode command if window width is larger than compact breakpoint (90) - if c.wWidth > 120 && c.sessionID != "" { - commands = append(commands, Command{ - ID: "toggle_sidebar", - Title: "Toggle Sidebar", - Description: "Toggle between compact and normal layout", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleCompactModeMsg{}) - }, - }) - } - if c.sessionID != "" { - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model.SupportsImages { - commands = append(commands, Command{ - ID: "file_picker", - Title: "Open File Picker", - Shortcut: "ctrl+f", - Description: "Open file picker", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenFilePickerMsg{}) - }, - }) - } - } - - // Add external editor command if $EDITOR is available - if os.Getenv("EDITOR") != "" { - commands = append(commands, Command{ - ID: "open_external_editor", - Title: "Open External Editor", - Shortcut: "ctrl+o", - Description: "Open external editor to compose message", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenExternalEditorMsg{}) - }, - }) - } - - return append(commands, []Command{ - { - ID: "toggle_yolo", - Title: "Toggle Yolo Mode", - Description: "Toggle yolo mode", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleYoloModeMsg{}) - }, - }, - { - ID: "toggle_help", - Title: "Toggle Help", - Shortcut: "ctrl+g", - Description: "Toggle help", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleHelpMsg{}) - }, - }, - { - ID: "init", - Title: "Initialize Project", - Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs), - Handler: func(cmd Command) tea.Cmd { - initPrompt, err := agent.InitializePrompt(*config.Get()) - if err != nil { - return util.ReportError(err) - } - return util.CmdHandler(chat.SendMsg{ - Text: initPrompt, - }) - }, - }, - { - ID: "quit", - Title: "Quit", - Description: "Quit", - Shortcut: "ctrl+c", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(QuitMsg{}) - }, - }, - }...) -} - -func (c *commandDialogCmp) ID() dialogs.DialogID { - return CommandsDialogID -} diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go deleted file mode 100644 index f07f1c5f4a6db353d6d53888a3bf869702bfb24c..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/keys.go +++ /dev/null @@ -1,133 +0,0 @@ -package commands - -import ( - "charm.land/bubbles/v2/key" -) - -type CommandsDialogKeyMap struct { - Select, - Next, - Previous, - Tab, - Close key.Binding -} - -func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { - return CommandsDialogKeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch selection"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k CommandsDialogKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k CommandsDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Tab, - key.NewBinding( - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Select, - k.Close, - } -} - -type ArgumentsDialogKeyMap struct { - Confirm key.Binding - Next key.Binding - Previous key.Binding - Close key.Binding -} - -func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { - return ArgumentsDialogKeyMap{ - Confirm: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - - Next: key.NewBinding( - key.WithKeys("tab", "down"), - key.WithHelp("tab/↓", "next"), - ), - Previous: key.NewBinding( - key.WithKeys("shift+tab", "up"), - key.WithHelp("shift+tab/↑", "previous"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Confirm, - k.Next, - k.Previous, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Confirm, - k.Next, - k.Previous, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go deleted file mode 100644 index d8a2850c3ea151021958a07b350df879d1db4554..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/copilot/device_flow.go +++ /dev/null @@ -1,281 +0,0 @@ -// Package copilot provides the dialog for Copilot device flow authentication. -package copilot - -import ( - "context" - "fmt" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/copilot" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/pkg/browser" -) - -// DeviceFlowState represents the current state of the device flow. -type DeviceFlowState int - -const ( - DeviceFlowStateDisplay DeviceFlowState = iota - DeviceFlowStateSuccess - DeviceFlowStateError - DeviceFlowStateUnavailable -) - -// DeviceAuthInitiatedMsg is sent when the device auth is initiated -// successfully. -type DeviceAuthInitiatedMsg struct { - deviceCode *copilot.DeviceCode -} - -// DeviceFlowCompletedMsg is sent when the device flow completes successfully. -type DeviceFlowCompletedMsg struct { - Token *oauth.Token -} - -// DeviceFlowErrorMsg is sent when the device flow encounters an error. -type DeviceFlowErrorMsg struct { - Error error -} - -// DeviceFlow handles the Copilot device flow authentication. -type DeviceFlow struct { - State DeviceFlowState - width int - deviceCode *copilot.DeviceCode - token *oauth.Token - cancelFunc context.CancelFunc - spinner spinner.Model -} - -// NewDeviceFlow creates a new device flow component. -func NewDeviceFlow() *DeviceFlow { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight) - return &DeviceFlow{ - State: DeviceFlowStateDisplay, - spinner: s, - } -} - -// Init initializes the device flow by calling the device auth API and starting polling. -func (d *DeviceFlow) Init() tea.Cmd { - return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth) -} - -// Update handles messages and state transitions. -func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - d.spinner, cmd = d.spinner.Update(msg) - - switch msg := msg.(type) { - case DeviceAuthInitiatedMsg: - return d, tea.Batch(cmd, d.startPolling(msg.deviceCode)) - case DeviceFlowCompletedMsg: - d.State = DeviceFlowStateSuccess - d.token = msg.Token - return d, nil - case DeviceFlowErrorMsg: - switch msg.Error { - case copilot.ErrNotAvailable: - d.State = DeviceFlowStateUnavailable - default: - d.State = DeviceFlowStateError - } - return d, nil - } - - return d, cmd -} - -// View renders the device flow dialog. -func (d *DeviceFlow) View() string { - t := styles.CurrentTheme() - - whiteStyle := lipgloss.NewStyle().Foreground(t.White) - primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) - greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight) - linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) - errorStyle := lipgloss.NewStyle().Foreground(t.Error) - mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted) - - switch d.State { - case DeviceFlowStateDisplay: - if d.deviceCode == nil { - return lipgloss.NewStyle(). - Margin(0, 1). - Render( - greenStyle.Render(d.spinner.View()) + - mutedStyle.Render("Initializing..."), - ) - } - - instructions := lipgloss.NewStyle(). - Margin(1, 1, 0, 1). - Width(d.width - 2). - Render( - whiteStyle.Render("Press ") + - primaryStyle.Render("enter") + - whiteStyle.Render(" to copy the code below and open the browser."), - ) - - codeBox := lipgloss.NewStyle(). - Width(d.width-2). - Height(7). - Align(lipgloss.Center, lipgloss.Center). - Background(t.BgBaseLighter). - Margin(1). - Render( - lipgloss.NewStyle(). - Bold(true). - Foreground(t.White). - Render(d.deviceCode.UserCode), - ) - - uri := d.deviceCode.VerificationURI - link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri) - url := mutedStyle. - Margin(0, 1). - Width(d.width - 2). - Render("Browser not opening? Refer to\n" + link) - - waiting := greenStyle. - Width(d.width-2). - Margin(1, 1, 0, 1). - Render(d.spinner.View() + "Verifying...") - - return lipgloss.JoinVertical( - lipgloss.Left, - instructions, - codeBox, - url, - waiting, - ) - - case DeviceFlowStateSuccess: - return greenStyle.Margin(0, 1).Render("Authentication successful!") - - case DeviceFlowStateError: - return lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render(errorStyle.Render("Authentication failed.")) - - case DeviceFlowStateUnavailable: - message := lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:") - freeMessage := lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render("You may be able to request free access if eligible. For more information, see:") - return lipgloss.JoinVertical( - lipgloss.Left, - message, - "", - linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL), - "", - freeMessage, - "", - linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL), - ) - - default: - return "" - } -} - -// SetWidth sets the width of the dialog. -func (d *DeviceFlow) SetWidth(w int) { - d.width = w -} - -// Cursor hides the cursor. -func (d *DeviceFlow) Cursor() *tea.Cursor { return nil } - -// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL. -func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd { - switch d.State { - case DeviceFlowStateDisplay: - return tea.Sequence( - tea.SetClipboard(d.deviceCode.UserCode), - func() tea.Msg { - if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) - case DeviceFlowStateUnavailable: - return tea.Sequence( - func() tea.Msg { - if err := browser.OpenURL(copilot.SignupURL); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) - default: - return nil - } -} - -// CopyCode copies just the user code to the clipboard. -func (d *DeviceFlow) CopyCode() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.deviceCode.UserCode), - util.ReportInfo("Code copied to clipboard"), - ) -} - -// Cancel cancels the device flow polling. -func (d *DeviceFlow) Cancel() { - if d.cancelFunc != nil { - d.cancelFunc() - } -} - -func (d *DeviceFlow) initiateDeviceAuth() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - deviceCode, err := copilot.RequestDeviceCode(ctx) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)} - } - - d.deviceCode = deviceCode - - return DeviceAuthInitiatedMsg{ - deviceCode: d.deviceCode, - } -} - -// startPolling starts polling for the device token. -func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - d.cancelFunc = cancel - - token, err := copilot.PollForToken(ctx, deviceCode) - if err != nil { - if ctx.Err() != nil { - return nil // cancelled, don't report error. - } - return DeviceFlowErrorMsg{Error: err} - } - - return DeviceFlowCompletedMsg{Token: token} - } -} diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go deleted file mode 100644 index 4dacd56daa8008b42ebe7ede8bdb6c955b27dbe5..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/dialogs.go +++ /dev/null @@ -1,165 +0,0 @@ -package dialogs - -import ( - "slices" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type DialogID string - -// DialogModel represents a dialog component that can be displayed. -type DialogModel interface { - util.Model - Position() (int, int) - ID() DialogID -} - -// CloseCallback allows dialogs to perform cleanup when closed. -type CloseCallback interface { - Close() tea.Cmd -} - -// OpenDialogMsg is sent to open a new dialog with specified dimensions. -type OpenDialogMsg struct { - Model DialogModel -} - -// CloseDialogMsg is sent to close the topmost dialog. -type CloseDialogMsg struct{} - -// DialogCmp manages a stack of dialogs with keyboard navigation. -type DialogCmp interface { - util.Model - - Dialogs() []DialogModel - HasDialogs() bool - GetLayers() []*lipgloss.Layer - ActiveModel() util.Model - ActiveDialogID() DialogID -} - -type dialogCmp struct { - width, height int - dialogs []DialogModel - idMap map[DialogID]int - keyMap KeyMap -} - -// NewDialogCmp creates a new dialog manager. -func NewDialogCmp() DialogCmp { - return dialogCmp{ - dialogs: []DialogModel{}, - keyMap: DefaultKeyMap(), - idMap: make(map[DialogID]int), - } -} - -func (d dialogCmp) Init() tea.Cmd { - return nil -} - -// Update handles dialog lifecycle and forwards messages to the active dialog. -func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - var cmds []tea.Cmd - d.width = msg.Width - d.height = msg.Height - for i := range d.dialogs { - u, cmd := d.dialogs[i].Update(msg) - d.dialogs[i] = u.(DialogModel) - cmds = append(cmds, cmd) - } - return d, tea.Batch(cmds...) - case OpenDialogMsg: - return d.handleOpen(msg) - case CloseDialogMsg: - if len(d.dialogs) == 0 { - return d, nil - } - inx := len(d.dialogs) - 1 - dialog := d.dialogs[inx] - delete(d.idMap, dialog.ID()) - d.dialogs = d.dialogs[:len(d.dialogs)-1] - if closeable, ok := dialog.(CloseCallback); ok { - return d, closeable.Close() - } - return d, nil - } - if d.HasDialogs() { - lastIndex := len(d.dialogs) - 1 - u, cmd := d.dialogs[lastIndex].Update(msg) - d.dialogs[lastIndex] = u.(DialogModel) - return d, cmd - } - return d, nil -} - -func (d dialogCmp) View() string { - return "" -} - -func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) { - if d.HasDialogs() { - dialog := d.dialogs[len(d.dialogs)-1] - if dialog.ID() == msg.Model.ID() { - return d, nil // Do not open a dialog if it's already the topmost one - } - if dialog.ID() == "quit" { - return d, nil // Do not open dialogs on top of quit - } - } - // if the dialog is already in the stack make it the last item - if _, ok := d.idMap[msg.Model.ID()]; ok { - existing := d.dialogs[d.idMap[msg.Model.ID()]] - // Reuse the model so we keep the state - msg.Model = existing - d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1) - } - d.idMap[msg.Model.ID()] = len(d.dialogs) - d.dialogs = append(d.dialogs, msg.Model) - var cmds []tea.Cmd - cmd := msg.Model.Init() - cmds = append(cmds, cmd) - _, cmd = msg.Model.Update(tea.WindowSizeMsg{ - Width: d.width, - Height: d.height, - }) - cmds = append(cmds, cmd) - return d, tea.Batch(cmds...) -} - -func (d dialogCmp) Dialogs() []DialogModel { - return d.dialogs -} - -func (d dialogCmp) ActiveModel() util.Model { - if len(d.dialogs) == 0 { - return nil - } - return d.dialogs[len(d.dialogs)-1] -} - -func (d dialogCmp) ActiveDialogID() DialogID { - if len(d.dialogs) == 0 { - return "" - } - return d.dialogs[len(d.dialogs)-1].ID() -} - -func (d dialogCmp) GetLayers() []*lipgloss.Layer { - layers := []*lipgloss.Layer{} - for _, dialog := range d.Dialogs() { - dialogView := dialog.View() - row, col := dialog.Position() - layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row)) - } - return layers -} - -func (d dialogCmp) HasDialogs() bool { - return len(d.dialogs) > 0 -} diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go deleted file mode 100644 index fd9f85e70d1a100ec33d89219dd4d276459bb6ee..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ /dev/null @@ -1,260 +0,0 @@ -package filepicker - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - - "charm.land/bubbles/v2/filepicker" - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/image" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB - FilePickerID = "filepicker" - fileSelectionHeight = 10 - previewHeight = 20 -) - -type FilePickedMsg struct { - Attachment message.Attachment -} - -type FilePicker interface { - dialogs.DialogModel -} - -type model struct { - wWidth int - wHeight int - width int - filePicker filepicker.Model - highlightedFile string - image image.Model - keyMap KeyMap - help help.Model -} - -var AllowedTypes = []string{".jpg", ".jpeg", ".png"} - -func NewFilePickerCmp(workingDir string) FilePicker { - t := styles.CurrentTheme() - fp := filepicker.New() - fp.AllowedTypes = AllowedTypes - - if workingDir != "" { - fp.CurrentDirectory = workingDir - } else { - // Fallback to current working directory, then home directory - if cwd, err := os.Getwd(); err == nil { - fp.CurrentDirectory = cwd - } else { - fp.CurrentDirectory = home.Dir() - } - } - - fp.ShowPermissions = false - fp.ShowSize = false - fp.AutoHeight = false - fp.Styles = t.S().FilePicker - fp.Cursor = "" - fp.SetHeight(fileSelectionHeight) - - image := image.New(1, 1, "") - - help := help.New() - help.Styles = t.S().Help - return &model{ - filePicker: fp, - image: image, - keyMap: DefaultKeyMap(), - help: help, - } -} - -func (m *model) Init() tea.Cmd { - return m.filePicker.Init() -} - -func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - m.width = min(70, m.wWidth) - styles := m.filePicker.Styles - styles.Directory = styles.Directory.Width(m.width - 4) - styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4) - styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4) - styles.File = styles.File.Width(m.width) - m.filePicker.Styles = styles - return m, nil - case tea.KeyPressMsg: - if key.Matches(msg, m.keyMap.Close) { - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if key.Matches(msg, m.filePicker.KeyMap.Back) { - // make sure we don't go back if we are at the home directory - if m.filePicker.CurrentDirectory == home.Dir() { - return m, nil - } - } - } - - var cmd tea.Cmd - var cmds []tea.Cmd - m.filePicker, cmd = m.filePicker.Update(msg) - cmds = append(cmds, cmd) - if m.highlightedFile != m.currentImage() && m.currentImage() != "" { - w, h := m.imagePreviewSize() - cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage()) - cmds = append(cmds, cmd) - } - m.highlightedFile = m.currentImage() - - // Did the user select a file? - if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect { - // Get the path of the selected file. - return m, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - func() tea.Msg { - isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize) - if err != nil { - return util.ReportError(fmt.Errorf("unable to read the image: %w", err)) - } - if isFileLarge { - return util.ReportError(fmt.Errorf("file too large, max 5MB")) - } - - content, err := os.ReadFile(path) - if err != nil { - return util.ReportError(fmt.Errorf("unable to read the image: %w", err)) - } - - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(path) - attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} - return FilePickedMsg{ - Attachment: attachment, - } - }, - ) - } - m.image, cmd = m.image.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -func (m *model) View() string { - t := styles.CurrentTheme() - - strs := []string{ - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)), - } - - // hide image preview if the terminal is too small - if x, y := m.imagePreviewSize(); x > 0 && y > 0 { - strs = append(strs, m.imagePreview()) - } - - strs = append( - strs, - m.filePicker.View(), - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - - content := lipgloss.JoinVertical( - lipgloss.Left, - strs..., - ) - return m.style().Render(content) -} - -func (m *model) currentImage() string { - for _, ext := range m.filePicker.AllowedTypes { - if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) { - return m.filePicker.HighlightedPath() - } - } - return "" -} - -func (m *model) imagePreview() string { - const padding = 2 - - t := styles.CurrentTheme() - w, h := m.imagePreviewSize() - - if m.currentImage() == "" { - imgPreview := t.S().Base. - Width(w - padding). - Height(h - padding). - Background(t.BgOverlay) - - return m.imagePreviewStyle().Render(imgPreview.Render()) - } - - return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View()) -} - -func (m *model) imagePreviewStyle() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base.Padding(1, 1, 1, 1) -} - -func (m *model) imagePreviewSize() (int, int) { - if m.wHeight-fileSelectionHeight-8 > previewHeight { - return m.width - 4, previewHeight - } - return 0, 0 -} - -func (m *model) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(m.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -// ID implements FilePicker. -func (m *model) ID() dialogs.DialogID { - return FilePickerID -} - -// Position implements FilePicker. -func (m *model) Position() (int, int) { - _, imageHeight := m.imagePreviewSize() - dialogHeight := fileSelectionHeight + imageHeight + 4 - row := (m.wHeight - dialogHeight) / 2 - - col := m.wWidth / 2 - col -= m.width / 2 - return row, col -} - -func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { - fileInfo, err := os.Stat(filePath) - if err != nil { - return false, fmt.Errorf("error getting file info: %w", err) - } - - if fileInfo.Size() > sizeLimit { - return true, nil - } - - return false, nil -} diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go deleted file mode 100644 index 1fc493ba148e9d48f0348b3f3d49a132ffe60da2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/keys.go +++ /dev/null @@ -1,80 +0,0 @@ -package filepicker - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines keyboard bindings for dialog management. -type KeyMap struct { - Select, - Down, - Up, - Forward, - Backward, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "accept"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("down/j", "move down"), - ), - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("up/k", "move up"), - ), - Forward: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("right/l", "move forward"), - ), - Backward: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("left/h", "move backward"), - ), - - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "close/exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Down, - k.Up, - k.Forward, - k.Backward, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), - key.WithHelp("↑↓←→", "navigate"), - ), - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/hyper/device_flow.go b/internal/tui/components/dialogs/hyper/device_flow.go deleted file mode 100644 index b88d3aae8a2d1a826d5827c9f4112911602db2a2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/hyper/device_flow.go +++ /dev/null @@ -1,267 +0,0 @@ -// Package hyper provides the dialog for Hyper device flow authentication. -package hyper - -import ( - "context" - "fmt" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/hyper" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/pkg/browser" -) - -// DeviceFlowState represents the current state of the device flow. -type DeviceFlowState int - -const ( - DeviceFlowStateDisplay DeviceFlowState = iota - DeviceFlowStateSuccess - DeviceFlowStateError -) - -// DeviceAuthInitiatedMsg is sent when the device auth is initiated -// successfully. -type DeviceAuthInitiatedMsg struct { - deviceCode string - expiresIn int -} - -// DeviceFlowCompletedMsg is sent when the device flow completes successfully. -type DeviceFlowCompletedMsg struct { - Token *oauth.Token -} - -// DeviceFlowErrorMsg is sent when the device flow encounters an error. -type DeviceFlowErrorMsg struct { - Error error -} - -// DeviceFlow handles the Hyper device flow authentication. -type DeviceFlow struct { - State DeviceFlowState - width int - deviceCode string - userCode string - verificationURL string - expiresIn int - token *oauth.Token - cancelFunc context.CancelFunc - spinner spinner.Model -} - -// NewDeviceFlow creates a new device flow component. -func NewDeviceFlow() *DeviceFlow { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight) - return &DeviceFlow{ - State: DeviceFlowStateDisplay, - spinner: s, - } -} - -// Init initializes the device flow by calling the device auth API and starting polling. -func (d *DeviceFlow) Init() tea.Cmd { - return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth) -} - -// Update handles messages and state transitions. -func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - d.spinner, cmd = d.spinner.Update(msg) - - switch msg := msg.(type) { - case DeviceAuthInitiatedMsg: - // Start polling now that we have the device code. - d.expiresIn = msg.expiresIn - return d, tea.Batch(cmd, d.startPolling(msg.deviceCode)) - case DeviceFlowCompletedMsg: - d.State = DeviceFlowStateSuccess - d.token = msg.Token - return d, nil - case DeviceFlowErrorMsg: - d.State = DeviceFlowStateError - return d, util.ReportError(msg.Error) - } - - return d, cmd -} - -// View renders the device flow dialog. -func (d *DeviceFlow) View() string { - t := styles.CurrentTheme() - - whiteStyle := lipgloss.NewStyle().Foreground(t.White) - primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) - greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight) - linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) - errorStyle := lipgloss.NewStyle().Foreground(t.Error) - mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted) - - switch d.State { - case DeviceFlowStateDisplay: - if d.userCode == "" { - return lipgloss.NewStyle(). - Margin(0, 1). - Render( - greenStyle.Render(d.spinner.View()) + - mutedStyle.Render("Initializing..."), - ) - } - - instructions := lipgloss.NewStyle(). - Margin(1, 1, 0, 1). - Width(d.width - 2). - Render( - whiteStyle.Render("Press ") + - primaryStyle.Render("enter") + - whiteStyle.Render(" to copy the code below and open the browser."), - ) - - codeBox := lipgloss.NewStyle(). - Width(d.width-2). - Height(7). - Align(lipgloss.Center, lipgloss.Center). - Background(t.BgBaseLighter). - Margin(1). - Render( - lipgloss.NewStyle(). - Bold(true). - Foreground(t.White). - Render(d.userCode), - ) - - link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL) - url := mutedStyle. - Margin(0, 1). - Width(d.width - 2). - Render("Browser not opening? Refer to\n" + link) - - waiting := greenStyle. - Width(d.width-2). - Margin(1, 1, 0, 1). - Render(d.spinner.View() + "Verifying...") - - return lipgloss.JoinVertical( - lipgloss.Left, - instructions, - codeBox, - url, - waiting, - ) - - case DeviceFlowStateSuccess: - return greenStyle.Margin(0, 1).Render("Authentication successful!") - - case DeviceFlowStateError: - return lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render(errorStyle.Render("Authentication failed.")) - - default: - return "" - } -} - -// SetWidth sets the width of the dialog. -func (d *DeviceFlow) SetWidth(w int) { - d.width = w -} - -// Cursor hides the cursor. -func (d *DeviceFlow) Cursor() *tea.Cursor { return nil } - -// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL. -func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.userCode), - func() tea.Msg { - if err := browser.OpenURL(d.verificationURL); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) -} - -// CopyCode copies just the user code to the clipboard. -func (d *DeviceFlow) CopyCode() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.userCode), - util.ReportInfo("Code copied to clipboard"), - ) -} - -// Cancel cancels the device flow polling. -func (d *DeviceFlow) Cancel() { - if d.cancelFunc != nil { - d.cancelFunc() - } -} - -func (d *DeviceFlow) initiateDeviceAuth() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - authResp, err := hyper.InitiateDeviceAuth(ctx) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)} - } - - d.deviceCode = authResp.DeviceCode - d.userCode = authResp.UserCode - d.verificationURL = authResp.VerificationURL - - return DeviceAuthInitiatedMsg{ - deviceCode: authResp.DeviceCode, - expiresIn: authResp.ExpiresIn, - } -} - -// startPolling starts polling for the device token. -func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - d.cancelFunc = cancel - - // Poll for refresh token. - refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn) - if err != nil { - if ctx.Err() != nil { - // Cancelled, don't report error. - return nil - } - return DeviceFlowErrorMsg{Error: err} - } - - // Exchange refresh token for access token. - token, err := hyper.ExchangeToken(ctx, refreshToken) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)} - } - - // Verify the access token works. - introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)} - } - if !introspect.Active { - return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")} - } - - return DeviceFlowCompletedMsg{Token: token} - } -} diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go deleted file mode 100644 index 178ea65612a0db8072f21c0a17335d7c627afae4..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/keys.go +++ /dev/null @@ -1,43 +0,0 @@ -package dialogs - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines keyboard bindings for dialog management. -type KeyMap struct { - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go deleted file mode 100644 index 6ab890ca83bdcce55cc3441683c9b2c6e6acf542..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/apikey.go +++ /dev/null @@ -1,203 +0,0 @@ -package models - -import ( - "fmt" - - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type APIKeyInputState int - -const ( - APIKeyInputStateInitial APIKeyInputState = iota - APIKeyInputStateVerifying - APIKeyInputStateVerified - APIKeyInputStateError -) - -type APIKeyStateChangeMsg struct { - State APIKeyInputState -} - -type APIKeyInput struct { - input textinput.Model - width int - spinner spinner.Model - providerName string - state APIKeyInputState - title string - showTitle bool -} - -func NewAPIKeyInput() *APIKeyInput { - t := styles.CurrentTheme() - - ti := textinput.New() - ti.Placeholder = "Enter your API key..." - ti.SetVirtualCursor(false) - ti.Prompt = "> " - ti.SetStyles(t.S().TextInput) - ti.Focus() - - return &APIKeyInput{ - input: ti, - state: APIKeyInputStateInitial, - spinner: spinner.New( - spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(t.S().Base.Foreground(t.Green)), - ), - providerName: "Provider", - showTitle: true, - } -} - -func (a *APIKeyInput) SetProviderName(name string) { - a.providerName = name - a.updateStatePresentation() -} - -func (a *APIKeyInput) SetShowTitle(show bool) { - a.showTitle = show -} - -func (a *APIKeyInput) GetTitle() string { - return a.title -} - -func (a *APIKeyInput) Init() tea.Cmd { - a.updateStatePresentation() - return a.spinner.Tick -} - -func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - if a.state == APIKeyInputStateVerifying { - var cmd tea.Cmd - a.spinner, cmd = a.spinner.Update(msg) - a.updateStatePresentation() - return a, cmd - } - return a, nil - case APIKeyStateChangeMsg: - a.state = msg.State - var cmd tea.Cmd - if msg.State == APIKeyInputStateVerifying { - cmd = a.spinner.Tick - } - a.updateStatePresentation() - return a, cmd - } - - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - return a, cmd -} - -func (a *APIKeyInput) updateStatePresentation() { - t := styles.CurrentTheme() - - prefixStyle := t.S().Base. - Foreground(t.Primary) - accentStyle := t.S().Base.Foreground(t.Green).Bold(true) - errorStyle := t.S().Base.Foreground(t.Cherry) - - switch a.state { - case APIKeyInputStateInitial: - titlePrefix := prefixStyle.Render("Enter your ") - a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".") - a.input.SetStyles(t.S().TextInput) - a.input.Prompt = "> " - case APIKeyInputStateVerifying: - titlePrefix := prefixStyle.Render("Verifying your ") - a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...") - ts := t.S().TextInput - // make the blurred state be the same - ts.Blurred.Prompt = ts.Focused.Prompt - a.input.Prompt = a.spinner.View() - a.input.Blur() - case APIKeyInputStateVerified: - a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.") - ts := t.S().TextInput - // make the blurred state be the same - ts.Blurred.Prompt = ts.Focused.Prompt - a.input.SetStyles(ts) - a.input.Prompt = styles.CheckIcon + " " - a.input.Blur() - case APIKeyInputStateError: - a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?") - ts := t.S().TextInput - ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry) - a.input.Focus() - a.input.SetStyles(ts) - a.input.Prompt = styles.ErrorIcon + " " - } -} - -func (a *APIKeyInput) View() string { - inputView := a.input.View() - - dataPath := config.GlobalConfigData() - dataPath = home.Short(dataPath) - helpText := styles.CurrentTheme().S().Muted. - Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath)) - - var content string - if a.showTitle && a.title != "" { - content = lipgloss.JoinVertical( - lipgloss.Left, - a.title, - "", - inputView, - "", - helpText, - ) - } else { - content = lipgloss.JoinVertical( - lipgloss.Left, - inputView, - "", - helpText, - ) - } - - return content -} - -func (a *APIKeyInput) Cursor() *tea.Cursor { - cursor := a.input.Cursor() - if cursor != nil && a.showTitle { - cursor.Y += 2 // Adjust for title and spacing - } - return cursor -} - -func (a *APIKeyInput) Value() string { - return a.input.Value() -} - -func (a *APIKeyInput) Tick() tea.Cmd { - if a.state == APIKeyInputStateVerifying { - return a.spinner.Tick - } - return nil -} - -func (a *APIKeyInput) SetWidth(width int) { - a.width = width - a.input.SetWidth(width - 4) -} - -func (a *APIKeyInput) Reset() { - a.state = APIKeyInputStateInitial - a.input.SetValue("") - a.input.Focus() - a.updateStatePresentation() -} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go deleted file mode 100644 index ff81404b1f1937fff09d917bf3a9e3b24f4d38c9..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/keys.go +++ /dev/null @@ -1,120 +0,0 @@ -package models - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Choose, - Tab, - Close key.Binding - - isAPIKeyHelp bool - isAPIKeyValid bool - - isHyperDeviceFlow bool - isCopilotDeviceFlow bool - isCopilotUnavailable bool -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "choose"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Choose: key.NewBinding( - key.WithKeys("left", "right", "h", "l"), - key.WithHelp("←→", "choose"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "toggle type"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - if k.isHyperDeviceFlow || k.isCopilotDeviceFlow { - return []key.Binding{ - key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy code"), - ), - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "copy & open"), - ), - k.Close, - } - } - if k.isCopilotUnavailable { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "open signup"), - ), - k.Close, - } - } - if k.isAPIKeyHelp && !k.isAPIKeyValid { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "submit"), - ), - k.Close, - } - } else if k.isAPIKeyValid { - return []key.Binding{ - k.Select, - } - } - return []key.Binding{ - key.NewBinding( - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Tab, - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go deleted file mode 100644 index 9640b894d8e5bfb8659440f18f4cf04fb413bf02..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list.go +++ /dev/null @@ -1,335 +0,0 @@ -package models - -import ( - "cmp" - "fmt" - "slices" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]] - -type ModelListComponent struct { - list listModel - modelType int - providers []catwalk.Provider -} - -func modelKey(providerID, modelID string) string { - if providerID == "" || modelID == "" { - return "" - } - return providerID + ":" + modelID -} - -func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent { - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - options := []list.ListOption{ - list.WithKeyMap(keyMap), - list.WithWrapNavigation(), - } - if shouldResize { - options = append(options, list.WithResizeByList()) - } - modelList := list.NewFilterableGroupedList( - []list.Group[list.CompletionItem[ModelOption]]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterPlaceholder(inputPlaceholder), - list.WithFilterListOptions( - options..., - ), - ) - - return &ModelListComponent{ - list: modelList, - modelType: LargeModelType, - } -} - -func (m *ModelListComponent) Init() tea.Cmd { - var cmds []tea.Cmd - if len(m.providers) == 0 { - cfg := config.Get() - providers, err := config.Providers(cfg) - filteredProviders := []catwalk.Provider{} - for _, p := range providers { - hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") - isHyper := p.ID == "hyper" - isCopilot := p.ID == catwalk.InferenceProviderCopilot - if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot { - filteredProviders = append(filteredProviders, p) - } - } - - m.providers = filteredProviders - if err != nil { - cmds = append(cmds, util.ReportError(err)) - } - } - cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType)) - return tea.Batch(cmds...) -} - -func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { - u, cmd := m.list.Update(msg) - m.list = u.(listModel) - return m, cmd -} - -func (m *ModelListComponent) View() string { - return m.list.View() -} - -func (m *ModelListComponent) Cursor() *tea.Cursor { - return m.list.Cursor() -} - -func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { - return m.list.SetSize(width, height) -} - -func (m *ModelListComponent) SelectedModel() *ModelOption { - s := m.list.SelectedItem() - if s == nil { - return nil - } - sv := *s - model := sv.Value() - return &model -} - -func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { - t := styles.CurrentTheme() - m.modelType = modelType - - var groups []list.Group[list.CompletionItem[ModelOption]] - // first none section - selectedItemID := "" - itemsByKey := make(map[string]list.CompletionItem[ModelOption]) - - cfg := config.Get() - var currentModel config.SelectedModel - selectedType := config.SelectedModelTypeLarge - if m.modelType == LargeModelType { - currentModel = cfg.Models[config.SelectedModelTypeLarge] - selectedType = config.SelectedModelTypeLarge - } else { - currentModel = cfg.Models[config.SelectedModelTypeSmall] - selectedType = config.SelectedModelTypeSmall - } - recentItems := cfg.RecentModels[selectedType] - - configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon) - configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured")) - - // Create a map to track which providers we've already added - addedProviders := make(map[string]bool) - - // First, add any configured providers that are not in the known providers list - // These should appear at the top of the list - knownProviders, err := config.Providers(cfg) - if err != nil { - return util.ReportError(err) - } - for providerID, providerConfig := range cfg.Providers.Seq2() { - if providerConfig.Disable { - continue - } - - // Check if this provider is not in the known providers list - if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) || - !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) { - // Convert config provider to provider.Provider format - configProvider := providerConfig.ToProvider() - - // Add this unknown provider to the list - name := configProvider.Name - if name == "" { - name = string(configProvider.ID) - } - section := list.NewItemSection(name) - section.SetInfo(configured) - group := list.Group[list.CompletionItem[ModelOption]]{ - Section: section, - } - for _, model := range configProvider.Models { - modelOption := ModelOption{ - Provider: configProvider, - Model: model, - } - key := modelKey(string(configProvider.ID), model.ID) - item := list.NewCompletionItem( - model.Name, - modelOption, - list.WithCompletionID(key), - ) - itemsByKey[key] = item - - group.Items = append(group.Items, item) - if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectedItemID = item.ID() - } - } - groups = append(groups, group) - - addedProviders[providerID] = true - } - } - - // Move "Charm Hyper" to first position - // (but still after recent models and custom providers). - sortedProviders := make([]catwalk.Provider, len(m.providers)) - copy(sortedProviders, m.providers) - slices.SortStableFunc(sortedProviders, func(a, b catwalk.Provider) int { - switch { - case a.ID == "hyper": - return -1 - case b.ID == "hyper": - return 1 - default: - return 0 - } - }) - - // Then add the known providers from the predefined list - for _, provider := range sortedProviders { - // Skip if we already added this provider as an unknown provider - if addedProviders[string(provider.ID)] { - continue - } - - providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID)) - if providerConfigured && providerConfig.Disable { - continue - } - - displayProvider := provider - if providerConfigured { - displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name) - modelIndex := make(map[string]int, len(displayProvider.Models)) - for i, model := range displayProvider.Models { - modelIndex[model.ID] = i - } - for _, model := range providerConfig.Models { - if model.ID == "" { - continue - } - if idx, ok := modelIndex[model.ID]; ok { - if model.Name != "" { - displayProvider.Models[idx].Name = model.Name - } - continue - } - if model.Name == "" { - model.Name = model.ID - } - displayProvider.Models = append(displayProvider.Models, model) - modelIndex[model.ID] = len(displayProvider.Models) - 1 - } - } - - name := displayProvider.Name - if name == "" { - name = string(displayProvider.ID) - } - - section := list.NewItemSection(name) - if providerConfigured { - section.SetInfo(configured) - } - group := list.Group[list.CompletionItem[ModelOption]]{ - Section: section, - } - for _, model := range displayProvider.Models { - modelOption := ModelOption{ - Provider: displayProvider, - Model: model, - } - key := modelKey(string(displayProvider.ID), model.ID) - item := list.NewCompletionItem( - model.Name, - modelOption, - list.WithCompletionID(key), - ) - itemsByKey[key] = item - group.Items = append(group.Items, item) - if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider { - selectedItemID = item.ID() - } - } - groups = append(groups, group) - } - - if len(recentItems) > 0 { - recentSection := list.NewItemSection("Recently used") - recentGroup := list.Group[list.CompletionItem[ModelOption]]{ - Section: recentSection, - } - var validRecentItems []config.SelectedModel - for _, recent := range recentItems { - key := modelKey(recent.Provider, recent.Model) - option, ok := itemsByKey[key] - if !ok { - continue - } - validRecentItems = append(validRecentItems, recent) - recentID := fmt.Sprintf("recent::%s", key) - modelOption := option.Value() - providerName := modelOption.Provider.Name - if providerName == "" { - providerName = string(modelOption.Provider.ID) - } - item := list.NewCompletionItem( - modelOption.Model.Name, - option.Value(), - list.WithCompletionID(recentID), - list.WithCompletionShortcut(providerName), - ) - recentGroup.Items = append(recentGroup.Items, item) - if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { - selectedItemID = recentID - } - } - - if len(validRecentItems) != len(recentItems) { - if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { - return util.ReportError(err) - } - } - - if len(recentGroup.Items) > 0 { - groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...) - } - } - - var cmds []tea.Cmd - - cmd := m.list.SetGroups(groups) - - if cmd != nil { - cmds = append(cmds, cmd) - } - cmd = m.list.SetSelected(selectedItemID) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return tea.Sequence(cmds...) -} - -// GetModelType returns the current model type -func (m *ModelListComponent) GetModelType() int { - return m.modelType -} - -func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { - m.list.SetInputPlaceholder(placeholder) -} diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go deleted file mode 100644 index 9b738a4b17fbaa2de18de080a769cce41a676007..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list_recent_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package models - -import ( - "encoding/json" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/log" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/stretchr/testify/require" -) - -// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop. -func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) { - t.Helper() - for cmd != nil { - msg := cmd() - var next tea.Cmd - _, next = m.Update(msg) - cmd = next - } -} - -// readConfigJSON reads and unmarshals the JSON config file at path. -func readConfigJSON(t *testing.T, path string) map[string]any { - t.Helper() - baseDir := filepath.Dir(path) - fileName := filepath.Base(path) - b, err := fs.ReadFile(os.DirFS(baseDir), fileName) - require.NoError(t, err) - var out map[string]any - require.NoError(t, json.Unmarshal(b, &out)) - return out -} - -// readRecentModels reads the recent_models section from the config file. -func readRecentModels(t *testing.T, path string) map[string]any { - t.Helper() - out := readConfigJSON(t, path) - rm, ok := out["recent_models"].(map[string]any) - require.True(t, ok) - return rm -} - -func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config so provider auto-update is disabled and we have recents - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "m2", "provider": "p1"}, // valid - map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Also create empty providers.json to prevent loading real providers - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance (no network due to auto-update disabled) - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build a small provider set for the list component - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent - }, - } - - // Create and initialize the component with our provider set - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Find all recent items (IDs prefixed with "recent::") and verify pruning - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.NotEmpty(t, recentItems, "no recent items found") - // Ensure the valid recent (p1:m2) is present and the invalid one is not - foundValid := false - for _, it := range recentItems { - if it.ID() == "recent::p1:m2" { - foundValid = true - } - require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned") - } - require.True(t, foundValid, "expected valid recent not found") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with pruned recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - largeAny, ok := rm["large"].([]any) - require.True(t, ok) - // Ensure that only valid recent(s) remain and the invalid one is removed - found := false - for _, v := range largeAny { - m := v.(map[string]any) - require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned") - if m["provider"] == "p1" && m["model"] == "m2" { - found = true - } - } - require.True(t, found, "persisted recents should include p1:m2") -} - -func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config with valid provider but one invalid model - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "m1", "provider": "p1"}, // valid - map[string]any{"model": "missing", "provider": "p1"}, // invalid model - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Create empty providers.json - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build provider set that only includes m1, not "missing" - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - }, - } - - // Create and initialize component - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Find all recent items - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.NotEmpty(t, recentItems, "valid recent should exist") - - // Verify the valid recent is present and invalid model is not - foundValid := false - for _, it := range recentItems { - if it.ID() == "recent::p1:m1" { - foundValid = true - } - require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned") - } - require.True(t, foundValid, "valid recent p1:m1 should be present") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with pruned recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - largeAny, ok := rm["large"].([]any) - require.True(t, ok) - require.Len(t, largeAny, 1, "should only have one valid model") - // Verify only p1:m1 remains - m := largeAny[0].(map[string]any) - require.Equal(t, "p1", m["provider"]) - require.Equal(t, "m1", m["model"]) -} - -func TestModelKey_EmptyInputs(t *testing.T) { - // Empty provider - require.Equal(t, "", modelKey("", "model")) - // Empty model - require.Equal(t, "", modelKey("provider", "")) - // Both empty - require.Equal(t, "", modelKey("", "")) - // Valid inputs - require.Equal(t, "p:m", modelKey("p", "m")) -} - -func TestModelList_AllRecentsInvalid(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config with only invalid recents - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "x", "provider": "unknown1"}, - map[string]any{"model": "y", "provider": "unknown2"}, - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Also create empty providers.json and data config - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance with isolated dataDir - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build provider set (doesn't include unknown1 or unknown2) - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - }, - } - - // Create and initialize component - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Verify no recent items exist in UI - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with empty recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - // When all recents are pruned, the value may be nil or an empty array - largeVal := rm["large"] - if largeVal == nil { - // nil is acceptable - means empty - return - } - largeAny, ok := largeVal.([]any) - require.True(t, ok, "large key should be nil or array") - require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries") -} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go deleted file mode 100644 index b06b4b475a9ababbda9e0702fc5552b0959741ba..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/models.go +++ /dev/null @@ -1,549 +0,0 @@ -// Package models provides the model selection dialog for the TUI. -package models - -import ( - "fmt" - "time" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "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" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - ModelsDialogID dialogs.DialogID = "models" - - defaultWidth = 60 -) - -const ( - LargeModelType int = iota - SmallModelType - - largeModelInputPlaceholder = "Choose a model for large, complex tasks" - smallModelInputPlaceholder = "Choose a model for small, simple tasks" -) - -// ModelSelectedMsg is sent when a model is selected -type ModelSelectedMsg struct { - Model config.SelectedModel - ModelType config.SelectedModelType -} - -// CloseModelDialogMsg is sent when a model is selected -type CloseModelDialogMsg struct{} - -// ModelDialog interface for the model selection dialog -type ModelDialog interface { - dialogs.DialogModel -} - -type ModelOption struct { - Provider catwalk.Provider - Model catwalk.Model -} - -type modelDialogCmp struct { - width int - wWidth int - wHeight int - - modelList *ModelListComponent - keyMap KeyMap - help help.Model - - // API key state - needsAPIKey bool - apiKeyInput *APIKeyInput - selectedModel *ModelOption - selectedModelType config.SelectedModelType - isAPIKeyValid bool - apiKeyValue string - - // Hyper device flow state - hyperDeviceFlow *hyper.DeviceFlow - showHyperDeviceFlow bool - - // Copilot device flow state - copilotDeviceFlow *copilot.DeviceFlow - showCopilotDeviceFlow bool -} - -func NewModelDialogCmp() ModelDialog { - keyMap := DefaultKeyMap() - - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true) - apiKeyInput := NewAPIKeyInput() - apiKeyInput.SetShowTitle(false) - help := help.New() - help.Styles = t.S().Help - - return &modelDialogCmp{ - modelList: modelList, - apiKeyInput: apiKeyInput, - width: defaultWidth, - keyMap: DefaultKeyMap(), - help: help, - } -} - -func (m *modelDialogCmp) Init() tea.Cmd { - return tea.Batch( - m.modelList.Init(), - m.apiKeyInput.Init(), - ) -} - -func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - m.apiKeyInput.SetWidth(m.width - 2) - m.help.SetWidth(m.width - 2) - return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) - case APIKeyStateChangeMsg: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - case hyper.DeviceFlowCompletedMsg: - return m, m.saveOauthTokenAndContinue(msg.Token, true) - case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg: - if m.hyperDeviceFlow != nil { - u, cmd := m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return m, cmd - } - return m, nil - case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: - if m.copilotDeviceFlow != nil { - u, cmd := m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return m, cmd - } - return m, nil - case copilot.DeviceFlowCompletedMsg: - return m, m.saveOauthTokenAndContinue(msg.Token, true) - case tea.KeyPressMsg: - switch { - // Handle Hyper device flow keys - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow: - return m, m.hyperDeviceFlow.CopyCode() - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow: - return m, m.copilotDeviceFlow.CopyCode() - case key.Matches(msg, m.keyMap.Select): - // If showing device flow, enter copies code and opens URL - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - return m, m.hyperDeviceFlow.CopyCodeAndOpenURL() - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - return m, m.copilotDeviceFlow.CopyCodeAndOpenURL() - } - selectedItem := m.modelList.SelectedModel() - if selectedItem == nil { - return m, nil - } - - modelType := config.SelectedModelTypeLarge - if m.modelList.GetModelType() == SmallModelType { - modelType = config.SelectedModelTypeSmall - } - - askForApiKey := func() { - m.keyMap.isAPIKeyHelp = true - m.showHyperDeviceFlow = false - m.showCopilotDeviceFlow = false - m.needsAPIKey = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.apiKeyInput.SetProviderName(selectedItem.Provider.Name) - } - - if m.isAPIKeyValid { - return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true) - } - if m.needsAPIKey { - // Handle API key submission - m.apiKeyValue = m.apiKeyInput.Value() - provider, err := m.getProvider(m.selectedModel.Provider.ID) - if err != nil || provider == nil { - return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID)) - } - providerConfig := config.ProviderConfig{ - ID: string(m.selectedModel.Provider.ID), - Name: m.selectedModel.Provider.Name, - APIKey: m.apiKeyValue, - Type: provider.Type, - BaseURL: provider.APIEndpoint, - } - return m, tea.Sequence( - util.CmdHandler(APIKeyStateChangeMsg{ - State: APIKeyInputStateVerifying, - }), - func() tea.Msg { - start := time.Now() - err := providerConfig.TestConnection(config.Get().Resolver()) - // intentionally wait for at least 750ms to make sure the user sees the spinner - elapsed := time.Since(start) - if elapsed < 750*time.Millisecond { - time.Sleep(750*time.Millisecond - elapsed) - } - if err == nil { - m.isAPIKeyValid = true - return APIKeyStateChangeMsg{ - State: APIKeyInputStateVerified, - } - } - return APIKeyStateChangeMsg{ - State: APIKeyInputStateError, - } - }, - ) - } - - // Check if provider is configured - if m.isProviderConfigured(string(selectedItem.Provider.ID)) { - return m, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(ModelSelectedMsg{ - Model: config.SelectedModel{ - Model: selectedItem.Model.ID, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: selectedItem.Model.DefaultReasoningEffort, - MaxTokens: selectedItem.Model.DefaultMaxTokens, - }, - ModelType: modelType, - }), - ) - } - switch selectedItem.Provider.ID { - case hyperp.Name: - m.showHyperDeviceFlow = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.hyperDeviceFlow = hyper.NewDeviceFlow() - m.hyperDeviceFlow.SetWidth(m.width - 2) - return m, m.hyperDeviceFlow.Init() - case catwalk.InferenceProviderCopilot: - if token, ok := config.Get().ImportCopilot(); ok { - m.selectedModel = selectedItem - m.selectedModelType = modelType - return m, m.saveOauthTokenAndContinue(token, true) - } - m.showCopilotDeviceFlow = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.copilotDeviceFlow = copilot.NewDeviceFlow() - m.copilotDeviceFlow.SetWidth(m.width - 2) - return m, m.copilotDeviceFlow.Init() - } - // For other providers, show API key input - askForApiKey() - return m, nil - case key.Matches(msg, m.keyMap.Tab): - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - case m.modelList.GetModelType() == LargeModelType: - m.modelList.SetInputPlaceholder(smallModelInputPlaceholder) - return m, m.modelList.SetModelType(SmallModelType) - default: - m.modelList.SetInputPlaceholder(largeModelInputPlaceholder) - return m, m.modelList.SetModelType(LargeModelType) - } - case key.Matches(msg, m.keyMap.Close): - switch { - case m.showHyperDeviceFlow: - if m.hyperDeviceFlow != nil { - m.hyperDeviceFlow.Cancel() - } - m.showHyperDeviceFlow = false - m.selectedModel = nil - case m.showCopilotDeviceFlow: - if m.copilotDeviceFlow != nil { - m.copilotDeviceFlow.Cancel() - } - m.showCopilotDeviceFlow = false - m.selectedModel = nil - case m.needsAPIKey: - if m.isAPIKeyValid { - return m, nil - } - // Go back to model selection - m.needsAPIKey = false - m.selectedModel = nil - m.isAPIKeyValid = false - m.apiKeyValue = "" - m.apiKeyInput.Reset() - return m, nil - default: - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - default: - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - default: - u, cmd := m.modelList.Update(msg) - m.modelList = u - return m, cmd - } - } - case tea.PasteMsg: - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - default: - var cmd tea.Cmd - m.modelList, cmd = m.modelList.Update(msg) - return m, cmd - } - case spinner.TickMsg: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - u, cmd = m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - u, cmd = m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - } - return m, cmd - default: - // Pass all other messages to the device flow for spinner animation - switch { - case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil: - u, cmd := m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return m, cmd - case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil: - u, cmd := m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return m, cmd - default: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - } - } - return m, nil -} - -func (m *modelDialogCmp) View() string { - t := styles.CurrentTheme() - - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - // Show Hyper device flow - m.keyMap.isHyperDeviceFlow = true - deviceFlowView := m.hyperDeviceFlow.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)), - deviceFlowView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - // Show Hyper device flow - m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable - m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable - deviceFlowView := m.copilotDeviceFlow.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)), - deviceFlowView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - - // Reset the flags when not showing device flow - m.keyMap.isHyperDeviceFlow = false - m.keyMap.isCopilotDeviceFlow = false - m.keyMap.isCopilotUnavailable = false - - switch { - case m.needsAPIKey: - // Show API key input - m.keyMap.isAPIKeyHelp = true - m.keyMap.isAPIKeyValid = m.isAPIKeyValid - apiKeyView := m.apiKeyInput.View() - apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView) - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)), - apiKeyView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - - // Show model selection - listView := m.modelList.View() - radio := m.modelTypeRadio() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio), - listView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) -} - -func (m *modelDialogCmp) Cursor() *tea.Cursor { - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - return m.hyperDeviceFlow.Cursor() - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - return m.copilotDeviceFlow.Cursor() - } - if m.needsAPIKey { - cursor := m.apiKeyInput.Cursor() - if cursor != nil { - cursor = m.moveCursor(cursor) - return cursor - } - } else { - cursor := m.modelList.Cursor() - if cursor != nil { - cursor = m.moveCursor(cursor) - return cursor - } - } - return nil -} - -func (m *modelDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(m.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (m *modelDialogCmp) listWidth() int { - return m.width - 2 -} - -func (m *modelDialogCmp) listHeight() int { - return m.wHeight / 2 -} - -func (m *modelDialogCmp) Position() (int, int) { - row := m.wHeight/4 - 2 // just a bit above the center - col := m.wWidth / 2 - col -= m.width / 2 - return row, col -} - -func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := m.Position() - if m.needsAPIKey { - offset := row + 3 // Border + title + API key input offset - cursor.Y += offset - cursor.X = cursor.X + col + 2 - } else { - offset := row + 3 // Border + title - cursor.Y += offset - cursor.X = cursor.X + col + 2 - } - return cursor -} - -func (m *modelDialogCmp) ID() dialogs.DialogID { - return ModelsDialogID -} - -func (m *modelDialogCmp) modelTypeRadio() string { - t := styles.CurrentTheme() - choices := []string{"Large Task", "Small Task"} - iconSelected := "◉" - iconUnselected := "○" - if m.modelList.GetModelType() == LargeModelType { - return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) - } - return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) -} - -func (m *modelDialogCmp) isProviderConfigured(providerID string) bool { - cfg := config.Get() - _, ok := cfg.Providers.Get(providerID) - return ok -} - -func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) { - cfg := config.Get() - providers, err := config.Providers(cfg) - if err != nil { - return nil, err - } - for _, p := range providers { - if p.ID == providerID { - return &p, nil - } - } - return nil, nil -} - -func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd { - if m.selectedModel == nil { - return util.ReportError(fmt.Errorf("no model selected")) - } - - cfg := config.Get() - err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey) - if err != nil { - return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) - } - - // Reset API key state and continue with model selection - selectedModel := *m.selectedModel - var cmds []tea.Cmd - if close { - cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{})) - } - cmds = append( - cmds, - util.CmdHandler(ModelSelectedMsg{ - Model: config.SelectedModel{ - Model: selectedModel.Model.ID, - Provider: string(selectedModel.Provider.ID), - ReasoningEffort: selectedModel.Model.DefaultReasoningEffort, - MaxTokens: selectedModel.Model.DefaultMaxTokens, - }, - ModelType: m.selectedModelType, - }), - ) - return tea.Sequence(cmds...) -} diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go deleted file mode 100644 index 5e7786ec1eddf1f3491f3a961c087f72911f1c33..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/keys.go +++ /dev/null @@ -1,113 +0,0 @@ -package permissions - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Left, - Right, - Tab, - Select, - Allow, - AllowSession, - Deny, - ToggleDiffMode, - ScrollDown, - ScrollUp key.Binding - ScrollLeft, - ScrollRight key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←", "previous"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→", "next"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch"), - ), - Allow: key.NewBinding( - key.WithKeys("a", "A", "ctrl+a"), - key.WithHelp("a", "allow"), - ), - AllowSession: key.NewBinding( - key.WithKeys("s", "S", "ctrl+s"), - key.WithHelp("s", "allow session"), - ), - Deny: key.NewBinding( - key.WithKeys("d", "D", "esc"), - key.WithHelp("d", "deny"), - ), - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - ToggleDiffMode: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "toggle diff mode"), - ), - ScrollDown: key.NewBinding( - key.WithKeys("shift+down", "J"), - key.WithHelp("shift+↓", "scroll down"), - ), - ScrollUp: key.NewBinding( - key.WithKeys("shift+up", "K"), - key.WithHelp("shift+↑", "scroll up"), - ), - ScrollLeft: key.NewBinding( - key.WithKeys("shift+left", "H"), - key.WithHelp("shift+←", "scroll left"), - ), - ScrollRight: key.NewBinding( - key.WithKeys("shift+right", "L"), - key.WithHelp("shift+→", "scroll right"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Left, - k.Right, - k.Tab, - k.Select, - k.Allow, - k.AllowSession, - k.Deny, - k.ToggleDiffMode, - k.ScrollDown, - k.ScrollUp, - k.ScrollLeft, - k.ScrollRight, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ToggleDiffMode, - key.NewBinding( - key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), - key.WithHelp("shift+←↓↑→", "scroll"), - ), - } -} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go deleted file mode 100644 index d743d36f5b4674cd09fe7761c005fc2b9979252b..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ /dev/null @@ -1,899 +0,0 @@ -package permissions - -import ( - "encoding/json" - "fmt" - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type PermissionAction string - -// Permission responses -const ( - PermissionAllow PermissionAction = "allow" - PermissionAllowForSession PermissionAction = "allow_session" - PermissionDeny PermissionAction = "deny" - - PermissionsDialogID dialogs.DialogID = "permissions" -) - -// PermissionResponseMsg represents the user's response to a permission request -type PermissionResponseMsg struct { - Permission permission.PermissionRequest - Action PermissionAction -} - -// PermissionDialogCmp interface for permission dialog component -type PermissionDialogCmp interface { - dialogs.DialogModel -} - -// permissionDialogCmp is the implementation of PermissionDialog -type permissionDialogCmp struct { - wWidth int - wHeight int - width int - height int - permission permission.PermissionRequest - contentViewPort viewport.Model - selectedOption int // 0: Allow, 1: Allow for session, 2: Deny - - // Diff view state - defaultDiffSplitMode bool // true for split, false for unified - diffSplitMode *bool // nil means use defaultDiffSplitMode - diffXOffset int // horizontal scroll offset - diffYOffset int // vertical scroll offset - - // Caching - cachedContent string - contentDirty bool - - positionRow int // Row position for dialog - positionCol int // Column position for dialog - - finalDialogHeight int - - keyMap KeyMap -} - -func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp { - if opts == nil { - opts = &Options{} - } - - // Create viewport for content - contentViewport := viewport.New() - return &permissionDialogCmp{ - contentViewPort: contentViewport, - selectedOption: 0, // Default to "Allow" - permission: permission, - diffSplitMode: opts.isSplitMode(), - keyMap: DefaultKeyMap(), - contentDirty: true, // Mark as dirty initially - } -} - -func (p *permissionDialogCmp) Init() tea.Cmd { - return p.contentViewPort.Init() -} - -func (p *permissionDialogCmp) supportsDiffView() bool { - return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName -} - -func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.wWidth = msg.Width - p.wHeight = msg.Height - p.contentDirty = true // Mark content as dirty on window resize - cmd := p.SetSize() - cmds = append(cmds, cmd) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab): - p.selectedOption = (p.selectedOption + 1) % 3 - return p, nil - case key.Matches(msg, p.keyMap.Left): - p.selectedOption = (p.selectedOption + 2) % 3 - case key.Matches(msg, p.keyMap.Select): - return p, p.selectCurrentOption() - case key.Matches(msg, p.keyMap.Allow): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.AllowSession): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.Deny): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.ToggleDiffMode): - if p.supportsDiffView() { - if p.diffSplitMode == nil { - diffSplitMode := !p.defaultDiffSplitMode - p.diffSplitMode = &diffSplitMode - } else { - *p.diffSplitMode = !*p.diffSplitMode - } - p.contentDirty = true // Mark content as dirty when diff mode changes - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollDown): - if p.supportsDiffView() { - p.scrollDown() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollUp): - if p.supportsDiffView() { - p.scrollUp() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollLeft): - if p.supportsDiffView() { - p.scrollLeft() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollRight): - if p.supportsDiffView() { - p.scrollRight() - return p, nil - } - default: - // Pass other keys to viewport - viewPort, cmd := p.contentViewPort.Update(msg) - p.contentViewPort = viewPort - cmds = append(cmds, cmd) - } - case tea.MouseWheelMsg: - if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) { - switch msg.Button { - case tea.MouseWheelDown: - p.scrollDown() - case tea.MouseWheelUp: - p.scrollUp() - case tea.MouseWheelLeft: - p.scrollLeft() - case tea.MouseWheelRight: - p.scrollRight() - } - } - } - - return p, tea.Batch(cmds...) -} - -func (p *permissionDialogCmp) scrollDown() { - p.diffYOffset += 1 - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollUp() { - p.diffYOffset = max(0, p.diffYOffset-1) - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollLeft() { - p.diffXOffset = max(0, p.diffXOffset-5) - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollRight() { - p.diffXOffset += 5 - p.contentDirty = true -} - -// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds. -// Returns true if the mouse is over the dialog area, false otherwise. -func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool { - if p.permission.ID == "" { - return false - } - var ( - dialogX = p.positionCol - dialogY = p.positionRow - dialogWidth = p.width - dialogHeight = p.finalDialogHeight - ) - return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight -} - -func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd { - var action PermissionAction - - switch p.selectedOption { - case 0: - action = PermissionAllow - case 1: - action = PermissionAllowForSession - case 2: - action = PermissionDeny - } - - return tea.Batch( - util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}), - util.CmdHandler(dialogs.CloseDialogMsg{}), - ) -} - -func (p *permissionDialogCmp) renderButtons() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - buttons := []core.ButtonOpts{ - { - Text: "Allow", - UnderlineIndex: 0, // "A" - Selected: p.selectedOption == 0, - }, - { - Text: "Allow for Session", - UnderlineIndex: 10, // "S" in "Session" - Selected: p.selectedOption == 1, - }, - { - Text: "Deny", - UnderlineIndex: 0, // "D" - Selected: p.selectedOption == 2, - }, - } - - content := core.SelectableButtons(buttons, " ") - if lipgloss.Width(content) > p.width-4 { - content = core.SelectableButtonsVertical(buttons, 1) - return baseStyle.AlignVertical(lipgloss.Center). - AlignHorizontal(lipgloss.Center). - Width(p.width - 4). - Render(content) - } - - return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content) -} - -func (p *permissionDialogCmp) renderHeader() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - toolKey := t.S().Muted.Render("Tool") - toolValue := t.S().Text. - Width(p.width - lipgloss.Width(toolKey)). - Render(fmt.Sprintf(" %s", p.permission.ToolName)) - - pathKey := t.S().Muted.Render("Path") - pathValue := t.S().Text. - Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path))) - - headerParts := []string{ - lipgloss.JoinHorizontal( - lipgloss.Left, - toolKey, - toolValue, - ), - lipgloss.JoinHorizontal( - lipgloss.Left, - pathKey, - pathValue, - ), - } - - // Add tool-specific header information - switch p.permission.ToolName { - case tools.BashToolName: - params := p.permission.Params.(tools.BashPermissionsParams) - descKey := t.S().Muted.Render("Desc") - descValue := t.S().Text. - Width(p.width - lipgloss.Width(descKey)). - Render(fmt.Sprintf(" %s", params.Description)) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - descKey, - descValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Render("Command"), - ) - case tools.DownloadToolName: - params := p.permission.Params.(tools.DownloadPermissionsParams) - urlKey := t.S().Muted.Render("URL") - urlValue := t.S().Text. - Width(p.width - lipgloss.Width(urlKey)). - Render(fmt.Sprintf(" %s", params.URL)) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - urlKey, - urlValue, - ), - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.EditToolName: - params := p.permission.Params.(tools.EditPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - - case tools.WriteToolName: - params := p.permission.Params.(tools.WritePermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.MultiEditToolName: - params := p.permission.Params.(tools.MultiEditPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.FetchToolName: - headerParts = append(headerParts, - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Bold(true).Render("URL"), - ) - case tools.AgenticFetchToolName: - headerParts = append(headerParts, - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Bold(true).Render("Web"), - ) - case tools.ViewToolName: - params := p.permission.Params.(tools.ViewPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.LSToolName: - params := p.permission.Params.(tools.LSPermissionsParams) - pathKey := t.S().Muted.Render("Directory") - pathValue := t.S().Text. - Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - pathKey, - pathValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - } - - return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) -} - -func (p *permissionDialogCmp) getOrGenerateContent() string { - // Return cached content if available and not dirty - if !p.contentDirty && p.cachedContent != "" { - return p.cachedContent - } - - // Generate new content - var content string - switch p.permission.ToolName { - case tools.BashToolName: - content = p.generateBashContent() - case tools.DownloadToolName: - content = p.generateDownloadContent() - case tools.EditToolName: - content = p.generateEditContent() - case tools.WriteToolName: - content = p.generateWriteContent() - case tools.MultiEditToolName: - content = p.generateMultiEditContent() - case tools.FetchToolName: - content = p.generateFetchContent() - case tools.AgenticFetchToolName: - content = p.generateAgenticFetchContent() - case tools.ViewToolName: - content = p.generateViewContent() - case tools.LSToolName: - content = p.generateLSContent() - default: - content = p.generateDefaultContent() - } - - // Cache the result - p.cachedContent = content - p.contentDirty = false - - return content -} - -func (p *permissionDialogCmp) generateBashContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok { - content := pr.Command - t := styles.CurrentTheme() - content = strings.TrimSpace(content) - lines := strings.Split(content, "\n") - - width := p.width - 4 - var out []string - for _, ln := range lines { - out = append(out, t.S().Muted. - Width(width). - Padding(0, 3). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render(ln)) - } - - // Ensure minimum of 7 lines for command display - minLines := 7 - for len(out) < minLines { - out = append(out, t.S().Muted. - Width(width). - Padding(0, 3). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render("")) - } - - // Use the cache for markdown rendering - renderedContent := strings.Join(out, "\n") - finalContent := baseStyle. - Width(p.contentViewPort.Width()). - Padding(1, 0). - Render(renderedContent) - - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateEditContent() string { - if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateWriteContent() string { - if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok { - // Use the cache for diff rendering - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateDownloadContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { - content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath)) - if pr.Timeout > 0 { - content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateMultiEditContent() string { - if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok { - // Use the cache for diff rendering - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateFetchContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(pr.URL) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateAgenticFetchContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok { - var content string - if pr.URL != "" { - content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt) - } else { - content = fmt.Sprintf("Prompt: %s", pr.Prompt) - } - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateViewContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok { - content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath)) - if pr.Offset > 0 { - content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1) - } - if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit - content += fmt.Sprintf("\nLines to read: %d", pr.Limit) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateLSContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok { - content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path)) - if len(pr.Ignore) > 0 { - content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", ")) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateDefaultContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - - content := p.permission.Description - - // Add pretty-printed JSON parameters for MCP tools - if p.permission.Params != nil { - var paramStr string - - // Ensure params is a string - if str, ok := p.permission.Params.(string); ok { - paramStr = str - } else { - paramStr = fmt.Sprintf("%v", p.permission.Params) - } - - // Try to parse as JSON for pretty printing - var parsed any - if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { - if b, err := json.MarshalIndent(parsed, "", " "); err == nil { - if content != "" { - content += "\n\n" - } - content += string(b) - } - } else { - // Not JSON, show as-is - if content != "" { - content += "\n\n" - } - content += paramStr - } - } - - content = strings.TrimSpace(content) - content = "\n" + content + "\n" - lines := strings.Split(content, "\n") - - width := p.width - 4 - var out []string - for _, ln := range lines { - ln = " " + ln // left padding - if len(ln) > width { - ln = ansi.Truncate(ln, width, "…") - } - out = append(out, t.S().Muted. - Width(width). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render(ln)) - } - - // Use the cache for markdown rendering - renderedContent := strings.Join(out, "\n") - finalContent := baseStyle. - Width(p.contentViewPort.Width()). - Render(renderedContent) - - if renderedContent == "" { - return "" - } - - return finalContent -} - -func (p *permissionDialogCmp) useDiffSplitMode() bool { - if p.diffSplitMode != nil { - return *p.diffSplitMode - } - return p.defaultDiffSplitMode -} - -func (p *permissionDialogCmp) styleViewport() string { - t := styles.CurrentTheme() - return t.S().Base.Render(p.contentViewPort.View()) -} - -func (p *permissionDialogCmp) render() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - title := core.Title("Permission Required", p.width-4) - // Render header - headerContent := p.renderHeader() - // Render buttons - buttons := p.renderButtons() - - p.contentViewPort.SetWidth(p.width - 4) - - // Always set viewport content (the caching is handled in getOrGenerateContent) - const minContentHeight = 9 - - availableDialogHeight := max(minContentHeight, p.height-minContentHeight) - p.contentViewPort.SetHeight(availableDialogHeight) - contentFinal := p.getOrGenerateContent() - contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal)) - - p.contentViewPort.SetHeight(contentHeight) - p.contentViewPort.SetContent(contentFinal) - - p.positionRow = p.wHeight / 2 - p.positionRow -= (contentHeight + 9) / 2 - p.positionRow -= 3 // Move dialog slightly higher than middle - - var contentHelp string - if p.supportsDiffView() { - contentHelp = help.New().View(p.keyMap) - } - - // Calculate content height dynamically based on window size - strs := []string{ - title, - "", - headerContent, - "", - p.styleViewport(), - "", - buttons, - "", - } - if contentHelp != "" { - strs = append(strs, "", contentHelp) - } - content := lipgloss.JoinVertical(lipgloss.Top, strs...) - - dialog := baseStyle. - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(p.width). - Render( - content, - ) - p.finalDialogHeight = lipgloss.Height(dialog) - return dialog -} - -func (p *permissionDialogCmp) View() string { - return p.render() -} - -func (p *permissionDialogCmp) SetSize() tea.Cmd { - if p.permission.ID == "" { - return nil - } - - oldWidth, oldHeight := p.width, p.height - - switch p.permission.ToolName { - case tools.BashToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.3) - case tools.DownloadToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.EditToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.WriteToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.MultiEditToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.FetchToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.3) - case tools.AgenticFetchToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.ViewToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.LSToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - default: - p.width = int(float64(p.wWidth) * 0.7) - p.height = int(float64(p.wHeight) * 0.5) - } - - // Default to diff split mode when dialog is wide enough. - p.defaultDiffSplitMode = p.width >= 140 - - // Set a maximum width for the dialog - p.width = min(p.width, 180) - - // Mark content as dirty if size changed - if oldWidth != p.width || oldHeight != p.height { - p.contentDirty = true - } - p.positionRow = p.wHeight / 2 - p.positionRow -= p.height / 2 - p.positionRow -= 3 // Move dialog slightly higher than middle - p.positionCol = p.wWidth / 2 - p.positionCol -= p.width / 2 - return nil -} - -func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string { - content, err := generator() - if err != nil { - return fmt.Sprintf("Error rendering markdown: %v", err) - } - - return content -} - -// ID implements PermissionDialogCmp. -func (p *permissionDialogCmp) ID() dialogs.DialogID { - return PermissionsDialogID -} - -// Position implements PermissionDialogCmp. -func (p *permissionDialogCmp) Position() (int, int) { - return p.positionRow, p.positionCol -} - -// Options for create a new permission dialog -type Options struct { - DiffMode string // split or unified, empty means use defaultDiffSplitMode -} - -// isSplitMode returns internal representation of diff mode switch -func (o Options) isSplitMode() *bool { - var split bool - - switch o.DiffMode { - case "split": - split = true - case "unified": - split = false - default: - return nil - } - - return &split -} diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go deleted file mode 100644 index 15b3e85e0da960a9a63562427f2f2e2f624ab627..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/keys.go +++ /dev/null @@ -1,75 +0,0 @@ -package quit - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines the keyboard bindings for the quit dialog. -type KeyMap struct { - LeftRight, - EnterSpace, - Yes, - No, - Tab, - Close key.Binding -} - -func DefaultKeymap() KeyMap { - return KeyMap{ - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch options"), - ), - EnterSpace: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "confirm"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y", "ctrl+c"), - key.WithHelp("y/Y/ctrl+c", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n/N", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch options"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.LeftRight, - k.EnterSpace, - k.Yes, - k.No, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.LeftRight, - k.EnterSpace, - } -} diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go deleted file mode 100644 index 4ffc04a0d1bf2397e2c00c7b321c360d9566d623..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/quit.go +++ /dev/null @@ -1,120 +0,0 @@ -package quit - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - question = "Are you sure you want to quit?" - QuitDialogID dialogs.DialogID = "quit" -) - -// QuitDialog represents a confirmation dialog for quitting the application. -type QuitDialog interface { - dialogs.DialogModel -} - -type quitDialogCmp struct { - wWidth int - wHeight int - - selectedNo bool // true if "No" button is selected - keymap KeyMap -} - -// NewQuitDialog creates a new quit confirmation dialog. -func NewQuitDialog() QuitDialog { - return &quitDialogCmp{ - selectedNo: true, // Default to "No" for safety - keymap: DefaultKeymap(), - } -} - -func (q *quitDialogCmp) Init() tea.Cmd { - return nil -} - -// Update handles keyboard input for the quit dialog. -func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - q.wWidth = msg.Width - q.wHeight = msg.Height - case tea.KeyPressMsg: - switch { - case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab): - q.selectedNo = !q.selectedNo - return q, nil - case key.Matches(msg, q.keymap.EnterSpace): - if !q.selectedNo { - return q, tea.Quit - } - return q, util.CmdHandler(dialogs.CloseDialogMsg{}) - case key.Matches(msg, q.keymap.Yes): - return q, tea.Quit - case key.Matches(msg, q.keymap.No, q.keymap.Close): - return q, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - } - return q, nil -} - -// View renders the quit dialog with Yes/No buttons. -func (q *quitDialogCmp) View() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - yesStyle := t.S().Text - noStyle := yesStyle - - if q.selectedNo { - noStyle = noStyle.Foreground(t.White).Background(t.Secondary) - yesStyle = yesStyle.Background(t.BgSubtle) - } else { - yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary) - noStyle = noStyle.Background(t.BgSubtle) - } - - const horizontalPadding = 3 - yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") + - yesStyle.PaddingRight(horizontalPadding).Render("ep!") - noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") + - noStyle.PaddingRight(horizontalPadding).Render("ope") - - buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render( - lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton), - ) - - content := baseStyle.Render( - lipgloss.JoinVertical( - lipgloss.Center, - question, - "", - buttons, - ), - ) - - quitDialogStyle := baseStyle. - Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - - return quitDialogStyle.Render(content) -} - -func (q *quitDialogCmp) Position() (int, int) { - row := q.wHeight / 2 - row -= 7 / 2 - col := q.wWidth / 2 - col -= (lipgloss.Width(question) + 4) / 2 - - return row, col -} - -func (q *quitDialogCmp) ID() dialogs.DialogID { - return QuitDialogID -} diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go deleted file mode 100644 index dfe6898b90b516903dc3b6b490641899c8cc6ca2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/reasoning/reasoning.go +++ /dev/null @@ -1,264 +0,0 @@ -package reasoning - -import ( - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "golang.org/x/text/cases" - "golang.org/x/text/language" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - ReasoningDialogID dialogs.DialogID = "reasoning" - - defaultWidth int = 50 -) - -type listModel = list.FilterableList[list.CompletionItem[EffortOption]] - -type EffortOption struct { - Title string - Effort string -} - -type ReasoningDialog interface { - dialogs.DialogModel -} - -type reasoningDialogCmp struct { - width int - wWidth int // Width of the terminal window - wHeight int // Height of the terminal window - - effortList listModel - keyMap ReasoningDialogKeyMap - help help.Model -} - -type ReasoningEffortSelectedMsg struct { - Effort string -} - -type ReasoningDialogKeyMap struct { - Next key.Binding - Previous key.Binding - Select key.Binding - Close key.Binding -} - -func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap { - return ReasoningDialogKeyMap{ - Next: key.NewBinding( - key.WithKeys("down", "j", "ctrl+n"), - key.WithHelp("↓/j/ctrl+n", "next"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "k", "ctrl+p"), - key.WithHelp("↑/k/ctrl+p", "previous"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "ctrl+c"), - key.WithHelp("esc/ctrl+c", "close"), - ), - } -} - -func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Select, k.Close} -} - -func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Next, k.Previous}, - {k.Select, k.Close}, - } -} - -func NewReasoningDialog() ReasoningDialog { - keyMap := DefaultReasoningDialogKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - effortList := list.NewFilterableList( - []list.CompletionItem[EffortOption]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - list.WithResizeByList(), - ), - ) - help := help.New() - help.Styles = t.S().Help - - return &reasoningDialogCmp{ - effortList: effortList, - width: defaultWidth, - keyMap: keyMap, - help: help, - } -} - -func (r *reasoningDialogCmp) Init() tea.Cmd { - return r.populateEffortOptions() -} - -func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd { - cfg := config.Get() - if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { - selectedModel := cfg.Models[agentCfg.Model] - model := cfg.GetModelByType(agentCfg.Model) - - // Get current reasoning effort - currentEffort := selectedModel.ReasoningEffort - if currentEffort == "" && model != nil { - currentEffort = model.DefaultReasoningEffort - } - - efforts := []EffortOption{} - caser := cases.Title(language.Und) - for _, level := range model.ReasoningLevels { - efforts = append(efforts, EffortOption{ - Title: caser.String(level), - Effort: level, - }) - } - - effortItems := []list.CompletionItem[EffortOption]{} - selectedID := "" - for _, effort := range efforts { - opts := []list.CompletionItemOption{ - list.WithCompletionID(effort.Effort), - } - if effort.Effort == currentEffort { - opts = append(opts, list.WithCompletionShortcut("current")) - selectedID = effort.Effort - } - effortItems = append(effortItems, list.NewCompletionItem( - effort.Title, - effort, - opts..., - )) - } - - cmd := r.effortList.SetItems(effortItems) - // Set the current effort as the selected item - if currentEffort != "" && selectedID != "" { - return tea.Sequence(cmd, r.effortList.SetSelected(selectedID)) - } - return cmd - } - return nil -} - -func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - r.wWidth = msg.Width - r.wHeight = msg.Height - return r, r.effortList.SetSize(r.listWidth(), r.listHeight()) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, r.keyMap.Select): - selectedItem := r.effortList.SelectedItem() - if selectedItem == nil { - return r, nil // No item selected, do nothing - } - effort := (*selectedItem).Value() - return r, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - func() tea.Msg { - return ReasoningEffortSelectedMsg{ - Effort: effort.Effort, - } - }, - ) - case key.Matches(msg, r.keyMap.Close): - return r, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := r.effortList.Update(msg) - r.effortList = u.(listModel) - return r, cmd - } - } - return r, nil -} - -func (r *reasoningDialogCmp) View() string { - t := styles.CurrentTheme() - listView := r.effortList - - header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4)) - content := lipgloss.JoinVertical( - lipgloss.Left, - header, - listView.View(), - "", - t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)), - ) - return r.style().Render(content) -} - -func (r *reasoningDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := r.effortList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = r.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (r *reasoningDialogCmp) listWidth() int { - return r.width - 2 // 4 for padding -} - -func (r *reasoningDialogCmp) listHeight() int { - listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections - return min(listHeight, r.wHeight/2) -} - -func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := r.Position() - offset := row + 3 - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -func (r *reasoningDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(r.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (r *reasoningDialogCmp) Position() (int, int) { - row := r.wHeight/4 - 2 // just a bit above the center - col := r.wWidth / 2 - col -= r.width / 2 - return row, col -} - -func (r *reasoningDialogCmp) ID() dialogs.DialogID { - return ReasoningDialogID -} diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go deleted file mode 100644 index 94b260bd71261699413151836c672b2498e03abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/keys.go +++ /dev/null @@ -1,67 +0,0 @@ -package sessions - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "tab", "ctrl+y"), - key.WithHelp("enter", "choose"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go deleted file mode 100644 index 11515eeedf8347b8eba5c94b7e0d35715d1380cc..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ /dev/null @@ -1,181 +0,0 @@ -package sessions - -import ( - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/event" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const SessionsDialogID dialogs.DialogID = "sessions" - -// SessionDialog interface for the session switching dialog -type SessionDialog interface { - dialogs.DialogModel -} - -type SessionsList = list.FilterableList[list.CompletionItem[session.Session]] - -type sessionDialogCmp struct { - selectedInx int - wWidth int - wHeight int - width int - selectedSessionID string - keyMap KeyMap - sessionsList SessionsList - help help.Model -} - -// NewSessionDialogCmp creates a new session switching dialog -func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog { - t := styles.CurrentTheme() - listKeyMap := list.DefaultKeyMap() - keyMap := DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - items := make([]list.CompletionItem[session.Session], len(sessions)) - if len(sessions) > 0 { - for i, session := range sessions { - items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID)) - } - } - - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - sessionsList := list.NewFilterableList( - items, - list.WithFilterPlaceholder("Enter a session name"), - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - ), - ) - help := help.New() - help.Styles = t.S().Help - s := &sessionDialogCmp{ - selectedSessionID: selectedID, - keyMap: DefaultKeyMap(), - sessionsList: sessionsList, - help: help, - } - - return s -} - -func (s *sessionDialogCmp) Init() tea.Cmd { - var cmds []tea.Cmd - cmds = append(cmds, s.sessionsList.Init()) - cmds = append(cmds, s.sessionsList.Focus()) - return tea.Sequence(cmds...) -} - -func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - var cmds []tea.Cmd - s.wWidth = msg.Width - s.wHeight = msg.Height - s.width = min(120, s.wWidth-8) - s.sessionsList.SetInputWidth(s.listWidth() - 2) - cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) - if s.selectedSessionID != "" { - cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID)) - } - return s, tea.Batch(cmds...) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Select): - selectedItem := s.sessionsList.SelectedItem() - if selectedItem != nil { - selected := *selectedItem - event.SessionSwitched() - return s, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler( - chat.SessionSelectedMsg(selected.Value()), - ), - ) - } - case key.Matches(msg, s.keyMap.Close): - return s, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := s.sessionsList.Update(msg) - s.sessionsList = u.(SessionsList) - return s, cmd - } - } - return s, nil -} - -func (s *sessionDialogCmp) View() string { - t := styles.CurrentTheme() - listView := s.sessionsList.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)), - listView, - "", - t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)), - ) - - return s.style().Render(content) -} - -func (s *sessionDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := s.sessionsList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = s.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (s *sessionDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(s.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (s *sessionDialogCmp) listHeight() int { - return s.wHeight/2 - 6 // 5 for the border, title and help -} - -func (s *sessionDialogCmp) listWidth() int { - return s.width - 2 // 2 for the border -} - -func (s *sessionDialogCmp) Position() (int, int) { - row := s.wHeight/4 - 2 // just a bit above the center - col := s.wWidth / 2 - col -= s.width / 2 - return row, col -} - -func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := s.Position() - offset := row + 3 // Border + title - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -// ID implements SessionDialog. -func (s *sessionDialogCmp) ID() dialogs.DialogID { - return SessionsDialogID -} diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go deleted file mode 100644 index c7898d472452fd8465394ccea1131a15224712b2..0000000000000000000000000000000000000000 --- a/internal/tui/components/files/files.go +++ /dev/null @@ -1,146 +0,0 @@ -package files - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// FileHistory represents a file history with initial and latest versions. -type FileHistory struct { - InitialVersion history.File - LatestVersion history.File -} - -// SessionFile represents a file with its history information. -type SessionFile struct { - History FileHistory - FilePath string - Additions int - Deletions int -} - -// RenderOptions contains options for rendering file lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderFileList renders a list of file status items with the given options. -func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { - t := styles.CurrentTheme() - fileList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "Modified Files" - } - section := t.S().Subtle.Render(sectionName) - fileList = append(fileList, section, "") - } - - if len(fileSlice) == 0 { - fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None")) - return fileList - } - - // Sort files by the latest version's created time - sort.Slice(fileSlice, func(i, j int) bool { - if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt { - return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0 - } - return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt - }) - - // Determine how many items to show - maxItems := len(fileSlice) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(fileSlice)) - } - - filesShown := 0 - for _, file := range fileSlice { - if file.Additions == 0 && file.Deletions == 0 { - continue // skip files with no changes - } - if filesShown >= maxItems { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - if rel, err := filepath.Rel(cwd, filePath); err == nil { - filePath = rel - } - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…") - - fileList = append(fileList, - core.Status( - core.StatusOpts{ - Title: filePath, - ExtraContent: extraContent, - }, - opts.MaxWidth, - ), - ) - filesShown++ - } - - return fileList -} - -// RenderFileBlock renders a complete file block with optional truncation indicator. -func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - fileList := RenderFileList(fileSlice, opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - totalFilesWithChanges := 0 - for _, file := range fileSlice { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > opts.MaxItems { - remaining := totalFilesWithChanges - opts.MaxItems - if remaining == 1 { - fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - fileList = append(fileList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, fileList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go deleted file mode 100644 index b526b1bb0a4b1eaf186a55475980bd81f5704ff6..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/image.go +++ /dev/null @@ -1,86 +0,0 @@ -// Based on the implementation by @trashhalo at: -// https://github.com/trashhalo/imgcat -package image - -import ( - "fmt" - _ "image/jpeg" - _ "image/png" - - tea "charm.land/bubbletea/v2" -) - -type Model struct { - url string - image string - width uint - height uint - err error -} - -func New(width, height uint, url string) Model { - return Model{ - width: width, - height: height, - url: url, - } -} - -func (m Model) Init() tea.Cmd { - return nil -} - -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - switch msg := msg.(type) { - case errMsg: - m.err = msg - return m, nil - case redrawMsg: - m.width = msg.width - m.height = msg.height - m.url = msg.url - return m, loadURL(m.url) - case loadMsg: - return handleLoadMsg(m, msg) - } - return m, nil -} - -func (m Model) View() string { - if m.err != nil { - return fmt.Sprintf("couldn't load image(s): %v", m.err) - } - return m.image -} - -type errMsg struct{ error } - -func (m Model) Redraw(width uint, height uint, url string) tea.Cmd { - return func() tea.Msg { - return redrawMsg{ - width: width, - height: height, - url: url, - } - } -} - -func (m Model) UpdateURL(url string) tea.Cmd { - return func() tea.Msg { - return redrawMsg{ - width: m.width, - height: m.height, - url: url, - } - } -} - -type redrawMsg struct { - width uint - height uint - url string -} - -func (m Model) IsLoading() bool { - return m.image == "" -} diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go deleted file mode 100644 index 2ca5d4bac77bdd660faf5bd41bdb1e385b4610a0..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/load.go +++ /dev/null @@ -1,169 +0,0 @@ -// Based on the implementation by @trashhalo at: -// https://github.com/trashhalo/imgcat -package image - -import ( - "bytes" - "context" - "encoding/base64" - "image" - "image/png" - "io" - "net/http" - "os" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/disintegration/imageorient" - "github.com/lucasb-eyer/go-colorful" - "github.com/muesli/termenv" - "github.com/nfnt/resize" - "github.com/srwiley/oksvg" - "github.com/srwiley/rasterx" -) - -type loadMsg struct { - io.ReadCloser -} - -func loadURL(url string) tea.Cmd { - var r io.ReadCloser - var err error - - if strings.HasPrefix(url, "http") { - var resp *http.Request - resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) - r = resp.Body - } else { - r, err = os.Open(url) - } - - if err != nil { - return func() tea.Msg { - return errMsg{err} - } - } - - return load(r) -} - -func load(r io.ReadCloser) tea.Cmd { - return func() tea.Msg { - return loadMsg{r} - } -} - -func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) { - defer msg.Close() - - img, err := readerToImage(m.width, m.height, m.url, msg) - if err != nil { - return m, func() tea.Msg { return errMsg{err} } - } - m.image = img - return m, nil -} - -func imageToString(width, height uint, img image.Image) (string, error) { - img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3) - b := img.Bounds() - w := b.Max.X - h := b.Max.Y - p := termenv.ColorProfile() - str := strings.Builder{} - for y := 0; y < h; y += 2 { - for x := w; x < int(width); x = x + 2 { - str.WriteString(" ") - } - for x := range w { - c1, _ := colorful.MakeColor(img.At(x, y)) - color1 := p.Color(c1.Hex()) - c2, _ := colorful.MakeColor(img.At(x, y+1)) - color2 := p.Color(c2.Hex()) - str.WriteString(termenv.String("▀"). - Foreground(color1). - Background(color2). - String()) - } - str.WriteString("\n") - } - return str.String(), nil -} - -func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) { - if strings.HasSuffix(strings.ToLower(url), ".svg") { - return svgToImage(width, height, r) - } - - img, _, err := imageorient.Decode(r) - if err != nil { - return "", err - } - - return imageToString(width, height, img) -} - -func svgToImage(width uint, height uint, r io.Reader) (string, error) { - // Original author: https://stackoverflow.com/users/10826783/usual-human - // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang - // Adapted to use size from SVG, and to use temp file. - - tmpPngFile, err := os.CreateTemp("", "img.*.png") - if err != nil { - return "", err - } - tmpPngPath := tmpPngFile.Name() - defer os.Remove(tmpPngPath) - defer tmpPngFile.Close() - - // Rasterize the SVG: - icon, err := oksvg.ReadIconStream(r) - if err != nil { - return "", err - } - w := int(icon.ViewBox.W) - h := int(icon.ViewBox.H) - icon.SetTarget(0, 0, float64(w), float64(h)) - rgba := image.NewRGBA(image.Rect(0, 0, w, h)) - icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1) - // Write rasterized image as PNG: - err = png.Encode(tmpPngFile, rgba) - if err != nil { - tmpPngFile.Close() - return "", err - } - tmpPngFile.Close() - - rPng, err := os.Open(tmpPngPath) - if err != nil { - return "", err - } - defer rPng.Close() - - img, _, err := imageorient.Decode(rPng) - if err != nil { - return "", err - } - return imageToString(width, height, img) -} - -// ImageFromBase64 renders an image from base64-encoded data. -func ImageFromBase64(width, height uint, data, mediaType string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - - r := bytes.NewReader(decoded) - - if strings.Contains(mediaType, "svg") { - return svgToImage(width, height, r) - } - - img, _, err := imageorient.Decode(r) - if err != nil { - return "", err - } - - return imageToString(width, height, img) -} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go deleted file mode 100644 index f9118143cbfd9a7bf19aa569bc85448746debecd..0000000000000000000000000000000000000000 --- a/internal/tui/components/lsp/lsp.go +++ /dev/null @@ -1,147 +0,0 @@ -package lsp - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// RenderOptions contains options for rendering LSP lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderLSPList renders a list of LSP status items with the given options. -func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string { - t := styles.CurrentTheme() - lspList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "LSPs" - } - section := t.S().Subtle.Render(sectionName) - lspList = append(lspList, section, "") - } - - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) == 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) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(lspConfigs)) - } - - for i, l := range lspConfigs { - if i >= maxItems { - break - } - - icon, description := iconAndDescription(l, t, lspStates) - - // Calculate diagnostic counts if we have LSP clients - var extraContent string - if lspClients != nil { - if client, ok := lspClients.Get(l.Name); ok { - counts := client.GetDiagnosticCounts() - errs := []string{} - if counts.Error > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error))) - } - if counts.Warning > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning))) - } - if counts.Hint > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint))) - } - if counts.Information > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information))) - } - extraContent = strings.Join(errs, " ") - } - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - Icon: icon.String(), - Title: l.Name, - Description: description, - ExtraContent: extraContent, - }, - opts.MaxWidth, - ), - ) - } - - 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] - switch info.State { - case lsp.StateStarting: - return t.ItemBusyIcon, t.S().Subtle.Render("starting...") - case lsp.StateReady: - return t.ItemOnlineIcon, "" - case lsp.StateError: - description := t.S().Subtle.Render("error") - if info.Error != nil { - description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error())) - } - return t.ItemErrorIcon, description - case lsp.StateDisabled: - return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive") - default: - return t.ItemOfflineIcon, "" - } -} - -// RenderLSPBlock renders a complete LSP block with optional truncation indicator. -func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - lspList := RenderLSPList(lspClients, opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) > opts.MaxItems { - remaining := len(lspConfigs) - opts.MaxItems - if remaining == 1 { - lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - lspList = append(lspList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, lspList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go deleted file mode 100644 index 78763ac85fdbb5b75e281ef39289f490e6bde949..0000000000000000000000000000000000000000 --- a/internal/tui/components/mcp/mcp.go +++ /dev/null @@ -1,138 +0,0 @@ -package mcp - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// RenderOptions contains options for rendering MCP lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderMCPList renders a list of MCP status items with the given options. -func RenderMCPList(opts RenderOptions) []string { - t := styles.CurrentTheme() - mcpList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "MCPs" - } - section := t.S().Subtle.Render(sectionName) - mcpList = append(mcpList, section, "") - } - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None")) - return mcpList - } - - // Get MCP states - mcpStates := mcp.GetStates() - - // Determine how many items to show - maxItems := len(mcps) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(mcps)) - } - - for i, l := range mcps { - if i >= maxItems { - break - } - - // Determine icon and color based on state - icon := t.ItemOfflineIcon - description := "" - extraContent := []string{} - - if state, exists := mcpStates[l.Name]; exists { - switch state.State { - case mcp.StateDisabled: - description = t.S().Subtle.Render("disabled") - case mcp.StateStarting: - icon = t.ItemBusyIcon - description = t.S().Subtle.Render("starting...") - case mcp.StateConnected: - icon = t.ItemOnlineIcon - if count := state.Counts.Tools; count > 0 { - label := "tools" - if count == 1 { - label = "tool" - } - extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label))) - } - if count := state.Counts.Prompts; count > 0 { - label := "prompts" - if count == 1 { - label = "prompt" - } - extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label))) - } - case mcp.StateError: - icon = t.ItemErrorIcon - if state.Error != nil { - description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) - } else { - description = t.S().Subtle.Render("error") - } - } - } else if l.MCP.Disabled { - description = t.S().Subtle.Render("disabled") - } - - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - Icon: icon.String(), - Title: l.Name, - Description: description, - ExtraContent: strings.Join(extraContent, " "), - }, - opts.MaxWidth, - ), - ) - } - - return mcpList -} - -// RenderMCPBlock renders a complete MCP block with optional truncation indicator. -func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - mcpList := RenderMCPList(opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - mcps := config.Get().MCP.Sorted() - if len(mcps) > opts.MaxItems { - remaining := len(mcps) - opts.MaxItems - if remaining == 1 { - mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - mcpList = append(mcpList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go deleted file mode 100644 index 8956bfa60dd36cee115cc82ef9ea2adb758219e9..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable.go +++ /dev/null @@ -1,329 +0,0 @@ -package list - -import ( - "regexp" - "slices" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/sahilm/fuzzy" -) - -// Pre-compiled regex for checking if a string is alphanumeric. -var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`) - -type FilterableItem interface { - Item - FilterValue() string -} - -type FilterableList[T FilterableItem] interface { - List[T] - Cursor() *tea.Cursor - SetInputWidth(int) - SetInputPlaceholder(string) - SetResultsSize(int) - Filter(q string) tea.Cmd - fuzzy.Source -} - -type HasMatchIndexes interface { - MatchIndexes([]int) -} - -type filterableOptions struct { - listOptions []ListOption - placeholder string - inputHidden bool - inputWidth int - inputStyle lipgloss.Style -} -type filterableList[T FilterableItem] struct { - *list[T] - *filterableOptions - width, height int - // stores all available items - items []T - resultsSize int - input textinput.Model - inputWidth int - query string -} - -type filterableListOption func(*filterableOptions) - -func WithFilterPlaceholder(ph string) filterableListOption { - return func(f *filterableOptions) { - f.placeholder = ph - } -} - -func WithFilterInputHidden() filterableListOption { - return func(f *filterableOptions) { - f.inputHidden = true - } -} - -func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { - return func(f *filterableOptions) { - f.inputStyle = inputStyle - } -} - -func WithFilterListOptions(opts ...ListOption) filterableListOption { - return func(f *filterableOptions) { - f.listOptions = opts - } -} - -func WithFilterInputWidth(inputWidth int) filterableListOption { - return func(f *filterableOptions) { - f.inputWidth = inputWidth - } -} - -func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] { - t := styles.CurrentTheme() - - f := &filterableList[T]{ - filterableOptions: &filterableOptions{ - inputStyle: t.S().Base, - placeholder: "Type to filter", - }, - } - for _, opt := range opts { - opt(f.filterableOptions) - } - f.list = New(items, f.listOptions...).(*list[T]) - - f.updateKeyMaps() - f.items = f.list.items - - if f.inputHidden { - return f - } - - ti := textinput.New() - ti.Placeholder = f.placeholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - f.input = ti - return f -} - -func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - // handle movements - case key.Matches(msg, f.keyMap.Down), - key.Matches(msg, f.keyMap.Up), - key.Matches(msg, f.keyMap.DownOneItem), - key.Matches(msg, f.keyMap.UpOneItem), - key.Matches(msg, f.keyMap.HalfPageDown), - key.Matches(msg, f.keyMap.HalfPageUp), - key.Matches(msg, f.keyMap.PageDown), - key.Matches(msg, f.keyMap.PageUp), - key.Matches(msg, f.keyMap.End), - key.Matches(msg, f.keyMap.Home): - u, cmd := f.list.Update(msg) - f.list = u.(*list[T]) - return f, cmd - default: - if !f.inputHidden { - var cmds []tea.Cmd - var cmd tea.Cmd - f.input, cmd = f.input.Update(msg) - cmds = append(cmds, cmd) - - if f.query != f.input.Value() { - cmd = f.Filter(f.input.Value()) - cmds = append(cmds, cmd) - } - f.query = f.input.Value() - return f, tea.Batch(cmds...) - } - } - } - u, cmd := f.list.Update(msg) - f.list = u.(*list[T]) - return f, cmd -} - -func (f *filterableList[T]) View() string { - if f.inputHidden { - return f.list.View() - } - - return lipgloss.JoinVertical( - lipgloss.Left, - f.inputStyle.Render(f.input.View()), - f.list.View(), - ) -} - -// removes bindings that are used for search -func (f *filterableList[T]) updateKeyMaps() { - removeLettersAndNumbers := func(bindings []string) []string { - var keep []string - for _, b := range bindings { - if len(b) != 1 { - keep = append(keep, b) - continue - } - if b == " " { - continue - } - m := alphanumericRegex.MatchString(b) - if !m { - keep = append(keep, b) - } - } - return keep - } - - updateBinding := func(binding key.Binding) key.Binding { - newKeys := removeLettersAndNumbers(binding.Keys()) - if len(newKeys) == 0 { - binding.SetEnabled(false) - return binding - } - binding.SetKeys(newKeys...) - return binding - } - - f.keyMap.Down = updateBinding(f.keyMap.Down) - f.keyMap.Up = updateBinding(f.keyMap.Up) - f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) - f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) - f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) - f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) - f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) - f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) - f.keyMap.End = updateBinding(f.keyMap.End) - f.keyMap.Home = updateBinding(f.keyMap.Home) -} - -func (m *filterableList[T]) GetSize() (int, int) { - return m.width, m.height -} - -func (f *filterableList[T]) SetSize(w, h int) tea.Cmd { - f.width = w - f.height = h - if f.inputHidden { - return f.list.SetSize(w, h) - } - if f.inputWidth == 0 { - f.input.SetWidth(w) - } else { - f.input.SetWidth(f.inputWidth) - } - return f.list.SetSize(w, h-(f.inputHeight())) -} - -func (f *filterableList[T]) inputHeight() int { - return lipgloss.Height(f.inputStyle.Render(f.input.View())) -} - -func (f *filterableList[T]) Filter(query string) tea.Cmd { - var cmds []tea.Cmd - for _, item := range f.items { - if i, ok := any(item).(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - - f.selectedItemIdx = -1 - if query == "" || len(f.items) == 0 { - return f.list.SetItems(f.visibleItems(f.items)) - } - - matches := fuzzy.FindFrom(query, f) - - var matchedItems []T - resultSize := len(matches) - if f.resultsSize > 0 && resultSize > f.resultsSize { - resultSize = f.resultsSize - } - for i := range resultSize { - match := matches[i] - item := f.items[match.Index] - if it, ok := any(item).(HasMatchIndexes); ok { - it.MatchIndexes(match.MatchedIndexes) - } - matchedItems = append(matchedItems, item) - } - - if f.direction == DirectionBackward { - slices.Reverse(matchedItems) - } - - cmds = append(cmds, f.list.SetItems(matchedItems)) - return tea.Batch(cmds...) -} - -func (f *filterableList[T]) SetItems(items []T) tea.Cmd { - f.items = items - return f.list.SetItems(f.visibleItems(items)) -} - -func (f *filterableList[T]) Cursor() *tea.Cursor { - if f.inputHidden { - return nil - } - return f.input.Cursor() -} - -func (f *filterableList[T]) Blur() tea.Cmd { - f.input.Blur() - return f.list.Blur() -} - -func (f *filterableList[T]) Focus() tea.Cmd { - f.input.Focus() - return f.list.Focus() -} - -func (f *filterableList[T]) IsFocused() bool { - return f.list.IsFocused() -} - -func (f *filterableList[T]) SetInputWidth(w int) { - f.inputWidth = w -} - -func (f *filterableList[T]) SetInputPlaceholder(ph string) { - f.placeholder = ph -} - -func (f *filterableList[T]) SetResultsSize(size int) { - f.resultsSize = size -} - -func (f *filterableList[T]) String(i int) string { - return f.items[i].FilterValue() -} - -func (f *filterableList[T]) Len() int { - return len(f.items) -} - -// visibleItems returns the subset of items that should be rendered based on -// the configured resultsSize limit. The underlying source (f.items) remains -// intact so filtering still searches the full set. -func (f *filterableList[T]) visibleItems(items []T) []T { - if f.resultsSize > 0 && len(items) > f.resultsSize { - return items[:f.resultsSize] - } - return items -} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go deleted file mode 100644 index 8597050cbc3820a53efe467182c8625f608616c2..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_group.go +++ /dev/null @@ -1,315 +0,0 @@ -package list - -import ( - "regexp" - "sort" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/sahilm/fuzzy" -) - -// Pre-compiled regex for checking if a string is alphanumeric. -// Note: This is duplicated from filterable.go to avoid circular dependencies. -var alphanumericRegexGroup = regexp.MustCompile(`^[a-zA-Z0-9]*$`) - -type FilterableGroupList[T FilterableItem] interface { - GroupedList[T] - Cursor() *tea.Cursor - SetInputWidth(int) - SetInputPlaceholder(string) -} -type filterableGroupList[T FilterableItem] struct { - *groupedList[T] - *filterableOptions - width, height int - groups []Group[T] - // stores all available items - input textinput.Model - inputWidth int - query string -} - -func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] { - t := styles.CurrentTheme() - - f := &filterableGroupList[T]{ - filterableOptions: &filterableOptions{ - inputStyle: t.S().Base, - placeholder: "Type to filter", - }, - } - for _, opt := range opts { - opt(f.filterableOptions) - } - f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T]) - - f.updateKeyMaps() - - if f.inputHidden { - return f - } - - ti := textinput.New() - ti.Placeholder = f.placeholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - f.input = ti - return f -} - -func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - // handle movements - case key.Matches(msg, f.keyMap.Down), - key.Matches(msg, f.keyMap.Up), - key.Matches(msg, f.keyMap.DownOneItem), - key.Matches(msg, f.keyMap.UpOneItem), - key.Matches(msg, f.keyMap.HalfPageDown), - key.Matches(msg, f.keyMap.HalfPageUp), - key.Matches(msg, f.keyMap.PageDown), - key.Matches(msg, f.keyMap.PageUp), - key.Matches(msg, f.keyMap.End), - key.Matches(msg, f.keyMap.Home): - u, cmd := f.groupedList.Update(msg) - f.groupedList = u.(*groupedList[T]) - return f, cmd - default: - if !f.inputHidden { - var cmds []tea.Cmd - var cmd tea.Cmd - f.input, cmd = f.input.Update(msg) - cmds = append(cmds, cmd) - - if f.query != f.input.Value() { - cmd = f.Filter(f.input.Value()) - cmds = append(cmds, cmd) - } - f.query = f.input.Value() - return f, tea.Batch(cmds...) - } - } - } - u, cmd := f.groupedList.Update(msg) - f.groupedList = u.(*groupedList[T]) - return f, cmd -} - -func (f *filterableGroupList[T]) View() string { - if f.inputHidden { - return f.groupedList.View() - } - - return lipgloss.JoinVertical( - lipgloss.Left, - f.inputStyle.Render(f.input.View()), - f.groupedList.View(), - ) -} - -// removes bindings that are used for search -func (f *filterableGroupList[T]) updateKeyMaps() { - removeLettersAndNumbers := func(bindings []string) []string { - var keep []string - for _, b := range bindings { - if len(b) != 1 { - keep = append(keep, b) - continue - } - if b == " " { - continue - } - m := alphanumericRegexGroup.MatchString(b) - if !m { - keep = append(keep, b) - } - } - return keep - } - - updateBinding := func(binding key.Binding) key.Binding { - newKeys := removeLettersAndNumbers(binding.Keys()) - if len(newKeys) == 0 { - binding.SetEnabled(false) - return binding - } - binding.SetKeys(newKeys...) - return binding - } - - f.keyMap.Down = updateBinding(f.keyMap.Down) - f.keyMap.Up = updateBinding(f.keyMap.Up) - f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) - f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) - f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) - f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) - f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) - f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) - f.keyMap.End = updateBinding(f.keyMap.End) - f.keyMap.Home = updateBinding(f.keyMap.Home) -} - -func (m *filterableGroupList[T]) GetSize() (int, int) { - return m.width, m.height -} - -func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd { - f.width = w - f.height = h - if f.inputHidden { - return f.groupedList.SetSize(w, h) - } - if f.inputWidth == 0 { - f.input.SetWidth(w) - } else { - f.input.SetWidth(f.inputWidth) - } - return f.groupedList.SetSize(w, h-(f.inputHeight())) -} - -func (f *filterableGroupList[T]) inputHeight() int { - return lipgloss.Height(f.inputStyle.Render(f.input.View())) -} - -func (f *filterableGroupList[T]) clearItemState() []tea.Cmd { - var cmds []tea.Cmd - for _, item := range f.items { - if i, ok := any(item).(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - return cmds -} - -func (f *filterableGroupList[T]) getGroupName(g Group[T]) string { - if section, ok := g.Section.(*itemSectionModel); ok { - return strings.ToLower(section.title) - } - return strings.ToLower(g.Section.ID()) -} - -func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) { - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(indexes) - } -} - -func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T { - if query == "" { - // No query, return all items with cleared match indexes - var items []T - for _, item := range group.Items { - f.setMatchIndexes(item, make([]int, 0)) - items = append(items, item) - } - return items - } - - name := f.getGroupName(group) + " " - - names := make([]string, len(group.Items)) - for i, item := range group.Items { - names[i] = strings.ToLower(name + item.FilterValue()) - } - - matches := fuzzy.Find(query, names) - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) - - if len(matches) > 0 { - var matchedItems []T - for _, match := range matches { - item := group.Items[match.Index] - var idxs []int - for _, idx := range match.MatchedIndexes { - // adjusts removing group name highlights - if idx < len(name) { - continue - } - idxs = append(idxs, idx-len(name)) - } - f.setMatchIndexes(item, idxs) - matchedItems = append(matchedItems, item) - } - return matchedItems - } - - return []T{} -} - -func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { - cmds := f.clearItemState() - f.selectedItemIdx = -1 - - if query == "" { - return f.groupedList.SetGroups(f.groups) - } - - query = strings.ToLower(strings.ReplaceAll(query, " ", "")) - - var result []Group[T] - for _, g := range f.groups { - if matches := fuzzy.Find(query, []string{f.getGroupName(g)}); len(matches) > 0 && matches[0].Score > 0 { - result = append(result, g) - continue - } - matchedItems := f.filterItemsInGroup(g, query) - if len(matchedItems) > 0 { - result = append(result, Group[T]{ - Section: g.Section, - Items: matchedItems, - }) - } - } - - cmds = append(cmds, f.groupedList.SetGroups(result)) - return tea.Batch(cmds...) -} - -func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd { - f.groups = groups - return f.groupedList.SetGroups(groups) -} - -func (f *filterableGroupList[T]) Cursor() *tea.Cursor { - if f.inputHidden { - return nil - } - return f.input.Cursor() -} - -func (f *filterableGroupList[T]) Blur() tea.Cmd { - f.input.Blur() - return f.groupedList.Blur() -} - -func (f *filterableGroupList[T]) Focus() tea.Cmd { - f.input.Focus() - return f.groupedList.Focus() -} - -func (f *filterableGroupList[T]) IsFocused() bool { - return f.groupedList.IsFocused() -} - -func (f *filterableGroupList[T]) SetInputWidth(w int) { - f.inputWidth = w -} - -func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) { - f.input.Placeholder = ph - f.placeholder = ph -} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go deleted file mode 100644 index ce61f2c0f4014c9d16c29675eff7ecbb060b2dfd..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package list - -import ( - "fmt" - "slices" - "testing" - - "github.com/charmbracelet/x/exp/golden" - "github.com/stretchr/testify/assert" -) - -func TestFilterableList(t *testing.T) { - t.Parallel() - t.Run("should create simple filterable list", func(t *testing.T) { - t.Parallel() - items := []FilterableItem{} - for i := range 5 { - item := NewFilterableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := NewFilterableList( - items, - WithFilterListOptions(WithDirectionForward()), - ).(*filterableList[FilterableItem]) - - l.SetSize(100, 10) - cmd := l.Init() - if cmd != nil { - cmd() - } - - assert.Equal(t, 0, l.selectedItemIdx) - golden.RequireEqual(t, []byte(l.View())) - }) -} - -func TestUpdateKeyMap(t *testing.T) { - t.Parallel() - l := NewFilterableList( - []FilterableItem{}, - WithFilterListOptions(WithDirectionForward()), - ).(*filterableList[FilterableItem]) - - hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") - fmt.Println(l.keyMap.Down.Keys()) - hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") - - hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") - - assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") - assert.False(t, hasJ, "should not contain j") - assert.False(t, hasUpperCaseK, "should also remove upper case K") - assert.True(t, hasCtrlJ, "should still have ctrl+j") -} - -type filterableItem struct { - *selectableItem -} - -func NewFilterableItem(content string) FilterableItem { - return &filterableItem{ - selectableItem: NewSelectableItem(content).(*selectableItem), - } -} - -func (f *filterableItem) FilterValue() string { - return f.content -} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go deleted file mode 100644 index b1408aa663a4847ad4acaaf89d8b2282cf2b3aab..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/grouped.go +++ /dev/null @@ -1,100 +0,0 @@ -package list - -import ( - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type Group[T Item] struct { - Section ItemSection - Items []T -} -type GroupedList[T Item] interface { - util.Model - layout.Sizeable - Items() []Item - Groups() []Group[T] - SetGroups([]Group[T]) tea.Cmd - MoveUp(int) tea.Cmd - MoveDown(int) tea.Cmd - GoToTop() tea.Cmd - GoToBottom() tea.Cmd - SelectItemAbove() tea.Cmd - SelectItemBelow() tea.Cmd - SetSelected(string) tea.Cmd - SelectedItem() *T -} -type groupedList[T Item] struct { - *list[Item] - groups []Group[T] -} - -func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] { - list := &list[Item]{ - confOptions: &confOptions{ - direction: DirectionForward, - keyMap: DefaultKeyMap(), - focused: true, - }, - items: []Item{}, - indexMap: make(map[string]int), - renderedItems: make(map[string]renderedItem), - } - for _, opt := range opts { - opt(list.confOptions) - } - - return &groupedList[T]{ - list: list, - } -} - -func (g *groupedList[T]) Init() tea.Cmd { - g.convertItems() - return g.render() -} - -func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - u, cmd := l.list.Update(msg) - l.list = u.(*list[Item]) - return l, cmd -} - -func (g *groupedList[T]) SelectedItem() *T { - item := g.list.SelectedItem() - if item == nil { - return nil - } - dRef := *item - c, ok := any(dRef).(T) - if !ok { - return nil - } - return &c -} - -func (g *groupedList[T]) convertItems() { - var items []Item - for _, g := range g.groups { - items = append(items, g.Section) - for _, g := range g.Items { - items = append(items, g) - } - } - g.items = items -} - -func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { - g.groups = groups - g.convertItems() - return g.SetItems(g.items) -} - -func (g *groupedList[T]) Groups() []Group[T] { - return g.groups -} - -func (g *groupedList[T]) Items() []Item { - return g.list.Items() -} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go deleted file mode 100644 index 3db5635b044d9845915d005dd5f7cdac233fe53f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/items.go +++ /dev/null @@ -1,399 +0,0 @@ -package list - -import ( - "image/color" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" - "github.com/google/uuid" - "github.com/rivo/uniseg" -) - -type Indexable interface { - SetIndex(int) -} - -type CompletionItem[T any] interface { - FilterableItem - layout.Focusable - layout.Sizeable - HasMatchIndexes - Value() T - Text() string -} - -type completionItemCmp[T any] struct { - width int - id string - text string - value T - focus bool - matchIndexes []int - bgColor color.Color - shortcut string -} - -type options struct { - id string - text string - bgColor color.Color - matchIndexes []int - shortcut string -} - -type CompletionItemOption func(*options) - -func WithCompletionBackgroundColor(c color.Color) CompletionItemOption { - return func(cmp *options) { - cmp.bgColor = c - } -} - -func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption { - return func(cmp *options) { - cmp.matchIndexes = indexes - } -} - -func WithCompletionShortcut(shortcut string) CompletionItemOption { - return func(cmp *options) { - cmp.shortcut = shortcut - } -} - -func WithCompletionID(id string) CompletionItemOption { - return func(cmp *options) { - cmp.id = id - } -} - -func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] { - c := &completionItemCmp[T]{ - text: text, - value: value, - } - o := &options{} - - for _, opt := range opts { - opt(o) - } - if o.id == "" { - o.id = uuid.NewString() - } - c.id = o.id - c.bgColor = o.bgColor - c.matchIndexes = o.matchIndexes - c.shortcut = o.shortcut - return c -} - -// Init implements CommandItem. -func (c *completionItemCmp[T]) Init() tea.Cmd { - return nil -} - -// Update implements CommandItem. -func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) { - return c, nil -} - -// View implements CommandItem. -func (c *completionItemCmp[T]) View() string { - t := styles.CurrentTheme() - - itemStyle := t.S().Base.Padding(0, 1).Width(c.width) - innerWidth := c.width - 2 // Account for padding - - if c.shortcut != "" { - innerWidth -= lipgloss.Width(c.shortcut) - } - - titleStyle := t.S().Text.Width(innerWidth) - titleMatchStyle := t.S().Text.Underline(true) - if c.bgColor != nil { - titleStyle = titleStyle.Background(c.bgColor) - titleMatchStyle = titleMatchStyle.Background(c.bgColor) - itemStyle = itemStyle.Background(c.bgColor) - } - - if c.focus { - titleStyle = t.S().TextSelected.Width(innerWidth) - titleMatchStyle = t.S().TextSelected.Underline(true) - itemStyle = itemStyle.Background(t.Primary) - } - - var truncatedTitle string - - if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { - // Smart truncation: ensure the last matching part is visible - truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) - } else { - // No matches, use regular truncation - truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") - } - - text := titleStyle.Render(truncatedTitle) - if len(c.matchIndexes) > 0 { - var ranges []lipgloss.Range - for _, rng := range matchedRanges(c.matchIndexes) { - // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. - // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. - // so we need to adjust it here: - start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) - ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) - } - text = lipgloss.StyleRanges(text, ranges...) - } - parts := []string{text} - if c.shortcut != "" { - // Add the shortcut at the end - shortcutStyle := t.S().Muted - if c.focus { - shortcutStyle = t.S().TextSelected - } - parts = append(parts, shortcutStyle.Render(c.shortcut)) - } - item := itemStyle.Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - parts..., - ), - ) - return item -} - -// Blur implements CommandItem. -func (c *completionItemCmp[T]) Blur() tea.Cmd { - c.focus = false - return nil -} - -// Focus implements CommandItem. -func (c *completionItemCmp[T]) Focus() tea.Cmd { - c.focus = true - return nil -} - -// GetSize implements CommandItem. -func (c *completionItemCmp[T]) GetSize() (int, int) { - return c.width, 1 -} - -// IsFocused implements CommandItem. -func (c *completionItemCmp[T]) IsFocused() bool { - return c.focus -} - -// SetSize implements CommandItem. -func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd { - c.width = width - return nil -} - -func (c *completionItemCmp[T]) MatchIndexes(indexes []int) { - c.matchIndexes = indexes -} - -func (c *completionItemCmp[T]) FilterValue() string { - return c.text -} - -func (c *completionItemCmp[T]) Value() T { - return c.value -} - -// smartTruncate implements fzf-style truncation that ensures the last matching part is visible -func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string { - if width <= 0 { - return "" - } - - textLen := ansi.StringWidth(text) - if textLen <= width { - return text - } - - if len(matchIndexes) == 0 { - return ansi.Truncate(text, width, "…") - } - - // Find the last match position - lastMatchPos := matchIndexes[len(matchIndexes)-1] - - // Convert byte position to visual width position - lastMatchVisualPos := 0 - bytePos := 0 - gr := uniseg.NewGraphemes(text) - for bytePos < lastMatchPos && gr.Next() { - bytePos += len(gr.Str()) - lastMatchVisualPos += max(1, gr.Width()) - } - - // Calculate how much space we need for the ellipsis - ellipsisWidth := 1 // "…" character width - availableWidth := width - ellipsisWidth - - // If the last match is within the available width, truncate from the end - if lastMatchVisualPos < availableWidth { - return ansi.Truncate(text, width, "…") - } - - // Calculate the start position to ensure the last match is visible - // We want to show some context before the last match if possible - startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) - - // Convert visual position back to byte position - startBytePos := 0 - currentVisualPos := 0 - gr = uniseg.NewGraphemes(text) - for currentVisualPos < startVisualPos && gr.Next() { - startBytePos += len(gr.Str()) - currentVisualPos += max(1, gr.Width()) - } - - // Extract the substring starting from startBytePos - truncatedText := text[startBytePos:] - - // Truncate to fit width with ellipsis - truncatedText = ansi.Truncate(truncatedText, availableWidth, "") - truncatedText = "…" + truncatedText - return truncatedText -} - -func matchedRanges(in []int) [][2]int { - if len(in) == 0 { - return [][2]int{} - } - current := [2]int{in[0], in[0]} - if len(in) == 1 { - return [][2]int{current} - } - var out [][2]int - for i := 1; i < len(in); i++ { - if in[i] == current[1]+1 { - current[1] = in[i] - } else { - out = append(out, current) - current = [2]int{in[i], in[i]} - } - } - out = append(out, current) - return out -} - -func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { - bytePos, byteStart, byteStop := 0, rng[0], rng[1] - pos, start, stop := 0, 0, 0 - gr := uniseg.NewGraphemes(str) - for byteStart > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - start = pos - for byteStop > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - stop = pos - return start, stop -} - -// ID implements CompletionItem. -func (c *completionItemCmp[T]) ID() string { - return c.id -} - -func (c *completionItemCmp[T]) Text() string { - return c.text -} - -type ItemSection interface { - Item - layout.Sizeable - Indexable - SetInfo(info string) - Title() string -} -type itemSectionModel struct { - width int - title string - inx int - id string - info string -} - -// ID implements ItemSection. -func (m *itemSectionModel) ID() string { - return m.id -} - -// Title implements ItemSection. -func (m *itemSectionModel) Title() string { - return m.title -} - -func NewItemSection(title string) ItemSection { - return &itemSectionModel{ - title: title, - inx: -1, - id: uuid.NewString(), - } -} - -func (m *itemSectionModel) Init() tea.Cmd { - return nil -} - -func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { - return m, nil -} - -func (m *itemSectionModel) View() string { - t := styles.CurrentTheme() - title := ansi.Truncate(m.title, m.width-2, "…") - style := t.S().Base.Padding(1, 1, 0, 1) - if m.inx == 0 { - style = style.Padding(0, 1, 0, 1) - } - title = t.S().Muted.Render(title) - section := "" - if m.info != "" { - section = core.SectionWithInfo(title, m.width-2, m.info) - } else { - section = core.Section(title, m.width-2) - } - - return style.Render(section) -} - -func (m *itemSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *itemSectionModel) IsSectionHeader() bool { - return true -} - -func (m *itemSectionModel) SetInfo(info string) { - m.info = info -} - -func (m *itemSectionModel) SetIndex(inx int) { - m.inx = inx -} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go deleted file mode 100644 index e470fbfbea2ea9f958949ebdfabe5fd679192f9c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/keys.go +++ /dev/null @@ -1,76 +0,0 @@ -package list - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Down, - Up, - DownOneItem, - UpOneItem, - PageDown, - PageUp, - HalfPageDown, - HalfPageUp, - Home, - End key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Down: key.NewBinding( - key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), - key.WithHelp("↓", "down"), - ), - Up: key.NewBinding( - key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), - key.WithHelp("↑", "up"), - ), - UpOneItem: key.NewBinding( - key.WithKeys("shift+up", "K"), - key.WithHelp("shift+↑", "up one item"), - ), - DownOneItem: key.NewBinding( - key.WithKeys("shift+down", "J"), - key.WithHelp("shift+↓", "down one item"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "half page down"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown", " ", "f"), - key.WithHelp("f/pgdn", "page down"), - ), - PageUp: key.NewBinding( - key.WithKeys("pgup", "b"), - key.WithHelp("b/pgup", "page up"), - ), - HalfPageUp: key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "half page up"), - ), - Home: key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "home"), - ), - End: key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "end"), - ), - } -} - -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Down, - k.Up, - k.DownOneItem, - k.UpOneItem, - k.HalfPageDown, - k.HalfPageUp, - k.Home, - k.End, - } -} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go deleted file mode 100644 index 653dcd7b2389d50ec19b4dc0f005f7a423e14012..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list.go +++ /dev/null @@ -1,1775 +0,0 @@ -package list - -import ( - "strings" - "sync" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/exp/ordered" - "github.com/rivo/uniseg" -) - -const maxGapSize = 100 - -var newlineBuffer = strings.Repeat("\n", maxGapSize) - -var ( - specialCharsMap map[string]struct{} - specialCharsOnce sync.Once -) - -func getSpecialCharsMap() map[string]struct{} { - specialCharsOnce.Do(func() { - specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons)) - for _, icon := range styles.SelectionIgnoreIcons { - specialCharsMap[icon] = struct{}{} - } - }) - return specialCharsMap -} - -type Item interface { - util.Model - layout.Sizeable - ID() string -} - -type HasAnim interface { - Item - Spinning() bool -} - -type List[T Item] interface { - util.Model - layout.Sizeable - layout.Focusable - - MoveUp(int) tea.Cmd - MoveDown(int) tea.Cmd - GoToTop() tea.Cmd - GoToBottom() tea.Cmd - SelectItemAbove() tea.Cmd - SelectItemBelow() tea.Cmd - SetItems([]T) tea.Cmd - SetSelected(string) tea.Cmd - SelectedItem() *T - Items() []T - UpdateItem(string, T) tea.Cmd - DeleteItem(string) tea.Cmd - PrependItem(T) tea.Cmd - AppendItem(T) tea.Cmd - StartSelection(col, line int) - EndSelection(col, line int) - SelectionStop() - SelectionClear() - SelectWord(col, line int) - SelectParagraph(col, line int) - GetSelectedText(paddingLeft int) string - HasSelection() bool -} - -type direction int - -const ( - DirectionForward direction = iota - DirectionBackward -) - -const ( - ItemNotFound = -1 - ViewportDefaultScrollSize = 5 -) - -type renderedItem struct { - view string - height int - start int - end int -} - -type confOptions struct { - width, height int - gap int - wrap bool - keyMap KeyMap - direction direction - selectedItemIdx int // Index of selected item (-1 if none) - selectedItemID string // Temporary storage for WithSelectedItem (resolved in New()) - focused bool - resize bool - enableMouse bool -} - -type list[T Item] struct { - *confOptions - - offset int - - indexMap map[string]int - items []T - renderedItems map[string]renderedItem - - rendered string - renderedHeight int // cached height of rendered content - lineOffsets []int // cached byte offsets for each line (for fast slicing) - - cachedView string - cachedViewOffset int - cachedViewDirty bool - - movingByItem bool - prevSelectedItemIdx int // Index of previously selected item (-1 if none) - selectionStartCol int - selectionStartLine int - selectionEndCol int - selectionEndLine int - - selectionActive bool -} - -type ListOption func(*confOptions) - -// WithSize sets the size of the list. -func WithSize(width, height int) ListOption { - return func(l *confOptions) { - l.width = width - l.height = height - } -} - -// WithGap sets the gap between items in the list. -func WithGap(gap int) ListOption { - return func(l *confOptions) { - l.gap = gap - } -} - -// WithDirectionForward sets the direction to forward -func WithDirectionForward() ListOption { - return func(l *confOptions) { - l.direction = DirectionForward - } -} - -// WithDirectionBackward sets the direction to forward -func WithDirectionBackward() ListOption { - return func(l *confOptions) { - l.direction = DirectionBackward - } -} - -// WithSelectedItem sets the initially selected item in the list. -func WithSelectedItem(id string) ListOption { - return func(l *confOptions) { - l.selectedItemID = id // Will be resolved to index in New() - } -} - -func WithKeyMap(keyMap KeyMap) ListOption { - return func(l *confOptions) { - l.keyMap = keyMap - } -} - -func WithWrapNavigation() ListOption { - return func(l *confOptions) { - l.wrap = true - } -} - -func WithFocus(focus bool) ListOption { - return func(l *confOptions) { - l.focused = focus - } -} - -func WithResizeByList() ListOption { - return func(l *confOptions) { - l.resize = true - } -} - -func WithEnableMouse() ListOption { - return func(l *confOptions) { - l.enableMouse = true - } -} - -func New[T Item](items []T, opts ...ListOption) List[T] { - list := &list[T]{ - confOptions: &confOptions{ - direction: DirectionForward, - keyMap: DefaultKeyMap(), - focused: true, - selectedItemIdx: -1, - }, - items: items, - indexMap: make(map[string]int, len(items)), - renderedItems: make(map[string]renderedItem), - prevSelectedItemIdx: -1, - selectionStartCol: -1, - selectionStartLine: -1, - selectionEndLine: -1, - selectionEndCol: -1, - } - for _, opt := range opts { - opt(list.confOptions) - } - - for inx, item := range items { - if i, ok := any(item).(Indexable); ok { - i.SetIndex(inx) - } - list.indexMap[item.ID()] = inx - } - - // Resolve selectedItemID to selectedItemIdx if specified - if list.selectedItemID != "" { - if idx, ok := list.indexMap[list.selectedItemID]; ok { - list.selectedItemIdx = idx - } - list.selectedItemID = "" // Clear temporary storage - } - - return list -} - -// Init implements List. -func (l *list[T]) Init() tea.Cmd { - return l.render() -} - -// Update implements List. -func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.MouseWheelMsg: - if l.enableMouse { - return l.handleMouseWheel(msg) - } - return l, nil - case anim.StepMsg: - // Fast path: if no items, skip processing - if len(l.items) == 0 { - return l, nil - } - - // Fast path: check if ANY items are actually spinning before processing - if !l.hasSpinningItems() { - return l, nil - } - - var cmds []tea.Cmd - itemsLen := len(l.items) - for i := range itemsLen { - if i >= len(l.items) { - continue - } - item := l.items[i] - if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() { - updated, cmd := animItem.Update(msg) - cmds = append(cmds, cmd) - if u, ok := updated.(T); ok { - cmds = append(cmds, l.UpdateItem(u.ID(), u)) - } - } - } - return l, tea.Batch(cmds...) - case tea.KeyPressMsg: - if l.focused { - switch { - case key.Matches(msg, l.keyMap.Down): - return l, l.MoveDown(ViewportDefaultScrollSize) - case key.Matches(msg, l.keyMap.Up): - return l, l.MoveUp(ViewportDefaultScrollSize) - case key.Matches(msg, l.keyMap.DownOneItem): - return l, l.SelectItemBelow() - case key.Matches(msg, l.keyMap.UpOneItem): - return l, l.SelectItemAbove() - case key.Matches(msg, l.keyMap.HalfPageDown): - return l, l.MoveDown(l.height / 2) - case key.Matches(msg, l.keyMap.HalfPageUp): - return l, l.MoveUp(l.height / 2) - case key.Matches(msg, l.keyMap.PageDown): - return l, l.MoveDown(l.height) - case key.Matches(msg, l.keyMap.PageUp): - return l, l.MoveUp(l.height) - case key.Matches(msg, l.keyMap.End): - return l, l.GoToBottom() - case key.Matches(msg, l.keyMap.Home): - return l, l.GoToTop() - } - s := l.SelectedItem() - if s == nil { - return l, nil - } - item := *s - var cmds []tea.Cmd - updated, cmd := item.Update(msg) - cmds = append(cmds, cmd) - if u, ok := updated.(T); ok { - cmds = append(cmds, l.UpdateItem(u.ID(), u)) - } - return l, tea.Batch(cmds...) - } - } - return l, nil -} - -func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg.Button { - case tea.MouseWheelDown: - cmd = l.MoveDown(ViewportDefaultScrollSize) - case tea.MouseWheelUp: - cmd = l.MoveUp(ViewportDefaultScrollSize) - } - return l, cmd -} - -func (l *list[T]) hasSpinningItems() bool { - for i := range l.items { - item := l.items[i] - if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() { - return true - } - } - return false -} - -func (l *list[T]) selectionView(view string, textOnly bool) string { - t := styles.CurrentTheme() - area := uv.Rect(0, 0, l.width, l.height) - scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) - uv.NewStyledString(view).Draw(scr, area) - - selArea := l.selectionArea(false) - specialChars := getSpecialCharsMap() - selStyle := uv.Style{ - Bg: t.TextSelection.GetBackground(), - Fg: t.TextSelection.GetForeground(), - } - - isNonWhitespace := func(r rune) bool { - return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r' - } - - type selectionBounds struct { - startX, endX int - inSelection bool - } - lineSelections := make([]selectionBounds, scr.Height()) - - for y := range scr.Height() { - bounds := selectionBounds{startX: -1, endX: -1, inSelection: false} - - if y >= selArea.Min.Y && y < selArea.Max.Y { - bounds.inSelection = true - if selArea.Min.Y == selArea.Max.Y-1 { - // Single line selection - bounds.startX = selArea.Min.X - bounds.endX = selArea.Max.X - } else if y == selArea.Min.Y { - // First line of multi-line selection - bounds.startX = selArea.Min.X - bounds.endX = scr.Width() - } else if y == selArea.Max.Y-1 { - // Last line of multi-line selection - bounds.startX = 0 - bounds.endX = selArea.Max.X - } else { - // Middle lines - bounds.startX = 0 - bounds.endX = scr.Width() - } - } - lineSelections[y] = bounds - } - - type lineBounds struct { - start, end int - } - lineTextBounds := make([]lineBounds, scr.Height()) - - // First pass: find text bounds for lines that have selections - for y := range scr.Height() { - bounds := lineBounds{start: -1, end: -1} - - // Only process lines that might have selections - if lineSelections[y].inSelection { - for x := range scr.Width() { - cell := scr.CellAt(x, y) - if cell == nil { - continue - } - - cellStr := cell.String() - if len(cellStr) == 0 { - continue - } - - char := rune(cellStr[0]) - _, isSpecial := specialChars[cellStr] - - if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil { - if bounds.start == -1 { - bounds.start = x - } - bounds.end = x + 1 // Position after last character - } - } - } - lineTextBounds[y] = bounds - } - - var selectedText strings.Builder - - // Second pass: apply selection highlighting - for y := range scr.Height() { - selBounds := lineSelections[y] - if !selBounds.inSelection { - continue - } - - textBounds := lineTextBounds[y] - if textBounds.start < 0 { - if textOnly { - // We don't want to get rid of all empty lines in text-only mode - selectedText.WriteByte('\n') - } - - continue // No text on this line - } - - // Only scan within the intersection of text bounds and selection bounds - scanStart := max(textBounds.start, selBounds.startX) - scanEnd := min(textBounds.end, selBounds.endX) - - for x := scanStart; x < scanEnd; x++ { - cell := scr.CellAt(x, y) - if cell == nil { - continue - } - - cellStr := cell.String() - if len(cellStr) > 0 { - if _, isSpecial := specialChars[cellStr]; isSpecial { - continue - } - if textOnly { - // Collect selected text without styles - selectedText.WriteString(cell.String()) - continue - } - - cell = cell.Clone() - cell.Style.Bg = selStyle.Bg - cell.Style.Fg = selStyle.Fg - scr.SetCell(x, y, cell) - } - } - - if textOnly { - // Make sure we add a newline after each line of selected text - selectedText.WriteByte('\n') - } - } - - if textOnly { - return strings.TrimSpace(selectedText.String()) - } - - return scr.Render() -} - -func (l *list[T]) View() string { - if l.height <= 0 || l.width <= 0 { - return "" - } - - if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" { - return l.cachedView - } - - t := styles.CurrentTheme() - - start, end := l.viewPosition() - viewStart := max(0, start) - viewEnd := end - - if viewStart > viewEnd { - return "" - } - - view := l.getLines(viewStart, viewEnd) - - if l.resize { - return view - } - - view = t.S().Base. - Height(l.height). - Width(l.width). - Render(view) - - if !l.hasSelection() { - l.cachedView = view - l.cachedViewOffset = l.offset - l.cachedViewDirty = false - return view - } - - return l.selectionView(view, false) -} - -func (l *list[T]) viewPosition() (int, int) { - start, end := 0, 0 - renderedLines := l.renderedHeight - 1 - if l.direction == DirectionForward { - start = max(0, l.offset) - end = min(l.offset+l.height-1, renderedLines) - } else { - start = max(0, renderedLines-l.offset-l.height+1) - end = max(0, renderedLines-l.offset) - } - start = min(start, end) - return start, end -} - -func (l *list[T]) setRendered(rendered string) { - l.rendered = rendered - l.renderedHeight = lipgloss.Height(rendered) - l.cachedViewDirty = true // Mark view cache as dirty - - if len(rendered) > 0 { - l.lineOffsets = make([]int, 0, l.renderedHeight) - l.lineOffsets = append(l.lineOffsets, 0) - - offset := 0 - for { - idx := strings.IndexByte(rendered[offset:], '\n') - if idx == -1 { - break - } - offset += idx + 1 - l.lineOffsets = append(l.lineOffsets, offset) - } - } else { - l.lineOffsets = nil - } -} - -func (l *list[T]) getLines(start, end int) string { - if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) { - return "" - } - - if end >= len(l.lineOffsets) { - end = len(l.lineOffsets) - 1 - } - if start > end { - return "" - } - - startOffset := l.lineOffsets[start] - var endOffset int - if end+1 < len(l.lineOffsets) { - endOffset = l.lineOffsets[end+1] - 1 - } else { - endOffset = len(l.rendered) - } - - if startOffset >= len(l.rendered) { - return "" - } - endOffset = min(endOffset, len(l.rendered)) - - return l.rendered[startOffset:endOffset] -} - -// getLine returns a single line from the rendered content using lineOffsets. -// This avoids allocating a new string for each line like strings.Split does. -func (l *list[T]) getLine(index int) string { - if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) { - return "" - } - - startOffset := l.lineOffsets[index] - var endOffset int - if index+1 < len(l.lineOffsets) { - endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline - } else { - endOffset = len(l.rendered) - } - - if startOffset >= len(l.rendered) { - return "" - } - endOffset = min(endOffset, len(l.rendered)) - - return l.rendered[startOffset:endOffset] -} - -// lineCount returns the number of lines in the rendered content. -func (l *list[T]) lineCount() int { - return len(l.lineOffsets) -} - -func (l *list[T]) recalculateItemPositions() { - l.recalculateItemPositionsFrom(0) -} - -func (l *list[T]) recalculateItemPositionsFrom(startIdx int) { - var currentContentHeight int - - if startIdx > 0 && startIdx <= len(l.items) { - prevItem := l.items[startIdx-1] - if rItem, ok := l.renderedItems[prevItem.ID()]; ok { - currentContentHeight = rItem.end + 1 + l.gap - } - } - - for i := startIdx; i < len(l.items); i++ { - item := l.items[i] - rItem, ok := l.renderedItems[item.ID()] - if !ok { - continue - } - rItem.start = currentContentHeight - rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems[item.ID()] = rItem - currentContentHeight = rItem.end + 1 + l.gap - } -} - -func (l *list[T]) render() tea.Cmd { - if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { - return nil - } - l.setDefaultSelected() - - var focusChangeCmd tea.Cmd - if l.focused { - focusChangeCmd = l.focusSelectedItem() - } else { - focusChangeCmd = l.blurSelectedItem() - } - if l.rendered != "" { - rendered, _ := l.renderIterator(0, false, "") - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - if l.focused { - l.scrollToSelection() - } - return focusChangeCmd - } - rendered, finishIndex := l.renderIterator(0, true, "") - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - - l.offset = 0 - rendered, _ = l.renderIterator(finishIndex, false, l.rendered) - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - if l.focused { - l.scrollToSelection() - } - - return focusChangeCmd -} - -func (l *list[T]) setDefaultSelected() { - if l.selectedItemIdx < 0 { - if l.direction == DirectionForward { - l.selectFirstItem() - } else { - l.selectLastItem() - } - } -} - -func (l *list[T]) scrollToSelection() { - if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { - l.selectedItemIdx = -1 - l.setDefaultSelected() - return - } - item := l.items[l.selectedItemIdx] - rItem, ok := l.renderedItems[item.ID()] - if !ok { - l.selectedItemIdx = -1 - l.setDefaultSelected() - return - } - - start, end := l.viewPosition() - if rItem.start <= start && rItem.end >= end { - return - } - if l.movingByItem { - if rItem.start >= start && rItem.end <= end { - return - } - defer func() { l.movingByItem = false }() - } else { - if rItem.start >= start && rItem.start <= end { - return - } - if rItem.end >= start && rItem.end <= end { - return - } - } - - if rItem.height >= l.height { - if l.direction == DirectionForward { - l.offset = rItem.start - } else { - l.offset = max(0, l.renderedHeight-(rItem.start+l.height)) - } - return - } - - renderedLines := l.renderedHeight - 1 - - if rItem.start < start { - if l.direction == DirectionForward { - l.offset = rItem.start - } else { - l.offset = max(0, renderedLines-rItem.start-l.height+1) - } - } else if rItem.end > end { - if l.direction == DirectionForward { - l.offset = max(0, rItem.end-l.height+1) - } else { - l.offset = max(0, renderedLines-rItem.end) - } - } -} - -func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { - if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { - return nil - } - item := l.items[l.selectedItemIdx] - rItem, ok := l.renderedItems[item.ID()] - if !ok { - return nil - } - start, end := l.viewPosition() - // item bigger than the viewport do nothing - if rItem.start <= start && rItem.end >= end { - return nil - } - // item already in view do nothing - if rItem.start >= start && rItem.end <= end { - return nil - } - - itemMiddle := rItem.start + rItem.height/2 - - if itemMiddle < start { - // select the first item in the viewport - // the item is most likely an item coming after this item - inx := l.selectedItemIdx - for { - inx = l.firstSelectableItemBelow(inx) - if inx == ItemNotFound { - return nil - } - if inx < 0 || inx >= len(l.items) { - continue - } - - item := l.items[inx] - renderedItem, ok := l.renderedItems[item.ID()] - if !ok { - continue - } - - // If the item is bigger than the viewport, select it - if renderedItem.start <= start && renderedItem.end >= end { - l.selectedItemIdx = inx - return l.render() - } - // item is in the view - if renderedItem.start >= start && renderedItem.start <= end { - l.selectedItemIdx = inx - return l.render() - } - } - } else if itemMiddle > end { - // select the first item in the viewport - // the item is most likely an item coming after this item - inx := l.selectedItemIdx - for { - inx = l.firstSelectableItemAbove(inx) - if inx == ItemNotFound { - return nil - } - if inx < 0 || inx >= len(l.items) { - continue - } - - item := l.items[inx] - renderedItem, ok := l.renderedItems[item.ID()] - if !ok { - continue - } - - // If the item is bigger than the viewport, select it - if renderedItem.start <= start && renderedItem.end >= end { - l.selectedItemIdx = inx - return l.render() - } - // item is in the view - if renderedItem.end >= start && renderedItem.end <= end { - l.selectedItemIdx = inx - return l.render() - } - } - } - return nil -} - -func (l *list[T]) selectFirstItem() { - inx := l.firstSelectableItemBelow(-1) - if inx != ItemNotFound { - l.selectedItemIdx = inx - } -} - -func (l *list[T]) selectLastItem() { - inx := l.firstSelectableItemAbove(len(l.items)) - if inx != ItemNotFound { - l.selectedItemIdx = inx - } -} - -func (l *list[T]) firstSelectableItemAbove(inx int) int { - unfocusableCount := 0 - for i := inx - 1; i >= 0; i-- { - if i < 0 || i >= len(l.items) { - continue - } - - item := l.items[i] - if _, ok := any(item).(layout.Focusable); ok { - return i - } - unfocusableCount++ - } - if unfocusableCount == inx && l.wrap { - return l.firstSelectableItemAbove(len(l.items)) - } - return ItemNotFound -} - -func (l *list[T]) firstSelectableItemBelow(inx int) int { - unfocusableCount := 0 - itemsLen := len(l.items) - for i := inx + 1; i < itemsLen; i++ { - if i < 0 || i >= len(l.items) { - continue - } - - item := l.items[i] - if _, ok := any(item).(layout.Focusable); ok { - return i - } - unfocusableCount++ - } - if unfocusableCount == itemsLen-inx-1 && l.wrap { - return l.firstSelectableItemBelow(-1) - } - return ItemNotFound -} - -func (l *list[T]) focusSelectedItem() tea.Cmd { - if l.selectedItemIdx < 0 || !l.focused { - return nil - } - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 2) - - // Blur the previously selected item if it's different - if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) { - prevItem := l.items[l.prevSelectedItemIdx] - if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() { - cmds = append(cmds, f.Blur()) - // Mark cache as needing update, but don't delete yet - // This allows the render to potentially reuse it - delete(l.renderedItems, prevItem.ID()) - } - } - - // Focus the currently selected item - if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { - item := l.items[l.selectedItemIdx] - if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() { - cmds = append(cmds, f.Focus()) - // Mark for re-render - delete(l.renderedItems, item.ID()) - } - } - - l.prevSelectedItemIdx = l.selectedItemIdx - return tea.Batch(cmds...) -} - -func (l *list[T]) blurSelectedItem() tea.Cmd { - if l.selectedItemIdx < 0 || l.focused { - return nil - } - - // Blur the currently selected item - if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { - item := l.items[l.selectedItemIdx] - if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() { - delete(l.renderedItems, item.ID()) - return f.Blur() - } - } - - return nil -} - -// renderFragment holds updated rendered view fragments -type renderFragment struct { - view string - gap int -} - -// renderIterator renders items starting from the specific index and limits height if limitHeight != -1 -// returns the last index and the rendered content so far -// we pass the rendered content around and don't use l.rendered to prevent jumping of the content -func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { - // Pre-allocate fragments with expected capacity - itemsLen := len(l.items) - expectedFragments := itemsLen - startInx - if limitHeight && l.height > 0 { - expectedFragments = min(expectedFragments, l.height) - } - fragments := make([]renderFragment, 0, expectedFragments) - - currentContentHeight := lipgloss.Height(rendered) - 1 - finalIndex := itemsLen - - // first pass: accumulate all fragments to render until the height limit is - // reached - for i := startInx; i < itemsLen; i++ { - if limitHeight && currentContentHeight >= l.height { - finalIndex = i - break - } - // cool way to go through the list in both directions - inx := i - - if l.direction != DirectionForward { - inx = (itemsLen - 1) - i - } - - if inx < 0 || inx >= len(l.items) { - continue - } - - item := l.items[inx] - - var rItem renderedItem - if cache, ok := l.renderedItems[item.ID()]; ok { - rItem = cache - } else { - rItem = l.renderItem(item) - rItem.start = currentContentHeight - rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems[item.ID()] = rItem - } - - gap := l.gap + 1 - if inx == itemsLen-1 { - gap = 0 - } - - fragments = append(fragments, renderFragment{view: rItem.view, gap: gap}) - - currentContentHeight = rItem.end + 1 + l.gap - } - - // second pass: build rendered string efficiently - var b strings.Builder - - // Pre-size the builder to reduce allocations - estimatedSize := len(rendered) - for _, f := range fragments { - estimatedSize += len(f.view) + f.gap - } - b.Grow(estimatedSize) - - if l.direction == DirectionForward { - b.WriteString(rendered) - for i := range fragments { - f := &fragments[i] - b.WriteString(f.view) - // Optimized gap writing using pre-allocated buffer - if f.gap > 0 { - if f.gap <= maxGapSize { - b.WriteString(newlineBuffer[:f.gap]) - } else { - b.WriteString(strings.Repeat("\n", f.gap)) - } - } - } - - return b.String(), finalIndex - } - - // iterate backwards as fragments are in reversed order - for i := len(fragments) - 1; i >= 0; i-- { - f := &fragments[i] - b.WriteString(f.view) - // Optimized gap writing using pre-allocated buffer - if f.gap > 0 { - if f.gap <= maxGapSize { - b.WriteString(newlineBuffer[:f.gap]) - } else { - b.WriteString(strings.Repeat("\n", f.gap)) - } - } - } - b.WriteString(rendered) - - return b.String(), finalIndex -} - -func (l *list[T]) renderItem(item Item) renderedItem { - view := item.View() - return renderedItem{ - view: view, - height: lipgloss.Height(view), - } -} - -// AppendItem implements List. -func (l *list[T]) AppendItem(item T) tea.Cmd { - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 4) - cmd := item.Init() - if cmd != nil { - cmds = append(cmds, cmd) - } - - newIndex := len(l.items) - l.items = append(l.items, item) - l.indexMap[item.ID()] = newIndex - - if l.width > 0 && l.height > 0 { - cmd = item.SetSize(l.width, l.height) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - cmd = l.render() - if cmd != nil { - cmds = append(cmds, cmd) - } - if l.direction == DirectionBackward { - if l.offset == 0 { - cmd = l.GoToBottom() - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap - } - l.offset = min(l.renderedHeight-1, l.offset+newLines) - } - } - } - return tea.Sequence(cmds...) -} - -// Blur implements List. -func (l *list[T]) Blur() tea.Cmd { - l.focused = false - return l.render() -} - -// DeleteItem implements List. -func (l *list[T]) DeleteItem(id string) tea.Cmd { - inx, ok := l.indexMap[id] - if !ok { - return nil - } - l.items = append(l.items[:inx], l.items[inx+1:]...) - delete(l.renderedItems, id) - delete(l.indexMap, id) - - // Only update indices for items after the deleted one - itemsLen := len(l.items) - for i := inx; i < itemsLen; i++ { - if i >= 0 && i < len(l.items) { - item := l.items[i] - l.indexMap[item.ID()] = i - } - } - - // Adjust selectedItemIdx if the deleted item was selected or before it - if l.selectedItemIdx == inx { - // Deleted item was selected, select the previous item if possible - if inx > 0 { - l.selectedItemIdx = inx - 1 - } else { - l.selectedItemIdx = -1 - } - } else if l.selectedItemIdx > inx { - // Selected item is after the deleted one, shift index down - l.selectedItemIdx-- - } - cmd := l.render() - if l.rendered != "" { - if l.renderedHeight <= l.height { - l.offset = 0 - } else { - maxOffset := l.renderedHeight - l.height - if l.offset > maxOffset { - l.offset = maxOffset - } - } - } - return cmd -} - -// Focus implements List. -func (l *list[T]) Focus() tea.Cmd { - l.focused = true - return l.render() -} - -// GetSize implements List. -func (l *list[T]) GetSize() (int, int) { - return l.width, l.height -} - -// GoToBottom implements List. -func (l *list[T]) GoToBottom() tea.Cmd { - l.offset = 0 - l.selectedItemIdx = -1 - l.direction = DirectionBackward - return l.render() -} - -// GoToTop implements List. -func (l *list[T]) GoToTop() tea.Cmd { - l.offset = 0 - l.selectedItemIdx = -1 - l.direction = DirectionForward - return l.render() -} - -// IsFocused implements List. -func (l *list[T]) IsFocused() bool { - return l.focused -} - -// Items implements List. -func (l *list[T]) Items() []T { - itemsLen := len(l.items) - result := make([]T, 0, itemsLen) - for i := range itemsLen { - if i >= 0 && i < len(l.items) { - item := l.items[i] - result = append(result, item) - } - } - return result -} - -func (l *list[T]) incrementOffset(n int) { - // no need for offset - if l.renderedHeight <= l.height { - return - } - maxOffset := l.renderedHeight - l.height - n = min(n, maxOffset-l.offset) - if n <= 0 { - return - } - l.offset += n - l.cachedViewDirty = true -} - -func (l *list[T]) decrementOffset(n int) { - n = min(n, l.offset) - if n <= 0 { - return - } - l.offset -= n - if l.offset < 0 { - l.offset = 0 - } - l.cachedViewDirty = true -} - -// MoveDown implements List. -func (l *list[T]) MoveDown(n int) tea.Cmd { - oldOffset := l.offset - if l.direction == DirectionForward { - l.incrementOffset(n) - } else { - l.decrementOffset(n) - } - - if oldOffset == l.offset { - // no change in offset, so no need to change selection - return nil - } - // if we are not actively selecting move the whole selection down - if l.hasSelection() && !l.selectionActive { - if l.selectionStartLine < l.selectionEndLine { - l.selectionStartLine -= n - l.selectionEndLine -= n - } else { - l.selectionStartLine -= n - l.selectionEndLine -= n - } - } - if l.selectionActive { - if l.selectionStartLine < l.selectionEndLine { - l.selectionStartLine -= n - } else { - l.selectionEndLine -= n - } - } - return l.changeSelectionWhenScrolling() -} - -// MoveUp implements List. -func (l *list[T]) MoveUp(n int) tea.Cmd { - oldOffset := l.offset - if l.direction == DirectionForward { - l.decrementOffset(n) - } else { - l.incrementOffset(n) - } - - if oldOffset == l.offset { - // no change in offset, so no need to change selection - return nil - } - - if l.hasSelection() && !l.selectionActive { - if l.selectionStartLine > l.selectionEndLine { - l.selectionStartLine += n - l.selectionEndLine += n - } else { - l.selectionStartLine += n - l.selectionEndLine += n - } - } - if l.selectionActive { - if l.selectionStartLine > l.selectionEndLine { - l.selectionStartLine += n - } else { - l.selectionEndLine += n - } - } - return l.changeSelectionWhenScrolling() -} - -// PrependItem implements List. -func (l *list[T]) PrependItem(item T) tea.Cmd { - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 4) - cmds = append(cmds, item.Init()) - - l.items = append([]T{item}, l.items...) - - // Shift selectedItemIdx since all items moved down by 1 - if l.selectedItemIdx >= 0 { - l.selectedItemIdx++ - } - - // Update index map incrementally: shift all existing indices up by 1 - // This is more efficient than rebuilding from scratch - newIndexMap := make(map[string]int, len(l.indexMap)+1) - for id, idx := range l.indexMap { - newIndexMap[id] = idx + 1 // All existing items shift down by 1 - } - newIndexMap[item.ID()] = 0 // New item is at index 0 - l.indexMap = newIndexMap - - if l.width > 0 && l.height > 0 { - cmds = append(cmds, item.SetSize(l.width, l.height)) - } - cmds = append(cmds, l.render()) - if l.direction == DirectionForward { - if l.offset == 0 { - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap - } - l.offset = min(l.renderedHeight-1, l.offset+newLines) - } - } - } - return tea.Batch(cmds...) -} - -// SelectItemAbove implements List. -func (l *list[T]) SelectItemAbove() tea.Cmd { - if l.selectedItemIdx < 0 { - return nil - } - - newIndex := l.firstSelectableItemAbove(l.selectedItemIdx) - if newIndex == ItemNotFound { - // no item above - return nil - } - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 2) - if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 { - // this means there is a section above and not showing on the top, move to the top - newIndex = l.selectedItemIdx - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - if newIndex == 1 { - peakAboveIndex := l.firstSelectableItemAbove(newIndex) - if peakAboveIndex == ItemNotFound { - // this means there is a section above move to the top - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if newIndex < 0 || newIndex >= len(l.items) { - return nil - } - l.prevSelectedItemIdx = l.selectedItemIdx - l.selectedItemIdx = newIndex - l.movingByItem = true - renderCmd := l.render() - if renderCmd != nil { - cmds = append(cmds, renderCmd) - } - return tea.Sequence(cmds...) -} - -// SelectItemBelow implements List. -func (l *list[T]) SelectItemBelow() tea.Cmd { - if l.selectedItemIdx < 0 { - return nil - } - - newIndex := l.firstSelectableItemBelow(l.selectedItemIdx) - if newIndex == ItemNotFound { - // no item below - return nil - } - if newIndex < 0 || newIndex >= len(l.items) { - return nil - } - if newIndex < l.selectedItemIdx { - // reset offset when wrap to the top to show the top section if it exists - l.offset = 0 - } - l.prevSelectedItemIdx = l.selectedItemIdx - l.selectedItemIdx = newIndex - l.movingByItem = true - return l.render() -} - -// SelectedItem implements List. -func (l *list[T]) SelectedItem() *T { - if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { - return nil - } - item := l.items[l.selectedItemIdx] - return &item -} - -// SetItems implements List. -func (l *list[T]) SetItems(items []T) tea.Cmd { - l.items = items - var cmds []tea.Cmd - for inx, item := range items { - if i, ok := any(item).(Indexable); ok { - i.SetIndex(inx) - } - cmds = append(cmds, item.Init()) - } - cmds = append(cmds, l.reset("")) - return tea.Batch(cmds...) -} - -// SetSelected implements List. -func (l *list[T]) SetSelected(id string) tea.Cmd { - l.prevSelectedItemIdx = l.selectedItemIdx - if idx, ok := l.indexMap[id]; ok { - l.selectedItemIdx = idx - } else { - l.selectedItemIdx = -1 - } - return l.render() -} - -func (l *list[T]) reset(selectedItemID string) tea.Cmd { - var cmds []tea.Cmd - l.rendered = "" - l.renderedHeight = 0 - l.offset = 0 - l.indexMap = make(map[string]int) - l.renderedItems = make(map[string]renderedItem) - itemsLen := len(l.items) - for i := range itemsLen { - if i < 0 || i >= len(l.items) { - continue - } - - item := l.items[i] - l.indexMap[item.ID()] = i - if l.width > 0 && l.height > 0 { - cmds = append(cmds, item.SetSize(l.width, l.height)) - } - } - // Convert selectedItemID to index after rebuilding indexMap - if selectedItemID != "" { - if idx, ok := l.indexMap[selectedItemID]; ok { - l.selectedItemIdx = idx - } else { - l.selectedItemIdx = -1 - } - } else { - l.selectedItemIdx = -1 - } - cmds = append(cmds, l.render()) - return tea.Batch(cmds...) -} - -// SetSize implements List. -func (l *list[T]) SetSize(width int, height int) tea.Cmd { - oldWidth := l.width - oldHeight := l.height - l.width = width - l.height = height - // Invalidate cache if height changed - if oldHeight != height { - l.cachedViewDirty = true - } - if oldWidth != width { - // Get current selected item ID before reset - selectedID := "" - if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { - item := l.items[l.selectedItemIdx] - selectedID = item.ID() - } - cmd := l.reset(selectedID) - return cmd - } - return nil -} - -// UpdateItem implements List. -func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 1) - if inx, ok := l.indexMap[id]; ok { - l.items[inx] = item - oldItem, hasOldItem := l.renderedItems[id] - oldPosition := l.offset - if l.direction == DirectionBackward { - oldPosition = (l.renderedHeight - 1) - l.offset - } - - delete(l.renderedItems, id) - cmd := l.render() - - // need to check for nil because of sequence not handling nil - if cmd != nil { - cmds = append(cmds, cmd) - } - if hasOldItem && l.direction == DirectionBackward { - // if we are the last item and there is no offset - // make sure to go to the bottom - if oldPosition < oldItem.end { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) - } - } - } else if hasOldItem && l.offset > oldItem.start { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) - } - } - } - return tea.Sequence(cmds...) -} - -func (l *list[T]) hasSelection() bool { - return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine -} - -// StartSelection implements List. -func (l *list[T]) StartSelection(col, line int) { - l.selectionStartCol = col - l.selectionStartLine = line - l.selectionEndCol = col - l.selectionEndLine = line - l.selectionActive = true -} - -// EndSelection implements List. -func (l *list[T]) EndSelection(col, line int) { - if !l.selectionActive { - return - } - l.selectionEndCol = col - l.selectionEndLine = line -} - -func (l *list[T]) SelectionStop() { - l.selectionActive = false -} - -func (l *list[T]) SelectionClear() { - l.selectionStartCol = -1 - l.selectionStartLine = -1 - l.selectionEndCol = -1 - l.selectionEndLine = -1 - l.selectionActive = false -} - -func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { - numLines := l.lineCount() - - if l.direction == DirectionBackward && numLines > l.height { - line = ((numLines - 1) - l.height) + line + 1 - } - - if l.offset > 0 { - if l.direction == DirectionBackward { - line -= l.offset - } else { - line += l.offset - } - } - - if line < 0 || line >= numLines { - return 0, 0 - } - - currentLine := ansi.Strip(l.getLine(line)) - gr := uniseg.NewGraphemes(currentLine) - startCol = -1 - upTo := col - for gr.Next() { - if gr.IsWordBoundary() && upTo > 0 { - startCol = col - upTo + 1 - } else if gr.IsWordBoundary() && upTo < 0 { - endCol = col - upTo + 1 - break - } - if upTo == 0 && gr.Str() == " " { - return 0, 0 - } - upTo -= 1 - } - if startCol == -1 { - return 0, 0 - } - return startCol, endCol -} - -func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) { - // Helper function to get a line with ANSI stripped and icons replaced - getCleanLine := func(index int) string { - rawLine := l.getLine(index) - cleanLine := ansi.Strip(rawLine) - for _, icon := range styles.SelectionIgnoreIcons { - cleanLine = strings.ReplaceAll(cleanLine, icon, " ") - } - return cleanLine - } - - numLines := l.lineCount() - if l.direction == DirectionBackward && numLines > l.height { - line = (numLines - 1) - l.height + line + 1 - } - - if l.offset > 0 { - if l.direction == DirectionBackward { - line -= l.offset - } else { - line += l.offset - } - } - - // Ensure line is within bounds - if line < 0 || line >= numLines { - return 0, 0, false - } - - if strings.TrimSpace(getCleanLine(line)) == "" { - return 0, 0, false - } - - // Find start of paragraph (search backwards for empty line or start of text) - startLine = line - for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" { - startLine-- - } - - // Find end of paragraph (search forwards for empty line or end of text) - endLine = line - for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" { - endLine++ - } - - // revert the line numbers if we are in backward direction - if l.direction == DirectionBackward && numLines > l.height { - startLine = startLine - (numLines - 1) + l.height - 1 - endLine = endLine - (numLines - 1) + l.height - 1 - } - if l.offset > 0 { - if l.direction == DirectionBackward { - startLine += l.offset - endLine += l.offset - } else { - startLine -= l.offset - endLine -= l.offset - } - } - return startLine, endLine, true -} - -// SelectWord selects the word at the given position. -func (l *list[T]) SelectWord(col, line int) { - startCol, endCol := l.findWordBoundaries(col, line) - l.selectionStartCol = startCol - l.selectionStartLine = line - l.selectionEndCol = endCol - l.selectionEndLine = line - l.selectionActive = false // Not actively selecting, just selected -} - -// SelectParagraph selects the paragraph at the given position. -func (l *list[T]) SelectParagraph(col, line int) { - startLine, endLine, found := l.findParagraphBoundaries(line) - if !found { - return - } - l.selectionStartCol = 0 - l.selectionStartLine = startLine - l.selectionEndCol = l.width - 1 - l.selectionEndLine = endLine - l.selectionActive = false // Not actively selecting, just selected -} - -// HasSelection returns whether there is an active selection. -func (l *list[T]) HasSelection() bool { - return l.hasSelection() -} - -func (l *list[T]) selectionArea(absolute bool) uv.Rectangle { - var startY int - if absolute { - startY, _ = l.viewPosition() - } - selArea := uv.Rectangle{ - Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY), - Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY), - } - selArea = selArea.Canon() - selArea.Max.Y++ // make max Y exclusive - return selArea -} - -// GetSelectedText returns the currently selected text. -func (l *list[T]) GetSelectedText(paddingLeft int) string { - if !l.hasSelection() { - return "" - } - - selArea := l.selectionArea(true) - if selArea.Empty() { - return "" - } - - selectionHeight := selArea.Dy() - - tempBuf := uv.NewScreenBuffer(l.width, selectionHeight) - tempBufArea := tempBuf.Bounds() - renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y) - styled := uv.NewStyledString(renderedLines) - styled.Draw(tempBuf, tempBufArea) - - // XXX: Left padding assumes the list component is rendered with absolute - // positioning. The chat component has a left margin of 1 and items in the - // list have a border of 1 plus a padding of 1. The paddingLeft parameter - // assumes this total left padding of 3 and we should fix that. - leftBorder := paddingLeft - 1 - - var b strings.Builder - for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ { - var pending strings.Builder - for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; { - cell := tempBuf.CellAt(x, y) - if cell == nil || cell.IsZero() { - x++ - continue - } - if y == 0 && x < selArea.Min.X { - x++ - continue - } - if y == selectionHeight-1 && x > selArea.Max.X-1 { - break - } - if cell.Width == 1 && cell.Content == " " { - pending.WriteString(cell.Content) - x++ - continue - } - b.WriteString(pending.String()) - pending.Reset() - b.WriteString(cell.Content) - x += cell.Width - } - if y < tempBufArea.Max.Y-1 { - b.WriteByte('\n') - } - } - - return b.String() -} diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go deleted file mode 100644 index 57ca7883f87e9facf82b46f60f66f2101a08428a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list_test.go +++ /dev/null @@ -1,653 +0,0 @@ -package list - -import ( - "fmt" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/exp/golden" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestList(t *testing.T) { - t.Parallel() - t.Run("should have correct positions in list that fits the items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 5 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 5, len(l.indexMap)) - require.Equal(t, 5, len(l.items)) - require.Equal(t, 5, len(l.renderedItems)) - assert.Equal(t, 5, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, 0, start) - assert.Equal(t, 4, end) - for i := range 5 { - item, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 5 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 4, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 5, len(l.indexMap)) - require.Equal(t, 5, len(l.items)) - require.Equal(t, 5, len(l.renderedItems)) - assert.Equal(t, 5, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, 0, start) - assert.Equal(t, 4, end) - for i := range 5 { - item, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 30, len(l.indexMap)) - require.Equal(t, 30, len(l.items)) - require.Equal(t, 30, len(l.renderedItems)) - assert.Equal(t, 30, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, 0, start) - assert.Equal(t, 9, end) - for i := range 30 { - item, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 29, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 30, len(l.indexMap)) - require.Equal(t, 30, len(l.items)) - require.Equal(t, 30, len(l.renderedItems)) - assert.Equal(t, 30, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, 20, start) - assert.Equal(t, 29, end) - for i := range 30 { - item, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 30, len(l.indexMap)) - require.Equal(t, 30, len(l.items)) - require.Equal(t, 30, len(l.renderedItems)) - expectedLines := 0 - for i := range 30 { - expectedLines += (i + 1) * 1 - } - assert.Equal(t, expectedLines, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, 0, start) - assert.Equal(t, 9, end) - currentPosition := 0 - for i := range 30 { - rItem, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, currentPosition, rItem.start) - assert.Equal(t, currentPosition+i, rItem.end) - currentPosition += i + 1 - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 29, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - require.Equal(t, 30, len(l.indexMap)) - require.Equal(t, 30, len(l.items)) - require.Equal(t, 30, len(l.renderedItems)) - expectedLines := 0 - for i := range 30 { - expectedLines += (i + 1) * 1 - } - assert.Equal(t, expectedLines, lipgloss.Height(l.rendered)) - assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") - start, end := l.viewPosition() - assert.Equal(t, expectedLines-10, start) - assert.Equal(t, expectedLines-1, end) - currentPosition := 0 - for i := range 30 { - rItem, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, currentPosition, rItem.start) - assert.Equal(t, currentPosition+i, rItem.end) - currentPosition += i + 1 - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should go to selected item at the beginning", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 10, l.selectedItemIdx) - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should go to selected item at the beginning backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 10, l.selectedItemIdx) - - golden.RequireEqual(t, []byte(l.View())) - }) -} - -func TestListMovement(t *testing.T) { - t.Parallel() - t.Run("should move viewport up", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(25)) - - assert.Equal(t, 25, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should move viewport up and down", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(25)) - execCmd(l, l.MoveDown(25)) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should move viewport down", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(25)) - - assert.Equal(t, 25, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should move viewport down and up", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(25)) - execCmd(l, l.MoveUp(25)) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - execCmd(l, l.AppendItem(NewSelectableItem("Testing"))) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 5, l.offset) - assert.Equal(t, 33, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[29] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 4, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3")) - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[30] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 0, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[1] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - execCmd(l, l.PrependItem(NewSelectableItem("New"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - execCmd(l, l.PrependItem(NewSelectableItem("Testing"))) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 5, l.offset) - assert.Equal(t, 33, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - item := items[0] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 4, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - items = append(items, NewSelectableItem("At top\nLine 2\nLine 3")) - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(3)) - viewBefore := l.View() - item := items[0] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 1, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - item := items[29] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - execCmd(l, l.AppendItem(NewSelectableItem("New"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) -} - -type SelectableItem interface { - Item - layout.Focusable -} - -type simpleItem struct { - width int - content string - id string -} -type selectableItem struct { - *simpleItem - focused bool -} - -func NewSimpleItem(content string) *simpleItem { - return &simpleItem{ - id: uuid.NewString(), - width: 0, - content: content, - } -} - -func NewSelectableItem(content string) SelectableItem { - return &selectableItem{ - simpleItem: NewSimpleItem(content), - focused: false, - } -} - -func (s *simpleItem) ID() string { - return s.id -} - -func (s *simpleItem) Init() tea.Cmd { - return nil -} - -func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) { - return s, nil -} - -func (s *simpleItem) View() string { - return lipgloss.NewStyle().Width(s.width).Render(s.content) -} - -func (l *simpleItem) GetSize() (int, int) { - return l.width, 0 -} - -// SetSize implements Item. -func (s *simpleItem) SetSize(width int, height int) tea.Cmd { - s.width = width - return nil -} - -func (s *selectableItem) View() string { - if s.focused { - return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content) - } - return lipgloss.NewStyle().Width(s.width).Render(s.content) -} - -// Blur implements SimpleItem. -func (s *selectableItem) Blur() tea.Cmd { - s.focused = false - return nil -} - -// Focus implements SimpleItem. -func (s *selectableItem) Focus() tea.Cmd { - s.focused = true - return nil -} - -// IsFocused implements SimpleItem. -func (s *selectableItem) IsFocused() bool { - return s.focused -} - -func execCmd(m util.Model, cmd tea.Cmd) { - for cmd != nil { - msg := cmd() - m, cmd = m.Update(msg) - } -} diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden deleted file mode 100644 index 01668d35b2d07b73b1daf709578d1dccf72a4cea..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -> Type to filter  -│Item 0  -Item 1  -Item 2  -Item 3  -Item 4  - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden deleted file mode 100644 index f167f64ffd978440b6df4f59911c384ed0538a66..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden deleted file mode 100644 index aaa3c01a3e5cec4da20bdb25af8bc9c86d8ccfd5..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden deleted file mode 100644 index a11b23ef049201e56929376a6638bd12718b7a3f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden +++ /dev/null @@ -1,20 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden deleted file mode 100644 index 55b683ef02e235e03bbe941093d557dd06dfd888..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden +++ /dev/null @@ -1,20 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden deleted file mode 100644 index d304f35cc7594d9070555ff914980787b7cfb987..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 6 -Item 6 -Item 6 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden deleted file mode 100644 index 65c98367d817411de97cfae7a34737efe0217d6b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -│Item 3 -│Item 3 -│Item 3 -│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden deleted file mode 100644 index 03582cc911ee2f3d50e428cd320c25a13c99147b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 28 -│Item 28 -│Item 28 -│Item 28 -│Item 28 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden deleted file mode 100644 index 8cea66d71fb8e43fc9e0ac8fcb6ee1000cfcb5e4..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing  \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden deleted file mode 100644 index faed253a104304630e9e33decc445622cde8739a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Testing  -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden deleted file mode 100644 index f377a4fd04f868d775c279849fd65723afaac901..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index bee9a3063ed375819298e01098524f15247ba280..0000000000000000000000000000000000000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,45 +0,0 @@ -package tui - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Quit key.Binding - Help key.Binding - Commands key.Binding - Suspend key.Binding - Models key.Binding - Sessions key.Binding - - pageBindings []key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "more"), - ), - Commands: key.NewBinding( - key.WithKeys("ctrl+p"), - key.WithHelp("ctrl+p", "commands"), - ), - Suspend: key.NewBinding( - key.WithKeys("ctrl+z"), - key.WithHelp("ctrl+z", "suspend"), - ), - Models: key.NewBinding( - key.WithKeys("ctrl+l", "ctrl+m"), - key.WithHelp("ctrl+l", "models"), - ), - Sessions: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "sessions"), - ), - } -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go deleted file mode 100644 index 9a4b69f5507fbb62b7ee93df6326f94cf79d22ad..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/chat.go +++ /dev/null @@ -1,1404 +0,0 @@ -package chat - -import ( - "context" - "errors" - "fmt" - "time" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/chat/editor" - "github.com/charmbracelet/crush/internal/tui/components/chat/header" - "github.com/charmbracelet/crush/internal/tui/components/chat/messages" - "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" - "github.com/charmbracelet/crush/internal/tui/components/chat/splash" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning" - "github.com/charmbracelet/crush/internal/tui/page" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" -) - -var ChatPageID page.PageID = "chat" - -type ( - ChatFocusedMsg struct { - Focused bool - } - CancelTimerExpiredMsg struct{} -) - -type PanelType string - -const ( - PanelTypeChat PanelType = "chat" - PanelTypeEditor PanelType = "editor" - PanelTypeSplash PanelType = "splash" -) - -// PillSection represents which pill section is focused when in pills panel. -type PillSection int - -const ( - PillSectionTodos PillSection = iota - PillSectionQueue -) - -const ( - CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode - CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode - EditorHeight = 5 // Height of the editor input area including padding - SideBarWidth = 31 // Width of the sidebar - SideBarDetailsPadding = 1 // Padding for the sidebar details section - HeaderHeight = 1 // Height of the header - - // Layout constants for borders and padding - BorderWidth = 1 // Width of component borders - LeftRightBorders = 2 // Left + right border width (1 + 1) - TopBottomBorders = 2 // Top + bottom border width (1 + 1) - DetailsPositioning = 2 // Positioning adjustment for details panel - - // Timing constants - CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires -) - -type ChatPage interface { - util.Model - layout.Help - IsChatFocused() bool -} - -// cancelTimerCmd creates a command that expires the cancel timer -func cancelTimerCmd() tea.Cmd { - return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg { - return CancelTimerExpiredMsg{} - }) -} - -type chatPage struct { - width, height int - detailsWidth, detailsHeight int - app *app.App - keyboardEnhancements tea.KeyboardEnhancementsMsg - - // Layout state - compact bool - forceCompact bool - focusedPane PanelType - - // Session - session session.Session - keyMap KeyMap - - // Components - header header.Header - sidebar sidebar.Sidebar - chat chat.MessageListCmp - editor editor.Editor - splash splash.Splash - - // Simple state flags - showingDetails bool - isCanceling bool - splashFullScreen bool - isOnboarding bool - isProjectInit bool - promptQueue int - - // Pills state - pillsExpanded bool - focusedPillSection PillSection - - // Todo spinner - todoSpinner spinner.Model -} - -func New(app *app.App) ChatPage { - t := styles.CurrentTheme() - return &chatPage{ - app: app, - keyMap: DefaultKeyMap(), - header: header.New(app.LSPClients), - sidebar: sidebar.New(app.History, app.LSPClients, false), - chat: chat.New(app), - editor: editor.New(app), - splash: splash.New(), - focusedPane: PanelTypeSplash, - todoSpinner: spinner.New( - spinner.WithSpinner(spinner.MiniDot), - spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)), - ), - } -} - -func (p *chatPage) Init() tea.Cmd { - cfg := config.Get() - compact := cfg.Options.TUI.CompactMode - p.compact = compact - p.forceCompact = compact - p.sidebar.SetCompactMode(p.compact) - - // Set splash state based on config - if !config.HasInitialDataConfig() { - // First-time setup: show model selection - p.splash.SetOnboarding(true) - p.isOnboarding = true - p.splashFullScreen = true - } else if b, _ := config.ProjectNeedsInitialization(); b { - // Project needs context initialization - p.splash.SetProjectInit(true) - p.isProjectInit = true - p.splashFullScreen = true - } else { - // Ready to chat: focus editor, splash in background - p.focusedPane = PanelTypeEditor - p.splashFullScreen = false - } - - return tea.Batch( - p.header.Init(), - p.sidebar.Init(), - p.chat.Init(), - p.editor.Init(), - p.splash.Init(), - ) -} - -func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - if p.session.ID != "" && p.app.AgentCoordinator != nil { - queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID) - if queueSize != p.promptQueue { - p.promptQueue = queueSize - cmds = append(cmds, p.SetSize(p.width, p.height)) - } - } - switch msg := msg.(type) { - case tea.KeyboardEnhancementsMsg: - p.keyboardEnhancements = msg - return p, nil - case tea.MouseWheelMsg: - if p.compact { - msg.Y -= 1 - } - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case tea.MouseClickMsg: - if p.isOnboarding || p.isProjectInit { - return p, nil - } - if p.compact { - msg.Y -= 1 - } - if p.isMouseOverChat(msg.X, msg.Y) { - p.focusedPane = PanelTypeChat - p.chat.Focus() - p.editor.Blur() - } else { - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - } - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - case tea.MouseMotionMsg: - if p.compact { - msg.Y -= 1 - } - if msg.Button == tea.MouseLeft { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case tea.MouseReleaseMsg: - if p.isOnboarding || p.isProjectInit { - return p, nil - } - if p.compact { - msg.Y -= 1 - } - if msg.Button == tea.MouseLeft { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case chat.SelectionCopyMsg: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - case tea.WindowSizeMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd) - case CancelTimerExpiredMsg: - p.isCanceling = false - return p, nil - case editor.OpenEditorMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case chat.SendMsg: - return p, p.sendMessage(msg.Text, msg.Attachments) - case chat.SessionSelectedMsg: - return p, p.setSession(msg) - case splash.SubmitAPIKeyMsg: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case commands.ToggleCompactModeMsg: - p.forceCompact = !p.forceCompact - var cmd tea.Cmd - if p.forceCompact { - p.setCompactMode(true) - cmd = p.updateCompactConfig(true) - } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint { - p.setCompactMode(false) - cmd = p.updateCompactConfig(false) - } - return p, tea.Batch(p.SetSize(p.width, p.height), cmd) - case commands.ToggleThinkingMsg: - return p, p.toggleThinking() - case commands.OpenReasoningDialogMsg: - return p, p.openReasoningDialog() - case reasoning.ReasoningEffortSelectedMsg: - return p, p.handleReasoningEffortSelected(msg.Effort) - case commands.OpenExternalEditorMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case pubsub.Event[session.Session]: - if msg.Payload.ID == p.session.ID { - prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - prevHasInProgress := p.hasInProgressTodo() - p.session = msg.Payload - newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - newHasInProgress := p.hasInProgressTodo() - if prevHasIncompleteTodos != newHasIncompleteTodos { - cmds = append(cmds, p.SetSize(p.width, p.height)) - } - if !prevHasInProgress && newHasInProgress { - cmds = append(cmds, p.todoSpinner.Tick) - } - } - u, cmd := p.header.Update(msg) - p.header = u.(header.Header) - cmds = append(cmds, cmd) - u, cmd = p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case chat.SessionClearedMsg: - u, cmd := p.header.Update(msg) - p.header = u.(header.Header) - cmds = append(cmds, cmd) - u, cmd = p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, cmd) - u, cmd = p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case filepicker.FilePickedMsg, - completions.CompletionsClosedMsg, - completions.SelectCompletionMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - - case hyper.DeviceFlowCompletedMsg, - hyper.DeviceAuthInitiatedMsg, - hyper.DeviceFlowErrorMsg, - copilot.DeviceAuthInitiatedMsg, - copilot.DeviceFlowErrorMsg, - copilot.DeviceFlowCompletedMsg: - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - return p, tea.Batch(cmds...) - case models.APIKeyStateChangeMsg: - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - return p, tea.Batch(cmds...) - case pubsub.Event[message.Message], - anim.StepMsg, - spinner.TickMsg: - // Update todo spinner if agent is busy and we have in-progress todos - agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() - if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy { - var cmd tea.Cmd - p.todoSpinner, cmd = p.todoSpinner.Update(msg) - cmds = append(cmds, cmd) - } - // Start spinner when agent becomes busy and we have in-progress todos - if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy { - cmds = append(cmds, p.todoSpinner.Tick) - } - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } else { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - } - - return p, tea.Batch(cmds...) - case commands.ToggleYoloModeMsg: - // update the editor style - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case pubsub.Event[history.File], sidebar.SessionFilesMsg: - u, cmd := p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case pubsub.Event[permission.PermissionNotification]: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - - case commands.CommandRunCustomMsg: - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before executing a command...") - } - - cmd := p.sendMessage(msg.Content, nil) - if cmd != nil { - return p, cmd - } - case splash.OnboardingCompleteMsg: - p.splashFullScreen = false - if b, _ := config.ProjectNeedsInitialization(); b { - p.splash.SetProjectInit(true) - p.splashFullScreen = true - return p, p.SetSize(p.width, p.height) - } - err := p.app.InitCoderAgent(context.TODO()) - if err != nil { - return p, util.ReportError(err) - } - p.isOnboarding = false - p.isProjectInit = false - p.focusedPane = PanelTypeEditor - return p, p.SetSize(p.width, p.height) - case commands.NewSessionsMsg: - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before starting a new session...") - } - return p, p.newSession() - case tea.KeyPressMsg: - switch { - case key.Matches(msg, p.keyMap.NewSession): - // if we have no agent do nothing - if p.app.AgentCoordinator == nil { - return p, nil - } - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before starting a new session...") - } - return p, p.newSession() - case key.Matches(msg, p.keyMap.AddAttachment): - // Skip attachment handling during onboarding/splash screen - if p.focusedPane == PanelTypeSplash || p.isOnboarding { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - return p, cmd - } - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model == nil { - return p, util.ReportWarn("No model configured yet") - } - if model.SupportsImages { - return p, util.CmdHandler(commands.OpenFilePickerMsg{}) - } else { - return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) - } - case key.Matches(msg, p.keyMap.Tab): - if p.session.ID == "" { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - return p, cmd - } - return p, p.changeFocus() - case key.Matches(msg, p.keyMap.Cancel): - if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() { - return p, p.cancel() - } - case key.Matches(msg, p.keyMap.Details): - p.toggleDetails() - return p, nil - case key.Matches(msg, p.keyMap.TogglePills): - if p.session.ID != "" { - return p, p.togglePillsExpanded() - } - case key.Matches(msg, p.keyMap.PillLeft): - if p.session.ID != "" && p.pillsExpanded { - return p, p.switchPillSection(-1) - } - case key.Matches(msg, p.keyMap.PillRight): - if p.session.ID != "" && p.pillsExpanded { - return p, p.switchPillSection(1) - } - } - - switch p.focusedPane { - case PanelTypeChat: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - case PanelTypeEditor: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - case PanelTypeSplash: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - case tea.PasteMsg: - switch p.focusedPane { - case PanelTypeEditor: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case PanelTypeChat: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case PanelTypeSplash: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - } - } - return p, tea.Batch(cmds...) -} - -func (p *chatPage) Cursor() *tea.Cursor { - if p.header.ShowingDetails() { - return nil - } - switch p.focusedPane { - case PanelTypeEditor: - return p.editor.Cursor() - case PanelTypeSplash: - return p.splash.Cursor() - default: - return nil - } -} - -func (p *chatPage) View() string { - var chatView string - t := styles.CurrentTheme() - - if p.session.ID == "" { - splashView := p.splash.View() - // Full screen during onboarding or project initialization - if p.splashFullScreen { - chatView = splashView - } else { - // Show splash + editor for new message state - editorView := p.editor.View() - chatView = lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Render(splashView), - editorView, - ) - } - } else { - messagesView := p.chat.View() - editorView := p.editor.View() - - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos - queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue - - // Use spinner when agent is busy, otherwise show static icon - agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() - inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon) - if agentBusy { - inProgressIcon = p.todoSpinner.View() - } - - var pills []string - if hasIncompleteTodos { - pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t)) - } - if hasQueue { - pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t)) - } - - var expandedList string - if p.pillsExpanded { - if todosFocused && hasIncompleteTodos { - expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth) - } else if queueFocused && hasQueue { - queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID) - expandedList = queueList(queueItems, t) - } - } - - var pillsArea string - if len(pills) > 0 { - pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...) - - // Add help hint for expanding/collapsing pills based on state. - var helpDesc string - if p.pillsExpanded { - helpDesc = "close" - } else { - helpDesc = "open" - } - // Style to match help section: keys in FgMuted, description in FgSubtle - helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space") - helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc) - helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) - pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) - - if expandedList != "" { - pillsArea = lipgloss.JoinVertical( - lipgloss.Left, - pillsRow, - expandedList, - ) - } else { - pillsArea = pillsRow - } - - pillsArea = t.S().Base. - MaxWidth(p.width). - MarginTop(1). - PaddingLeft(3). - Render(pillsArea) - } - - if p.compact { - headerView := p.header.View() - views := []string{headerView, messagesView} - if pillsArea != "" { - views = append(views, pillsArea) - } - views = append(views, editorView) - chatView = lipgloss.JoinVertical(lipgloss.Left, views...) - } else { - sidebarView := p.sidebar.View() - var messagesColumn string - if pillsArea != "" { - messagesColumn = lipgloss.JoinVertical( - lipgloss.Left, - messagesView, - pillsArea, - ) - } else { - messagesColumn = messagesView - } - messages := lipgloss.JoinHorizontal( - lipgloss.Left, - messagesColumn, - sidebarView, - ) - chatView = lipgloss.JoinVertical( - lipgloss.Left, - messages, - p.editor.View(), - ) - } - } - - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(chatView).X(0).Y(0), - } - - if p.showingDetails { - style := t.S().Base. - Width(p.detailsWidth). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version) - details := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - p.sidebar.View(), - version, - ), - ) - layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) - } - canvas := lipgloss.NewCompositor(layers...) - return canvas.Render() -} - -func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd { - return func() tea.Msg { - err := config.Get().SetCompactMode(compact) - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update compact mode configuration: " + err.Error(), - } - } - return nil - } -} - -func (p *chatPage) toggleThinking() tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - currentModel := cfg.Models[agentCfg.Model] - - // Toggle the thinking mode - currentModel.Think = !currentModel.Think - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update thinking mode: " + err.Error(), - } - } - - // Update the agent with the new configuration - go p.app.UpdateAgentModel(context.TODO()) - - status := "disabled" - if currentModel.Think { - status = "enabled" - } - return util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: "Thinking mode " + status, - } - } -} - -func (p *chatPage) openReasoningDialog() tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - model := cfg.GetModelByType(agentCfg.Model) - providerCfg := cfg.GetProviderForModel(agentCfg.Model) - - if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 { - // Return the OpenDialogMsg directly so it bubbles up to the main TUI - return dialogs.OpenDialogMsg{ - Model: reasoning.NewReasoningDialog(), - } - } - return nil - } -} - -func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - currentModel := cfg.Models[agentCfg.Model] - - // Update the model configuration - currentModel.ReasoningEffort = effort - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update reasoning effort: " + err.Error(), - } - } - - // Update the agent with the new configuration - if err := p.app.UpdateAgentModel(context.TODO()); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update reasoning effort: " + err.Error(), - } - } - - return util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: "Reasoning effort set to " + effort, - } - } -} - -func (p *chatPage) setCompactMode(compact bool) { - if p.compact == compact { - return - } - p.compact = compact - if compact { - p.sidebar.SetCompactMode(true) - } else { - p.setShowDetails(false) - } -} - -func (p *chatPage) handleCompactMode(newWidth int, newHeight int) { - if p.forceCompact { - return - } - if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact { - p.setCompactMode(true) - } - if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact { - p.setCompactMode(false) - } -} - -func (p *chatPage) SetSize(width, height int) tea.Cmd { - p.handleCompactMode(width, height) - p.width = width - p.height = height - var cmds []tea.Cmd - - if p.session.ID == "" { - if p.splashFullScreen { - cmds = append(cmds, p.splash.SetSize(width, height)) - } else { - cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) - } - } else { - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - hasPills := hasIncompleteTodos || hasQueue - - pillsAreaHeight := 0 - if hasPills { - pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top - if p.pillsExpanded { - if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos { - pillsAreaHeight += len(p.session.Todos) - } else if p.focusedPillSection == PillSectionQueue && hasQueue { - pillsAreaHeight += p.promptQueue - } - } - } - - if p.compact { - cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight)) - p.detailsWidth = width - DetailsPositioning - cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.header.SetWidth(width-BorderWidth)) - } else { - cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight)) - } - cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) - } - return tea.Batch(cmds...) -} - -func (p *chatPage) newSession() tea.Cmd { - if p.session.ID == "" { - return nil - } - - p.session = session.Session{} - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - p.isCanceling = false - return tea.Batch( - util.CmdHandler(chat.SessionClearedMsg{}), - p.SetSize(p.width, p.height), - ) -} - -func (p *chatPage) setSession(sess session.Session) tea.Cmd { - if p.session.ID == sess.ID { - return nil - } - - var cmds []tea.Cmd - p.session = sess - - if p.hasInProgressTodo() { - cmds = append(cmds, p.todoSpinner.Tick) - } - - cmds = append(cmds, p.SetSize(p.width, p.height)) - cmds = append(cmds, p.chat.SetSession(sess)) - cmds = append(cmds, p.sidebar.SetSession(sess)) - cmds = append(cmds, p.header.SetSession(sess)) - cmds = append(cmds, p.editor.SetSession(sess)) - - return tea.Sequence(cmds...) -} - -func (p *chatPage) changeFocus() tea.Cmd { - if p.session.ID == "" { - return nil - } - - switch p.focusedPane { - case PanelTypeEditor: - p.focusedPane = PanelTypeChat - p.chat.Focus() - p.editor.Blur() - case PanelTypeChat: - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - } - return nil -} - -func (p *chatPage) togglePillsExpanded() tea.Cmd { - hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0 - if !hasPills { - return nil - } - p.pillsExpanded = !p.pillsExpanded - if p.pillsExpanded { - if hasIncompleteTodos(p.session.Todos) { - p.focusedPillSection = PillSectionTodos - } else { - p.focusedPillSection = PillSectionQueue - } - } - return p.SetSize(p.width, p.height) -} - -func (p *chatPage) switchPillSection(dir int) tea.Cmd { - if !p.pillsExpanded { - return nil - } - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - - if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos { - p.focusedPillSection = PillSectionTodos - return p.SetSize(p.width, p.height) - } - if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue { - p.focusedPillSection = PillSectionQueue - return p.SetSize(p.width, p.height) - } - return nil -} - -func (p *chatPage) cancel() tea.Cmd { - if p.isCanceling { - p.isCanceling = false - if p.app.AgentCoordinator != nil { - p.app.AgentCoordinator.Cancel(p.session.ID) - } - return nil - } - - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 { - p.app.AgentCoordinator.ClearQueue(p.session.ID) - return nil - } - p.isCanceling = true - return cancelTimerCmd() -} - -func (p *chatPage) setShowDetails(show bool) { - p.showingDetails = show - p.header.SetDetailsOpen(p.showingDetails) - if !p.compact { - p.sidebar.SetCompactMode(false) - } -} - -func (p *chatPage) toggleDetails() { - if p.session.ID == "" || !p.compact { - return - } - p.setShowDetails(!p.showingDetails) -} - -func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { - session := p.session - var cmds []tea.Cmd - if p.session.ID == "" { - // XXX: The second argument here is the session name, which we leave - // blank as it will be auto-generated. Ideally, we remove the need for - // that argument entirely. - newSession, err := p.app.Sessions.Create(context.Background(), "") - if err != nil { - return util.ReportError(err) - } - session = newSession - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) - } - if p.app.AgentCoordinator == nil { - return util.ReportError(fmt.Errorf("coder agent is not initialized")) - } - cmds = append(cmds, p.chat.GoToBottom()) - cmds = append(cmds, func() tea.Msg { - _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...) - if err != nil { - isCancelErr := errors.Is(err, context.Canceled) - isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) - if isCancelErr || isPermissionErr { - return nil - } - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: err.Error(), - } - } - return nil - }) - return tea.Batch(cmds...) -} - -func (p *chatPage) Bindings() []key.Binding { - bindings := []key.Binding{ - p.keyMap.NewSession, - p.keyMap.AddAttachment, - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() { - cancelBinding := p.keyMap.Cancel - if p.isCanceling { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "press again to cancel"), - ) - } - bindings = append([]key.Binding{cancelBinding}, bindings...) - } - - switch p.focusedPane { - case PanelTypeChat: - bindings = append([]key.Binding{ - key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus editor"), - ), - }, bindings...) - bindings = append(bindings, p.chat.Bindings()...) - case PanelTypeEditor: - bindings = append([]key.Binding{ - key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ), - }, bindings...) - bindings = append(bindings, p.editor.Bindings()...) - case PanelTypeSplash: - bindings = append(bindings, p.splash.Bindings()...) - } - - return bindings -} - -func (p *chatPage) Help() help.KeyMap { - var shortList []key.Binding - var fullList [][]key.Binding - switch { - case p.isOnboarding: - switch { - case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2(): - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "copy url & open signup"), - ), - key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - ), - ) - default: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "submit"), - ), - ) - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isOnboarding && !p.splash.IsShowingAPIKey(): - shortList = append(shortList, - // Choose model - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑/↓", "choose"), - ), - // Accept selection - key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "accept"), - ), - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isOnboarding && p.splash.IsShowingAPIKey(): - if p.splash.IsAPIKeyValid() { - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "continue"), - ), - ) - } else { - shortList = append(shortList, - // Go back - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "back"), - ), - ) - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isProjectInit: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - default: - if p.editor.IsCompletionsOpen() { - shortList = append(shortList, - key.NewBinding( - key.WithKeys("tab", "enter"), - key.WithHelp("tab/enter", "complete"), - ), - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑/↓", "choose"), - ), - ) - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - return core.NewSimpleHelp(shortList, fullList) - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() { - cancelBinding := key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ) - if p.isCanceling { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "press again to cancel"), - ) - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "clear queue"), - ) - } - shortList = append(shortList, cancelBinding) - fullList = append(fullList, - []key.Binding{ - cancelBinding, - }, - ) - } - globalBindings := []key.Binding{} - // we are in a session - if p.session.ID != "" { - var tabKey key.Binding - switch p.focusedPane { - case PanelTypeEditor: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) - case PanelTypeChat: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus editor"), - ) - default: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) - } - shortList = append(shortList, tabKey) - globalBindings = append(globalBindings, tabKey) - - // Show left/right to switch sections when expanded and both exist - hasTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - if p.pillsExpanded && hasTodos && hasQueue { - shortList = append(shortList, p.keyMap.PillLeft) - globalBindings = append(globalBindings, p.keyMap.PillLeft) - } - } - commandsBinding := key.NewBinding( - key.WithKeys("ctrl+p"), - key.WithHelp("ctrl+p", "commands"), - ) - if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() { - commandsBinding.SetHelp("/ or ctrl+p", "commands") - } - modelsBinding := key.NewBinding( - key.WithKeys("ctrl+m", "ctrl+l"), - key.WithHelp("ctrl+l", "models"), - ) - if p.keyboardEnhancements.Flags > 0 { - // non-zero flags mean we have at least key disambiguation - modelsBinding.SetHelp("ctrl+m", "models") - } - helpBinding := key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "more"), - ) - globalBindings = append(globalBindings, commandsBinding, modelsBinding) - globalBindings = append(globalBindings, - key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "sessions"), - ), - ) - if p.session.ID != "" { - globalBindings = append(globalBindings, - key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new sessions"), - )) - } - shortList = append(shortList, - // Commands - commandsBinding, - modelsBinding, - ) - fullList = append(fullList, globalBindings) - - switch p.focusedPane { - case PanelTypeChat: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑↓", "scroll"), - ), - messages.CopyKey, - ) - fullList = append(fullList, - []key.Binding{ - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑↓", "scroll"), - ), - key.NewBinding( - key.WithKeys("shift+up", "shift+down"), - key.WithHelp("shift+↑↓", "next/prev item"), - ), - key.NewBinding( - key.WithKeys("pgup", "b"), - key.WithHelp("b/pgup", "page up"), - ), - key.NewBinding( - key.WithKeys("pgdown", " ", "f"), - key.WithHelp("f/pgdn", "page down"), - ), - }, - []key.Binding{ - key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "half page up"), - ), - key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "half page down"), - ), - key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "home"), - ), - key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "end"), - ), - }, - []key.Binding{ - messages.CopyKey, - messages.ClearSelectionKey, - }, - ) - case PanelTypeEditor: - newLineBinding := key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - // "ctrl+j" is a common keybinding for newline in many editors. If - // the terminal supports "shift+enter", we substitute the help text - // to reflect that. - key.WithHelp("ctrl+j", "newline"), - ) - if p.keyboardEnhancements.Flags > 0 { - // Non-zero flags mean we have at least key disambiguation. - newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc) - } - shortList = append(shortList, newLineBinding) - fullList = append(fullList, - []key.Binding{ - newLineBinding, - key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "add image"), - ), - key.NewBinding( - key.WithKeys("@"), - key.WithHelp("@", "mention file"), - ), - key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "open editor"), - ), - }) - - if p.editor.HasAttachments() { - fullList = append(fullList, []key.Binding{ - key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - key.NewBinding( - key.WithKeys("ctrl+r", "r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - }) - } - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - // Help - helpBinding, - ) - fullList = append(fullList, []key.Binding{ - key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "less"), - ), - }) - } - - return core.NewSimpleHelp(shortList, fullList) -} - -func (p *chatPage) IsChatFocused() bool { - return p.focusedPane == PanelTypeChat -} - -// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds. -// Returns true if the mouse is over the chat area, false otherwise. -func (p *chatPage) isMouseOverChat(x, y int) bool { - // No session means no chat area - if p.session.ID == "" { - return false - } - - var chatX, chatY, chatWidth, chatHeight int - - if p.compact { - // In compact mode: chat area starts after header and spans full width - chatX = 0 - chatY = HeaderHeight - chatWidth = p.width - chatHeight = p.height - EditorHeight - HeaderHeight - } else { - // In non-compact mode: chat area spans from left edge to sidebar - chatX = 0 - chatY = 0 - chatWidth = p.width - SideBarWidth - chatHeight = p.height - EditorHeight - } - - // Check if mouse coordinates are within chat bounds - return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight -} - -func (p *chatPage) hasInProgressTodo() bool { - for _, todo := range p.session.Todos { - if todo.Status == session.TodoStatusInProgress { - return true - } - } - return false -} diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go deleted file mode 100644 index f22ec2bb4915b3d30e72df7f6f867e88447f5b7b..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/keys.go +++ /dev/null @@ -1,53 +0,0 @@ -package chat - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding - TogglePills key.Binding - PillLeft key.Binding - PillRight key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - NewSession: key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new session"), - ), - AddAttachment: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "add attachment"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "change focus"), - ), - Details: key.NewBinding( - key.WithKeys("ctrl+d"), - key.WithHelp("ctrl+d", "toggle details"), - ), - TogglePills: key.NewBinding( - key.WithKeys("ctrl+space"), - key.WithHelp("ctrl+space", "toggle tasks"), - ), - PillLeft: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←/→", "switch section"), - ), - PillRight: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("←/→", "switch section"), - ), - } -} diff --git a/internal/tui/page/chat/pills.go b/internal/tui/page/chat/pills.go deleted file mode 100644 index 40a363626946907641ad25bb44a1e9c3df752945..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/pills.go +++ /dev/null @@ -1,125 +0,0 @@ -package chat - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat/todos" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -func hasIncompleteTodos(todos []session.Todo) bool { - for _, todo := range todos { - if todo.Status != session.TodoStatusCompleted { - return true - } - } - return false -} - -const ( - pillHeightWithBorder = 3 - maxTaskDisplayLength = 40 - maxQueueDisplayLength = 60 -) - -func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string { - if queue <= 0 { - return "" - } - triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent) - if queue < 10 { - triangles = triangles[:queue] - } - - content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue) - - style := t.S().Base.PaddingLeft(1).PaddingRight(1) - if !pillsPanelFocused || focused { - style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay) - } else { - style = style.BorderStyle(lipgloss.HiddenBorder()) - } - return style.Render(content) -} - -func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string { - if !hasIncompleteTodos(todos) { - return "" - } - - completed := 0 - var currentTodo *session.Todo - for i := range todos { - switch todos[i].Status { - case session.TodoStatusCompleted: - completed++ - case session.TodoStatusInProgress: - if currentTodo == nil { - currentTodo = &todos[i] - } - } - } - - total := len(todos) - - label := "To-Do" - progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total)) - - var content string - if pillsPanelFocused { - content = fmt.Sprintf("%s %s", label, progress) - } else if currentTodo != nil { - taskText := currentTodo.Content - if currentTodo.ActiveForm != "" { - taskText = currentTodo.ActiveForm - } - if len(taskText) > maxTaskDisplayLength { - taskText = taskText[:maxTaskDisplayLength-1] + "…" - } - task := t.S().Base.Foreground(t.FgSubtle).Render(taskText) - content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task) - } else { - content = fmt.Sprintf("%s %s", label, progress) - } - - style := t.S().Base.PaddingLeft(1).PaddingRight(1) - if !pillsPanelFocused || focused { - style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay) - } else { - style = style.BorderStyle(lipgloss.HiddenBorder()) - } - return style.Render(content) -} - -func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string { - return todos.FormatTodosList(sessionTodos, spinnerView, t, width) -} - -func queueList(queueItems []string, t *styles.Theme) string { - if len(queueItems) == 0 { - return "" - } - - var lines []string - for _, item := range queueItems { - text := item - if len(text) > maxQueueDisplayLength { - text = text[:maxQueueDisplayLength-1] + "…" - } - prefix := t.S().Base.Foreground(t.FgMuted).Render(" •") + " " - lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text)) - } - - return strings.Join(lines, "\n") -} - -func sectionLine(availableWidth int, t *styles.Theme) string { - if availableWidth <= 0 { - return "" - } - line := strings.Repeat("─", availableWidth) - return t.S().Base.Foreground(t.Border).Render(line) -} diff --git a/internal/tui/page/page.go b/internal/tui/page/page.go deleted file mode 100644 index 482df5fd7b85706fb59a90e9ca5938de8fb729ea..0000000000000000000000000000000000000000 --- a/internal/tui/page/page.go +++ /dev/null @@ -1,8 +0,0 @@ -package page - -type PageID string - -// PageChangeMsg is used to change the current page -type PageChangeMsg struct { - ID PageID -} diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go deleted file mode 100644 index 44508e5a24e68ea0507af0f2649ddc372711104d..0000000000000000000000000000000000000000 --- a/internal/tui/styles/charmtone.go +++ /dev/null @@ -1,83 +0,0 @@ -package styles - -import ( - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/exp/charmtone" -) - -func NewCharmtoneTheme() *Theme { - t := &Theme{ - Name: "charmtone", - IsDark: true, - - Primary: charmtone.Charple, - Secondary: charmtone.Dolly, - Tertiary: charmtone.Bok, - Accent: charmtone.Zest, - - // Backgrounds - BgBase: charmtone.Pepper, - BgBaseLighter: charmtone.BBQ, - BgSubtle: charmtone.Charcoal, - BgOverlay: charmtone.Iron, - - // Foregrounds - FgBase: charmtone.Ash, - FgMuted: charmtone.Squid, - FgHalfMuted: charmtone.Smoke, - FgSubtle: charmtone.Oyster, - FgSelected: charmtone.Salt, - - // Borders - Border: charmtone.Charcoal, - BorderFocus: charmtone.Charple, - - // Status - Success: charmtone.Guac, - Error: charmtone.Sriracha, - Warning: charmtone.Zest, - Info: charmtone.Malibu, - - // Colors - White: charmtone.Butter, - - BlueLight: charmtone.Sardine, - BlueDark: charmtone.Damson, - Blue: charmtone.Malibu, - - Yellow: charmtone.Mustard, - Citron: charmtone.Citron, - - Green: charmtone.Julep, - GreenDark: charmtone.Guac, - GreenLight: charmtone.Bok, - - Red: charmtone.Coral, - RedDark: charmtone.Sriracha, - RedLight: charmtone.Salmon, - Cherry: charmtone.Cherry, - } - - // Text selection. - t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) - - // LSP and MCP status. - t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") - t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron) - t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral) - t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac) - - // Editor: Yolo Mode. - t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") - t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) - t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") - t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid) - - // oAuth Chooser. - t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac) - t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep) - t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron) - t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid) - - return t -} diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go deleted file mode 100644 index bd656336a8236f5f9aa57fc2d7b98a1e84d4c932..0000000000000000000000000000000000000000 --- a/internal/tui/styles/chroma.go +++ /dev/null @@ -1,79 +0,0 @@ -package styles - -import ( - "charm.land/glamour/v2/ansi" - "github.com/alecthomas/chroma/v2" -) - -func chromaStyle(style ansi.StylePrimitive) string { - var s string - - if style.Color != nil { - s = *style.Color - } - if style.BackgroundColor != nil { - if s != "" { - s += " " - } - s += "bg:" + *style.BackgroundColor - } - if style.Italic != nil && *style.Italic { - if s != "" { - s += " " - } - s += "italic" - } - if style.Bold != nil && *style.Bold { - if s != "" { - s += " " - } - s += "bold" - } - if style.Underline != nil && *style.Underline { - if s != "" { - s += " " - } - s += "underline" - } - - return s -} - -func GetChromaTheme() chroma.StyleEntries { - t := CurrentTheme() - rules := t.S().Markdown.CodeBlock - - return chroma.StyleEntries{ - chroma.Text: chromaStyle(rules.Chroma.Text), - chroma.Error: chromaStyle(rules.Chroma.Error), - chroma.Comment: chromaStyle(rules.Chroma.Comment), - chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc), - chroma.Keyword: chromaStyle(rules.Chroma.Keyword), - chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved), - chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace), - chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType), - chroma.Operator: chromaStyle(rules.Chroma.Operator), - chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation), - chroma.Name: chromaStyle(rules.Chroma.Name), - chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin), - chroma.NameTag: chromaStyle(rules.Chroma.NameTag), - chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute), - chroma.NameClass: chromaStyle(rules.Chroma.NameClass), - chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant), - chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator), - chroma.NameException: chromaStyle(rules.Chroma.NameException), - chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction), - chroma.NameOther: chromaStyle(rules.Chroma.NameOther), - chroma.Literal: chromaStyle(rules.Chroma.Literal), - chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber), - chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate), - chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString), - chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape), - chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted), - chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph), - chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted), - chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong), - chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading), - chroma.Background: chromaStyle(rules.Chroma.Background), - } -} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go deleted file mode 100644 index 0db13358a2f9812293c18497b71ba138484b8f17..0000000000000000000000000000000000000000 --- a/internal/tui/styles/icons.go +++ /dev/null @@ -1,48 +0,0 @@ -package styles - -const ( - CheckIcon string = "✓" - ErrorIcon string = "×" - WarningIcon string = "⚠" - InfoIcon string = "ⓘ" - HintIcon string = "∵" - SpinnerIcon string = "..." - ArrowRightIcon string = "→" - CenterSpinnerIcon string = "⋯" - LoadingIcon string = "⟳" - ImageIcon string = "■" - TextIcon string = "☰" - ModelIcon string = "◇" - - // Tool call icons - ToolPending string = "●" - ToolSuccess string = "✓" - ToolError string = "×" - - BorderThin string = "│" - BorderThick string = "▌" - - // Todo icons - TodoCompletedIcon string = "✓" - TodoPendingIcon string = "•" -) - -var SelectionIgnoreIcons = []string{ - // CheckIcon, - // ErrorIcon, - // WarningIcon, - // InfoIcon, - // HintIcon, - // SpinnerIcon, - // LoadingIcon, - // DocumentIcon, - // ModelIcon, - // - // // Tool call icons - // ToolPending, - // ToolSuccess, - // ToolError, - - BorderThin, - BorderThick, -} diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go deleted file mode 100644 index fd857703ee21912ee3ddc7d6112798317c34fa59..0000000000000000000000000000000000000000 --- a/internal/tui/styles/markdown.go +++ /dev/null @@ -1,205 +0,0 @@ -package styles - -import ( - "fmt" - "image/color" - - "charm.land/glamour/v2" - "charm.land/glamour/v2/ansi" -) - -// lipglossColorToHex converts a color.Color to hex string -func lipglossColorToHex(c color.Color) string { - r, g, b, _ := c.RGBA() - return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) -} - -// Helper functions for style pointers -func boolPtr(b bool) *bool { return &b } -func stringPtr(s string) *string { return &s } -func uintPtr(u uint) *uint { return &u } - -// returns a glamour TermRenderer configured with the current theme -func GetMarkdownRenderer(width int) *glamour.TermRenderer { - t := CurrentTheme() - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(t.S().Markdown), - glamour.WithWordWrap(width), - ) - return r -} - -// returns a glamour TermRenderer with no colors (plain text with structure) -func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer { - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(PlainMarkdownStyle()), - glamour.WithWordWrap(width), - ) - return r -} - -// PlainMarkdownStyle returns a glamour style config with no colors -func PlainMarkdownStyle() ansi.StyleConfig { - t := CurrentTheme() - bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter)) - fgColor := stringPtr(lipglossColorToHex(t.FgMuted)) - return ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: defaultListIndent, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - HorizontalRule: ansi.StylePrimitive{ - Format: "\n--------\n", - Color: fgColor, - BackgroundColor: bgColor, - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - Color: fgColor, - BackgroundColor: bgColor, - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - Color: fgColor, - BackgroundColor: bgColor, - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Underline: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - LinkText: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Image: ansi.StylePrimitive{ - Underline: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - ImageText: ansi.StylePrimitive{ - Format: "Image: {{.text}} →", - Color: fgColor, - BackgroundColor: bgColor, - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Margin: uintPtr(defaultMargin), - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n ", - Color: fgColor, - BackgroundColor: bgColor, - }, - } -} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go deleted file mode 100644 index b03603c57439f5f950f9860d3287b0f9d13742e5..0000000000000000000000000000000000000000 --- a/internal/tui/styles/theme.go +++ /dev/null @@ -1,709 +0,0 @@ -package styles - -import ( - "fmt" - "image/color" - "strings" - "sync" - - "charm.land/bubbles/v2/filepicker" - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/glamour/v2/ansi" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" - "github.com/charmbracelet/x/exp/charmtone" - "github.com/lucasb-eyer/go-colorful" - "github.com/rivo/uniseg" -) - -const ( - defaultListIndent = 2 - defaultListLevelIndent = 4 - defaultMargin = 2 -) - -type Theme struct { - Name string - IsDark bool - - Primary color.Color - Secondary color.Color - Tertiary color.Color - Accent color.Color - - BgBase color.Color - BgBaseLighter color.Color - BgSubtle color.Color - BgOverlay color.Color - - FgBase color.Color - FgMuted color.Color - FgHalfMuted color.Color - FgSubtle color.Color - FgSelected color.Color - - Border color.Color - BorderFocus color.Color - - Success color.Color - Error color.Color - Warning color.Color - Info color.Color - - // Colors - // White - White color.Color - - // Blues - BlueLight color.Color - BlueDark color.Color - Blue color.Color - - // Yellows - Yellow color.Color - Citron color.Color - - // Greens - Green color.Color - GreenDark color.Color - GreenLight color.Color - - // Reds - Red color.Color - RedDark color.Color - RedLight color.Color - Cherry color.Color - - // Text selection. - TextSelection lipgloss.Style - - // LSP and MCP status indicators. - ItemOfflineIcon lipgloss.Style - ItemBusyIcon lipgloss.Style - ItemErrorIcon lipgloss.Style - ItemOnlineIcon lipgloss.Style - - // Editor: Yolo Mode. - YoloIconFocused lipgloss.Style - YoloIconBlurred lipgloss.Style - YoloDotsFocused lipgloss.Style - YoloDotsBlurred lipgloss.Style - - // oAuth Chooser. - AuthBorderSelected lipgloss.Style - AuthTextSelected lipgloss.Style - AuthBorderUnselected lipgloss.Style - AuthTextUnselected lipgloss.Style - - styles *Styles - stylesOnce sync.Once -} - -type Styles struct { - Base lipgloss.Style - SelectedBase lipgloss.Style - - Title lipgloss.Style - Subtitle lipgloss.Style - Text lipgloss.Style - TextSelected lipgloss.Style - Muted lipgloss.Style - Subtle lipgloss.Style - - Success lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - Info lipgloss.Style - - // Markdown & Chroma - Markdown ansi.StyleConfig - - // Inputs - TextInput textinput.Styles - TextArea textarea.Styles - - // Help - Help help.Styles - - // Diff - Diff diffview.Style - - // FilePicker - FilePicker filepicker.Styles -} - -func (t *Theme) S() *Styles { - t.stylesOnce.Do(func() { - t.styles = t.buildStyles() - }) - return t.styles -} - -func (t *Theme) buildStyles() *Styles { - base := lipgloss.NewStyle(). - Foreground(t.FgBase) - return &Styles{ - Base: base, - - SelectedBase: base.Background(t.Primary), - - Title: base. - Foreground(t.Accent). - Bold(true), - - Subtitle: base. - Foreground(t.Secondary). - Bold(true), - - Text: base, - TextSelected: base.Background(t.Primary).Foreground(t.FgSelected), - - Muted: base.Foreground(t.FgMuted), - - Subtle: base.Foreground(t.FgSubtle), - - Success: base.Foreground(t.Success), - - Error: base.Foreground(t.Error), - - Warning: base.Foreground(t.Warning), - - Info: base.Foreground(t.Info), - - TextInput: textinput.Styles{ - Focused: textinput.StyleState{ - Text: base, - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.Tertiary), - Suggestion: base.Foreground(t.FgSubtle), - }, - Blurred: textinput.StyleState{ - Text: base.Foreground(t.FgMuted), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.FgMuted), - Suggestion: base.Foreground(t.FgSubtle), - }, - Cursor: textinput.CursorStyle{ - Color: t.Secondary, - Shape: tea.CursorBlock, - Blink: true, - }, - }, - TextArea: textarea.Styles{ - Focused: textarea.StyleState{ - Base: base, - Text: base, - LineNumber: base.Foreground(t.FgSubtle), - CursorLine: base, - CursorLineNumber: base.Foreground(t.FgSubtle), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.Tertiary), - }, - Blurred: textarea.StyleState{ - Base: base, - Text: base.Foreground(t.FgMuted), - LineNumber: base.Foreground(t.FgMuted), - CursorLine: base, - CursorLineNumber: base.Foreground(t.FgMuted), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.FgMuted), - }, - Cursor: textarea.CursorStyle{ - Color: t.Secondary, - Shape: tea.CursorBlock, - Blink: true, - }, - }, - - Markdown: ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - // BlockPrefix: "\n", - // BlockSuffix: "\n", - Color: stringPtr(charmtone.Smoke.Hex()), - }, - // Margin: uintPtr(defaultMargin), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: defaultListIndent, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Color: stringPtr(charmtone.Malibu.Hex()), - Bold: boolPtr(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr(charmtone.Zest.Hex()), - BackgroundColor: stringPtr(charmtone.Charple.Hex()), - Bold: boolPtr(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(false), - }, - }, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), - Format: "\n--------\n", - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zinc.Hex()), - Underline: boolPtr(true), - }, - LinkText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(true), - }, - Image: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), - Underline: boolPtr(true), - }, - ImageText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), - Format: "Image: {{.text}} →", - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr(charmtone.Coral.Hex()), - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), - }, - Margin: uintPtr(defaultMargin), - }, - Chroma: &ansi.Chroma{ - Text: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), - }, - Error: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Butter.Hex()), - BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), - }, - Comment: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Oyster.Hex()), - }, - CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bengal.Hex()), - }, - Keyword: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Malibu.Hex()), - }, - KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), - }, - KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), - }, - KeywordType: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guppy.Hex()), - }, - Operator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salmon.Hex()), - }, - Punctuation: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zest.Hex()), - }, - Name: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), - }, - NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), - }, - NameTag: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Mauve.Hex()), - }, - NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Hazy.Hex()), - }, - NameClass: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salt.Hex()), - Underline: boolPtr(true), - Bold: boolPtr(true), - }, - NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Citron.Hex()), - }, - NameFunction: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - }, - LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Julep.Hex()), - }, - LiteralString: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cumin.Hex()), - }, - LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bok.Hex()), - }, - GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Coral.Hex()), - }, - GenericEmph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - }, - GenericStrong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), - }, - Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), - }, - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n ", - }, - }, - - Help: help.Styles{ - ShortKey: base.Foreground(t.FgMuted), - ShortDesc: base.Foreground(t.FgSubtle), - ShortSeparator: base.Foreground(t.Border), - Ellipsis: base.Foreground(t.Border), - FullKey: base.Foreground(t.FgMuted), - FullDesc: base.Foreground(t.FgSubtle), - FullSeparator: base.Foreground(t.Border), - }, - - Diff: diffview.Style{ - DividerLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(t.FgHalfMuted). - Background(t.BgBaseLighter), - Code: lipgloss.NewStyle(). - Foreground(t.FgHalfMuted). - Background(t.BgBaseLighter), - }, - MissingLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Background(t.BgBaseLighter), - Code: lipgloss.NewStyle(). - Background(t.BgBaseLighter), - }, - EqualLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(t.FgMuted). - Background(t.BgBase), - Code: lipgloss.NewStyle(). - Foreground(t.FgMuted). - Background(t.BgBase), - }, - InsertLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#2b322a")), - Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#323931")), - Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#323931")), - }, - DeleteLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#312929")), - Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#383030")), - Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#383030")), - }, - }, - FilePicker: filepicker.Styles{ - DisabledCursor: base.Foreground(t.FgMuted), - Cursor: base.Foreground(t.FgBase), - Symlink: base.Foreground(t.FgSubtle), - Directory: base.Foreground(t.Primary), - File: base.Foreground(t.FgBase), - DisabledFile: base.Foreground(t.FgMuted), - DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted), - Permission: base.Foreground(t.FgMuted), - Selected: base.Background(t.Primary).Foreground(t.FgBase), - FileSize: base.Foreground(t.FgMuted), - EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"), - }, - } -} - -type Manager struct { - themes map[string]*Theme - current *Theme -} - -var ( - defaultManager *Manager - defaultManagerOnce sync.Once -) - -func initDefaultManager() *Manager { - defaultManagerOnce.Do(func() { - defaultManager = newManager() - }) - return defaultManager -} - -func SetDefaultManager(m *Manager) { - defaultManager = m -} - -func DefaultManager() *Manager { - return initDefaultManager() -} - -func CurrentTheme() *Theme { - return initDefaultManager().Current() -} - -func newManager() *Manager { - m := &Manager{ - themes: make(map[string]*Theme), - } - - t := NewCharmtoneTheme() // default theme - m.Register(t) - m.current = m.themes[t.Name] - - return m -} - -func (m *Manager) Register(theme *Theme) { - m.themes[theme.Name] = theme -} - -func (m *Manager) Current() *Theme { - return m.current -} - -func (m *Manager) SetTheme(name string) error { - if theme, ok := m.themes[name]; ok { - m.current = theme - return nil - } - return fmt.Errorf("theme %s not found", name) -} - -func (m *Manager) List() []string { - names := make([]string, 0, len(m.themes)) - for name := range m.themes { - names = append(names, name) - } - return names -} - -// ParseHex converts hex string to color -func ParseHex(hex string) color.Color { - var r, g, b uint8 - fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) - return color.RGBA{R: r, G: g, B: b, A: 255} -} - -// Alpha returns a color with transparency -func Alpha(c color.Color, alpha uint8) color.Color { - r, g, b, _ := c.RGBA() - return color.RGBA{ - R: uint8(r >> 8), - G: uint8(g >> 8), - B: uint8(b >> 8), - A: alpha, - } -} - -// Darken makes a color darker by percentage (0-100) -func Darken(c color.Color, percent float64) color.Color { - r, g, b, a := c.RGBA() - factor := 1.0 - percent/100.0 - return color.RGBA{ - R: uint8(float64(r>>8) * factor), - G: uint8(float64(g>>8) * factor), - B: uint8(float64(b>>8) * factor), - A: uint8(a >> 8), - } -} - -// Lighten makes a color lighter by percentage (0-100) -func Lighten(c color.Color, percent float64) color.Color { - r, g, b, a := c.RGBA() - factor := percent / 100.0 - return color.RGBA{ - R: uint8(min(255, float64(r>>8)+255*factor)), - G: uint8(min(255, float64(g>>8)+255*factor)), - B: uint8(min(255, float64(b>>8)+255*factor)), - A: uint8(a >> 8), - } -} - -func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string { - if input == "" { - return []string{""} - } - t := CurrentTheme() - if len(input) == 1 { - style := t.S().Base.Foreground(color1) - if bold { - style.Bold(true) - } - return []string{style.Render(input)} - } - var clusters []string - gr := uniseg.NewGraphemes(input) - for gr.Next() { - clusters = append(clusters, string(gr.Runes())) - } - - ramp := blendColors(len(clusters), color1, color2) - for i, c := range ramp { - style := t.S().Base.Foreground(c) - if bold { - style.Bold(true) - } - clusters[i] = style.Render(clusters[i]) - } - return clusters -} - -// ApplyForegroundGrad renders a given string with a horizontal gradient -// foreground. -func ApplyForegroundGrad(input string, color1, color2 color.Color) string { - if input == "" { - return "" - } - var o strings.Builder - clusters := ForegroundGrad(input, false, color1, color2) - for _, c := range clusters { - fmt.Fprint(&o, c) - } - return o.String() -} - -// ApplyBoldForegroundGrad renders a given string with a horizontal gradient -// foreground. -func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string { - if input == "" { - return "" - } - var o strings.Builder - clusters := ForegroundGrad(input, true, color1, color2) - for _, c := range clusters { - fmt.Fprint(&o, c) - } - return o.String() -} - -// blendColors returns a slice of colors blended between the given keys. -// Blending is done in Hcl to stay in gamut. -func blendColors(size int, stops ...color.Color) []color.Color { - if len(stops) < 2 { - return nil - } - - stopsPrime := make([]colorful.Color, len(stops)) - for i, k := range stops { - stopsPrime[i], _ = colorful.MakeColor(k) - } - - numSegments := len(stopsPrime) - 1 - blended := make([]color.Color, 0, size) - - // Calculate how many colors each segment should have. - segmentSizes := make([]int, numSegments) - baseSize := size / numSegments - remainder := size % numSegments - - // Distribute the remainder across segments. - for i := range numSegments { - segmentSizes[i] = baseSize - if i < remainder { - segmentSizes[i]++ - } - } - - // Generate colors for each segment. - for i := range numSegments { - c1 := stopsPrime[i] - c2 := stopsPrime[i+1] - segmentSize := segmentSizes[i] - - for j := range segmentSize { - var t float64 - if segmentSize > 1 { - t = float64(j) / float64(segmentSize-1) - } - c := c1.BlendHcl(c2, t) - blended = append(blended, c) - } - } - - return blended -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index e91fae5592b8d51963e524d0662d868cbfed6869..0000000000000000000000000000000000000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,710 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "math/rand" - "regexp" - "slices" - "strings" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/event" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/stringext" - cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/chat/splash" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/status" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions" - "github.com/charmbracelet/crush/internal/tui/page" - "github.com/charmbracelet/crush/internal/tui/page/chat" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "golang.org/x/mod/semver" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -var lastMouseEvent time.Time - -func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { - switch msg.(type) { - case tea.MouseWheelMsg, tea.MouseMotionMsg: - now := time.Now() - // trackpad is sending too many requests - if now.Sub(lastMouseEvent) < 15*time.Millisecond { - return nil - } - lastMouseEvent = now - } - return msg -} - -// appModel represents the main application model that manages pages, dialogs, and UI state. -type appModel struct { - wWidth, wHeight int // Window dimensions - width, height int - keyMap KeyMap - - currentPage page.PageID - previousPage page.PageID - pages map[page.PageID]util.Model - loadedPages map[page.PageID]bool - - // Status - status status.StatusCmp - showingFullHelp bool - - app *app.App - - dialog dialogs.DialogCmp - completions completions.Completions - isConfigured bool - - // Chat Page Specific - selectedSessionID string // The ID of the currently selected session - - // sendProgressBar instructs the TUI to send progress bar updates to the - // terminal. - sendProgressBar bool - - // QueryVersion instructs the TUI to query for the terminal version when it - // starts. - QueryVersion bool -} - -// Init initializes the application model and returns initial commands. -func (a appModel) Init() tea.Cmd { - item, ok := a.pages[a.currentPage] - if !ok { - return nil - } - - var cmds []tea.Cmd - cmd := item.Init() - cmds = append(cmds, cmd) - a.loadedPages[a.currentPage] = true - - cmd = a.status.Init() - cmds = append(cmds, cmd) - if a.QueryVersion { - cmds = append(cmds, tea.RequestTerminalVersion) - } - - return tea.Batch(cmds...) -} - -// Update handles incoming messages and updates the application state. -func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - a.isConfigured = config.HasInitialDataConfig() - - switch msg := msg.(type) { - case tea.EnvMsg: - // Is this Windows Terminal? - if !a.sendProgressBar { - a.sendProgressBar = slices.Contains(msg, "WT_SESSION") - } - case tea.TerminalVersionMsg: - if a.sendProgressBar { - return a, nil - } - termVersion := strings.ToLower(msg.Name) - switch { - case stringext.ContainsAny(termVersion, "ghostty", "rio"): - a.sendProgressBar = true - case strings.Contains(termVersion, "iterm2"): - // iTerm2 supports progress bars from version v3.6.6 - matches := regexp.MustCompile(`^iterm2 (\d+\.\d+\.\d+)$`).FindStringSubmatch(termVersion) - if len(matches) == 2 && semver.Compare("v"+matches[1], "v3.6.6") >= 0 { - a.sendProgressBar = true - } - } - return a, nil - case tea.KeyboardEnhancementsMsg: - // A non-zero value means we have key disambiguation support. - if msg.Flags > 0 { - a.keyMap.Models.SetHelp("ctrl+m", "models") - } - for id, page := range a.pages { - m, pageCmd := page.Update(msg) - a.pages[id] = m - - if pageCmd != nil { - cmds = append(cmds, pageCmd) - } - } - return a, tea.Batch(cmds...) - case tea.WindowSizeMsg: - a.wWidth, a.wHeight = msg.Width, msg.Height - a.completions.Update(msg) - return a, a.handleWindowResize(msg.Width, msg.Height) - - case pubsub.Event[mcp.Event]: - switch msg.Payload.Type { - case mcp.EventStateChanged: - return a, a.handleStateChanged(context.Background()) - case mcp.EventPromptsListChanged: - return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name) - case mcp.EventToolsListChanged: - return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name) - } - - // Completions messages - case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, - completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg: - u, completionCmd := a.completions.Update(msg) - if model, ok := u.(completions.Completions); ok { - a.completions = model - } - - return a, completionCmd - - // Dialog messages - case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: - u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{}) - a.completions = u.(completions.Completions) - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - return a, tea.Batch(completionCmd, dialogCmd) - case commands.ShowArgumentsDialogMsg: - var args []commands.Argument - for _, arg := range msg.ArgNames { - args = append(args, commands.Argument{ - Name: arg, - Title: cases.Title(language.English).String(arg), - Required: true, - }) - } - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: commands.NewCommandArgumentsDialog( - msg.CommandID, - msg.CommandID, - msg.CommandID, - msg.Description, - args, - msg.OnSubmit, - ), - }, - ) - case commands.ShowMCPPromptArgumentsDialogMsg: - args := make([]commands.Argument, 0, len(msg.Prompt.Arguments)) - for _, arg := range msg.Prompt.Arguments { - args = append(args, commands.Argument(*arg)) - } - dialog := commands.NewCommandArgumentsDialog( - msg.Prompt.Name, - msg.Prompt.Title, - msg.Prompt.Name, - msg.Prompt.Description, - args, - msg.OnSubmit, - ) - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: dialog, - }, - ) - // Page change messages - case page.PageChangeMsg: - return a, a.moveToPage(msg.ID) - - // Status Messages - case util.InfoMsg, util.ClearStatusMsg: - s, statusCmd := a.status.Update(msg) - a.status = s.(status.StatusCmp) - cmds = append(cmds, statusCmd) - return a, tea.Batch(cmds...) - - // Session - case cmpChat.SessionSelectedMsg: - a.selectedSessionID = msg.ID - case cmpChat.SessionClearedMsg: - a.selectedSessionID = "" - // Commands - case commands.SwitchSessionsMsg: - return a, func() tea.Msg { - allSessions, _ := a.app.Sessions.List(context.Background()) - return dialogs.OpenDialogMsg{ - Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), - } - } - - case commands.SwitchModelMsg: - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: models.NewModelDialogCmp(), - }, - ) - // Compact - case commands.CompactMsg: - return a, func() tea.Msg { - err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID) - if err != nil { - return util.ReportError(err)() - } - return nil - } - case commands.QuitMsg: - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: quit.NewQuitDialog(), - }) - case commands.ToggleYoloModeMsg: - a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests()) - case commands.ToggleHelpMsg: - a.status.ToggleFullHelp() - a.showingFullHelp = !a.showingFullHelp - return a, a.handleWindowResize(a.wWidth, a.wHeight) - // Model Switch - case models.ModelSelectedMsg: - if a.app.AgentCoordinator.IsBusy() { - return a, util.ReportWarn("Agent is busy, please wait...") - } - - cfg := config.Get() - if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { - return a, util.ReportError(err) - } - - go a.app.UpdateAgentModel(context.TODO()) - - modelTypeName := "large" - if msg.ModelType == config.SelectedModelTypeSmall { - modelTypeName = "small" - } - return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model)) - - // File Picker - case commands.OpenFilePickerMsg: - event.FilePickerOpened() - - if a.dialog.ActiveDialogID() == filepicker.FilePickerID { - // If the commands dialog is already open, close it - return a, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()), - }) - // Permissions - case pubsub.Event[permission.PermissionNotification]: - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - // Forward to view. - updated, itemCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - return a, itemCmd - case pubsub.Event[permission.PermissionRequest]: - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{ - DiffMode: config.Get().Options.TUI.DiffMode, - }), - }) - case permissions.PermissionResponseMsg: - switch msg.Action { - case permissions.PermissionAllow: - a.app.Permissions.Grant(msg.Permission) - case permissions.PermissionAllowForSession: - a.app.Permissions.GrantPersistent(msg.Permission) - case permissions.PermissionDeny: - a.app.Permissions.Deny(msg.Permission) - } - return a, nil - case splash.OnboardingCompleteMsg: - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - a.isConfigured = config.HasInitialDataConfig() - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - return a, tea.Batch(cmds...) - - case tea.KeyPressMsg: - return a, a.handleKeyPressMsg(msg) - - case tea.MouseWheelMsg: - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - cmds = append(cmds, dialogCmd) - } else { - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - } - return a, tea.Batch(cmds...) - case tea.PasteMsg: - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - if model, ok := u.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, dialogCmd) - } else { - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - } - return a, tea.Batch(cmds...) - // Update Available - case pubsub.UpdateAvailableMsg: - // Show update notification in status bar - statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion) - if msg.IsDevelopment { - statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion) - } - s, statusCmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeUpdate, - Msg: statusMsg, - TTL: 10 * time.Second, - }) - a.status = s.(status.StatusCmp) - return a, statusCmd - } - s, _ := a.status.Update(msg) - a.status = s.(status.StatusCmp) - - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, cmd := item.Update(msg) - a.pages[a.currentPage] = updated - - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - if model, ok := u.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, dialogCmd) - } - cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) -} - -// handleWindowResize processes window resize events and updates all components. -func (a *appModel) handleWindowResize(width, height int) tea.Cmd { - var cmds []tea.Cmd - - // TODO: clean up these magic numbers. - if a.showingFullHelp { - height -= 5 - } else { - height -= 2 - } - - a.width, a.height = width, height - // Update status bar - s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height}) - if model, ok := s.(status.StatusCmp); ok { - a.status = model - } - cmds = append(cmds, cmd) - - // Update the current view. - for p, page := range a.pages { - updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height}) - a.pages[p] = updated - - cmds = append(cmds, pageCmd) - } - - // Update the dialogs - dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height}) - if model, ok := dialog.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, cmd) - - return tea.Batch(cmds...) -} - -// handleKeyPressMsg processes keyboard input and routes to appropriate handlers. -func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { - // Check this first as the user should be able to quit no matter what. - if key.Matches(msg, a.keyMap.Quit) { - if a.dialog.ActiveDialogID() == quit.QuitDialogID { - return tea.Quit - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: quit.NewQuitDialog(), - }) - } - - if a.completions.Open() { - // completions - keyMap := a.completions.KeyMap() - switch { - case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down), - key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel), - key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert): - u, cmd := a.completions.Update(msg) - a.completions = u.(completions.Completions) - return cmd - } - } - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - return dialogCmd - } - switch { - // help - case key.Matches(msg, a.keyMap.Help): - a.status.ToggleFullHelp() - a.showingFullHelp = !a.showingFullHelp - return a.handleWindowResize(a.wWidth, a.wHeight) - // dialogs - case key.Matches(msg, a.keyMap.Commands): - // if the app is not configured show no commands - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == commands.CommandsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() { - return nil - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: commands.NewCommandDialog(a.selectedSessionID), - }) - case key.Matches(msg, a.keyMap.Models): - // if the app is not configured show no models - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == models.ModelsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() { - return nil - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: models.NewModelDialogCmp(), - }) - case key.Matches(msg, a.keyMap.Sessions): - // if the app is not configured show no sessions - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == sessions.SessionsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID { - return nil - } - var cmds []tea.Cmd - cmds = append(cmds, - func() tea.Msg { - allSessions, _ := a.app.Sessions.List(context.Background()) - return dialogs.OpenDialogMsg{ - Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), - } - }, - ) - return tea.Sequence(cmds...) - case key.Matches(msg, a.keyMap.Suspend): - if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() { - return util.ReportWarn("Agent is busy, please wait...") - } - return tea.Suspend - default: - item, ok := a.pages[a.currentPage] - if !ok { - return nil - } - - updated, cmd := item.Update(msg) - a.pages[a.currentPage] = updated - return cmd - } -} - -// moveToPage handles navigation between different pages in the application. -func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { - if a.app.AgentCoordinator.IsBusy() { - // TODO: maybe remove this : For now we don't move to any page if the agent is busy - return util.ReportWarn("Agent is busy, please wait...") - } - - var cmds []tea.Cmd - if _, ok := a.loadedPages[pageID]; !ok { - cmd := a.pages[pageID].Init() - cmds = append(cmds, cmd) - a.loadedPages[pageID] = true - } - a.previousPage = a.currentPage - a.currentPage = pageID - if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok { - cmd := sizable.SetSize(a.width, a.height) - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// View renders the complete application interface including pages, dialogs, and overlays. -func (a *appModel) View() tea.View { - var view tea.View - t := styles.CurrentTheme() - view.AltScreen = true - view.MouseMode = tea.MouseModeCellMotion - view.BackgroundColor = t.BgBase - if a.wWidth < 25 || a.wHeight < 15 { - view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight). - Align(lipgloss.Center, lipgloss.Center). - Render(t.S().Base. - Padding(1, 4). - Foreground(t.White). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(t.Primary). - Render("Window too small!"), - ) - return view - } - - page := a.pages[a.currentPage] - if withHelp, ok := page.(core.KeyMapHelp); ok { - a.status.SetKeyMap(withHelp.Help()) - } - pageView := page.View() - components := []string{ - pageView, - } - components = append(components, a.status.View()) - - appView := lipgloss.JoinVertical(lipgloss.Top, components...) - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(appView), - } - if a.dialog.HasDialogs() { - layers = append( - layers, - a.dialog.GetLayers()..., - ) - } - - var cursor *tea.Cursor - if v, ok := page.(util.Cursor); ok { - cursor = v.Cursor() - // Hide the cursor if it's positioned outside the textarea - statusHeight := a.height - strings.Count(pageView, "\n") + 1 - if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding - cursor = nil - } - } - activeView := a.dialog.ActiveModel() - if activeView != nil { - cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor - if v, ok := activeView.(util.Cursor); ok { - cursor = v.Cursor() - } - } - - if a.completions.Open() && cursor != nil { - cmp := a.completions.View() - x, y := a.completions.Position() - layers = append( - layers, - lipgloss.NewLayer(cmp).X(x).Y(y), - ) - } - - comp := lipgloss.NewCompositor(layers...) - view.Content = comp.Render() - view.Cursor = cursor - - if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() { - // HACK: use a random percentage to prevent ghostty from hiding it - // after a timeout. - view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) - } - return view -} - -func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd { - return func() tea.Msg { - a.app.UpdateAgentModel(ctx) - return nil - } -} - -func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd { - return func() tea.Msg { - mcp.RefreshPrompts(ctx, name) - return nil - } -} - -func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd { - return func() tea.Msg { - mcp.RefreshTools(ctx, name) - return nil - } -} - -// New creates and initializes a new TUI application model. -func New(app *app.App) *appModel { - chatPage := chat.New(app) - keyMap := DefaultKeyMap() - keyMap.pageBindings = chatPage.Bindings() - - model := &appModel{ - currentPage: chat.ChatPageID, - app: app, - status: status.NewStatusCmp(), - loadedPages: make(map[page.PageID]bool), - keyMap: keyMap, - - pages: map[page.PageID]util.Model{ - chat.ChatPageID: chatPage, - }, - - dialog: dialogs.NewDialogCmp(), - completions: completions.New(), - } - - return model -} diff --git a/internal/tui/util/shell.go b/internal/tui/util/shell.go deleted file mode 100644 index 7bf30e2640e79a80291077faa5134a9eea28a87b..0000000000000000000000000000000000000000 --- a/internal/tui/util/shell.go +++ /dev/null @@ -1,15 +0,0 @@ -package util - -import ( - "context" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/uiutil" -) - -// ExecShell parses a shell command string and executes it with exec.Command. -// Uses shell.Fields for proper handling of shell syntax like quotes and -// arguments while preserving TTY handling for terminal editors. -func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd { - return uiutil.ExecShell(ctx, cmdStr, callback) -} diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go deleted file mode 100644 index 5df57c11cc4491b25a048c7437057408f1e9c30f..0000000000000000000000000000000000000000 --- a/internal/tui/util/util.go +++ /dev/null @@ -1,45 +0,0 @@ -package util - -import ( - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/uiutil" -) - -type Cursor = uiutil.Cursor - -type Model interface { - Init() tea.Cmd - Update(tea.Msg) (Model, tea.Cmd) - View() string -} - -func CmdHandler(msg tea.Msg) tea.Cmd { - return uiutil.CmdHandler(msg) -} - -func ReportError(err error) tea.Cmd { - return uiutil.ReportError(err) -} - -type InfoType = uiutil.InfoType - -const ( - InfoTypeInfo = uiutil.InfoTypeInfo - InfoTypeSuccess = uiutil.InfoTypeSuccess - InfoTypeWarn = uiutil.InfoTypeWarn - InfoTypeError = uiutil.InfoTypeError - InfoTypeUpdate = uiutil.InfoTypeUpdate -) - -func ReportInfo(info string) tea.Cmd { - return uiutil.ReportInfo(info) -} - -func ReportWarn(warn string) tea.Cmd { - return uiutil.ReportWarn(warn) -} - -type ( - InfoMsg = uiutil.InfoMsg - ClearStatusMsg = uiutil.ClearStatusMsg -) diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..9bb2ceaf20da8b75df3a40390111b2a8be7f94c2 --- /dev/null +++ b/internal/ui/AGENTS.md @@ -0,0 +1,61 @@ +# UI Development Instructions + +## General Guidelines +- Never use commands to send messages when you can directly mutate children or state. +- Keep things simple; do not overcomplicate. +- Create files if needed to separate logic; do not nest models. +- Never do IO or expensive work in `Update`; always use a `tea.Cmd`. +- Never change the model state inside of a command use messages and than update the state in the main loop + +## Architecture + +### Main Model (`model/ui.go`) +Keep most of the logic and state in the main model. This is where: +- Message routing happens +- Focus and UI state is managed +- Layout calculations are performed +- Dialogs are orchestrated + +### Components Should Be Dumb +Components should not handle bubbletea messages directly. Instead: +- Expose methods for state changes +- Return `tea.Cmd` from methods when side effects are needed +- Handle their own rendering via `Render(width int) string` + +### Chat Logic (`model/chat.go`) +Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`). + +## Key Patterns + +### Composition Over Inheritance +Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus. + +### Interfaces +- List item interfaces are in `list/item.go` +- Chat message interfaces are in `chat/messages.go` +- Dialog interface is in `dialog/dialog.go` + +### Styling +- All styles are defined in `styles/styles.go` +- Access styles via `*common.Common` passed to components +- Use semantic color fields rather than hardcoded colors + +### Dialogs +- Implement the dialog interface in `dialog/dialog.go` +- Return message types from `Update()` to signal actions to the main model +- Use the overlay system for managing dialog lifecycle + +## File Organization +- `model/` - Main UI model and major components (chat, sidebar, etc.) +- `chat/` - Chat message item types and renderers +- `dialog/` - Dialog implementations +- `list/` - Generic list component with lazy rendering +- `common/` - Shared utilities and the Common struct +- `styles/` - All style definitions +- `anim/` - Animation system +- `logo/` - Logo rendering + +## Common Gotchas +- Always account for padding/borders in width calculations +- Use `tea.Batch()` when returning multiple commands +- Pass `*common.Common` to components that need styles or app access diff --git a/internal/tui/components/anim/anim.go b/internal/ui/anim/anim.go similarity index 91% rename from internal/tui/components/anim/anim.go rename to internal/ui/anim/anim.go index 1ffa8074b09afb201a4238c848f3d289450173ce..3e159b102324a68bb93b8f9cbd3e128bf60dcf0f 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/ui/anim/anim.go @@ -16,7 +16,6 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/tui/util" ) const ( @@ -85,10 +84,11 @@ func settingsHash(opts Settings) string { } // StepMsg is a message type used to trigger the next step in the animation. -type StepMsg struct{ id int } +type StepMsg struct{ ID string } // Settings defines settings for the animation. type Settings struct { + ID string Size int Label string LabelColor color.Color @@ -115,7 +115,7 @@ type Anim struct { step atomic.Int64 // current main frame step ellipsisStep atomic.Int64 // current ellipsis frame step ellipsisFrames *csync.Slice[string] // ellipsis animation frames - id int + id string } // New creates a new Anim instance with the specified width and label. @@ -135,7 +135,11 @@ func New(opts Settings) *Anim { opts.LabelColor = defaultLabelColor } - a.id = nextID() + if opts.ID != "" { + a.id = opts.ID + } else { + a.id = fmt.Sprintf("%d", nextID()) + } a.startTime = time.Now() a.cyclingCharWidth = opts.Size a.labelColor = opts.LabelColor @@ -313,42 +317,36 @@ func (a *Anim) Width() (w int) { return w } -// Init starts the animation. -func (a *Anim) Init() tea.Cmd { +// Start starts the animation. +func (a *Anim) Start() tea.Cmd { return a.Step() } -// Update processes animation steps (or not). -func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case StepMsg: - if msg.id != a.id { - // Reject messages that are not for this instance. - return a, nil - } +// Animate advances the animation to the next step. +func (a *Anim) Animate(msg StepMsg) tea.Cmd { + if msg.ID != a.id { + return nil + } - step := a.step.Add(1) - if int(step) >= len(a.cyclingFrames) { - a.step.Store(0) - } + step := a.step.Add(1) + if int(step) >= len(a.cyclingFrames) { + a.step.Store(0) + } - if a.initialized.Load() && a.labelWidth > 0 { - // Manage the ellipsis animation. - ellipsisStep := a.ellipsisStep.Add(1) - if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { - a.ellipsisStep.Store(0) - } - } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { - a.initialized.Store(true) + if a.initialized.Load() && a.labelWidth > 0 { + // Manage the ellipsis animation. + ellipsisStep := a.ellipsisStep.Add(1) + if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { + a.ellipsisStep.Store(0) } - return a, a.Step() - default: - return a, nil + } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { + a.initialized.Store(true) } + return a.Step() } -// View renders the current state of the animation. -func (a *Anim) View() string { +// Render renders the current state of the animation. +func (a *Anim) Render() string { var b strings.Builder step := int(a.step.Load()) for i := range a.width { @@ -384,7 +382,7 @@ func (a *Anim) View() string { // Step is a command that triggers the next step in the animation. func (a *Anim) Step() tea.Cmd { return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { - return StepMsg{id: a.id} + return StepMsg{ID: a.id} }) } diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go new file mode 100644 index 0000000000000000000000000000000000000000..558c7576ee1edb3756be3dc7b4ccfcb89a5597b7 --- /dev/null +++ b/internal/ui/attachments/attachments.go @@ -0,0 +1,135 @@ +package attachments + +import ( + "fmt" + "math" + "path/filepath" + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/x/ansi" +) + +const maxFilename = 15 + +type Keymap struct { + DeleteMode, + DeleteAll, + Escape key.Binding +} + +func New(renderer *Renderer, keyMap Keymap) *Attachments { + return &Attachments{ + keyMap: keyMap, + renderer: renderer, + } +} + +type Attachments struct { + renderer *Renderer + keyMap Keymap + list []message.Attachment + deleting bool +} + +func (m *Attachments) List() []message.Attachment { return m.list } +func (m *Attachments) Reset() { m.list = nil } + +func (m *Attachments) Update(msg tea.Msg) bool { + switch msg := msg.(type) { + case message.Attachment: + m.list = append(m.list, msg) + return true + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.DeleteMode): + if len(m.list) > 0 { + m.deleting = true + } + return true + case m.deleting && key.Matches(msg, m.keyMap.Escape): + m.deleting = false + return true + case m.deleting && key.Matches(msg, m.keyMap.DeleteAll): + m.deleting = false + m.list = nil + return true + case m.deleting: + // Handle digit keys for individual attachment deletion. + r := msg.Code + if r >= '0' && r <= '9' { + num := int(r - '0') + if num < len(m.list) { + m.list = slices.Delete(m.list, num, num+1) + } + m.deleting = false + } + return true + } + } + return false +} + +func (m *Attachments) Render(width int) string { + return m.renderer.Render(m.list, m.deleting, width) +} + +func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer { + return &Renderer{ + normalStyle: normalStyle, + textStyle: textStyle, + imageStyle: imageStyle, + deletingStyle: deletingStyle, + } +} + +type Renderer struct { + normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style +} + +func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string { + var chips []string + + maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename))) + fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1 + + for i, att := range attachments { + filename := filepath.Base(att.FileName) + // Truncate if needed. + if ansi.StringWidth(filename) > maxFilename { + filename = ansi.Truncate(filename, maxFilename, "…") + } + + if deleting { + chips = append( + chips, + r.deletingStyle.Render(fmt.Sprintf("%d", i)), + r.normalStyle.Render(filename), + ) + } else { + chips = append( + chips, + r.icon(att).String(), + r.normalStyle.Render(filename), + ) + } + + if i == fits && len(attachments) > i { + chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits))) + break + } + } + + return lipgloss.JoinHorizontal(lipgloss.Left, chips...) +} + +func (r *Renderer) icon(a message.Attachment) lipgloss.Style { + if a.IsImage() { + return r.imageStyle + } + return r.textStyle +} diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..c2a439ff23d0bd046b75076ea30de68b60cdcc54 --- /dev/null +++ b/internal/ui/chat/agent.go @@ -0,0 +1,302 @@ +package chat + +import ( + "encoding/json" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Agent Tool +// ----------------------------------------------------------------------------- + +// NestedToolContainer is an interface for tool items that can contain nested tool calls. +type NestedToolContainer interface { + NestedTools() []ToolMessageItem + SetNestedTools(tools []ToolMessageItem) + AddNestedTool(tool ToolMessageItem) +} + +// AgentToolMessageItem is a message item that represents an agent tool call. +type AgentToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgentToolMessageItem)(nil) + _ NestedToolContainer = (*AgentToolMessageItem)(nil) +) + +// NewAgentToolMessageItem creates a new [AgentToolMessageItem]. +func NewAgentToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgentToolMessageItem { + t := &AgentToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) + // For the agent tool we keep spinning until the tool call is finished. + t.spinningFunc = func(state SpinningState) bool { + return !state.HasResult() && !state.IsCanceled() + } + return t +} + +// Animate progresses the message animation if it should be spinning. +func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if a.result != nil || a.Status() == ToolStatusCanceled { + return nil + } + if msg.ID == a.ID() { + return a.anim.Animate(msg) + } + for _, nestedTool := range a.nestedTools { + if msg.ID != nestedTool.ID() { + continue + } + if s, ok := nestedTool.(Animatable); ok { + return s.Animate(msg) + } + } + return nil +} + +// NestedTools returns the nested tools. +func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgentToolRenderContext renders agent tool messages. +type AgentToolRenderContext struct { + agent *AgentToolMessageItem +} + +// RenderTool implements the [ToolRenderer] interface. +func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { + return pendingTool(sty, "Agent", opts.Anim) + } + + var params agent.AgentParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) + if opts.Compact { + return header + } + + // Build the task tag and prompt. + taskTag := sty.Tool.AgentTaskTag.Render("Task") + taskTagWidth := lipgloss.Width(taskTag) + + // Calculate remaining width for prompt. + remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing + + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + taskTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.agent.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String()) + + // Show animation if still running. + if !opts.HasResult() && !opts.IsCanceled() { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.HasResult() && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} + +// ----------------------------------------------------------------------------- +// Agentic Fetch Tool +// ----------------------------------------------------------------------------- + +// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call. +type AgenticFetchToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil) + _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil) +) + +// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem]. +func NewAgenticFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgenticFetchToolMessageItem { + t := &AgenticFetchToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) + // For the agentic fetch tool we keep spinning until the tool call is finished. + t.spinningFunc = func(state SpinningState) bool { + return !state.HasResult() && !state.IsCanceled() + } + return t +} + +// NestedTools returns the nested tools. +func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgenticFetchToolRenderContext renders agentic fetch tool messages. +type AgenticFetchToolRenderContext struct { + fetch *AgenticFetchToolMessageItem +} + +// agenticFetchParams matches tools.AgenticFetchParams. +type agenticFetchParams struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt"` +} + +// RenderTool implements the [ToolRenderer] interface. +func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { + return pendingTool(sty, "Agentic Fetch", opts.Anim) + } + + var params agenticFetchParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + // Build header with optional URL param. + toolParams := []string{} + if params.URL != "" { + toolParams = append(toolParams, params.URL) + } + + header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + // Build the prompt tag. + promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt") + promptTagWidth := lipgloss.Width(promptTag) + + // Calculate remaining width for prompt text. + remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing + + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + promptTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.fetch.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String()) + + // Show animation if still running. + if !opts.HasResult() && !opts.IsCanceled() { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.HasResult() && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go new file mode 100644 index 0000000000000000000000000000000000000000..4ce71dda2515e5489900c33eb716e1d6d884409a --- /dev/null +++ b/internal/ui/chat/assistant.go @@ -0,0 +1,266 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// assistantMessageTruncateFormat is the text shown when an assistant message is +// truncated. +const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]" + +// maxCollapsedThinkingHeight defines the maximum height of the thinking +const maxCollapsedThinkingHeight = 10 + +// AssistantMessageItem represents an assistant message in the chat UI. +// +// This item includes thinking, and the content but does not include the tool calls. +type AssistantMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + message *message.Message + sty *styles.Styles + anim *anim.Anim + thinkingExpanded bool + thinkingBoxHeight int // Tracks the rendered thinking box height for click detection. +} + +// NewAssistantMessageItem creates a new AssistantMessageItem. +func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem { + a := &AssistantMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + message: message, + sty: sty, + } + + a.anim = anim.New(anim.Settings{ + ID: a.ID(), + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + return a +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (a *AssistantMessageItem) StartAnimation() tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Animate(msg) +} + +// ID implements MessageItem. +func (a *AssistantMessageItem) ID() string { + return a.message.ID +} + +// RawRender implements [MessageItem]. +func (a *AssistantMessageItem) RawRender(width int) string { + cappedWidth := cappedMessageWidth(width) + + var spinner string + if a.isSpinning() { + spinner = a.renderSpinning() + } + + content, height, ok := a.getCachedRender(cappedWidth) + if !ok { + content = a.renderMessageContent(cappedWidth) + height = lipgloss.Height(content) + // cache the rendered content + a.setCachedRender(content, cappedWidth, height) + } + + highlightedContent := a.renderHighlighted(content, cappedWidth, height) + if spinner != "" { + if highlightedContent != "" { + highlightedContent += "\n\n" + } + return highlightedContent + spinner + } + + return highlightedContent +} + +// Render implements MessageItem. +func (a *AssistantMessageItem) Render(width int) string { + style := a.sty.Chat.Message.AssistantBlurred + if a.focused { + style = a.sty.Chat.Message.AssistantFocused + } + return style.Render(a.RawRender(width)) +} + +// renderMessageContent renders the message content including thinking, main content, and finish reason. +func (a *AssistantMessageItem) renderMessageContent(width int) string { + var messageParts []string + thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking) + content := strings.TrimSpace(a.message.Content().Text) + // if the massage has reasoning content add that first + if thinking != "" { + messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width)) + } + + // then add the main content + if content != "" { + // add a spacer between thinking and content + if thinking != "" { + messageParts = append(messageParts, "") + } + messageParts = append(messageParts, a.renderMarkdown(content, width)) + } + + // finally add any finish reason info + if a.message.IsFinished() { + switch a.message.FinishReason() { + case message.FinishReasonCanceled: + messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled")) + case message.FinishReasonError: + messageParts = append(messageParts, a.renderError(width)) + } + } + + return strings.Join(messageParts, "\n") +} + +// renderThinking renders the thinking/reasoning content with footer. +func (a *AssistantMessageItem) renderThinking(thinking string, width int) string { + renderer := common.PlainMarkdownRenderer(a.sty, width) + rendered, err := renderer.Render(thinking) + if err != nil { + rendered = thinking + } + rendered = strings.TrimSpace(rendered) + + lines := strings.Split(rendered, "\n") + totalLines := len(lines) + + isTruncated := totalLines > maxCollapsedThinkingHeight + if !a.thinkingExpanded && isTruncated { + lines = lines[totalLines-maxCollapsedThinkingHeight:] + hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( + fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight), + ) + lines = append([]string{hint, ""}, lines...) + } + + thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width) + result := thinkingStyle.Render(strings.Join(lines, "\n")) + a.thinkingBoxHeight = lipgloss.Height(result) + + var footer string + // if thinking is done add the thought for footer + if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 { + duration := a.message.ThinkingDuration() + if duration.String() != "0s" { + footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") + + a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String()) + } + } + + if footer != "" { + result += "\n\n" + footer + } + + return result +} + +// renderMarkdown renders content as markdown. +func (a *AssistantMessageItem) renderMarkdown(content string, width int) string { + renderer := common.MarkdownRenderer(a.sty, width) + result, err := renderer.Render(content) + if err != nil { + return content + } + return strings.TrimSuffix(result, "\n") +} + +func (a *AssistantMessageItem) renderSpinning() string { + if a.message.IsThinking() { + a.anim.SetLabel("Thinking") + } else if a.message.IsSummaryMessage { + a.anim.SetLabel("Summarizing") + } + return a.anim.Render() +} + +// renderError renders an error message. +func (a *AssistantMessageItem) renderError(width int) string { + finishPart := a.message.FinishPart() + errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR") + truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...") + title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated)) + details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details) + return fmt.Sprintf("%s\n\n%s", title, details) +} + +// isSpinning returns true if the assistant message is still generating. +func (a *AssistantMessageItem) isSpinning() bool { + isThinking := a.message.IsThinking() + isFinished := a.message.IsFinished() + hasContent := strings.TrimSpace(a.message.Content().Text) != "" + hasToolCalls := len(a.message.ToolCalls()) > 0 + return (isThinking || !isFinished) && !hasContent && !hasToolCalls +} + +// SetMessage is used to update the underlying message. +func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd { + wasSpinning := a.isSpinning() + a.message = message + a.clearCache() + if !wasSpinning && a.isSpinning() { + return a.StartAnimation() + } + return nil +} + +// ToggleExpanded toggles the expanded state of the thinking box. +func (a *AssistantMessageItem) ToggleExpanded() { + a.thinkingExpanded = !a.thinkingExpanded + a.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + // check if the click is within the thinking box + if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight { + a.ToggleExpanded() + return true + } + return false +} + +// HandleKeyEvent implements KeyEventHandler. +func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if k := key.String(); k == "c" || k == "y" { + text := a.message.Content().Text + return true, common.CopyToClipboard(text, "Message copied to clipboard") + } + return false, nil +} diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go new file mode 100644 index 0000000000000000000000000000000000000000..18be27ee01b4fcc21749789fc65ec0b71c2b0d4b --- /dev/null +++ b/internal/ui/chat/bash.go @@ -0,0 +1,248 @@ +package chat + +import ( + "cmp" + "encoding/json" + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// ----------------------------------------------------------------------------- +// Bash Tool +// ----------------------------------------------------------------------------- + +// BashToolMessageItem is a message item that represents a bash tool call. +type BashToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*BashToolMessageItem)(nil) + +// NewBashToolMessageItem creates a new [BashToolMessageItem]. +func NewBashToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled) +} + +// BashToolRenderContext renders bash tool messages. +type BashToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Bash", opts.Anim) + } + + var params tools.BashParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + params.Command = "failed to parse command" + } + + // Check if this is a background job. + var meta tools.BashResponseMetadata + if opts.HasResult() { + _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + } + + if meta.Background { + description := cmp.Or(meta.Description, params.Command) + content := "Command: " + params.Command + "\n" + opts.Result.Content + return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content) + } + + // Regular bash command. + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + toolParams := []string{cmd} + if params.RunInBackground { + toolParams = append(toolParams, "background", "true") + } + + header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + output := meta.Output + if output == "" && opts.Result.Content != tools.BashNoOutput { + output = opts.Result.Content + } + if output == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Job Output Tool +// ----------------------------------------------------------------------------- + +// JobOutputToolMessageItem is a message item for job_output tool calls. +type JobOutputToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil) + +// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem]. +func NewJobOutputToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled) +} + +// JobOutputToolRenderContext renders job_output tool messages. +type JobOutputToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobOutputParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.HasResult() && opts.Result.Metadata != "" { + var meta tools.JobOutputResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.HasResult() { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) +} + +// ----------------------------------------------------------------------------- +// Job Kill Tool +// ----------------------------------------------------------------------------- + +// JobKillToolMessageItem is a message item for job_kill tool calls. +type JobKillToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobKillToolMessageItem)(nil) + +// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem]. +func NewJobKillToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled) +} + +// JobKillToolRenderContext renders job_kill tool messages. +type JobKillToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobKillParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.HasResult() && opts.Result.Metadata != "" { + var meta tools.JobKillResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.HasResult() { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) +} + +// renderJobTool renders a job-related tool with the common pattern: +// header → nested check → early state → body. +func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { + header := jobHeader(sty, opts.Status, action, shellID, description, width) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if content == "" { + return header + } + + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// jobHeader builds a header for job-related tools. +// Format: "● Job (Action) PID shellID description..." +func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string { + icon := toolIcon(sty, status) + jobPart := sty.Tool.JobToolName.Render("Job") + actionPart := sty.Tool.JobAction.Render("(" + action + ")") + pidPart := sty.Tool.JobPID.Render("PID " + shellID) + + prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart) + + if description == "" { + return prefix + } + + prefixWidth := lipgloss.Width(prefix) + availableWidth := width - prefixWidth - 1 + if availableWidth < 10 { + return prefix + } + + truncatedDesc := ansi.Truncate(description, availableWidth, "…") + return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc) +} + +// joinToolParts joins header and body with a blank line separator. +func joinToolParts(header, body string) string { + return strings.Join([]string{header, "", body}, "\n") +} diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go new file mode 100644 index 0000000000000000000000000000000000000000..68d2ac4a00dc880c27904468008fb8f6b2fcf9c5 --- /dev/null +++ b/internal/ui/chat/diagnostics.go @@ -0,0 +1,68 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Diagnostics Tool +// ----------------------------------------------------------------------------- + +// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call. +type DiagnosticsToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil) + +// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem]. +func NewDiagnosticsToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled) +} + +// DiagnosticsToolRenderContext renders diagnostics tool messages. +type DiagnosticsToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Diagnostics", opts.Anim) + } + + var params tools.DiagnosticsParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + // Show "project" if no file path, otherwise show the file path. + mainParam := "project" + if params.FilePath != "" { + mainParam = fsext.PrettyPath(params.FilePath) + } + + header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..e3f3a809550385dfd0ec557e98151ffc731acc93 --- /dev/null +++ b/internal/ui/chat/fetch.go @@ -0,0 +1,192 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Fetch Tool +// ----------------------------------------------------------------------------- + +// FetchToolMessageItem is a message item that represents a fetch tool call. +type FetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*FetchToolMessageItem)(nil) + +// NewFetchToolMessageItem creates a new [FetchToolMessageItem]. +func NewFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled) +} + +// FetchToolRenderContext renders fetch tool messages. +type FetchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Fetch", opts.Anim) + } + + var params tools.FetchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.Format != "" { + toolParams = append(toolParams, "format", params.Format) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + // Determine file extension for syntax highlighting based on format. + file := getFileExtensionForFormat(params.Format) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting. +func getFileExtensionForFormat(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} + +// ----------------------------------------------------------------------------- +// WebFetch Tool +// ----------------------------------------------------------------------------- + +// WebFetchToolMessageItem is a message item that represents a web_fetch tool call. +type WebFetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil) + +// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem]. +func NewWebFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled) +} + +// WebFetchToolRenderContext renders web_fetch tool messages. +type WebFetchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Fetch", opts.Anim) + } + + var params tools.WebFetchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// WebSearch Tool +// ----------------------------------------------------------------------------- + +// WebSearchToolMessageItem is a message item that represents a web_search tool call. +type WebSearchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil) + +// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem]. +func NewWebSearchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled) +} + +// WebSearchToolRenderContext renders web_search tool messages. +type WebSearchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Search", opts.Anim) + } + + var params tools.WebSearchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Query} + header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go new file mode 100644 index 0000000000000000000000000000000000000000..d558f79d597871bf6074d33c76b44549ee6725d5 --- /dev/null +++ b/internal/ui/chat/file.go @@ -0,0 +1,340 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// View Tool +// ----------------------------------------------------------------------------- + +// ViewToolMessageItem is a message item that represents a view tool call. +type ViewToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*ViewToolMessageItem)(nil) + +// NewViewToolMessageItem creates a new [ViewToolMessageItem]. +func NewViewToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled) +} + +// ViewToolRenderContext renders view tool messages. +type ViewToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "View", opts.Anim) + } + + var params tools.ViewParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if params.Limit != 0 { + toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) + } + if params.Offset != 0 { + toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) + } + + header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Handle image content. + if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { + body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType) + return joinToolParts(header, body) + } + + // Try to get content from metadata first (contains actual file content). + var meta tools.ViewResponseMetadata + content := opts.Result.Content + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" { + content = meta.Content + } + + if content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Write Tool +// ----------------------------------------------------------------------------- + +// WriteToolMessageItem is a message item that represents a write tool call. +type WriteToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WriteToolMessageItem)(nil) + +// NewWriteToolMessageItem creates a new [WriteToolMessageItem]. +func NewWriteToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled) +} + +// WriteToolRenderContext renders write tool messages. +type WriteToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Write", opts.Anim) + } + + var params tools.WriteParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if params.Content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Edit Tool +// ----------------------------------------------------------------------------- + +// EditToolMessageItem is a message item that represents an edit tool call. +type EditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*EditToolMessageItem)(nil) + +// NewEditToolMessageItem creates a new [EditToolMessageItem]. +func NewEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled) +} + +// EditToolRenderContext renders edit tool messages. +type EditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // Edit tool uses full width for diffs. + if opts.IsPending() { + return pendingTool(sty, "Edit", opts.Anim) + } + + var params tools.EditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Get diff content from metadata. + var meta tools.EditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) + } + + // Render diff. + body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// MultiEdit Tool +// ----------------------------------------------------------------------------- + +// MultiEditToolMessageItem is a message item that represents a multi-edit tool call. +type MultiEditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil) + +// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem]. +func NewMultiEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled) +} + +// MultiEditToolRenderContext renders multi-edit tool messages. +type MultiEditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // MultiEdit tool uses full width for diffs. + if opts.IsPending() { + return pendingTool(sty, "Multi-Edit", opts.Anim) + } + + var params tools.MultiEditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if len(params.Edits) > 0 { + toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) + } + + header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Get diff content from metadata. + var meta tools.MultiEditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) + } + + // Render diff with optional failed edits note. + body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Download Tool +// ----------------------------------------------------------------------------- + +// DownloadToolMessageItem is a message item that represents a download tool call. +type DownloadToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DownloadToolMessageItem)(nil) + +// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem]. +func NewDownloadToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled) +} + +// DownloadToolRenderContext renders download tool messages. +type DownloadToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Download", opts.Anim) + } + + var params tools.DownloadParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.FilePath != "" { + toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath)) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} 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/lsp_restart.go b/internal/ui/chat/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..66c316fcaf7c949711babeb9ebe864e558ae5bc0 --- /dev/null +++ b/internal/ui/chat/lsp_restart.go @@ -0,0 +1,62 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// LSPRestartToolMessageItem is a message item that represents a lsprestart tool call. +type LSPRestartToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSPRestartToolMessageItem)(nil) + +// NewLSPRestartToolMessageItem creates a new [LSPRestartToolMessageItem]. +func NewLSPRestartToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled) +} + +// LSPRestartToolRenderContext renders lsprestart tool messages. +type LSPRestartToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Restart LSP", opts.Anim) + } + + var params tools.LSPRestartParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + toolParams := []string{} + if params.Name != "" { + toolParams = append(toolParams, params.Name) + } + + header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..c4d124e7381a9ddaa39f56750367d3f2cf4d207f --- /dev/null +++ b/internal/ui/chat/mcp.go @@ -0,0 +1,121 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// MCPToolMessageItem is a message item that represents a bash tool call. +type MCPToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MCPToolMessageItem)(nil) + +// NewMCPToolMessageItem creates a new [MCPToolMessageItem]. +func NewMCPToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled) +} + +// MCPToolRenderContext renders bash tool messages. +type MCPToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3) + if len(toolNameParts) != 3 { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth) + } + mcpName := prettyName(toolNameParts[1]) + toolName := prettyName(toolNameParts[2]) + + mcpName = sty.Tool.MCPName.Render(mcpName) + toolName = sty.Tool.MCPToolName.Render(toolName) + + name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName) + + 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 + // see if the result is json + 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) +} + +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + +// looksLikeMarkdown checks if content appears to be markdown by looking for +// common markdown patterns. +func looksLikeMarkdown(content string) bool { + patterns := []string{ + "# ", // headers + "## ", // headers + "**", // bold + "```", // code fence + "- ", // unordered list + "1. ", // ordered list + "> ", // blockquote + "---", // horizontal rule + "***", // horizontal rule + } + for _, p := range patterns { + if strings.Contains(content, p) { + return true + } + } + return false +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..0c5668a20d52c5975dc63cb37da8090e9aa0ca7f --- /dev/null +++ b/internal/ui/chat/messages.go @@ -0,0 +1,319 @@ +package chat + +import ( + "fmt" + "image" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// 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 + +// Identifiable is an interface for items that can provide a unique identifier. +type Identifiable interface { + ID() string +} + +// Animatable is an interface for items that support animation. +type Animatable interface { + StartAnimation() tea.Cmd + Animate(msg anim.StepMsg) tea.Cmd +} + +// Expandable is an interface for items that can be expanded or collapsed. +type Expandable interface { + // 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. +type KeyEventHandler interface { + HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) +} + +// MessageItem represents a [message.Message] item that can be displayed in the +// UI and be part of a [list.List] identifiable by a unique ID. +type MessageItem interface { + list.Item + list.RawRenderable + Identifiable +} + +// HighlightableMessageItem is a message item that supports highlighting. +type HighlightableMessageItem interface { + MessageItem + list.Highlightable +} + +// FocusableMessageItem is a message item that supports focus. +type FocusableMessageItem interface { + MessageItem + list.Focusable +} + +// SendMsg represents a message to send a chat message. +type SendMsg struct { + Text string + Attachments []message.Attachment +} + +type highlightableMessageItem struct { + startLine int + startCol int + endLine int + endCol int + highlighter list.Highlighter +} + +var _ list.Highlightable = (*highlightableMessageItem)(nil) + +// isHighlighted returns true if the item has a highlight range set. +func (h *highlightableMessageItem) isHighlighted() bool { + return h.startLine != -1 || h.endLine != -1 +} + +// renderHighlighted highlights the content if necessary. +func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string { + if !h.isHighlighted() { + return content + } + area := image.Rect(0, 0, width, height) + return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) +} + +// SetHighlight implements list.Highlightable. +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 + h.startLine = startLine + h.startCol = max(0, startCol-offset) + h.endLine = endLine + if endCol >= 0 { + h.endCol = max(0, endCol-offset) + } else { + h.endCol = endCol + } +} + +// Highlight implements list.Highlightable. +func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) { + return h.startLine, h.startCol, h.endLine, h.endCol +} + +func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { + return &highlightableMessageItem{ + startLine: -1, + startCol: -1, + endLine: -1, + endCol: -1, + highlighter: list.ToHighlighter(sty.TextSelection), + } +} + +// cachedMessageItem caches rendered message content to avoid re-rendering. +// +// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on +// +// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths +// the issue with that could be memory usage +type cachedMessageItem struct { + // rendered is the cached rendered string + rendered string + // width and height are the dimensions of the cached render + width int + height int +} + +// getCachedRender returns the cached render if it exists for the given width. +func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) { + if c.width == width && c.rendered != "" { + return c.rendered, c.height, true + } + return "", 0, false +} + +// setCachedRender sets the cached render. +func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) { + c.rendered = rendered + c.width = width + c.height = height +} + +// clearCache clears the cached render. +func (c *cachedMessageItem) clearCache() { + c.rendered = "" + c.width = 0 + c.height = 0 +} + +// focusableMessageItem is a base struct for message items that can be focused. +type focusableMessageItem struct { + focused bool +} + +// SetFocused implements MessageItem. +func (f *focusableMessageItem) SetFocused(focused bool) { + f.focused = focused +} + +// AssistantInfoID returns a stable ID for assistant info items. +func AssistantInfoID(messageID string) string { + return fmt.Sprintf("%s:assistant-info", messageID) +} + +// AssistantInfoItem renders model info and response time after assistant completes. +type AssistantInfoItem struct { + *cachedMessageItem + + id string + message *message.Message + sty *styles.Styles + lastUserMessageTime time.Time +} + +// NewAssistantInfoItem creates a new AssistantInfoItem. +func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem { + return &AssistantInfoItem{ + cachedMessageItem: &cachedMessageItem{}, + id: AssistantInfoID(message.ID), + message: message, + sty: sty, + lastUserMessageTime: lastUserMessageTime, + } +} + +// ID implements MessageItem. +func (a *AssistantInfoItem) ID() string { + return a.id +} + +// RawRender implements MessageItem. +func (a *AssistantInfoItem) RawRender(width int) string { + innerWidth := max(0, width-MessageLeftPaddingTotal) + content, _, ok := a.getCachedRender(innerWidth) + if !ok { + content = a.renderContent(innerWidth) + height := lipgloss.Height(content) + a.setCachedRender(content, innerWidth, height) + } + return content +} + +// Render implements MessageItem. +func (a *AssistantInfoItem) Render(width int) string { + return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width)) +} + +func (a *AssistantInfoItem) renderContent(width int) string { + finishData := a.message.FinishPart() + if finishData == nil { + return "" + } + finishTime := time.Unix(finishData.Time, 0) + duration := finishTime.Sub(a.lastUserMessageTime) + infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String()) + icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon) + model := config.Get().GetModel(a.message.Provider, a.message.Model) + if model == nil { + model = &catwalk.Model{Name: "Unknown Model"} + } + modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name) + providerName := a.message.Provider + if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok { + providerName = providerConfig.Name + } + provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName)) + assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg) + return common.Section(a.sty, assistant, width) +} + +// cappedMessageWidth returns the maximum width for message content for readability. +func cappedMessageWidth(availableWidth int) int { + return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth) +} + +// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It +// returns all parts of the message as [MessageItem]s. +// +// For assistant messages with tool calls, pass a toolResults map to link results. +// Use BuildToolResultMap to create this map from all messages in a session. +func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { + switch msg.Role { + case message.User: + r := attachments.NewRenderer( + sty.Attachments.Normal, + sty.Attachments.Deleting, + sty.Attachments.Image, + sty.Attachments.Text, + ) + return []MessageItem{NewUserMessageItem(sty, msg, r)} + case message.Assistant: + var items []MessageItem + if ShouldRenderAssistantMessage(msg) { + items = append(items, NewAssistantMessageItem(sty, msg)) + } + for _, tc := range msg.ToolCalls() { + var result *message.ToolResult + if tr, ok := toolResults[tc.ID]; ok { + result = &tr + } + items = append(items, NewToolMessageItem( + sty, + msg.ID, + tc, + result, + msg.FinishReason() == message.FinishReasonCanceled, + )) + } + return items + } + return []MessageItem{} +} + +// ShouldRenderAssistantMessage determines if an assistant message should be rendered +// +// In some cases the assistant message only has tools so we do not want to render an +// empty message. +func ShouldRenderAssistantMessage(msg *message.Message) bool { + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + isError := msg.FinishReason() == message.FinishReasonError + isCancelled := msg.FinishReason() == message.FinishReasonCanceled + hasToolCalls := len(msg.ToolCalls()) > 0 + return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled +} + +// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. +// Tool result messages (role == message.Tool) contain the results that should be linked +// to tool calls in assistant messages. +func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { + resultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + if msg.Role == message.Tool { + for _, result := range msg.ToolResults() { + if result.ToolCallID != "" { + resultMap[result.ToolCallID] = result + } + } + } + } + return resultMap +} diff --git a/internal/ui/chat/references.go b/internal/ui/chat/references.go new file mode 100644 index 0000000000000000000000000000000000000000..2d7efe8df3ed38bf3768d7ae13c433fc05c17418 --- /dev/null +++ b/internal/ui/chat/references.go @@ -0,0 +1,63 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ReferencesToolMessageItem is a message item that represents a references tool call. +type ReferencesToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*ReferencesToolMessageItem)(nil) + +// NewReferencesToolMessageItem creates a new [ReferencesToolMessageItem]. +func NewReferencesToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled) +} + +// ReferencesToolRenderContext renders references tool messages. +type ReferencesToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Find References", opts.Anim) + } + + var params tools.ReferencesParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + toolParams := []string{params.Symbol} + if params.Path != "" { + toolParams = append(toolParams, "path", fsext.PrettyPath(params.Path)) + } + + header := toolHeader(sty, opts.Status, "Find References", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go new file mode 100644 index 0000000000000000000000000000000000000000..2342f671fdaed3bfdcf56619864bd3b60987d8a6 --- /dev/null +++ b/internal/ui/chat/search.go @@ -0,0 +1,256 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Glob Tool +// ----------------------------------------------------------------------------- + +// GlobToolMessageItem is a message item that represents a glob tool call. +type GlobToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GlobToolMessageItem)(nil) + +// NewGlobToolMessageItem creates a new [GlobToolMessageItem]. +func NewGlobToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled) +} + +// GlobToolRenderContext renders glob tool messages. +type GlobToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Glob", opts.Anim) + } + + var params tools.GlobParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + + header := toolHeader(sty, opts.Status, "Glob", 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 + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Grep Tool +// ----------------------------------------------------------------------------- + +// GrepToolMessageItem is a message item that represents a grep tool call. +type GrepToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GrepToolMessageItem)(nil) + +// NewGrepToolMessageItem creates a new [GrepToolMessageItem]. +func NewGrepToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled) +} + +// GrepToolRenderContext renders grep tool messages. +type GrepToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Grep", opts.Anim) + } + + var params tools.GrepParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + if params.Include != "" { + toolParams = append(toolParams, "include", params.Include) + } + if params.LiteralText { + toolParams = append(toolParams, "literal", "true") + } + + header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// LS Tool +// ----------------------------------------------------------------------------- + +// LSToolMessageItem is a message item that represents an ls tool call. +type LSToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSToolMessageItem)(nil) + +// NewLSToolMessageItem creates a new [LSToolMessageItem]. +func NewLSToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled) +} + +// LSToolRenderContext renders ls tool messages. +type LSToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "List", opts.Anim) + } + + var params tools.LSParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + path := params.Path + if path == "" { + path = "." + } + path = fsext.PrettyPath(path) + + header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Sourcegraph Tool +// ----------------------------------------------------------------------------- + +// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call. +type SourcegraphToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil) + +// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem]. +func NewSourcegraphToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled) +} + +// SourcegraphToolRenderContext renders sourcegraph tool messages. +type SourcegraphToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Sourcegraph", opts.Anim) + } + + var params tools.SourcegraphParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Query} + if params.Count != 0 { + toolParams = append(toolParams, "count", formatNonZero(params.Count)) + } + if params.ContextWindow != 0 { + toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) + } + + header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go new file mode 100644 index 0000000000000000000000000000000000000000..5678d0e47f4c3a808c13c1dc6209f9194e9f9482 --- /dev/null +++ b/internal/ui/chat/todos.go @@ -0,0 +1,192 @@ +package chat + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// ----------------------------------------------------------------------------- +// Todos Tool +// ----------------------------------------------------------------------------- + +// TodosToolMessageItem is a message item that represents a todos tool call. +type TodosToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*TodosToolMessageItem)(nil) + +// NewTodosToolMessageItem creates a new [TodosToolMessageItem]. +func NewTodosToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled) +} + +// TodosToolRenderContext renders todos tool messages. +type TodosToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "To-Do", opts.Anim) + } + + var params tools.TodosParams + var meta tools.TodosResponseMetadata + var headerText string + var body string + + // Parse params for pending state (before result is available). + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err == nil { + completedCount := 0 + inProgressTask := "" + for _, todo := range params.Todos { + if todo.Status == "completed" { + completedCount++ + } + if todo.Status == "in_progress" { + if todo.ActiveForm != "" { + inProgressTask = todo.ActiveForm + } else { + inProgressTask = todo.Content + } + } + } + + // Default display from params (used when pending or no metadata). + ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos))) + headerText = ratio + if inProgressTask != "" { + headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask) + } + + // If we have metadata, use it for richer display. + if opts.HasResult() && opts.Result.Metadata != "" { + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + if meta.IsNew { + if meta.JustStarted != "" { + headerText = fmt.Sprintf("created %d todos, starting first", meta.Total) + } else { + headerText = fmt.Sprintf("created %d todos", meta.Total) + } + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else { + // Build header based on what changed. + hasCompleted := len(meta.JustCompleted) > 0 + hasStarted := meta.JustStarted != "" + allCompleted := meta.Completed == meta.Total + + ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total)) + if hasCompleted && hasStarted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted))) + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasCompleted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted))) + if allCompleted { + text = sty.Subtle.Render(" · completed all") + } + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasStarted { + headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task")) + } else { + headerText = ratio + } + + // Build body with details. + if allCompleted { + // Show all todos when all are completed, like when created. + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else if meta.JustStarted != "" { + body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + + sty.Base.Render(meta.JustStarted) + } + } + } + } + } + + toolParams := []string{headerText} + header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if body == "" { + return header + } + + return joinToolParts(header, sty.Tool.Body.Render(body)) +} + +// FormatTodosList formats a list of todos for display. +func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string { + if len(todos) == 0 { + return "" + } + + sorted := make([]session.Todo, len(todos)) + copy(sorted, todos) + sortTodos(sorted) + + var lines []string + for _, todo := range sorted { + var prefix string + textStyle := sty.Base + + switch todo.Status { + case session.TodoStatusCompleted: + prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " " + case session.TodoStatusInProgress: + prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ") + default: + prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " " + } + + text := todo.Content + if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" { + text = todo.ActiveForm + } + line := prefix + textStyle.Render(text) + line = ansi.Truncate(line, width, "…") + + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// sortTodos sorts todos by status: completed, in_progress, pending. +func sortTodos(todos []session.Todo) { + slices.SortStableFunc(todos, func(a, b session.Todo) int { + return statusOrder(a.Status) - statusOrder(b.Status) + }) +} + +// statusOrder returns the sort order for a todo status. +func statusOrder(s session.TodoStatus) int { + switch s { + case session.TodoStatusCompleted: + return 0 + case session.TodoStatusInProgress: + return 1 + default: + return 2 + } +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..f7702cc1fe516bb3dee7d57ce15fed050299019f --- /dev/null +++ b/internal/ui/chat/tools.go @@ -0,0 +1,1390 @@ +package chat + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/agent/tools" + "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" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output. +const responseContextHeight = 10 + +// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body +const toolBodyLeftPaddingTotal = 2 + +// ToolStatus represents the current state of a tool call. +type ToolStatus int + +const ( + ToolStatusAwaitingPermission ToolStatus = iota + ToolStatusRunning + ToolStatusSuccess + ToolStatusError + ToolStatusCanceled +) + +// ToolMessageItem represents a tool call message in the chat UI. +type ToolMessageItem interface { + MessageItem + + ToolCall() message.ToolCall + SetToolCall(tc message.ToolCall) + SetResult(res *message.ToolResult) + MessageID() string + SetMessageID(id string) + SetStatus(status ToolStatus) + Status() ToolStatus +} + +// Compactable is an interface for tool items that can render in a compacted mode. +// When compact mode is enabled, tools render as a compact single-line header. +type Compactable interface { + SetCompact(compact bool) +} + +// SpinningState contains the state passed to SpinningFunc for custom spinning logic. +type SpinningState struct { + ToolCall message.ToolCall + Result *message.ToolResult + Status ToolStatus +} + +// IsCanceled returns true if the tool status is canceled. +func (s *SpinningState) IsCanceled() bool { + return s.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (s *SpinningState) HasResult() bool { + return s.Result != nil +} + +// SpinningFunc is a function type for custom spinning logic. +// Returns true if the tool should show the spinning animation. +type SpinningFunc func(state SpinningState) bool + +// DefaultToolRenderContext implements the default [ToolRenderer] interface. +type DefaultToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + +// ToolRenderOpts contains the data needed to render a tool call. +type ToolRenderOpts struct { + ToolCall message.ToolCall + Result *message.ToolResult + Anim *anim.Anim + ExpandedContent bool + Compact bool + IsSpinning bool + Status ToolStatus +} + +// IsPending returns true if the tool call is still pending (not finished and +// not canceled). +func (o *ToolRenderOpts) IsPending() bool { + return !o.ToolCall.Finished && !o.IsCanceled() +} + +// IsCanceled returns true if the tool status is canceled. +func (o *ToolRenderOpts) IsCanceled() bool { + return o.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (o *ToolRenderOpts) HasResult() bool { + return o.Result != nil +} + +// HasEmptyResult returns true if the result is nil or has empty content. +func (o *ToolRenderOpts) HasEmptyResult() bool { + return o.Result == nil || o.Result.Content == "" +} + +// ToolRenderer represents an interface for rendering tool calls. +type ToolRenderer interface { + RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string +} + +// ToolRendererFunc is a function type that implements the [ToolRenderer] interface. +type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string + +// RenderTool implements the ToolRenderer interface. +func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return f(sty, width, opts) +} + +// baseToolMessageItem represents a tool call message that can be displayed in the UI. +type baseToolMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + toolRenderer ToolRenderer + toolCall message.ToolCall + result *message.ToolResult + messageID string + status ToolStatus + // we use this so we can efficiently cache + // tools that have a capped width (e.x bash.. and others) + hasCappedWidth bool + // isCompact indicates this tool should render in compact mode. + isCompact bool + // spinningFunc allows tools to override the default spinning logic. + // If nil, uses the default: !toolCall.Finished && !canceled. + spinningFunc SpinningFunc + + sty *styles.Styles + anim *anim.Anim + expandedContent bool +} + +var _ Expandable = (*baseToolMessageItem)(nil) + +// newBaseToolMessageItem is the internal constructor for base tool message items. +func newBaseToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + toolRenderer ToolRenderer, + canceled bool, +) *baseToolMessageItem { + // we only do full width for diffs (as far as I know) + hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName + + status := ToolStatusRunning + if canceled { + status = ToolStatusCanceled + } + + t := &baseToolMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + sty: sty, + toolRenderer: toolRenderer, + toolCall: toolCall, + result: result, + status: status, + hasCappedWidth: hasCappedWidth, + } + t.anim = anim.New(anim.Settings{ + ID: toolCall.ID, + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + + return t +} + +// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name. +// +// It returns a specific tool message item type if implemented, otherwise it +// returns a generic tool message item. The messageID is the ID of the assistant +// message containing this tool call. +func NewToolMessageItem( + sty *styles.Styles, + messageID string, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + var item ToolMessageItem + switch toolCall.Name { + case tools.BashToolName: + item = NewBashToolMessageItem(sty, toolCall, result, canceled) + case tools.JobOutputToolName: + item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled) + case tools.JobKillToolName: + item = NewJobKillToolMessageItem(sty, toolCall, result, canceled) + case tools.ViewToolName: + item = NewViewToolMessageItem(sty, toolCall, result, canceled) + case tools.WriteToolName: + item = NewWriteToolMessageItem(sty, toolCall, result, canceled) + case tools.EditToolName: + item = NewEditToolMessageItem(sty, toolCall, result, canceled) + case tools.MultiEditToolName: + item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled) + case tools.GlobToolName: + item = NewGlobToolMessageItem(sty, toolCall, result, canceled) + case tools.GrepToolName: + item = NewGrepToolMessageItem(sty, toolCall, result, canceled) + case tools.LSToolName: + item = NewLSToolMessageItem(sty, toolCall, result, canceled) + case tools.DownloadToolName: + item = NewDownloadToolMessageItem(sty, toolCall, result, canceled) + case tools.FetchToolName: + item = NewFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.SourcegraphToolName: + item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) + case tools.DiagnosticsToolName: + item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) + case agent.AgentToolName: + item = NewAgentToolMessageItem(sty, toolCall, result, canceled) + case tools.AgenticFetchToolName: + item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebFetchToolName: + item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebSearchToolName: + item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled) + case tools.TodosToolName: + item = NewTodosToolMessageItem(sty, toolCall, result, canceled) + case tools.ReferencesToolName: + item = NewReferencesToolMessageItem(sty, toolCall, result, canceled) + case tools.LSPRestartToolName: + item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled) + default: + if strings.HasPrefix(toolCall.Name, "mcp_") { + item = NewMCPToolMessageItem(sty, toolCall, result, canceled) + } else { + item = NewGenericToolMessageItem(sty, toolCall, result, canceled) + } + } + item.SetMessageID(messageID) + return item +} + +// SetCompact implements the Compactable interface. +func (t *baseToolMessageItem) SetCompact(compact bool) { + t.isCompact = compact + t.clearCache() +} + +// ID returns the unique identifier for this tool message item. +func (t *baseToolMessageItem) ID() string { + return t.toolCall.ID +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (t *baseToolMessageItem) StartAnimation() tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Animate(msg) +} + +// RawRender implements [MessageItem]. +func (t *baseToolMessageItem) RawRender(width int) string { + toolItemWidth := width - MessageLeftPaddingTotal + if t.hasCappedWidth { + toolItemWidth = cappedMessageWidth(width) + } + + content, height, ok := t.getCachedRender(toolItemWidth) + // if we are spinning or there is no cache rerender + if !ok || t.isSpinning() { + content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ + ToolCall: t.toolCall, + Result: t.result, + Anim: t.anim, + ExpandedContent: t.expandedContent, + Compact: t.isCompact, + IsSpinning: t.isSpinning(), + Status: t.computeStatus(), + }) + height = lipgloss.Height(content) + // cache the rendered content + t.setCachedRender(content, toolItemWidth, height) + } + + return t.renderHighlighted(content, toolItemWidth, height) +} + +// Render renders the tool message item at the given width. +func (t *baseToolMessageItem) Render(width int) string { + style := t.sty.Chat.Message.ToolCallBlurred + if t.focused { + style = t.sty.Chat.Message.ToolCallFocused + } + + if t.isCompact { + style = t.sty.Chat.Message.ToolCallCompact + } + + return style.Render(t.RawRender(width)) +} + +// ToolCall returns the tool call associated with this message item. +func (t *baseToolMessageItem) ToolCall() message.ToolCall { + return t.toolCall +} + +// SetToolCall sets the tool call associated with this message item. +func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) { + t.toolCall = tc + t.clearCache() +} + +// SetResult sets the tool result associated with this message item. +func (t *baseToolMessageItem) SetResult(res *message.ToolResult) { + t.result = res + t.clearCache() +} + +// MessageID returns the ID of the message containing this tool call. +func (t *baseToolMessageItem) MessageID() string { + return t.messageID +} + +// SetMessageID sets the ID of the message containing this tool call. +func (t *baseToolMessageItem) SetMessageID(id string) { + t.messageID = id +} + +// SetStatus sets the tool status. +func (t *baseToolMessageItem) SetStatus(status ToolStatus) { + t.status = status + t.clearCache() +} + +// Status returns the current tool status. +func (t *baseToolMessageItem) Status() ToolStatus { + return t.status +} + +// computeStatus computes the effective status considering the result. +func (t *baseToolMessageItem) computeStatus() ToolStatus { + if t.result != nil { + if t.result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + return t.status +} + +// isSpinning returns true if the tool should show animation. +func (t *baseToolMessageItem) isSpinning() bool { + if t.spinningFunc != nil { + return t.spinningFunc(SpinningState{ + ToolCall: t.toolCall, + Result: t.result, + Status: t.status, + }) + } + return !t.toolCall.Finished && t.status != ToolStatusCanceled +} + +// SetSpinningFunc sets a custom function to determine if the tool should spin. +func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) { + t.spinningFunc = fn +} + +// ToggleExpanded toggles the expanded state of the thinking box. +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 { + return btn == ansi.MouseLeft +} + +// HandleKeyEvent implements KeyEventHandler. +func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if k := key.String(); k == "c" || k == "y" { + text := t.formatToolForCopy() + return true, common.CopyToClipboard(text, "Tool content copied to clipboard") + } + return false, nil +} + +// pendingTool renders a tool that is still in progress with an animation. +func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { + icon := sty.Tool.IconPending.Render() + toolName := sty.Tool.NameNormal.Render(name) + + var animView string + if anim != nil { + animView = anim.Render() + } + + return fmt.Sprintf("%s %s %s", icon, toolName, animView) +} + +// toolEarlyStateContent handles error/cancelled/pending states before content rendering. +// Returns the rendered output and true if early state was handled. +func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { + var msg string + switch opts.Status { + case ToolStatusError: + msg = toolErrorContent(sty, opts.Result, width) + case ToolStatusCanceled: + msg = sty.Tool.StateCancelled.Render("Canceled.") + case ToolStatusAwaitingPermission: + msg = sty.Tool.StateWaiting.Render("Requesting permission...") + case ToolStatusRunning: + msg = sty.Tool.StateWaiting.Render("Waiting for tool response...") + default: + return "", false + } + return msg, true +} + +// toolErrorContent formats an error message with ERROR tag. +func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string { + if result == nil { + return "" + } + errContent := strings.ReplaceAll(result.Content, "\n", " ") + errTag := sty.Tool.ErrorTag.Render("ERROR") + tagWidth := lipgloss.Width(errTag) + errContent = ansi.Truncate(errContent, width-tagWidth-3, "…") + return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent)) +} + +// toolIcon returns the status icon for a tool call. +// toolIcon returns the status icon for a tool call based on its status. +func toolIcon(sty *styles.Styles, status ToolStatus) string { + switch status { + case ToolStatusSuccess: + return sty.Tool.IconSuccess.String() + case ToolStatusError: + return sty.Tool.IconError.String() + case ToolStatusCanceled: + return sty.Tool.IconCancelled.String() + default: + return sty.Tool.IconPending.String() + } +} + +// toolParamList formats parameters as "main (key=value, ...)" with truncation. +// toolParamList formats tool parameters as "main (key=value, ...)" with truncation. +func toolParamList(sty *styles.Styles, params []string, width int) string { + // minSpaceForMainParam is the min space required for the main param + // if this is less that the value set we will only show the main param nothing else + const minSpaceForMainParam = 30 + if len(params) == 0 { + return "" + } + + mainParam := params[0] + + // Build key=value pairs from remaining params (consecutive key, value pairs). + var kvPairs []string + for i := 1; i+1 < len(params); i += 2 { + if params[i+1] != "" { + kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1])) + } + } + + // Try to include key=value pairs if there's enough space. + output := mainParam + if len(kvPairs) > 0 { + partsStr := strings.Join(kvPairs, ", ") + if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam { + output = fmt.Sprintf("%s (%s)", mainParam, partsStr) + } + } + + if width >= 0 { + output = ansi.Truncate(output, width, "…") + } + return sty.Tool.ParamMain.Render(output) +} + +// toolHeader builds the tool header line: "● ToolName params..." +func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string { + icon := toolIcon(sty, status) + nameStyle := sty.Tool.NameNormal + if nested { + nameStyle = sty.Tool.NameNested + } + toolName := nameStyle.Render(name) + prefix := fmt.Sprintf("%s %s ", icon, toolName) + prefixWidth := lipgloss.Width(prefix) + remainingWidth := width - prefixWidth + paramsStr := toolParamList(sty, params, remainingWidth) + return prefix + paramsStr +} + +// toolOutputPlainContent renders plain text with optional expansion support. +func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = stringext.NormalizeSpace(content) + lines := strings.Split(content, "\n") + + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) // Show all + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + ln = " " + ln + if lipgloss.Width(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, sty.Tool.ContentLine.Width(width).Render(ln)) + } + + wasTruncated := len(lines) > responseContextHeight + + if !expanded && wasTruncated { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight))) + } + + return strings.Join(out, "\n") +} + +// toolOutputCodeContent renders code with syntax highlighting and line numbers. +func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string { + content = stringext.NormalizeSpace(content) + + lines := strings.Split(content, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + // Truncate if needed. + displayLines := lines + if len(lines) > maxLines { + displayLines = lines[:maxLines] + } + + bg := sty.Tool.ContentCodeBg + highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg) + highlightedLines := strings.Split(highlighted, "\n") + + // Calculate line number width. + maxLineNumber := len(displayLines) + offset + maxDigits := getDigits(maxLineNumber) + numFmt := fmt.Sprintf("%%%dd", maxDigits) + + bodyWidth := width - toolBodyLeftPaddingTotal + codeWidth := bodyWidth - maxDigits + + var out []string + for i, ln := range highlightedLines { + lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) + + // Truncate accounting for padding that will be added. + ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…") + + codeLine := sty.Tool.ContentCodeLine. + Width(codeWidth). + Render(ln) + + out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine)) + } + + // Add truncation message if needed. + if len(lines) > maxLines && !expanded { + out = append(out, sty.Tool.ContentCodeTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} + +// toolOutputImageContent renders image data with size info. +func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string { + dataSize := len(data) * 3 / 4 + sizeStr := formatSize(dataSize) + + loaded := sty.Base.Foreground(sty.Green).Render("Loaded") + arrow := sty.Base.Foreground(sty.GreenDark).Render("→") + typeStyled := sty.Base.Render(mediaType) + sizeStyled := sty.Subtle.Render(sizeStr) + + return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)) +} + +// getDigits returns the number of digits in a number. +func getDigits(n int) int { + if n == 0 { + return 1 + } + if n < 0 { + n = -n + } + digits := 0 + for n > 0 { + n /= 10 + digits++ + } + return digits +} + +// formatSize formats byte size into human readable format. +func formatSize(bytes int) string { + const ( + kb = 1024 + mb = kb * 1024 + ) + switch { + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// toolOutputDiffContent renders a diff between old and new content. +func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, oldContent). + After(file, newContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > maxTextWidth { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg + } + + return sty.Tool.Body.Render(formatted) +} + +// formatTimeout converts timeout seconds to a duration string (e.g., "30s"). +// Returns empty string if timeout is 0. +func formatTimeout(timeout int) string { + if timeout == 0 { + return "" + } + return fmt.Sprintf("%ds", timeout) +} + +// formatNonZero returns string representation of non-zero integers, empty string for zero. +func formatNonZero(value int) string { + if value == 0 { + return "" + } + return fmt.Sprintf("%d", value) +} + +// toolOutputMultiEditDiffContent renders a diff with optional failed edits note. +func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, meta.OldContent). + After(file, meta.NewContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > maxTextWidth { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") + } + + // Add failed edits note if any exist. + if len(meta.EditsFailed) > 0 { + noteTag := sty.Tool.NoteTag.Render("Note") + noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits) + note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg)) + formatted = formatted + "\n\n" + note + } + + return sty.Tool.Body.Render(formatted) +} + +// roundedEnumerator creates a tree enumerator with rounded corners. +func roundedEnumerator(lPadding, width int) tree.Enumerator { + if width == 0 { + width = 2 + } + if lPadding == 0 { + lPadding = 1 + } + return func(children tree.Children, index int) string { + line := strings.Repeat("─", width) + padding := strings.Repeat(" ", lPadding) + if children.Length()-1 == index { + return padding + "╰" + line + } + return padding + "├" + line + } +} + +// toolOutputMarkdownContent renders markdown content with optional truncation. +func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = stringext.NormalizeSpace(content) + + // Cap width for readability. + if width > maxTextWidth { + width = maxTextWidth + } + + renderer := common.PlainMarkdownRenderer(sty, width) + rendered, err := renderer.Render(content) + if err != nil { + return toolOutputPlainContent(sty, content, width, expanded) + } + + lines := strings.Split(rendered, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + out = append(out, ln) + } + + if len(lines) > maxLines && !expanded { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} + +// formatToolForCopy formats the tool call for clipboard copying. +func (t *baseToolMessageItem) formatToolForCopy() string { + var parts []string + + toolName := prettifyToolName(t.toolCall.Name) + parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName)) + + if t.toolCall.Input != "" { + params := t.formatParametersForCopy() + if params != "" { + parts = append(parts, "### Parameters:") + parts = append(parts, params) + } + } + + if t.result != nil && t.result.ToolCallID != "" { + if t.result.IsError { + parts = append(parts, "### Error:") + parts = append(parts, t.result.Content) + } else { + parts = append(parts, "### Result:") + content := t.formatResultForCopy() + if content != "" { + parts = append(parts, content) + } + } + } else if t.status == ToolStatusCanceled { + parts = append(parts, "### Status:") + parts = append(parts, "Cancelled") + } else { + parts = append(parts, "### Status:") + parts = append(parts, "Pending...") + } + + return strings.Join(parts, "\n\n") +} + +// formatParametersForCopy formats tool parameters for clipboard copying. +func (t *baseToolMessageItem) formatParametersForCopy() string { + switch t.toolCall.Name { + case tools.BashToolName: + var params tools.BashParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + return fmt.Sprintf("**Command:** %s", cmd) + } + case tools.ViewToolName: + var params tools.ViewParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) + if params.Limit > 0 { + parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit)) + } + if params.Offset > 0 { + parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset)) + } + return strings.Join(parts, "\n") + } + case tools.EditToolName: + var params tools.EditParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.MultiEditToolName: + var params tools.MultiEditParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) + parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits))) + return strings.Join(parts, "\n") + } + case tools.WriteToolName: + var params tools.WriteParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.FetchToolName: + var params tools.FetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) + if params.Format != "" { + parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format)) + } + if params.Timeout > 0 { + parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout)) + } + return strings.Join(parts, "\n") + } + case tools.AgenticFetchToolName: + var params tools.AgenticFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + if params.URL != "" { + parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) + } + if params.Prompt != "" { + parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt)) + } + return strings.Join(parts, "\n") + } + case tools.WebFetchToolName: + var params tools.WebFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**URL:** %s", params.URL) + } + case tools.GrepToolName: + var params tools.GrepParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) + if params.Path != "" { + parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) + } + if params.Include != "" { + parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include)) + } + if params.LiteralText { + parts = append(parts, "**Literal:** true") + } + return strings.Join(parts, "\n") + } + case tools.GlobToolName: + var params tools.GlobParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) + if params.Path != "" { + parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) + } + return strings.Join(parts, "\n") + } + case tools.LSToolName: + var params tools.LSParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + path := params.Path + if path == "" { + path = "." + } + return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path)) + } + case tools.DownloadToolName: + var params tools.DownloadParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) + parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath))) + if params.Timeout > 0 { + parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String())) + } + return strings.Join(parts, "\n") + } + case tools.SourcegraphToolName: + var params tools.SourcegraphParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query)) + if params.Count > 0 { + parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count)) + } + if params.ContextWindow > 0 { + parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow)) + } + return strings.Join(parts, "\n") + } + case tools.DiagnosticsToolName: + return "**Project:** diagnostics" + case agent.AgentToolName: + var params agent.AgentParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**Task:**\n%s", params.Prompt) + } + } + + var params map[string]any + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + var parts []string + for key, value := range params { + displayKey := strings.ReplaceAll(key, "_", " ") + if len(displayKey) > 0 { + displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:] + } + parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value)) + } + return strings.Join(parts, "\n") + } + + return "" +} + +// formatResultForCopy formats tool results for clipboard copying. +func (t *baseToolMessageItem) formatResultForCopy() string { + if t.result == nil { + return "" + } + + if t.result.Data != "" { + if strings.HasPrefix(t.result.MIMEType, "image/") { + return fmt.Sprintf("[Image: %s]", t.result.MIMEType) + } + return fmt.Sprintf("[Media: %s]", t.result.MIMEType) + } + + switch t.toolCall.Name { + case tools.BashToolName: + return t.formatBashResultForCopy() + case tools.ViewToolName: + return t.formatViewResultForCopy() + case tools.EditToolName: + return t.formatEditResultForCopy() + case tools.MultiEditToolName: + return t.formatMultiEditResultForCopy() + case tools.WriteToolName: + return t.formatWriteResultForCopy() + case tools.FetchToolName: + return t.formatFetchResultForCopy() + case tools.AgenticFetchToolName: + return t.formatAgenticFetchResultForCopy() + case tools.WebFetchToolName: + return t.formatWebFetchResultForCopy() + case agent.AgentToolName: + return t.formatAgentResultForCopy() + case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName: + return fmt.Sprintf("```\n%s\n```", t.result.Content) + default: + return t.result.Content + } +} + +// formatBashResultForCopy formats bash tool results for clipboard. +func (t *baseToolMessageItem) formatBashResultForCopy() string { + if t.result == nil { + return "" + } + + var meta tools.BashResponseMetadata + if t.result.Metadata != "" { + json.Unmarshal([]byte(t.result.Metadata), &meta) + } + + output := meta.Output + if output == "" && t.result.Content != tools.BashNoOutput { + output = t.result.Content + } + + if output == "" { + return "" + } + + return fmt.Sprintf("```bash\n%s\n```", output) +} + +// formatViewResultForCopy formats view tool results for clipboard. +func (t *baseToolMessageItem) formatViewResultForCopy() string { + if t.result == nil { + return "" + } + + var meta tools.ViewResponseMetadata + if t.result.Metadata != "" { + json.Unmarshal([]byte(t.result.Metadata), &meta) + } + + if meta.Content == "" { + return t.result.Content + } + + lang := "" + if meta.FilePath != "" { + ext := strings.ToLower(filepath.Ext(meta.FilePath)) + switch ext { + case ".go": + lang = "go" + case ".js", ".mjs": + lang = "javascript" + case ".ts": + lang = "typescript" + case ".py": + lang = "python" + case ".rs": + lang = "rust" + case ".java": + lang = "java" + case ".c": + lang = "c" + case ".cpp", ".cc", ".cxx": + lang = "cpp" + case ".sh", ".bash": + lang = "bash" + case ".json": + lang = "json" + case ".yaml", ".yml": + lang = "yaml" + case ".xml": + lang = "xml" + case ".html": + lang = "html" + case ".css": + lang = "css" + case ".md": + lang = "markdown" + } + } + + var result strings.Builder + if lang != "" { + fmt.Fprintf(&result, "```%s\n", lang) + } else { + result.WriteString("```\n") + } + result.WriteString(meta.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatEditResultForCopy formats edit tool results for clipboard. +func (t *baseToolMessageItem) formatEditResultForCopy() string { + if t.result == nil || t.result.Metadata == "" { + if t.result != nil { + return t.result.Content + } + return "" + } + + var meta tools.EditResponseMetadata + if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil { + return t.result.Content + } + + var params tools.EditParams + json.Unmarshal([]byte(t.toolCall.Input), ¶ms) + + var result strings.Builder + + if meta.OldContent != "" || meta.NewContent != "" { + fileName := params.FilePath + if fileName != "" { + fileName = fsext.PrettyPath(fileName) + } + diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) + + fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals) + result.WriteString("```diff\n") + result.WriteString(diffContent) + result.WriteString("\n```") + } + + return result.String() +} + +// formatMultiEditResultForCopy formats multi-edit tool results for clipboard. +func (t *baseToolMessageItem) formatMultiEditResultForCopy() string { + if t.result == nil || t.result.Metadata == "" { + if t.result != nil { + return t.result.Content + } + return "" + } + + var meta tools.MultiEditResponseMetadata + if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil { + return t.result.Content + } + + var params tools.MultiEditParams + json.Unmarshal([]byte(t.toolCall.Input), ¶ms) + + var result strings.Builder + if meta.OldContent != "" || meta.NewContent != "" { + fileName := params.FilePath + if fileName != "" { + fileName = fsext.PrettyPath(fileName) + } + diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) + + fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals) + result.WriteString("```diff\n") + result.WriteString(diffContent) + result.WriteString("\n```") + } + + return result.String() +} + +// formatWriteResultForCopy formats write tool results for clipboard. +func (t *baseToolMessageItem) formatWriteResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.WriteParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + lang := "" + if params.FilePath != "" { + ext := strings.ToLower(filepath.Ext(params.FilePath)) + switch ext { + case ".go": + lang = "go" + case ".js", ".mjs": + lang = "javascript" + case ".ts": + lang = "typescript" + case ".py": + lang = "python" + case ".rs": + lang = "rust" + case ".java": + lang = "java" + case ".c": + lang = "c" + case ".cpp", ".cc", ".cxx": + lang = "cpp" + case ".sh", ".bash": + lang = "bash" + case ".json": + lang = "json" + case ".yaml", ".yml": + lang = "yaml" + case ".xml": + lang = "xml" + case ".html": + lang = "html" + case ".css": + lang = "css" + case ".md": + lang = "markdown" + } + } + + var result strings.Builder + fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath)) + if lang != "" { + fmt.Fprintf(&result, "```%s\n", lang) + } else { + result.WriteString("```\n") + } + result.WriteString(params.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatFetchResultForCopy formats fetch tool results for clipboard. +func (t *baseToolMessageItem) formatFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.FetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + var result strings.Builder + if params.URL != "" { + fmt.Fprintf(&result, "URL: %s\n", params.URL) + } + if params.Format != "" { + fmt.Fprintf(&result, "Format: %s\n", params.Format) + } + if params.Timeout > 0 { + fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout) + } + result.WriteString("\n") + + result.WriteString(t.result.Content) + + return result.String() +} + +// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard. +func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.AgenticFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + var result strings.Builder + if params.URL != "" { + fmt.Fprintf(&result, "URL: %s\n", params.URL) + } + if params.Prompt != "" { + fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt) + } + + result.WriteString("```markdown\n") + result.WriteString(t.result.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatWebFetchResultForCopy formats web fetch tool results for clipboard. +func (t *baseToolMessageItem) formatWebFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.WebFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL)) + result.WriteString("```markdown\n") + result.WriteString(t.result.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatAgentResultForCopy formats agent tool results for clipboard. +func (t *baseToolMessageItem) formatAgentResultForCopy() string { + if t.result == nil { + return "" + } + + var result strings.Builder + + if t.result.Content != "" { + result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content)) + } + + return result.String() +} + +// prettifyToolName returns a human-readable name for tool names. +func prettifyToolName(name string) string { + switch name { + case agent.AgentToolName: + return "Agent" + case tools.BashToolName: + return "Bash" + case tools.JobOutputToolName: + return "Job: Output" + case tools.JobKillToolName: + return "Job: Kill" + case tools.DownloadToolName: + return "Download" + case tools.EditToolName: + return "Edit" + case tools.MultiEditToolName: + return "Multi-Edit" + case tools.FetchToolName: + return "Fetch" + case tools.AgenticFetchToolName: + return "Agentic Fetch" + case tools.WebFetchToolName: + return "Fetch" + case tools.WebSearchToolName: + return "Search" + case tools.GlobToolName: + return "Glob" + case tools.GrepToolName: + return "Grep" + case tools.LSToolName: + return "List" + case tools.SourcegraphToolName: + return "Sourcegraph" + case tools.TodosToolName: + return "To-Do" + case tools.ViewToolName: + return "View" + case tools.WriteToolName: + return "Write" + default: + return genericPrettyName(name) + } +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go new file mode 100644 index 0000000000000000000000000000000000000000..91211590ce66dd0dd7edbde03becdf469e26b521 --- /dev/null +++ b/internal/ui/chat/user.go @@ -0,0 +1,104 @@ +package chat + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// UserMessageItem represents a user message in the chat UI. +type UserMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + attachments *attachments.Renderer + message *message.Message + sty *styles.Styles +} + +// NewUserMessageItem creates a new UserMessageItem. +func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem { + return &UserMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + attachments: attachments, + message: message, + sty: sty, + } +} + +// RawRender implements [MessageItem]. +func (m *UserMessageItem) RawRender(width int) string { + cappedWidth := cappedMessageWidth(width) + + content, height, ok := m.getCachedRender(cappedWidth) + // cache hit + if ok { + return m.renderHighlighted(content, cappedWidth, height) + } + + renderer := common.MarkdownRenderer(m.sty, cappedWidth) + + msgContent := strings.TrimSpace(m.message.Content().Text) + result, err := renderer.Render(msgContent) + if err != nil { + content = msgContent + } else { + content = strings.TrimSuffix(result, "\n") + } + + if len(m.message.BinaryContent()) > 0 { + attachmentsStr := m.renderAttachments(cappedWidth) + if content == "" { + content = attachmentsStr + } else { + content = strings.Join([]string{content, "", attachmentsStr}, "\n") + } + } + + height = lipgloss.Height(content) + m.setCachedRender(content, cappedWidth, height) + return m.renderHighlighted(content, cappedWidth, height) +} + +// Render implements MessageItem. +func (m *UserMessageItem) Render(width int) string { + style := m.sty.Chat.Message.UserBlurred + if m.focused { + style = m.sty.Chat.Message.UserFocused + } + return style.Render(m.RawRender(width)) +} + +// ID implements MessageItem. +func (m *UserMessageItem) ID() string { + return m.message.ID +} + +// renderAttachments renders attachments. +func (m *UserMessageItem) renderAttachments(width int) string { + var attachments []message.Attachment + for _, at := range m.message.BinaryContent() { + attachments = append(attachments, message.Attachment{ + FileName: at.Path, + MimeType: at.MIMEType, + }) + } + return m.attachments.Render(attachments, false, width) +} + +// HandleKeyEvent implements KeyEventHandler. +func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if k := key.String(); k == "c" || k == "y" { + text := m.message.Content().Text + return true, common.CopyToClipboard(text, "Message copied to clipboard") + } + return false, nil +} diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go new file mode 100644 index 0000000000000000000000000000000000000000..90a2dc929a004e734a18e69b874b36cbd0f4f667 --- /dev/null +++ b/internal/ui/common/button.go @@ -0,0 +1,69 @@ +package common + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ButtonOpts defines the configuration for a single button +type ButtonOpts struct { + // Text is the button label + Text string + // UnderlineIndex is the 0-based index of the character to underline (-1 for none) + UnderlineIndex int + // Selected indicates whether this button is currently selected + Selected bool + // Padding inner horizontal padding defaults to 2 if this is 0 + Padding int +} + +// Button creates a button with an underlined character and selection state +func Button(t *styles.Styles, opts ButtonOpts) string { + // Select style based on selection state + style := t.ButtonBlur + if opts.Selected { + style = t.ButtonFocus + } + + text := opts.Text + if opts.Padding == 0 { + opts.Padding = 2 + } + + // the index is out of bound + if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 { + opts.UnderlineIndex = -1 + } + + text = style.Padding(0, opts.Padding).Render(text) + + if opts.UnderlineIndex != -1 { + text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true))) + } + + return text +} + +// ButtonGroup creates a row of selectable buttons +// Spacing is the separator between buttons +// Use " " or similar for horizontal layout +// Use "\n" for vertical layout +// Defaults to " " (horizontal) +func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string { + if len(buttons) == 0 { + return "" + } + + if spacing == "" { + spacing = " " + } + + parts := make([]string, len(buttons)) + for i, button := range buttons { + parts[i] = Button(t, button) + } + + return strings.Join(parts, spacing) +} 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/common/common.go b/internal/ui/common/common.go new file mode 100644 index 0000000000000000000000000000000000000000..6e7c632474389aa5455295e4132818941bc18244 --- /dev/null +++ b/internal/ui/common/common.go @@ -0,0 +1,100 @@ +package common + +import ( + "fmt" + "image" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" +) + +// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB). +const MaxAttachmentSize = int64(5 * 1024 * 1024) + +// AllowedImageTypes defines the permitted image file types. +var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"} + +// Common defines common UI options and configurations. +type Common struct { + App *app.App + Styles *styles.Styles +} + +// Config returns the configuration associated with this [Common] instance. +func (c *Common) Config() *config.Config { + return c.App.Config() +} + +// DefaultCommon returns the default common UI configurations. +func DefaultCommon(app *app.App) *Common { + s := styles.DefaultStyles() + return &Common{ + App: app, + Styles: &s, + } +} + +// CenterRect returns a new [Rectangle] centered within the given area with the +// specified width and height. +func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle { + centerX := area.Min.X + area.Dx()/2 + centerY := area.Min.Y + area.Dy()/2 + minX := centerX - width/2 + minY := centerY - height/2 + maxX := minX + width + maxY := minY + height + return image.Rect(minX, minY, maxX, maxY) +} + +// BottomLeftRect returns a new [Rectangle] positioned at the bottom-left within the given area with the +// specified width and height. +func BottomLeftRect(area uv.Rectangle, width, height int) uv.Rectangle { + minX := area.Min.X + maxX := minX + width + maxY := area.Max.Y + minY := maxY - height + return image.Rect(minX, minY, maxX, maxY) +} + +// IsFileTooBig checks if the file at the given path exceeds the specified size +// limit. +func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return false, fmt.Errorf("error getting file info: %w", err) + } + + if fileInfo.Size() > sizeLimit { + return true, nil + } + + return false, nil +} + +// CopyToClipboard copies the given text to the clipboard using both OSC 52 +// (terminal escape sequence) and native clipboard for maximum compatibility. +// Returns a command that reports success to the user with the given message. +func CopyToClipboard(text, successMessage string) tea.Cmd { + return CopyToClipboardWithCallback(text, successMessage, nil) +} + +// CopyToClipboardWithCallback copies text to clipboard and executes a callback +// before showing the success message. +// This is useful when you need to perform additional actions like clearing UI state. +func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd) tea.Cmd { + return tea.Sequence( + tea.SetClipboard(text), + func() tea.Msg { + _ = clipboard.WriteAll(text) + return nil + }, + callback, + util.ReportInfo(successMessage), + ) +} diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go new file mode 100644 index 0000000000000000000000000000000000000000..4fdbb3e4c48f23caccd715066d9087fea8654202 --- /dev/null +++ b/internal/ui/common/diff.go @@ -0,0 +1,16 @@ +package common + +import ( + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/crush/internal/ui/diffview" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// DiffFormatter returns a diff formatter with the given styles that can be +// used to format diff outputs. +func DiffFormatter(s *styles.Styles) *diffview.DiffView { + formatDiff := diffview.New() + style := chroma.MustNewStyle("crush", s.ChromaTheme()) + diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4) + return diff +} diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go new file mode 100644 index 0000000000000000000000000000000000000000..a129d1861e483c5c2064dc70d70ebd5c09cbd1f8 --- /dev/null +++ b/internal/ui/common/elements.go @@ -0,0 +1,190 @@ +package common + +import ( + "cmp" + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// PrettyPath formats a file path with home directory shortening and applies +// muted styling. +func PrettyPath(t *styles.Styles, path string, width int) string { + formatted := home.Short(path) + return t.Muted.Width(width).Render(formatted) +} + +// ModelContextInfo contains token usage and cost information for a model. +type ModelContextInfo struct { + ContextUsed int64 + ModelContext int64 + Cost float64 +} + +// ModelInfo renders model information including name, provider, reasoning +// settings, and optional context usage/cost. +func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string { + modelIcon := t.Subtle.Render(styles.ModelIcon) + modelName = t.Base.Render(modelName) + + // Build first line with model name and optionally provider on the same line + var firstLine string + if providerName != "" { + providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName)) + modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo) + + // Check if it fits on one line + if lipgloss.Width(modelWithProvider) <= width { + firstLine = modelWithProvider + } else { + // If it doesn't fit, put provider on next line + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + } else { + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + + parts := []string{firstLine} + + // If provider didn't fit on first line, add it as second line + if providerName != "" && !strings.Contains(firstLine, "via") { + providerInfo := fmt.Sprintf("via %s", providerName) + parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo)) + } + + if reasoningInfo != "" { + parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo)) + } + + if context != nil { + formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost) + parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo)) + } + + return lipgloss.NewStyle().Width(width).Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) +} + +// formatTokensAndCost formats token usage and cost with appropriate units +// (K/M) and percentage of context window. +func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { + var formattedTokens string + switch { + case tokens >= 1_000_000: + formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) + default: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + if strings.HasSuffix(formattedTokens, ".0K") { + formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) + } + if strings.HasSuffix(formattedTokens, ".0M") { + formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) + } + + percentage := (float64(tokens) / float64(contextWindow)) * 100 + + formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost)) + + formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens)) + formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) + formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) + if percentage > 80 { + formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens) + } + + return fmt.Sprintf("%s %s", formattedTokens, formattedCost) +} + +// StatusOpts defines options for rendering a status line with icon, title, +// description, and optional extra content. +type StatusOpts struct { + Icon string // if empty no icon will be shown + Title string + TitleColor color.Color + Description string + DescriptionColor color.Color + ExtraContent string // additional content to append after the description +} + +// Status renders a status line with icon, title, description, and extra +// content. The description is truncated if it exceeds the available width. +func Status(t *styles.Styles, opts StatusOpts, width int) string { + icon := opts.Icon + title := opts.Title + description := opts.Description + + titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground()) + descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground()) + + title = t.Base.Foreground(titleColor).Render(title) + + if description != "" { + extraContentWidth := lipgloss.Width(opts.ExtraContent) + if extraContentWidth > 0 { + extraContentWidth += 1 + } + description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") + description = t.Base.Foreground(descriptionColor).Render(description) + } + + content := []string{} + if icon != "" { + content = append(content, icon) + } + content = append(content, title) + if description != "" { + content = append(content, description) + } + if opts.ExtraContent != "" { + content = append(content, opts.ExtraContent) + } + + return strings.Join(content, " ") +} + +// Section renders a section header with a title and a horizontal line filling +// the remaining width. +func Section(t *styles.Styles, text string, width int, info ...string) string { + char := styles.SectionSeparator + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + + var infoText string + if len(info) > 0 { + infoText = strings.Join(info, " ") + if len(infoText) > 0 { + infoText = " " + infoText + remainingWidth -= lipgloss.Width(infoText) + } + } + + text = t.Section.Title.Render(text) + if remainingWidth > 0 { + text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText + } + return text +} + +// DialogTitle renders a dialog title with a decorative line filling the +// remaining width. +func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string { + char := "╱" + length := lipgloss.Width(title) + 1 + remainingWidth := width - length + if remainingWidth > 0 { + lines := strings.Repeat(char, remainingWidth) + lines = styles.ApplyForegroundGrad(t, lines, fromColor, toColor) + title = title + " " + lines + } + return title +} diff --git a/internal/tui/highlight/highlight.go b/internal/ui/common/highlight.go similarity index 65% rename from internal/tui/highlight/highlight.go rename to internal/ui/common/highlight.go index c8cf833056603d18e6bd7ecac8f27a6652fdfde7..642a7859d110a86af57feeb447907612a6b12098 100644 --- a/internal/tui/highlight/highlight.go +++ b/internal/ui/common/highlight.go @@ -1,4 +1,4 @@ -package highlight +package common import ( "bytes" @@ -7,11 +7,14 @@ import ( "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" - chromaStyles "github.com/alecthomas/chroma/v2/styles" - "github.com/charmbracelet/crush/internal/tui/styles" + chromastyles "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/crush/internal/ui/styles" ) -func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) { +// SyntaxHighlight applies syntax highlighting to the given source code based +// on the file name and background color. It returns the highlighted code as a +// string. +func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) { // Determine the language lexer to use l := lexers.Match(fileName) if l == nil { @@ -28,7 +31,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) { f = formatters.Fallback } - style := chroma.MustNewStyle("crush", styles.GetChromaTheme()) + style := chroma.MustNewStyle("crush", st.ChromaTheme()) // Modify the style to use the provided background s, err := style.Builder().Transform( @@ -39,7 +42,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) { }, ).Build() if err != nil { - s = chromaStyles.Fallback + s = chromastyles.Fallback } // Tokenize and format diff --git a/internal/ui/common/interface.go b/internal/ui/common/interface.go new file mode 100644 index 0000000000000000000000000000000000000000..a0cef2e9c2b8b0236ffb7a041a551fb0ad41f560 --- /dev/null +++ b/internal/ui/common/interface.go @@ -0,0 +1,11 @@ +package common + +import ( + tea "charm.land/bubbletea/v2" +) + +// Model represents a common interface for UI components. +type Model[T any] interface { + Update(msg tea.Msg) (T, tea.Cmd) + View() string +} diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go new file mode 100644 index 0000000000000000000000000000000000000000..f5af8121d1667658725b4424a4ab303804c75b42 --- /dev/null +++ b/internal/ui/common/markdown.go @@ -0,0 +1,26 @@ +package common + +import ( + "charm.land/glamour/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with +// the given styles and width. +func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(sty.Markdown), + glamour.WithWordWrap(width), + ) + return r +} + +// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors +// (plain text with structure) and the given width. +func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(sty.PlainMarkdown), + glamour.WithWordWrap(width), + ) + return r +} diff --git a/internal/ui/common/scrollbar.go b/internal/ui/common/scrollbar.go new file mode 100644 index 0000000000000000000000000000000000000000..7e701659348c90100534c18620f5e9949db3d050 --- /dev/null +++ b/internal/ui/common/scrollbar.go @@ -0,0 +1,46 @@ +package common + +import ( + "strings" + + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// Scrollbar renders a vertical scrollbar based on content and viewport size. +// Returns an empty string if content fits within viewport (no scrolling needed). +func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string { + if height <= 0 || contentSize <= viewportSize { + return "" + } + + // Calculate thumb size (minimum 1 character). + thumbSize := max(1, height*viewportSize/contentSize) + + // Calculate thumb position. + maxOffset := contentSize - viewportSize + if maxOffset <= 0 { + return "" + } + + // Calculate where the thumb starts. + trackSpace := height - thumbSize + thumbPos := 0 + if trackSpace > 0 && maxOffset > 0 { + thumbPos = min(trackSpace, offset*trackSpace/maxOffset) + } + + // Build the scrollbar. + var sb strings.Builder + for i := range height { + if i > 0 { + sb.WriteString("\n") + } + if i >= thumbPos && i < thumbPos+thumbSize { + sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)) + } else { + sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)) + } + } + + return sb.String() +} diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..a23ba5bf181f00856082b17aed8ef1ba5a816e93 --- /dev/null +++ b/internal/ui/completions/completions.go @@ -0,0 +1,273 @@ +package completions + +import ( + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" +) + +const ( + minHeight = 1 + maxHeight = 10 + minWidth = 10 + maxWidth = 100 +) + +// SelectionMsg is sent when a completion is selected. +type SelectionMsg struct { + Value any + Insert bool // If true, insert without closing. +} + +// ClosedMsg is sent when the completions are closed. +type ClosedMsg struct{} + +// FilesLoadedMsg is sent when files have been loaded for completions. +type FilesLoadedMsg struct { + Files []string +} + +// Completions represents the completions popup component. +type Completions struct { + // Popup dimensions + width int + height int + + // State + open bool + query string + + // Key bindings + keyMap KeyMap + + // List component + list *list.FilterableList + + // Styling + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// New creates a new completions component. +func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions { + l := list.NewFilterableList() + l.SetGap(0) + l.SetReverse(true) + + return &Completions{ + keyMap: DefaultKeyMap(), + list: l, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// IsOpen returns whether the completions popup is open. +func (c *Completions) IsOpen() bool { + return c.open +} + +// Query returns the current filter query. +func (c *Completions) Query() string { + return c.query +} + +// Size returns the visible size of the popup. +func (c *Completions) Size() (width, height int) { + visible := len(c.list.FilteredItems()) + return c.width, min(visible, c.height) +} + +// KeyMap returns the key bindings. +func (c *Completions) KeyMap() KeyMap { + return c.keyMap +} + +// OpenWithFiles opens the completions with file items from the filesystem. +func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { + return func() tea.Msg { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + return FilesLoadedMsg{Files: files} + } +} + +// SetFiles sets the file items on the completions popup. +func (c *Completions) SetFiles(files []string) { + items := make([]list.FilterableItem, 0, len(files)) + for _, file := range files { + file = strings.TrimPrefix(file, "./") + item := NewCompletionItem( + file, + FileCompletionValue{Path: file}, + c.normalStyle, + c.focusedStyle, + c.matchStyle, + ) + items = append(items, item) + } + + c.open = true + c.query = "" + c.list.SetItems(items...) + c.list.SetFilter("") // Clear any previous filter. + c.list.Focus() + + c.width = maxWidth + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() + + c.updateSize() +} + +// Close closes the completions popup. +func (c *Completions) Close() { + c.open = false +} + +// Filter filters the completions with the given query. +func (c *Completions) Filter(query string) { + if !c.open { + return + } + + if query == c.query { + return + } + + c.query = query + c.list.SetFilter(query) + + c.updateSize() +} + +func (c *Completions) updateSize() { + items := c.list.FilteredItems() + start, end := c.list.VisibleItemIndices() + width := 0 + for i := start; i <= end; i++ { + item := c.list.ItemAt(i) + s := item.(interface{ Text() string }).Text() + width = max(width, ansi.StringWidth(s)) + } + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// HasItems returns whether there are visible items. +func (c *Completions) HasItems() bool { + return len(c.list.FilteredItems()) > 0 +} + +// Update handles key events for the completions. +func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { + if !c.open { + return nil, false + } + + switch { + case key.Matches(msg, c.keyMap.Up): + c.selectPrev() + return nil, true + + case key.Matches(msg, c.keyMap.Down): + c.selectNext() + return nil, true + + case key.Matches(msg, c.keyMap.UpInsert): + c.selectPrev() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.DownInsert): + c.selectNext() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.Select): + return c.selectCurrent(false), true + + case key.Matches(msg, c.keyMap.Cancel): + c.Close() + return ClosedMsg{}, true + } + + return nil, false +} + +// selectPrev selects the previous item with circular navigation. +func (c *Completions) selectPrev() { + items := c.list.FilteredItems() + if len(items) == 0 { + return + } + if !c.list.SelectPrev() { + c.list.WrapToEnd() + } + c.list.ScrollToSelected() +} + +// selectNext selects the next item with circular navigation. +func (c *Completions) selectNext() { + items := c.list.FilteredItems() + if len(items) == 0 { + return + } + if !c.list.SelectNext() { + c.list.WrapToStart() + } + c.list.ScrollToSelected() +} + +// selectCurrent returns a command with the currently selected item. +func (c *Completions) selectCurrent(insert bool) tea.Msg { + items := c.list.FilteredItems() + if len(items) == 0 { + return nil + } + + selected := c.list.Selected() + if selected < 0 || selected >= len(items) { + return nil + } + + item, ok := items[selected].(*CompletionItem) + if !ok { + return nil + } + + if !insert { + c.open = false + } + + return SelectionMsg{ + Value: item.Value(), + Insert: insert, + } +} + +// Render renders the completions popup. +func (c *Completions) Render() string { + if !c.open { + return "" + } + + items := c.list.FilteredItems() + if len(items) == 0 { + return "" + } + + return c.list.Render() +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..1114083fd1a118649921ead3ea2288d6e6085632 --- /dev/null +++ b/internal/ui/completions/item.go @@ -0,0 +1,185 @@ +package completions + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// FileCompletionValue represents a file path completion value. +type FileCompletionValue struct { + Path string +} + +// CompletionItem represents an item in the completions list. +type CompletionItem struct { + text string + value any + match fuzzy.Match + focused bool + cache map[int]string + + // Styles + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// NewCompletionItem creates a new completion item. +func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem { + return &CompletionItem{ + text: text, + value: value, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// Text returns the display text of the item. +func (c *CompletionItem) Text() string { + return c.text +} + +// Value returns the value of the item. +func (c *CompletionItem) Value() any { + return c.value +} + +// Filter implements [list.FilterableItem]. +func (c *CompletionItem) Filter() string { + return c.text +} + +// SetMatch implements [list.MatchSettable]. +func (c *CompletionItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.match = m +} + +// SetFocused implements [list.Focusable]. +func (c *CompletionItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// Render implements [list.Item]. +func (c *CompletionItem) Render(width int) string { + return renderItem( + c.normalStyle, + c.focusedStyle, + c.matchStyle, + c.text, + c.focused, + width, + c.cache, + &c.match, + ) +} + +func renderItem( + normalStyle, focusedStyle, matchStyle lipgloss.Style, + text string, + focused bool, + width int, + cache map[int]string, + match *fuzzy.Match, +) string { + if cache == nil { + cache = make(map[int]string) + } + + cached, ok := cache[width] + if ok { + return cached + } + + innerWidth := width - 2 // Account for padding + // Truncate if needed. + if ansi.StringWidth(text) > innerWidth { + text = ansi.Truncate(text, innerWidth, "…") + } + + // Select base style. + style := normalStyle + matchStyle = matchStyle.Background(style.GetBackground()) + if focused { + style = focusedStyle + matchStyle = matchStyle.Background(style.GetBackground()) + } + + // Render full-width text with background. + content := style.Padding(0, 1).Width(width).Render(text) + + // Apply match highlighting using StyleRanges. + if len(match.MatchedIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(match.MatchedIndexes) { + start, stop := bytePosToVisibleCharPos(text, rng) + // Offset by 1 for the padding space. + ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle)) + } + content = lipgloss.StyleRanges(content, ranges...) + } + + cache[width] = content + return content +} + +// matchedRanges converts a list of match indexes into contiguous ranges. +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +// bytePosToVisibleCharPos converts byte positions to visible character positions. +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} + +// Ensure CompletionItem implements the required interfaces. +var ( + _ list.Item = (*CompletionItem)(nil) + _ list.FilterableItem = (*CompletionItem)(nil) + _ list.MatchSettable = (*CompletionItem)(nil) + _ list.Focusable = (*CompletionItem)(nil) +) diff --git a/internal/tui/components/completions/keys.go b/internal/ui/completions/keys.go similarity index 80% rename from internal/tui/components/completions/keys.go rename to internal/ui/completions/keys.go index 7adaaa02195e5266df0ecb3823fa15d918adb4ab..d150f1a96b05018bfeaf6fea0b45d2c5ea65ac06 100644 --- a/internal/tui/components/completions/keys.go +++ b/internal/ui/completions/keys.go @@ -4,6 +4,7 @@ import ( "charm.land/bubbles/v2/key" ) +// KeyMap defines the key bindings for the completions component. type KeyMap struct { Down, Up, @@ -13,6 +14,7 @@ type KeyMap struct { UpInsert key.Binding } +// DefaultKeyMap returns the default key bindings for completions. func DefaultKeyMap() KeyMap { return KeyMap{ Down: key.NewBinding( @@ -42,7 +44,7 @@ func DefaultKeyMap() KeyMap { } } -// KeyBindings implements layout.KeyMapProvider +// KeyBindings returns all key bindings as a slice. func (k KeyMap) KeyBindings() []key.Binding { return []key.Binding{ k.Down, @@ -52,7 +54,7 @@ func (k KeyMap) KeyBindings() []key.Binding { } } -// FullHelp implements help.KeyMap. +// FullHelp returns the full help for the key bindings. func (k KeyMap) FullHelp() [][]key.Binding { m := [][]key.Binding{} slice := k.KeyBindings() @@ -63,7 +65,7 @@ func (k KeyMap) FullHelp() [][]key.Binding { return m } -// ShortHelp implements help.KeyMap. +// ShortHelp returns the short help for the key bindings. func (k KeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.Up, diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go new file mode 100644 index 0000000000000000000000000000000000000000..2776d886d49d979fd7673fb830dfa9d9a11f9006 --- /dev/null +++ b/internal/ui/dialog/actions.go @@ -0,0 +1,165 @@ +package dialog + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + + tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" +) + +// ActionClose is a message to close the current dialog. +type ActionClose struct{} + +// ActionQuit is a message to quit the application. +type ActionQuit = tea.QuitMsg + +// ActionOpenDialog is a message to open a dialog. +type ActionOpenDialog struct { + DialogID string +} + +// ActionSelectSession is a message indicating a session has been selected. +type ActionSelectSession struct { + Session session.Session +} + +// ActionSelectModel is a message indicating a model has been selected. +type ActionSelectModel struct { + Provider catwalk.Provider + Model config.SelectedModel + ModelType config.SelectedModelType +} + +// Messages for commands +type ( + ActionNewSession struct{} + ActionToggleHelp struct{} + ActionToggleCompactMode struct{} + ActionToggleThinking struct{} + ActionExternalEditor struct{} + ActionToggleYoloMode struct{} + // ActionInitializeProject is a message to initialize a project. + ActionInitializeProject struct{} + ActionSummarize struct { + SessionID string + } + // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected. + ActionSelectReasoningEffort struct { + Effort string + } + ActionPermissionResponse struct { + Permission permission.PermissionRequest + Action PermissionAction + } + // ActionRunCustomCommand is a message to run a custom command. + ActionRunCustomCommand struct { + Content string + Arguments []commands.Argument + Args map[string]string // Actual argument values + } + // ActionRunMCPPrompt is a message to run a custom command. + ActionRunMCPPrompt struct { + Title string + Description string + PromptID string + ClientID string + Arguments []commands.Argument + Args map[string]string // Actual argument values + } +) + +// Messages for API key input dialog. +type ( + ActionChangeAPIKeyState struct { + State APIKeyInputState + } +) + +// Messages for OAuth2 device flow dialog. +type ( + // ActionInitiateOAuth is sent when the device auth is initiated + // successfully. + ActionInitiateOAuth struct { + DeviceCode string + UserCode string + ExpiresIn int + VerificationURL string + Interval int + } + + // ActionCompleteOAuth is sent when the device flow completes successfully. + ActionCompleteOAuth struct { + Token *oauth.Token + } + + // ActionOAuthErrored is sent when the device flow encounters an error. + ActionOAuthErrored struct { + Error error + } +) + +// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the +// Bubble Tea program loop. +type ActionCmd struct { + Cmd tea.Cmd +} + +// ActionFilePickerSelected is a message indicating a file has been selected in +// the file picker dialog. +type ActionFilePickerSelected struct { + Path string +} + +// Cmd returns a command that reads the file at path and sends a +// [message.Attachement] to the program. +func (a ActionFilePickerSelected) Cmd() tea.Cmd { + path := a.Path + if path == "" { + return nil + } + return func() tea.Msg { + isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize) + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + if isFileLarge { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "file too large, max 5MB", + } + } + + content, err := os.ReadFile(path) + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } + } +} diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go new file mode 100644 index 0000000000000000000000000000000000000000..f06d9ff8f1af19d6dc04564bf82c8d523eee6525 --- /dev/null +++ b/internal/ui/dialog/api_key_input.go @@ -0,0 +1,327 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "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" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/exp/charmtone" +) + +type APIKeyInputState int + +const ( + APIKeyInputStateInitial APIKeyInputState = iota + APIKeyInputStateVerifying + APIKeyInputStateVerified + APIKeyInputStateError +) + +// APIKeyInputID is the identifier for the model selection dialog. +const APIKeyInputID = "api_key_input" + +// APIKeyInput represents a model selection dialog. +type APIKeyInput struct { + com *common.Common + isOnboarding bool + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + + width int + state APIKeyInputState + + keyMap struct { + Submit key.Binding + Close key.Binding + } + input textinput.Model + spinner spinner.Model + help help.Model +} + +var _ Dialog = (*APIKeyInput)(nil) + +// NewAPIKeyInput creates a new Models dialog. +func NewAPIKeyInput( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*APIKeyInput, tea.Cmd) { + t := com.Styles + + m := APIKeyInput{} + m.com = com + m.isOnboarding = isOnboarding + m.provider = provider + m.model = model + m.modelType = modelType + m.width = 60 + + innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2 + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = "Enter your API key..." + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.Green)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "submit"), + ) + m.keyMap.Close = CloseKey + + return &m, nil +} + +// ID implements Dialog. +func (m *APIKeyInput) ID() string { + return APIKeyInputID +} + +// HandleMsg implements [Dialog]. +func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case ActionChangeAPIKeyState: + m.state = msg.State + switch m.state { + case APIKeyInputStateVerifying: + cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey) + return ActionCmd{cmd} + } + case spinner.TickMsg: + switch m.state { + case APIKeyInputStateVerifying: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.KeyPressMsg: + switch { + case m.state == APIKeyInputStateVerifying: + // do nothing + case key.Matches(msg, m.keyMap.Close): + switch m.state { + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + default: + return ActionClose{} + } + case key.Matches(msg, m.keyMap.Submit): + switch m.state { + case APIKeyInputStateInitial, APIKeyInputStateError: + return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying} + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.PasteMsg: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + return nil +} + +// Draw implements [Dialog]. +func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := m.com.Styles + + textStyle := t.Dialog.SecondaryText + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(m.width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize()) + + m.input.Prompt = m.spinner.View() + + content := strings.Join([]string{ + m.headerView(), + inputStyle.Render(m.inputView()), + textStyle.Render("This will be written in your global configuration:"), + textStyle.Render(config.GlobalConfigData()), + "", + helpStyle.Render(m.help.View(m)), + }, "\n") + + cur := m.Cursor() + + if m.isOnboarding { + view := content + DrawOnboardingCursor(scr, area, view, cur) + + // FIXME(@andreynering): Figure it out how to properly fix this + if cur != nil { + cur.Y -= 1 + cur.X -= 1 + } + } else { + view := dialogStyle.Render(content) + DrawCenterCursor(scr, area, view, cur) + } + return cur +} + +func (m *APIKeyInput) headerView() string { + var ( + t = m.com.Styles + titleStyle = t.Dialog.Title + textStyle = t.Dialog.PrimaryText + dialogStyle = t.Dialog.View.Width(m.width) + ) + if m.isOnboarding { + return textStyle.Render(m.dialogTitle()) + } + headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset, m.com.Styles.Primary, m.com.Styles.Secondary) +} + +func (m *APIKeyInput) dialogTitle() string { + var ( + t = m.com.Styles + textStyle = t.Dialog.TitleText + errorStyle = t.Dialog.TitleError + accentStyle = t.Dialog.TitleAccent + ) + switch m.state { + case APIKeyInputStateInitial: + return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".") + case APIKeyInputStateVerifying: + return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...") + case APIKeyInputStateVerified: + return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.") + case APIKeyInputStateError: + return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?") + } + return "" +} + +func (m *APIKeyInput) inputView() string { + t := m.com.Styles + + switch m.state { + case APIKeyInputStateInitial: + m.input.Prompt = "> " + m.input.SetStyles(t.TextInput) + m.input.Focus() + case APIKeyInputStateVerifying: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = m.spinner.View() + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateVerified: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = styles.CheckIcon + " " + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateError: + ts := t.TextInput + ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry) + + m.input.Prompt = styles.LSPErrorIcon + " " + m.input.SetStyles(ts) + m.input.Focus() + } + return m.input.View() +} + +// Cursor returns the cursor position relative to the dialog. +func (m *APIKeyInput) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +// FullHelp returns the full help view. +func (m *APIKeyInput) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + m.keyMap.Submit, + m.keyMap.Close, + }, + } +} + +// ShortHelp returns the full help view. +func (m *APIKeyInput) ShortHelp() []key.Binding { + return []key.Binding{ + m.keyMap.Submit, + m.keyMap.Close, + } +} + +func (m *APIKeyInput) verifyAPIKey() tea.Msg { + start := time.Now() + + providerConfig := config.ProviderConfig{ + ID: string(m.provider.ID), + Name: m.provider.Name, + APIKey: m.input.Value(), + Type: m.provider.Type, + BaseURL: m.provider.APIEndpoint, + } + err := providerConfig.TestConnection(config.Get().Resolver()) + + // intentionally wait for at least 750ms to make sure the user sees the spinner + elapsed := time.Since(start) + minimum := 750 * time.Millisecond + if elapsed < minimum { + time.Sleep(minimum - elapsed) + } + + if err == nil { + return ActionChangeAPIKeyState{APIKeyInputStateVerified} + } + return ActionChangeAPIKeyState{APIKeyInputStateError} +} + +func (m *APIKeyInput) saveKeyAndContinue() Action { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) + if err != nil { + return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} + } + + return ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..96eff11940841e2377e85fafeab9850fb844f139 --- /dev/null +++ b/internal/ui/dialog/arguments.go @@ -0,0 +1,399 @@ +package dialog + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" +) + +// ArgumentsID is the identifier for the arguments dialog. +const ArgumentsID = "arguments" + +// Dialog sizing for arguments. +const ( + maxInputWidth = 120 + minInputWidth = 30 + maxViewportHeight = 20 + argumentsFieldHeight = 3 // label + input + spacing per field +) + +// Arguments represents a dialog for collecting command arguments. +type Arguments struct { + com *common.Common + title string + arguments []commands.Argument + inputs []textinput.Model + focused int + spinner spinner.Model + loading bool + + description string + resultAction Action + + help help.Model + keyMap struct { + Confirm, + Next, + Previous, + ScrollUp, + ScrollDown, + Close key.Binding + } + + viewport viewport.Model +} + +var _ Dialog = (*Arguments)(nil) + +// NewArguments creates a new arguments dialog. +func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments { + a := &Arguments{ + com: com, + title: title, + description: description, + arguments: arguments, + resultAction: resultAction, + } + + a.help = help.New() + a.help.Styles = com.Styles.DialogHelpStyles() + + a.keyMap.Confirm = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + a.keyMap.Next = key.NewBinding( + key.WithKeys("down", "tab"), + key.WithHelp("↓/tab", "next"), + ) + a.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "shift+tab"), + key.WithHelp("↑/shift+tab", "previous"), + ) + a.keyMap.Close = CloseKey + + // Create input fields for each argument. + a.inputs = make([]textinput.Model, len(arguments)) + for i, arg := range arguments { + input := textinput.New() + input.SetVirtualCursor(false) + input.SetStyles(com.Styles.TextInput) + input.Prompt = "> " + // Use description as placeholder if available, otherwise title + if arg.Description != "" { + input.Placeholder = arg.Description + } else { + input.Placeholder = arg.Title + } + + if i == 0 { + input.Focus() + } else { + input.Blur() + } + + a.inputs[i] = input + } + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + a.spinner = s + + return a +} + +// ID implements Dialog. +func (a *Arguments) ID() string { + return ArgumentsID +} + +// focusInput changes focus to a new input by index with wrap-around. +func (a *Arguments) focusInput(newIndex int) { + a.inputs[a.focused].Blur() + + // Wrap around: Go's modulo can return negative, so add len first. + n := len(a.inputs) + a.focused = ((newIndex % n) + n) % n + + a.inputs[a.focused].Focus() + + // Ensure the newly focused field is visible in the viewport + a.ensureFieldVisible(a.focused) +} + +// isFieldVisible checks if a field at the given index is visible in the viewport. +func (a *Arguments) isFieldVisible(fieldIndex int) bool { + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportBottom := viewportTop + a.viewport.Height() - 1 + + return fieldStart >= viewportTop && fieldEnd <= viewportBottom +} + +// ensureFieldVisible scrolls the viewport to make the field visible. +func (a *Arguments) ensureFieldVisible(fieldIndex int) { + if a.isFieldVisible(fieldIndex) { + return + } + + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportHeight := a.viewport.Height() + + // If field is above viewport, scroll up to show it at top + if fieldStart < viewportTop { + a.viewport.SetYOffset(fieldStart) + return + } + + // If field is below viewport, scroll down to show it at bottom + if fieldEnd > viewportTop+viewportHeight-1 { + a.viewport.SetYOffset(fieldEnd - viewportHeight + 1) + } +} + +// findVisibleFieldByOffset returns the field index closest to the given viewport offset. +func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int { + offset := a.viewport.YOffset() + if !fromTop { + offset += a.viewport.Height() - 1 + } + + fieldIndex := offset / argumentsFieldHeight + if fieldIndex >= len(a.inputs) { + return len(a.inputs) - 1 + } + return fieldIndex +} + +// HandleMsg implements Dialog. +func (a *Arguments) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + if a.loading { + var cmd tea.Cmd + a.spinner, cmd = a.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, a.keyMap.Close): + return ActionClose{} + case key.Matches(msg, a.keyMap.Confirm): + // If we're on the last input or there's only one input, submit. + if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 { + args := make(map[string]string) + var warning tea.Cmd + for i, arg := range a.arguments { + args[arg.ID] = a.inputs[i].Value() + if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" { + warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.") + break + } + } + if warning != nil { + return ActionCmd{Cmd: warning} + } + + switch action := a.resultAction.(type) { + case ActionRunCustomCommand: + action.Args = args + return action + case ActionRunMCPPrompt: + action.Args = args + return action + } + } + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Next): + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Previous): + a.focusInput(a.focused - 1) + default: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.MouseWheelMsg: + a.viewport, _ = a.viewport.Update(msg) + // If focused field scrolled out of view, focus the visible field + if !a.isFieldVisible(a.focused) { + a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown)) + } + case tea.PasteMsg: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +// we pass the description height to offset the cursor correctly. +func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor { + cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor()) + if cursor == nil { + return nil + } + cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1 + return cursor +} + +// Draw implements Dialog. +func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + s := a.com.Styles + + dialogContentStyle := s.Dialog.Arguments.Content + possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize() + // Build fields with label and input. + caser := cases.Title(language.English) + + var fields []string + for i, arg := range a.arguments { + isFocused := i == a.focused + + // Try to pretty up the title for the label. + title := strings.ReplaceAll(arg.Title, "_", " ") + title = strings.ReplaceAll(title, "-", " ") + titleParts := strings.Fields(title) + for i, part := range titleParts { + titleParts[i] = caser.String(strings.ToLower(part)) + } + labelText := strings.Join(titleParts, " ") + + markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred + + labelStyle := s.Dialog.Arguments.InputLabelBlurred + if isFocused { + labelStyle = s.Dialog.Arguments.InputLabelFocused + markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused + } + if arg.Required { + labelText += markRequiredStyle.String() + } + label := labelStyle.Render(labelText) + + labelWidth := lipgloss.Width(labelText) + placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder) + + inputWidth := max(placeholderWidth, labelWidth, minInputWidth) + inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth)) + a.inputs[i].SetWidth(inputWidth) + + inputLine := a.inputs[i].View() + + field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "") + fields = append(fields, field) + } + + renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...) + + // Anchor width to the longest field, capped at maxInputWidth. + const scrollbarWidth = 1 + width := lipgloss.Width(renderedFields) + height := lipgloss.Height(renderedFields) + + // Use standard header + titleStyle := s.Dialog.Title + + titleText := a.title + if titleText == "" { + titleText = "Arguments" + } + + header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary) + + // Add description if available. + var description string + if a.description != "" { + descStyle := s.Dialog.Arguments.Description.Width(width) + description = descStyle.Render(a.description) + } + + helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a)) + if a.loading { + helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...") + } + + availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing + viewportHeight := min(height, maxViewportHeight, availableHeight) + + a.viewport.SetWidth(width) // -1 for scrollbar + a.viewport.SetHeight(viewportHeight) + a.viewport.SetContent(renderedFields) + + scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset()) + content := a.viewport.View() + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + contentParts := []string{} + if description != "" { + contentParts = append(contentParts, description) + } + contentParts = append(contentParts, content) + + view := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render(header), + dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)), + helpView, + ) + + dialog := s.Dialog.View.Render(view) + + descriptionHeight := 0 + if a.description != "" { + descriptionHeight = lipgloss.Height(description) + } + cur := a.Cursor(descriptionHeight) + + DrawCenterCursor(scr, area, dialog, cur) + return cur +} + +// StartLoading implements [LoadingDialog]. +func (a *Arguments) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Arguments) StopLoading() { + a.loading = false +} + +// ShortHelp implements help.KeyMap. +func (a *Arguments) ShortHelp() []key.Binding { + return []key.Binding{ + a.keyMap.Confirm, + a.keyMap.Next, + a.keyMap.Close, + } +} + +// FullHelp implements help.KeyMap. +func (a *Arguments) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous}, + {a.keyMap.Close}, + } +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..0b0185b03a3c992ce55ff9164ceba6115260c174 --- /dev/null +++ b/internal/ui/dialog/commands.go @@ -0,0 +1,480 @@ +package dialog + +import ( + "os" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// CommandsID is the identifier for the commands dialog. +const CommandsID = "commands" + +// CommandType represents the type of commands being displayed. +type CommandType uint + +// String returns the string representation of the CommandType. +func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } + +const ( + sidebarCompactModeBreakpoint = 120 + defaultCommandsDialogMaxHeight = 20 + defaultCommandsDialogMaxWidth = 70 +) + +const ( + SystemCommands CommandType = iota + UserCommands + MCPPrompts +) + +// Commands represents a dialog that shows available commands. +type Commands struct { + com *common.Common + keyMap struct { + Select, + UpDown, + Next, + Previous, + Tab, + ShiftTab, + Close key.Binding + } + + sessionID string // can be empty for non-session-specific commands + selected CommandType + + spinner spinner.Model + loading bool + + help help.Model + input textinput.Model + list *list.FilterableList + + windowWidth int + + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt +} + +var _ Dialog = (*Commands)(nil) + +// NewCommands creates a new commands dialog. +func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) { + c := &Commands{ + com: com, + selected: SystemCommands, + sessionID: sessionID, + customCommands: customCommands, + mcpPrompts: mcpPrompts, + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + c.help = help + + c.list = list.NewFilterableList() + c.list.Focus() + c.list.SetSelected(0) + + c.input = textinput.New() + c.input.SetVirtualCursor(false) + c.input.Placeholder = "Type to filter" + c.input.SetStyles(com.Styles.TextInput) + c.input.Focus() + + c.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + c.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + c.keyMap.Next = key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "next item"), + ) + c.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + c.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ) + c.keyMap.ShiftTab = key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "switch selection prev"), + ) + closeKey := CloseKey + closeKey.SetHelp("esc", "cancel") + c.keyMap.Close = closeKey + + // Set initial commands + c.setCommandItems(c.selected) + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + c.spinner = s + + return c, nil +} + +// ID implements Dialog. +func (c *Commands) ID() string { + return CommandsID +} + +// HandleMsg implements [Dialog]. +func (c *Commands) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + if c.loading { + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Close): + return ActionClose{} + case key.Matches(msg, c.keyMap.Previous): + c.list.Focus() + if c.list.IsSelectedFirst() { + c.list.SelectLast() + c.list.ScrollToBottom() + break + } + c.list.SelectPrev() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Next): + c.list.Focus() + if c.list.IsSelectedLast() { + c.list.SelectFirst() + c.list.ScrollToTop() + break + } + c.list.SelectNext() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Select): + if selectedItem := c.list.SelectedItem(); selectedItem != nil { + if item, ok := selectedItem.(*CommandItem); ok && item != nil { + return item.Action() + } + } + case key.Matches(msg, c.keyMap.Tab): + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { + c.selected = c.nextCommandType() + c.setCommandItems(c.selected) + } + case key.Matches(msg, c.keyMap.ShiftTab): + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { + c.selected = c.previousCommandType() + c.setCommandItems(c.selected) + } + default: + var cmd tea.Cmd + for _, item := range c.list.FilteredItems() { + if item, ok := item.(*CommandItem); ok && item != nil { + if msg.String() == item.Shortcut() { + return item.Action() + } + } + } + c.input, cmd = c.input.Update(msg) + value := c.input.Value() + c.list.SetFilter(value) + c.list.ScrollToTop() + c.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (c *Commands) Cursor() *tea.Cursor { + return InputCursor(c.com.Styles, c.input.Cursor()) +} + +// commandsRadioView generates the command type selector radio buttons. +func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string { + if !hasUserCmds && !hasMCPPrompts { + return "" + } + + selectedFn := func(t CommandType) string { + if t == selected { + return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) + } + return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) + } + + parts := []string{ + selectedFn(SystemCommands), + } + + if hasUserCmds { + parts = append(parts, selectedFn(UserCommands)) + } + if hasMCPPrompts { + parts = append(parts, selectedFn(MCPPrompts)) + } + + return strings.Join(parts, " ") +} + +// 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()-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), + // we need to reset the command items when width changes + c.setCommandItems(c.selected) + } + + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + c.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding + + c.list.SetSize(innerWidth, height-heightOffset) + c.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Commands" + rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) + inputView := t.Dialog.InputPrompt.Render(c.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render()) + rc.AddPart(listView) + rc.Help = c.help.View(c) + + if c.loading { + rc.Help = c.spinner.View() + " Generating Prompt..." + } + + view := rc.Render() + + cur := c.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (c *Commands) ShortHelp() []key.Binding { + return []key.Binding{ + c.keyMap.Tab, + c.keyMap.UpDown, + c.keyMap.Select, + c.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (c *Commands) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab}, + {c.keyMap.Close}, + } +} + +// nextCommandType returns the next command type in the cycle. +func (c *Commands) nextCommandType() CommandType { + switch c.selected { + case SystemCommands: + if len(c.customCommands) > 0 { + return UserCommands + } + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + fallthrough + case UserCommands: + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + fallthrough + case MCPPrompts: + return SystemCommands + default: + return SystemCommands + } +} + +// previousCommandType returns the previous command type in the cycle. +func (c *Commands) previousCommandType() CommandType { + switch c.selected { + case SystemCommands: + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + case UserCommands: + return SystemCommands + case MCPPrompts: + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + default: + return SystemCommands + } +} + +// setCommandItems sets the command items based on the specified command type. +func (c *Commands) setCommandItems(commandType CommandType) { + c.selected = commandType + + commandItems := []list.FilterableItem{} + switch c.selected { + case SystemCommands: + for _, cmd := range c.defaultCommands() { + commandItems = append(commandItems, cmd) + } + case UserCommands: + for _, cmd := range c.customCommands { + action := ActionRunCustomCommand{ + Content: cmd.Content, + Arguments: cmd.Arguments, + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)) + } + case MCPPrompts: + for _, cmd := range c.mcpPrompts { + action := ActionRunMCPPrompt{ + Title: cmd.Title, + Description: cmd.Description, + PromptID: cmd.PromptID, + ClientID: cmd.ClientID, + Arguments: cmd.Arguments, + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action)) + } + } + + c.list.SetItems(commandItems...) + c.list.SetFilter("") + c.list.ScrollToTop() + c.list.SetSelected(0) + c.input.SetValue("") +} + +// defaultCommands returns the list of default system commands. +func (c *Commands) defaultCommands() []*CommandItem { + commands := []*CommandItem{ + NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}), + NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}), + NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), + } + + // Only show compact command if there's an active session + if c.sessionID != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID})) + } + + // Add reasoning toggle for models that support it + cfg := c.com.Config() + if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { + providerCfg := cfg.GetProviderForModel(agentCfg.Model) + model := cfg.GetModelByType(agentCfg.Model) + if providerCfg != nil && model != nil && model.CanReason { + selectedModel := cfg.Models[agentCfg.Model] + + // Anthropic models: thinking toggle + if model.CanReason && len(model.ReasoningLevels) == 0 { + status := "Enable" + if selectedModel.Think { + status = "Disable" + } + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{})) + } + + // OpenAI models: reasoning effort dialog + if len(model.ReasoningLevels) > 0 { + commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{ + DialogID: ReasoningID, + })) + } + } + } + // Only show toggle compact mode command if window width is larger than compact breakpoint (120) + if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) + } + if c.sessionID != "" { + cfg := c.com.Config() + agentCfg := cfg.Agents[config.AgentCoder] + model := cfg.GetModelByType(agentCfg.Model) + if model != nil && model.SupportsImages { + commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{ + // TODO: Pass in the file picker dialog id + })) + } + } + + // Add external editor command if $EDITOR is available + // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv + if os.Getenv("EDITOR") != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{})) + } + + return append(commands, + NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}), + NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}), + NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}), + NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}), + ) +} + +// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed. +func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) { + c.customCommands = customCommands + if c.selected == UserCommands { + c.setCommandItems(c.selected) + } +} + +// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed. +func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) { + c.mcpPrompts = mcpPrompts + if c.selected == MCPPrompts { + c.setCommandItems(c.selected) + } +} + +// StartLoading implements [LoadingDialog]. +func (a *Commands) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Commands) StopLoading() { + a.loading = false +} diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go new file mode 100644 index 0000000000000000000000000000000000000000..89cd552f8ef5acfb326e9fbcae87b0a542b35022 --- /dev/null +++ b/internal/ui/dialog/commands_item.go @@ -0,0 +1,76 @@ +package dialog + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/sahilm/fuzzy" +) + +// CommandItem wraps a uicmd.Command to implement the ListItem interface. +type CommandItem struct { + id string + title string + shortcut string + action Action + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var _ ListItem = &CommandItem{} + +// NewCommandItem creates a new CommandItem. +func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem { + return &CommandItem{ + id: id, + t: t, + title: title, + shortcut: shortcut, + action: action, + } +} + +// Filter implements ListItem. +func (c *CommandItem) Filter() string { + return c.title +} + +// ID implements ListItem. +func (c *CommandItem) ID() string { + return c.id +} + +// SetFocused implements ListItem. +func (c *CommandItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// SetMatch implements ListItem. +func (c *CommandItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.m = m +} + +// Action returns the action associated with the command item. +func (c *CommandItem) Action() Action { + return c.action +} + +// Shortcut returns the shortcut associated with the command item. +func (c *CommandItem) Shortcut() string { + return c.shortcut +} + +// Render implements ListItem. +func (c *CommandItem) Render(width int) string { + styles := ListItemStyles{ + ItemBlurred: c.t.Dialog.NormalItem, + ItemFocused: c.t.Dialog.SelectedItem, + InfoTextBlurred: c.t.Base, + InfoTextFocused: c.t.Base, + } + return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m) +} diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go new file mode 100644 index 0000000000000000000000000000000000000000..ca5dcb704d42dc6475369bf6d8020f707e16190e --- /dev/null +++ b/internal/ui/dialog/common.go @@ -0,0 +1,150 @@ +package dialog + +import ( + "image/color" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// InputCursor adjusts the cursor position for an input field within a dialog. +func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { + if cur != nil { + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View + inputStyle := t.Dialog.InputPrompt + // Adjust cursor position to account for dialog layout + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetMarginTop() + + dialogStyle.GetBorderTopSize() + } + return cur +} + +// RenderContext is a dialog rendering context that can be used to render +// common dialog layouts. +type RenderContext struct { + // Styles is the styles to use for rendering. + Styles *styles.Styles + // TitleStyle is the style of the dialog title by default it uses Styles.Dialog.Title + TitleStyle lipgloss.Style + // ViewStyle is the style of the dialog title by default it uses Styles.Dialog.View + ViewStyle lipgloss.Style + // TitleGradientFromColor is the color the title gradient starts by defaults its Style.Primary + TitleGradientFromColor color.Color + // TitleGradientToColor is the color the title gradient starts by defaults its Style.Secondary + TitleGradientToColor color.Color + // Width is the total width of the dialog including any margins, borders, + // and paddings. + Width int + // Gap is the gap between content parts. Zero means no gap. + Gap int + // Title is the title of the dialog. This will be styled using the default + // dialog title style and prepended to the content parts slice. + Title string + // TitleInfo is additional information to display next to the title. This + // part is displayed as is, any styling must be applied before setting this + // field. + TitleInfo string + // Parts are the rendered parts of the dialog. + Parts []string + // Help is the help view content. This will be appended to the content parts + // slice using the default dialog help style. + Help string + // IsOnboarding indicates whether to render the dialog as part of the + // onboarding flow. This means that the content will be rendered at the + // bottom left of the screen. + IsOnboarding bool +} + +// NewRenderContext creates a new RenderContext with the provided styles and width. +func NewRenderContext(t *styles.Styles, width int) *RenderContext { + return &RenderContext{ + Styles: t, + TitleStyle: t.Dialog.Title, + ViewStyle: t.Dialog.View, + TitleGradientFromColor: t.Primary, + TitleGradientToColor: t.Secondary, + Width: width, + Parts: []string{}, + } +} + +// AddPart adds a rendered part to the dialog. +func (rc *RenderContext) AddPart(part string) { + if len(part) > 0 { + rc.Parts = append(rc.Parts, part) + } +} + +// Render renders the dialog using the provided context. +func (rc *RenderContext) Render() string { + titleStyle := rc.TitleStyle + dialogStyle := rc.ViewStyle.Width(rc.Width) + + var parts []string + + if len(rc.Title) > 0 { + var titleInfoWidth int + if len(rc.TitleInfo) > 0 { + titleInfoWidth = lipgloss.Width(rc.TitleInfo) + } + title := common.DialogTitle(rc.Styles, rc.Title, + max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize()- + titleInfoWidth), rc.TitleGradientFromColor, rc.TitleGradientToColor) + if len(rc.TitleInfo) > 0 { + title += rc.TitleInfo + } + parts = append(parts, titleStyle.Render(title)) + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } + } + + if rc.Gap <= 0 { + parts = append(parts, rc.Parts...) + } else { + for i, p := range rc.Parts { + if len(p) > 0 { + parts = append(parts, p) + } + if i < len(rc.Parts)-1 { + parts = append(parts, make([]string, rc.Gap)...) + } + } + } + + if len(rc.Help) > 0 { + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } + helpStyle := rc.Styles.Dialog.HelpView + helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + parts = append(parts, helpView) + } + + content := strings.Join(parts, "\n") + if rc.IsOnboarding { + return content + } + return dialogStyle.Render(content) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go new file mode 100644 index 0000000000000000000000000000000000000000..990b4ed68174bee20d627dec5f7176d9466b77d8 --- /dev/null +++ b/internal/ui/dialog/dialog.go @@ -0,0 +1,213 @@ +package dialog + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// Dialog sizing constants. +const ( + // defaultDialogMaxWidth is the maximum width for standard dialogs. + defaultDialogMaxWidth = 120 + // defaultDialogHeight is the default height for standard dialogs. + defaultDialogHeight = 30 + // titleContentHeight is the height of the title content line. + titleContentHeight = 1 + // inputContentHeight is the height of the input content line. + inputContentHeight = 1 +) + +// CloseKey is the default key binding to close dialogs. +var CloseKey = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "exit"), +) + +// Action represents an action taken in a dialog after handling a message. +type Action any + +// Dialog is a component that can be displayed on top of the UI. +type Dialog interface { + // ID returns the unique identifier of the dialog. + ID() string + // HandleMsg processes a message and returns an action. An [Action] can be + // anything and the caller is responsible for handling it appropriately. + HandleMsg(msg tea.Msg) Action + // Draw draws the dialog onto the provided screen within the specified area + // and returns the desired cursor position on the screen. + Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor +} + +// LoadingDialog is a dialog that can show a loading state. +type LoadingDialog interface { + StartLoading() tea.Cmd + StopLoading() +} + +// Overlay manages multiple dialogs as an overlay. +type Overlay struct { + dialogs []Dialog +} + +// NewOverlay creates a new [Overlay] instance. +func NewOverlay(dialogs ...Dialog) *Overlay { + return &Overlay{ + dialogs: dialogs, + } +} + +// HasDialogs checks if there are any active dialogs. +func (d *Overlay) HasDialogs() bool { + return len(d.dialogs) > 0 +} + +// ContainsDialog checks if a dialog with the specified ID exists. +func (d *Overlay) ContainsDialog(dialogID string) bool { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return true + } + } + return false +} + +// OpenDialog opens a new dialog to the stack. +func (d *Overlay) OpenDialog(dialog Dialog) { + d.dialogs = append(d.dialogs, dialog) +} + +// CloseDialog closes the dialog with the specified ID from the stack. +func (d *Overlay) CloseDialog(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + d.removeDialog(i) + return + } + } +} + +// CloseFrontDialog closes the front dialog in the stack. +func (d *Overlay) CloseFrontDialog() { + if len(d.dialogs) == 0 { + return + } + d.removeDialog(len(d.dialogs) - 1) +} + +// Dialog returns the dialog with the specified ID, or nil if not found. +func (d *Overlay) Dialog(dialogID string) Dialog { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return dialog + } + } + return nil +} + +// DialogLast returns the front dialog, or nil if there are no dialogs. +func (d *Overlay) DialogLast() Dialog { + if len(d.dialogs) == 0 { + return nil + } + return d.dialogs[len(d.dialogs)-1] +} + +// BringToFront brings the dialog with the specified ID to the front. +func (d *Overlay) BringToFront(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + // Move the dialog to the end of the slice + d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...) + d.dialogs = append(d.dialogs, dialog) + return + } + } +} + +// Update handles dialog updates. +func (d *Overlay) Update(msg tea.Msg) tea.Msg { + if len(d.dialogs) == 0 { + return nil + } + + idx := len(d.dialogs) - 1 // active dialog is the last one + dialog := d.dialogs[idx] + if dialog == nil { + return nil + } + + return dialog.HandleMsg(msg) +} + +// StartLoading starts the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StartLoading() tea.Cmd { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + return ld.StartLoading() + } + return nil +} + +// StopLoading stops the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StopLoading() { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + ld.StopLoading() + } +} + +// DrawCenterCursor draws the given string view centered in the screen area and +// adjusts the cursor position accordingly. +func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { + width, height := lipgloss.Size(view) + center := common.CenterRect(area, width, height) + if cur != nil { + cur.X += center.Min.X + cur.Y += center.Min.Y + } + uv.NewStyledString(view).Draw(scr, center) +} + +// DrawCenter draws the given string view centered in the screen area. +func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) { + DrawCenterCursor(scr, area, view, nil) +} + +// DrawOnboarding draws the given string view centered in the screen area. +func DrawOnboarding(scr uv.Screen, area uv.Rectangle, view string) { + DrawOnboardingCursor(scr, area, view, nil) +} + +// DrawOnboardingCursor draws the given string view positioned at the bottom +// left area of the screen. +func DrawOnboardingCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { + width, height := lipgloss.Size(view) + bottomLeft := common.BottomLeftRect(area, width, height) + if cur != nil { + cur.X += bottomLeft.Min.X + cur.Y += bottomLeft.Min.Y + } + uv.NewStyledString(view).Draw(scr, bottomLeft) +} + +// Draw renders the overlay and its dialogs. +func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var cur *tea.Cursor + for _, dialog := range d.dialogs { + cur = dialog.Draw(scr, area) + } + return cur +} + +// removeDialog removes a dialog from the stack. +func (d *Overlay) removeDialog(idx int) { + if idx < 0 || idx >= len(d.dialogs) { + return + } + d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...) +} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0 --- /dev/null +++ b/internal/ui/dialog/filepicker.go @@ -0,0 +1,312 @@ +package dialog + +import ( + "fmt" + "image" + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "os" + "strings" + "sync" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" + fimage "github.com/charmbracelet/crush/internal/ui/image" + uv "github.com/charmbracelet/ultraviolet" +) + +// FilePickerID is the identifier for the FilePicker dialog. +const FilePickerID = "filepicker" + +// FilePicker is a dialog that allows users to select files or directories. +type FilePicker struct { + com *common.Common + + imgEnc fimage.Encoding + imgPrevWidth, imgPrevHeight int + cellSizeW, cellSizeH int + + fp filepicker.Model + help help.Model + previewingImage bool // indicates if an image is being previewed + isTmux bool + + km struct { + Select, + Down, + Up, + Forward, + Backward, + Navigate, + Close key.Binding + } +} + +// 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. +func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { + f := new(FilePicker) + f.com = com + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + f.help = help + + f.km.Select = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "accept"), + ) + f.km.Down = key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ) + f.km.Up = key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ) + f.km.Forward = key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("right/l", "move forward"), + ) + f.km.Backward = key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("left/h", "move backward"), + ) + f.km.Navigate = key.NewBinding( + key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), + key.WithHelp("↑↓←→", "navigate"), + ) + f.km.Close = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "close/exit"), + ) + + fp := filepicker.New() + fp.AllowedTypes = common.AllowedImageTypes + fp.ShowPermissions = false + fp.ShowSize = false + fp.AutoHeight = false + fp.Styles = com.Styles.FilePicker + fp.Cursor = "" + fp.CurrentDirectory = f.WorkingDir() + + f.fp = fp + + return f, f.fp.Init() +} + +// SetImageCapabilities sets the image capabilities for the [FilePicker]. +func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) { + if caps != nil { + if caps.SupportsKittyGraphics() { + f.imgEnc = fimage.EncodingKitty + } + f.cellSizeW, f.cellSizeH = caps.CellSize() + _, f.isTmux = caps.Env.LookupEnv("TMUX") + } +} + +// WorkingDir returns the current working directory of the [FilePicker]. +func (f *FilePicker) WorkingDir() string { + wd := f.com.Config().WorkingDir() + if len(wd) > 0 { + return wd + } + + cwd, err := os.Getwd() + if err != nil { + return home.Dir() + } + + return cwd +} + +// ShortHelp returns the short help key bindings for the [FilePicker] dialog. +func (f *FilePicker) ShortHelp() []key.Binding { + return []key.Binding{ + f.km.Navigate, + f.km.Select, + f.km.Close, + } +} + +// FullHelp returns the full help key bindings for the [FilePicker] dialog. +func (f *FilePicker) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + f.km.Select, + f.km.Down, + f.km.Up, + f.km.Forward, + }, + { + f.km.Backward, + f.km.Close, + }, + } +} + +// ID returns the identifier of the [FilePicker] dialog. +func (f *FilePicker) ID() string { + return FilePickerID +} + +// HandleMsg updates the [FilePicker] dialog based on the given message. +func (f *FilePicker) HandleMsg(msg tea.Msg) Action { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.km.Close): + return ActionClose{} + } + } + + var cmd tea.Cmd + f.fp, cmd = f.fp.Update(msg) + if selFile := f.fp.HighlightedPath(); selFile != "" { + var allowed bool + for _, allowedExt := range f.fp.AllowedTypes { + if strings.HasSuffix(strings.ToLower(selFile), allowedExt) { + allowed = true + break + } + } + + f.previewingImage = allowed + if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + f.previewingImage = false + 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), + func() tea.Msg { + f.previewingImage = true + return nil + }, + )) + } + } + } + if cmd != nil { + cmds = append(cmds, cmd) + } + + if didSelect, path := f.fp.DidSelectFile(msg); didSelect { + return ActionFilePickerSelected{Path: path} + } + + return ActionCmd{tea.Batch(cmds...)} +} + +const ( + filePickerMinWidth = 70 + filePickerMinHeight = 10 +) + +// Draw renders the [FilePicker] dialog as a string. +func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + width := max(0, min(filePickerMinWidth, area.Dx())) + height := max(0, min(10, area.Dy())) + innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize() + imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize() + imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize() + f.imgPrevWidth = imgPrevWidth + f.imgPrevHeight = imgPrevHeight + f.fp.SetHeight(height) + + styles := f.com.Styles.FilePicker + styles.File = styles.File.Width(innerWidth) + styles.Directory = styles.Directory.Width(innerWidth) + styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth) + styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth) + f.fp.Styles = styles + + t := f.com.Styles + rc := NewRenderContext(t, width) + rc.Gap = 1 + rc.Title = "Add Image" + rc.Help = f.help.View(f) + + imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight)) + rc.AddPart(imgPreview) + + files := strings.TrimSpace(f.fp.View()) + rc.AddPart(files) + + view := rc.Render() + + DrawCenter(scr, area, view) + return nil +} + +var ( + imagePreviewCache = map[string]string{} + imagePreviewMutex sync.RWMutex +) + +// imagePreview returns the image preview section of the [FilePicker] dialog. +func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string { + if !f.previewingImage { + key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight) + imagePreviewMutex.RLock() + cached, ok := imagePreviewCache[key] + imagePreviewMutex.RUnlock() + if ok { + return cached + } + + var sb strings.Builder + for y := range imgPrevHeight { + for range imgPrevWidth { + sb.WriteRune('█') + } + if y < imgPrevHeight-1 { + sb.WriteRune('\n') + } + } + + imagePreviewMutex.Lock() + imagePreviewCache[key] = sb.String() + imagePreviewMutex.Unlock() + + return sb.String() + } + + if id := f.fp.HighlightedPath(); id != "" { + r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight) + return r + } + + return "" +} + +func loadImage(path string) (img image.Image, err error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err = image.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go new file mode 100644 index 0000000000000000000000000000000000000000..2f729e19995790fc1bb57fbea4b80191195df8da --- /dev/null +++ b/internal/ui/dialog/models.go @@ -0,0 +1,534 @@ +package dialog + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" +) + +// ModelType represents the type of model to select. +type ModelType int + +const ( + ModelTypeLarge ModelType = iota + ModelTypeSmall +) + +// String returns the string representation of the [ModelType]. +func (mt ModelType) String() string { + switch mt { + case ModelTypeLarge: + return "Large Task" + case ModelTypeSmall: + return "Small Task" + default: + return "Unknown" + } +} + +// Config returns the corresponding config model type. +func (mt ModelType) Config() config.SelectedModelType { + switch mt { + case ModelTypeLarge: + return config.SelectedModelTypeLarge + case ModelTypeSmall: + return config.SelectedModelTypeSmall + default: + return "" + } +} + +// Placeholder returns the input placeholder for the model type. +func (mt ModelType) Placeholder() string { + switch mt { + case ModelTypeLarge: + return largeModelInputPlaceholder + case ModelTypeSmall: + return smallModelInputPlaceholder + default: + return "" + } +} + +const ( + onboardingModelInputPlaceholder = "Find your fave" + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" +) + +// ModelsID is the identifier for the model selection dialog. +const ModelsID = "models" + +const defaultModelsDialogMaxWidth = 70 + +// Models represents a model selection dialog. +type Models struct { + com *common.Common + isOnboarding bool + + modelType ModelType + providers []catwalk.Provider + + keyMap struct { + Tab key.Binding + UpDown key.Binding + Select key.Binding + Next key.Binding + Previous key.Binding + Close key.Binding + } + list *ModelsList + input textinput.Model + help help.Model +} + +var _ Dialog = (*Models)(nil) + +// NewModels creates a new Models dialog. +func NewModels(com *common.Common, isOnboarding bool) (*Models, error) { + t := com.Styles + m := &Models{} + m.com = com + m.isOnboarding = isOnboarding + + help := help.New() + help.Styles = t.DialogHelpStyles() + + m.help = help + m.list = NewModelsList(t) + m.list.Focus() + m.list.SetSelected(0) + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = onboardingModelInputPlaceholder + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + + m.keyMap.Tab = key.NewBinding( + key.WithKeys("tab", "shift+tab"), + key.WithHelp("tab", "toggle type"), + ) + m.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + m.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + m.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + m.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + m.keyMap.Close = CloseKey + + providers, err := getFilteredProviders(com.Config()) + if err != nil { + return nil, fmt.Errorf("failed to get providers: %w", err) + } + + m.providers = providers + if err := m.setProviderItems(); err != nil { + return nil, fmt.Errorf("failed to set provider items: %w", err) + } + + return m, nil +} + +// ID implements Dialog. +func (m *Models) ID() string { + return ModelsID +} + +// HandleMsg implements Dialog. +func (m *Models) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Close): + return ActionClose{} + case key.Matches(msg, m.keyMap.Previous): + m.list.Focus() + if m.list.IsSelectedFirst() { + m.list.SelectLast() + m.list.ScrollToBottom() + break + } + m.list.SelectPrev() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Next): + m.list.Focus() + if m.list.IsSelectedLast() { + m.list.SelectFirst() + m.list.ScrollToTop() + break + } + m.list.SelectNext() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Select): + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + break + } + + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + break + } + + return ActionSelectModel{ + Provider: modelItem.prov, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), + } + case key.Matches(msg, m.keyMap.Tab): + if m.isOnboarding { + break + } + if m.modelType == ModelTypeLarge { + m.modelType = ModelTypeSmall + } else { + m.modelType = ModelTypeLarge + } + if err := m.setProviderItems(); err != nil { + return util.ReportError(err) + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + value := m.input.Value() + m.list.Focus() + m.list.SetFilter(value) + m.list.SelectFirst() + m.list.ScrollToTop() + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor for the dialog. +func (m *Models) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +// modelTypeRadioView returns the radio view for model type selection. +func (m *Models) modelTypeRadioView() string { + t := m.com.Styles + textStyle := t.HalfMuted + largeRadioStyle := t.RadioOff + smallRadioStyle := t.RadioOff + if m.modelType == ModelTypeLarge { + largeRadioStyle = t.RadioOn + } else { + smallRadioStyle = t.RadioOn + } + + largeRadio := largeRadioStyle.Padding(0, 1).Render() + smallRadio := smallRadioStyle.Padding(0, 1).Render() + + return fmt.Sprintf("%s%s %s%s", + largeRadio, textStyle.Render(ModelTypeLarge.String()), + smallRadio, textStyle.Render(ModelTypeSmall.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()-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 + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding + m.list.SetSize(innerWidth, height-heightOffset) + m.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Switch Model" + rc.TitleInfo = m.modelTypeRadioView() + + if m.isOnboarding { + titleText := t.Dialog.PrimaryText.Render("To start, let's choose a provider and model.") + rc.AddPart(titleText) + } + + inputView := t.Dialog.InputPrompt.Render(m.input.View()) + rc.AddPart(inputView) + + listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render()) + rc.AddPart(listView) + + rc.Help = m.help.View(m) + + cur := m.Cursor() + + if m.isOnboarding { + rc.Title = "" + rc.TitleInfo = "" + rc.IsOnboarding = true + view := rc.Render() + DrawOnboardingCursor(scr, area, view, cur) + + // FIXME(@andreynering): Figure it out how to properly fix this + if cur != nil { + cur.Y -= 1 + cur.X -= 1 + } + } else { + view := rc.Render() + DrawCenterCursor(scr, area, view, cur) + } + return cur +} + +// ShortHelp returns the short help view. +func (m *Models) ShortHelp() []key.Binding { + if m.isOnboarding { + return []key.Binding{ + m.keyMap.UpDown, + m.keyMap.Select, + } + } + return []key.Binding{ + m.keyMap.UpDown, + m.keyMap.Tab, + m.keyMap.Select, + m.keyMap.Close, + } +} + +// FullHelp returns the full help view. +func (m *Models) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + m.keyMap.Select, + m.keyMap.Next, + m.keyMap.Previous, + m.keyMap.Tab, + }, + { + m.keyMap.Close, + }, + } +} + +// setProviderItems sets the provider items in the list. +func (m *Models) setProviderItems() error { + t := m.com.Styles + cfg := m.com.Config() + + var selectedItemID string + selectedType := m.modelType.Config() + currentModel := cfg.Models[selectedType] + recentItems := cfg.RecentModels[selectedType] + + // Track providers already added to avoid duplicates + addedProviders := make(map[string]bool) + + // Get a list of known providers to compare against + knownProviders, err := config.Providers(cfg) + if err != nil { + return fmt.Errorf("failed to get providers: %w", err) + } + + containsProviderFunc := func(id string) func(p catwalk.Provider) bool { + return func(p catwalk.Provider) bool { + return p.ID == catwalk.InferenceProvider(id) + } + } + + // itemsMap contains the keys of added model items. + itemsMap := make(map[string]*ModelItem) + groups := []ModelGroup{} + for id, p := range cfg.Providers.Seq2() { + if p.Disable { + continue + } + + // Check if this provider is not in the known providers list + if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) || + !slices.ContainsFunc(m.providers, containsProviderFunc(id)) { + provider := p.ToProvider() + + // Add this unknown provider to the list + name := cmp.Or(p.Name, id) + + addedProviders[id] = true + + group := NewModelGroup(t, name, true) + for _, model := range p.Models { + item := NewModelItem(t, provider, model, m.modelType, false) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + if len(group.Items) > 0 { + groups = append(groups, group) + } + } + } + + // Move "Charm Hyper" to first position. + // (But still after recent models and custom providers). + slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int { + switch { + case a.ID == "hyper": + return -1 + case b.ID == "hyper": + return 1 + default: + return 0 + } + }) + + // Now add known providers from the predefined list + for _, provider := range m.providers { + providerID := string(provider.ID) + if addedProviders[providerID] { + continue + } + + providerConfig, providerConfigured := cfg.Providers.Get(providerID) + if providerConfigured && providerConfig.Disable { + continue + } + + displayProvider := provider + if providerConfigured { + displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name) + modelIndex := make(map[string]int, len(displayProvider.Models)) + for i, model := range displayProvider.Models { + modelIndex[model.ID] = i + } + for _, model := range providerConfig.Models { + if model.ID == "" { + continue + } + if idx, ok := modelIndex[model.ID]; ok { + if model.Name != "" { + displayProvider.Models[idx].Name = model.Name + } + continue + } + if model.Name == "" { + model.Name = model.ID + } + displayProvider.Models = append(displayProvider.Models, model) + modelIndex[model.ID] = len(displayProvider.Models) - 1 + } + } + + name := displayProvider.Name + if name == "" { + name = providerID + } + + group := NewModelGroup(t, name, providerConfigured) + for _, model := range displayProvider.Models { + item := NewModelItem(t, provider, model, m.modelType, false) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + + groups = append(groups, group) + } + + if len(recentItems) > 0 { + recentGroup := NewModelGroup(t, "Recently used", false) + + var validRecentItems []config.SelectedModel + for _, recent := range recentItems { + key := modelKey(recent.Provider, recent.Model) + item, ok := itemsMap[key] + if !ok { + continue + } + + // Show provider for recent items + item = NewModelItem(t, item.prov, item.model, m.modelType, true) + item.showProvider = true + + validRecentItems = append(validRecentItems, recent) + recentGroup.AppendItems(item) + if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { + selectedItemID = item.ID() + } + } + + if len(validRecentItems) != len(recentItems) { + // FIXME: Does this need to be here? Is it mutating the config during a read? + if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { + return fmt.Errorf("failed to update recent models: %w", err) + } + } + + if len(recentGroup.Items) > 0 { + groups = append([]ModelGroup{recentGroup}, groups...) + } + } + + // Set model groups in the list. + m.list.SetGroups(groups...) + m.list.SetSelectedItem(selectedItemID) + m.list.ScrollToTop() + + // Update placeholder based on model type + if !m.isOnboarding { + m.input.Placeholder = m.modelType.Placeholder() + } + + return nil +} + +func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) { + providers, err := config.Providers(cfg) + if err != nil { + return nil, fmt.Errorf("failed to get providers: %w", err) + } + var filteredProviders []catwalk.Provider + for _, p := range providers { + var ( + isAzure = p.ID == catwalk.InferenceProviderAzure + isCopilot = p.ID == catwalk.InferenceProviderCopilot + isHyper = string(p.ID) == "hyper" + hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$") + _, isConfigured = cfg.Providers.Get(string(p.ID)) + ) + if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured { + filteredProviders = append(filteredProviders, p) + } + } + return filteredProviders, nil +} + +func modelKey(providerID, modelID string) string { + if providerID == "" || modelID == "" { + return "" + } + return providerID + ":" + modelID +} diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go new file mode 100644 index 0000000000000000000000000000000000000000..645b26e987b38baabd27338d43a19a4652144788 --- /dev/null +++ b/internal/ui/dialog/models_item.go @@ -0,0 +1,130 @@ +package dialog + +import ( + "charm.land/catwalk/pkg/catwalk" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/sahilm/fuzzy" +) + +// ModelGroup represents a group of model items. +type ModelGroup struct { + Title string + Items []*ModelItem + configured bool + t *styles.Styles +} + +// NewModelGroup creates a new ModelGroup. +func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup { + return ModelGroup{ + Title: title, + Items: items, + configured: configured, + t: t, + } +} + +// AppendItems appends [ModelItem]s to the group. +func (m *ModelGroup) AppendItems(items ...*ModelItem) { + m.Items = append(m.Items, items...) +} + +// Render implements [list.Item]. +func (m *ModelGroup) Render(width int) string { + var configured string + if m.configured { + configuredIcon := m.t.ToolCallSuccess.Render() + configuredText := m.t.Subtle.Render("Configured") + configured = configuredIcon + " " + configuredText + } + + title := " " + m.Title + " " + title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…") + + return common.Section(m.t, title, width, configured) +} + +// ModelItem represents a list item for a model type. +type ModelItem struct { + prov catwalk.Provider + model catwalk.Model + modelType ModelType + + cache map[int]string + t *styles.Styles + m fuzzy.Match + focused bool + showProvider bool +} + +// SelectedModel returns this model item as a [config.SelectedModel] instance. +func (m *ModelItem) SelectedModel() config.SelectedModel { + return config.SelectedModel{ + Model: m.model.ID, + Provider: string(m.prov.ID), + ReasoningEffort: m.model.DefaultReasoningEffort, + MaxTokens: m.model.DefaultMaxTokens, + } +} + +// SelectedModelType returns the type of model represented by this item. +func (m *ModelItem) SelectedModelType() config.SelectedModelType { + return m.modelType.Config() +} + +var _ ListItem = &ModelItem{} + +// NewModelItem creates a new ModelItem. +func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem { + return &ModelItem{ + prov: prov, + model: model, + modelType: typ, + t: t, + cache: make(map[int]string), + showProvider: showProvider, + } +} + +// Filter implements ListItem. +func (m *ModelItem) Filter() string { + return m.model.Name +} + +// ID implements ListItem. +func (m *ModelItem) ID() string { + return modelKey(string(m.prov.ID), m.model.ID) +} + +// Render implements ListItem. +func (m *ModelItem) Render(width int) string { + var providerInfo string + if m.showProvider { + providerInfo = string(m.prov.Name) + } + styles := ListItemStyles{ + ItemBlurred: m.t.Dialog.NormalItem, + ItemFocused: m.t.Dialog.SelectedItem, + InfoTextBlurred: m.t.Base, + InfoTextFocused: m.t.Base, + } + return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) +} + +// SetFocused implements ListItem. +func (m *ModelItem) SetFocused(focused bool) { + if m.focused != focused { + m.cache = nil + } + m.focused = focused +} + +// SetMatch implements ListItem. +func (m *ModelItem) SetMatch(fm fuzzy.Match) { + m.cache = nil + m.m = fm +} diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go new file mode 100644 index 0000000000000000000000000000000000000000..cd2f2a9570d8676ded81384a0720325c52e6e232 --- /dev/null +++ b/internal/ui/dialog/models_list.go @@ -0,0 +1,281 @@ +package dialog + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/sahilm/fuzzy" +) + +// ModelsList is a list specifically for model items and groups. +type ModelsList struct { + *list.List + groups []ModelGroup + query string + t *styles.Styles +} + +// NewModelsList creates a new list suitable for model items and groups. +func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { + f := &ModelsList{ + List: list.NewList(), + groups: groups, + t: sty, + } + f.RegisterRenderCallback(list.FocusedRenderCallback(f.List)) + return f +} + +// Len returns the number of model items across all groups. +func (f *ModelsList) Len() int { + n := 0 + for _, g := range f.groups { + n += len(g.Items) + } + return n +} + +// SetGroups sets the model groups and updates the list items. +func (f *ModelsList) SetGroups(groups ...ModelGroup) { + f.groups = groups + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + f.SetItems(items...) +} + +// SetFilter sets the filter query and updates the list items. +func (f *ModelsList) SetFilter(q string) { + f.query = q + f.SetItems(f.VisibleItems()...) +} + +// SetSelected sets the selected item index. It overrides the base method to +// skip non-model items. +func (f *ModelsList) SetSelected(index int) { + if index < 0 || index >= f.Len() { + f.List.SetSelected(index) + return + } + + f.List.SetSelected(index) + for { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return + } + f.List.SetSelected(index + 1) + index++ + if index >= f.Len() { + return + } + } +} + +// SetSelectedItem sets the selected item in the list by item ID. +func (f *ModelsList) SetSelectedItem(itemID string) { + if itemID == "" { + f.SetSelected(0) + return + } + + count := 0 + for _, g := range f.groups { + for _, item := range g.Items { + if item.ID() == itemID { + f.SetSelected(count) + return + } + count++ + } + } +} + +// SelectNext selects the next model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectNext() (v bool) { + v = f.List.SelectNext() + for v { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectNext() + } + return v +} + +// SelectPrev selects the previous model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectPrev() (v bool) { + v = f.List.SelectPrev() + for v { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectPrev() + } + return v +} + +// SelectFirst selects the first model item in the list. +func (f *ModelsList) SelectFirst() (v bool) { + v = f.List.SelectFirst() + for v { + selectedItem := f.SelectedItem() + _, ok := selectedItem.(*ModelItem) + if ok { + return v + } + v = f.List.SelectNext() + } + return v +} + +// SelectLast selects the last model item in the list. +func (f *ModelsList) SelectLast() (v bool) { + v = f.List.SelectLast() + for v { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectPrev() + } + return v +} + +// IsSelectedFirst checks if the selected item is the first model item. +func (f *ModelsList) IsSelectedFirst() bool { + originalIndex := f.Selected() + f.SelectFirst() + isFirst := f.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isFirst +} + +// IsSelectedLast checks if the selected item is the last model item. +func (f *ModelsList) IsSelectedLast() bool { + originalIndex := f.Selected() + f.SelectLast() + isLast := f.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isLast +} + +// VisibleItems returns the visible items after filtering. +func (f *ModelsList) VisibleItems() []list.Item { + query := strings.ToLower(strings.ReplaceAll(f.query, " ", "")) + + if query == "" { + // No filter, return all items with group headers + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + item.SetMatch(fuzzy.Match{}) + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + return items + } + + filterableItems := make([]list.FilterableItem, 0, f.Len()) + for _, g := range f.groups { + for _, item := range g.Items { + filterableItems = append(filterableItems, item) + } + } + + items := []list.Item{} + visitedGroups := map[int]bool{} + + // Reconstruct groups with matched items + // Find which group this item belongs to + for gi, g := range f.groups { + addedCount := 0 + name := strings.ToLower(g.Title) + " " + + names := make([]string, len(filterableItems)) + for i, item := range filterableItems { + ms := item.(*ModelItem) + names[i] = fmt.Sprintf("%s%s", name, ms.Filter()) + } + + matches := fuzzy.Find(query, names) + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + for _, match := range matches { + item := filterableItems[match.Index].(*ModelItem) + idxs := []int{} + for _, idx := range match.MatchedIndexes { + // Adjusts removing provider name highlights + if idx < len(name) { + continue + } + idxs = append(idxs, idx-len(name)) + } + + match.MatchedIndexes = idxs + if slices.Contains(g.Items, item) { + if !visitedGroups[gi] { + // Add section header + items = append(items, &g) + visitedGroups[gi] = true + } + // Add the matched item + item.SetMatch(match) + items = append(items, item) + addedCount++ + } + } + if addedCount > 0 { + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + } + + return items +} + +// Render renders the filterable list. +func (f *ModelsList) Render() string { + f.SetItems(f.VisibleItems()...) + return f.List.Render() +} + +type modelGroups []ModelGroup + +func (m modelGroups) Len() int { + n := 0 + for _, g := range m { + n += len(g.Items) + } + return n +} + +func (m modelGroups) String(i int) string { + count := 0 + for _, g := range m { + if i < count+len(g.Items) { + return g.Items[i-count].Filter() + } + count += len(g.Items) + } + return "" +} diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..93d5fe052db11d036d29d7790810807d5630bb57 --- /dev/null +++ b/internal/ui/dialog/oauth.go @@ -0,0 +1,388 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "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/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +type OAuthProvider interface { + name() string + initiateAuth() tea.Msg + startPolling(deviceCode string, expiresIn int) tea.Cmd + stopPolling() tea.Msg +} + +// OAuthState represents the current state of the device flow. +type OAuthState int + +const ( + OAuthStateInitializing OAuthState = iota + OAuthStateDisplay + OAuthStateSuccess + OAuthStateError +) + +// OAuthID is the identifier for the model selection dialog. +const OAuthID = "oauth" + +// OAuth handles the OAuth flow authentication. +type OAuth struct { + com *common.Common + isOnboarding bool + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + oAuthProvider OAuthProvider + + State OAuthState + + spinner spinner.Model + help help.Model + keyMap struct { + Copy key.Binding + Submit key.Binding + Close key.Binding + } + + width int + deviceCode string + userCode string + verificationURL string + expiresIn int + interval int + token *oauth.Token + cancelFunc context.CancelFunc +} + +var _ Dialog = (*OAuth)(nil) + +// newOAuth creates a new device flow component. +func newOAuth( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, + oAuthProvider OAuthProvider, +) (*OAuth, tea.Cmd) { + t := com.Styles + + m := OAuth{} + m.com = com + m.isOnboarding = isOnboarding + m.provider = provider + m.model = model + m.modelType = modelType + m.oAuthProvider = oAuthProvider + m.width = 60 + m.State = OAuthStateInitializing + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.GreenLight)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Copy = key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy code"), + ) + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "copy & open"), + ) + m.keyMap.Close = CloseKey + + return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) +} + +// ID implements Dialog. +func (m *OAuth) ID() string { + return OAuthID +} + +// HandleMsg handles messages and state transitions. +func (m *OAuth) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + switch m.State { + case OAuthStateInitializing, OAuthStateDisplay: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Copy): + cmd := m.copyCode() + return ActionCmd{cmd} + + case key.Matches(msg, m.keyMap.Submit): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + cmd := m.copyCodeAndOpenURL() + return ActionCmd{cmd} + } + + case key.Matches(msg, m.keyMap.Close): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + return ActionClose{} + } + } + + case ActionInitiateOAuth: + m.deviceCode = msg.DeviceCode + m.userCode = msg.UserCode + m.expiresIn = msg.ExpiresIn + m.verificationURL = msg.VerificationURL + m.interval = msg.Interval + m.State = OAuthStateDisplay + return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} + + case ActionCompleteOAuth: + m.State = OAuthStateSuccess + m.token = msg.Token + return ActionCmd{m.oAuthProvider.stopPolling} + + case ActionOAuthErrored: + m.State = OAuthStateError + cmd := tea.Batch(m.oAuthProvider.stopPolling, util.ReportError(msg.Error)) + return ActionCmd{cmd} + } + return nil +} + +// View renders the device flow dialog. +func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var ( + t = m.com.Styles + dialogStyle = t.Dialog.View.Width(m.width) + ) + if m.isOnboarding { + view := m.dialogContent() + DrawOnboarding(scr, area, view) + } else { + view := dialogStyle.Render(m.dialogContent()) + DrawCenter(scr, area, view) + } + return nil +} + +func (m *OAuth) dialogContent() string { + var ( + t = m.com.Styles + helpStyle = t.Dialog.HelpView + ) + + switch m.State { + case OAuthStateInitializing: + return m.innerDialogContent() + + default: + elements := []string{ + m.headerContent(), + m.innerDialogContent(), + helpStyle.Render(m.help.View(m)), + } + return strings.Join(elements, "\n") + } +} + +func (m *OAuth) headerContent() string { + var ( + t = m.com.Styles + titleStyle = t.Dialog.Title + textStyle = t.Dialog.PrimaryText + dialogStyle = t.Dialog.View.Width(m.width) + headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + dialogTitle = fmt.Sprintf("Authenticate with %s", m.oAuthProvider.name()) + ) + if m.isOnboarding { + return textStyle.Render(dialogTitle) + } + return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset, t.Primary, t.Secondary) +} + +func (m *OAuth) innerDialogContent() string { + var ( + t = m.com.Styles + whiteStyle = lipgloss.NewStyle().Foreground(t.White) + primaryStyle = lipgloss.NewStyle().Foreground(t.Primary) + greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight) + linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) + errorStyle = lipgloss.NewStyle().Foreground(t.Error) + mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted) + ) + + switch m.State { + case OAuthStateInitializing: + return lipgloss.NewStyle(). + Margin(1, 1). + Width(m.width - 2). + Align(lipgloss.Center). + Render( + greenStyle.Render(m.spinner.View()) + + mutedStyle.Render("Initializing..."), + ) + + case OAuthStateDisplay: + instructions := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + whiteStyle.Render("Press ") + + primaryStyle.Render("enter") + + whiteStyle.Render(" to copy the code below and open the browser."), + ) + + codeBox := lipgloss.NewStyle(). + Width(m.width-2). + Height(7). + Align(lipgloss.Center, lipgloss.Center). + Background(t.BgBaseLighter). + Margin(0, 1). + Render( + lipgloss.NewStyle(). + Bold(true). + Foreground(t.White). + Render(m.userCode), + ) + + link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL) + url := mutedStyle. + Margin(0, 1). + Width(m.width - 2). + Render("Browser not opening? Refer to\n" + link) + + waiting := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + "", + instructions, + "", + codeBox, + "", + url, + "", + waiting, + "", + ) + + case OAuthStateSuccess: + return greenStyle. + Margin(1). + Width(m.width - 2). + Render("Authentication successful!") + + case OAuthStateError: + return lipgloss.NewStyle(). + Margin(1). + Width(m.width - 2). + Render(errorStyle.Render("Authentication failed.")) + + default: + return "" + } +} + +// FullHelp returns the full help view. +func (m *OAuth) FullHelp() [][]key.Binding { + return [][]key.Binding{m.ShortHelp()} +} + +// ShortHelp returns the full help view. +func (m *OAuth) ShortHelp() []key.Binding { + switch m.State { + case OAuthStateError: + return []key.Binding{m.keyMap.Close} + + case OAuthStateSuccess: + return []key.Binding{ + key.NewBinding( + key.WithKeys("finish", "ctrl+y", "esc"), + key.WithHelp("enter", "finish"), + ), + } + + default: + return []key.Binding{ + m.keyMap.Copy, + m.keyMap.Submit, + m.keyMap.Close, + } + } +} + +func (d *OAuth) copyCode() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + util.ReportInfo("Code copied to clipboard"), + ) +} + +func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + func() tea.Msg { + if err := browser.OpenURL(d.verificationURL); err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + util.ReportInfo("Code copied and URL opened"), + ) +} + +func (m *OAuth) saveKeyAndContinue() Action { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) + if err != nil { + return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} + } + + return ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..8afb0df23134bb9e820ae2385d6b9b6838e07d98 --- /dev/null +++ b/internal/ui/dialog/oauth_copilot.go @@ -0,0 +1,78 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "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" +) + +func NewOAuthCopilot( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*OAuth, tea.Cmd) { + return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthCopilot{}) +} + +type OAuthCopilot struct { + deviceCode *copilot.DeviceCode + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthCopilot)(nil) + +func (m *OAuthCopilot) name() string { + return "GitHub Copilot" +} + +func (m *OAuthCopilot) initiateAuth() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deviceCode, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)} + } + + m.deviceCode = deviceCode + + return ActionInitiateOAuth{ + DeviceCode: deviceCode.DeviceCode, + UserCode: deviceCode.UserCode, + VerificationURL: deviceCode.VerificationURI, + ExpiresIn: deviceCode.ExpiresIn, + Interval: deviceCode.Interval, + } +} + +func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + token, err := copilot.PollForToken(ctx, m.deviceCode) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled, don't report error. + } + return ActionOAuthErrored{Error: err} + } + + return ActionCompleteOAuth{Token: token} + } +} + +func (m *OAuthCopilot) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go new file mode 100644 index 0000000000000000000000000000000000000000..d90c385db782478721fd3e9efa49a2984f34304d --- /dev/null +++ b/internal/ui/dialog/oauth_hyper.go @@ -0,0 +1,96 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "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" +) + +func NewOAuthHyper( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*OAuth, tea.Cmd) { + return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthHyper{}) +} + +type OAuthHyper struct { + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthHyper)(nil) + +func (m *OAuthHyper) name() string { + return "Hyper" +} + +func (m *OAuthHyper) initiateAuth() tea.Msg { + minimumWait := 750 * time.Millisecond + startTime := time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + authResp, err := hyper.InitiateDeviceAuth(ctx) + + ellapsed := time.Since(startTime) + if ellapsed < minimumWait { + time.Sleep(minimumWait - ellapsed) + } + + if err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} + } + + return ActionInitiateOAuth{ + DeviceCode: authResp.DeviceCode, + UserCode: authResp.UserCode, + ExpiresIn: authResp.ExpiresIn, + VerificationURL: authResp.VerificationURL, + } +} + +func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn) + if err != nil { + if ctx.Err() != nil { + return nil + } + return ActionOAuthErrored{err} + } + + token, err := hyper.ExchangeToken(ctx, refreshToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} + } + + introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} + } + if !introspect.Active { + return ActionOAuthErrored{fmt.Errorf("access token is not active")} + } + + return ActionCompleteOAuth{token} + } +} + +func (m *OAuthHyper) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..daabc10b1aea0ee9db6c4e3608be62e7cfcbfd39 --- /dev/null +++ b/internal/ui/dialog/permissions.go @@ -0,0 +1,790 @@ +package dialog + +import ( + "encoding/json" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// PermissionsID is the identifier for the permissions dialog. +const PermissionsID = "permissions" + +// PermissionAction represents the user's response to a permission request. +type PermissionAction string + +const ( + PermissionAllow PermissionAction = "allow" + PermissionAllowForSession PermissionAction = "allow_session" + PermissionDeny PermissionAction = "deny" +) + +// Permissions dialog sizing constants. +const ( + // diffMaxWidth is the maximum width for diff views. + diffMaxWidth = 180 + // diffSizeRatio is the size ratio for diff views relative to window. + diffSizeRatio = 0.8 + // simpleMaxWidth is the maximum width for simple content dialogs. + simpleMaxWidth = 100 + // simpleSizeRatio is the size ratio for simple content dialogs. + simpleSizeRatio = 0.6 + // simpleHeightRatio is the height ratio for simple content dialogs. + simpleHeightRatio = 0.5 + // splitModeMinWidth is the minimum width to enable split diff mode. + splitModeMinWidth = 140 + // layoutSpacingLines is the number of empty lines used for layout spacing. + layoutSpacingLines = 4 + // minWindowWidth is the minimum window width before forcing fullscreen. + minWindowWidth = 77 + // minWindowHeight is the minimum window height before forcing fullscreen. + minWindowHeight = 20 +) + +// Permissions represents a dialog for permission requests. +type Permissions struct { + com *common.Common + windowWidth int // Terminal window dimensions. + windowHeight int + fullscreen bool // true when dialog is fullscreen + + permission permission.PermissionRequest + selectedOption int // 0: Allow, 1: Allow for session, 2: Deny + + viewport viewport.Model + viewportDirty bool // true when viewport content needs to be re-rendered + viewportWidth int + + // Diff view state. + diffSplitMode *bool // nil means use default based on width + defaultDiffSplitMode bool // default split mode based on width + diffXOffset int // horizontal scroll offset for diff view + unifiedDiffContent string + splitDiffContent string + + help help.Model + keyMap permissionsKeyMap +} + +type permissionsKeyMap struct { + Left key.Binding + Right key.Binding + Tab key.Binding + Select key.Binding + Allow key.Binding + AllowSession key.Binding + Deny key.Binding + Close key.Binding + ToggleDiffMode key.Binding + ToggleFullscreen key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Choose key.Binding + Scroll key.Binding +} + +func defaultPermissionsKeyMap() permissionsKeyMap { + return permissionsKeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←", "previous"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→", "next"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next option"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Allow: key.NewBinding( + key.WithKeys("a", "A", "ctrl+a"), + key.WithHelp("a", "allow"), + ), + AllowSession: key.NewBinding( + key.WithKeys("s", "S", "ctrl+s"), + key.WithHelp("s", "allow session"), + ), + Deny: key.NewBinding( + key.WithKeys("d", "D"), + key.WithHelp("d", "deny"), + ), + Close: CloseKey, + ToggleDiffMode: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "toggle diff view"), + ), + ToggleFullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle fullscreen"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "scroll down"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("shift+left", "H"), + key.WithHelp("shift+←", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("shift+right", "L"), + key.WithHelp("shift+→", "scroll right"), + ), + Choose: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "choose"), + ), + Scroll: key.NewBinding( + key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), + key.WithHelp("shift+←↓↑→", "scroll"), + ), + } +} + +var _ Dialog = (*Permissions)(nil) + +// PermissionsOption configures the permissions dialog. +type PermissionsOption func(*Permissions) + +// WithDiffMode sets the initial diff mode (split or unified). +func WithDiffMode(split bool) PermissionsOption { + return func(p *Permissions) { + p.diffSplitMode = &split + } +} + +// NewPermissions creates a new permissions dialog. +func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions { + h := help.New() + h.Styles = com.Styles.DialogHelpStyles() + + km := defaultPermissionsKeyMap() + + // Configure viewport with matching keybindings. + vp := viewport.New() + vp.KeyMap = viewport.KeyMap{ + Up: km.ScrollUp, + Down: km.ScrollDown, + Left: km.ScrollLeft, + Right: km.ScrollRight, + // Disable other viewport keys to avoid conflicts with dialog shortcuts. + PageUp: key.NewBinding(key.WithDisabled()), + PageDown: key.NewBinding(key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithDisabled()), + HalfPageDown: key.NewBinding(key.WithDisabled()), + } + + p := &Permissions{ + com: com, + permission: perm, + selectedOption: 0, + viewport: vp, + help: h, + keyMap: km, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// Calculate usable content width (dialog border + horizontal padding). +func (p *Permissions) calculateContentWidth(width int) int { + t := p.com.Styles + const dialogHorizontalPadding = 2 + return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding +} + +// ID implements [Dialog]. +func (*Permissions) ID() string { + return PermissionsID +} + +// HandleMsg implements [Dialog]. +func (p *Permissions) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.Close): + // Escape denies the permission request. + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab): + p.selectedOption = (p.selectedOption + 1) % 3 + case key.Matches(msg, p.keyMap.Left): + // Add 2 instead of subtracting 1 to avoid negative modulo. + p.selectedOption = (p.selectedOption + 2) % 3 + case key.Matches(msg, p.keyMap.Select): + return p.selectCurrentOption() + case key.Matches(msg, p.keyMap.Allow): + return p.respond(PermissionAllow) + case key.Matches(msg, p.keyMap.AllowSession): + return p.respond(PermissionAllowForSession) + case key.Matches(msg, p.keyMap.Deny): + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.ToggleDiffMode): + if p.hasDiffView() { + newMode := !p.isSplitMode() + p.diffSplitMode = &newMode + p.viewportDirty = true + } + case key.Matches(msg, p.keyMap.ToggleFullscreen): + if p.hasDiffView() { + p.fullscreen = !p.fullscreen + } + case key.Matches(msg, p.keyMap.ScrollDown): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollUp): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollLeft): + if p.hasDiffView() { + p.scrollLeft() + } else { + p.viewport, _ = p.viewport.Update(msg) + } + case key.Matches(msg, p.keyMap.ScrollRight): + if p.hasDiffView() { + p.scrollRight() + } else { + p.viewport, _ = p.viewport.Update(msg) + } + } + case tea.MouseWheelMsg: + if p.hasDiffView() { + switch msg.Button { + case tea.MouseWheelLeft: + p.scrollLeft() + case tea.MouseWheelRight: + p.scrollRight() + default: + p.viewport, _ = p.viewport.Update(msg) + } + } else { + p.viewport, _ = p.viewport.Update(msg) + } + default: + // Pass unhandled keys to viewport for non-diff content scrolling. + if !p.hasDiffView() { + p.viewport, _ = p.viewport.Update(msg) + p.viewportDirty = true + } + } + + return nil +} + +func (p *Permissions) selectCurrentOption() tea.Msg { + switch p.selectedOption { + case 0: + return p.respond(PermissionAllow) + case 1: + return p.respond(PermissionAllowForSession) + default: + return p.respond(PermissionDeny) + } +} + +func (p *Permissions) respond(action PermissionAction) tea.Msg { + return ActionPermissionResponse{ + Permission: p.permission, + Action: action, + } +} + +func (p *Permissions) hasDiffView() bool { + switch p.permission.ToolName { + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName: + return true + } + return false +} + +func (p *Permissions) isSplitMode() bool { + if p.diffSplitMode != nil { + return *p.diffSplitMode + } + return p.defaultDiffSplitMode +} + +const horizontalScrollStep = 5 + +func (p *Permissions) scrollLeft() { + p.diffXOffset = max(0, p.diffXOffset-horizontalScrollStep) + p.viewportDirty = true +} + +func (p *Permissions) scrollRight() { + p.diffXOffset += horizontalScrollStep + p.viewportDirty = true +} + +// Draw implements [Dialog]. +func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := p.com.Styles + // Force fullscreen when window is too small. + forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight + + // Calculate dialog dimensions based on fullscreen state and content type. + var width, maxHeight int + if forceFullscreen || (p.fullscreen && p.hasDiffView()) { + // Use nearly full window for fullscreen. + width = area.Dx() + maxHeight = area.Dy() + } else if p.hasDiffView() { + // Wide for side-by-side diffs, capped for readability. + width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth) + maxHeight = int(float64(area.Dy()) * diffSizeRatio) + } else { + // Narrower for simple content like commands/URLs. + width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth) + maxHeight = int(float64(area.Dy()) * simpleHeightRatio) + } + + dialogStyle := t.Dialog.View.Width(width).Padding(0, 1) + + contentWidth := p.calculateContentWidth(width) + header := p.renderHeader(contentWidth) + buttons := p.renderButtons(contentWidth) + helpView := p.help.View(p) + + // Calculate available height for content. + headerHeight := lipgloss.Height(header) + buttonsHeight := lipgloss.Height(buttons) + helpHeight := lipgloss.Height(helpView) + frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines + + p.defaultDiffSplitMode = width >= splitModeMinWidth + + // Pre-render content to measure its actual height. + renderedContent := p.renderContent(contentWidth) + contentHeight := lipgloss.Height(renderedContent) + + // For non-diff views, shrink dialog to fit content if it's smaller than max. + var availableHeight int + if !p.hasDiffView() && !forceFullscreen { + fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight + neededHeight := fixedHeight + contentHeight + if neededHeight < maxHeight { + availableHeight = contentHeight + } else { + availableHeight = maxHeight - fixedHeight + } + availableHeight = max(availableHeight, 3) + } else { + availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight + } + + // Determine if scrollbar is needed. + needsScrollbar := p.hasDiffView() || contentHeight > availableHeight + viewportWidth := contentWidth + if needsScrollbar { + viewportWidth = contentWidth - 1 // Reserve space for scrollbar. + } + + if p.viewport.Width() != viewportWidth { + // Mark content as dirty if width has changed. + p.viewportDirty = true + renderedContent = p.renderContent(viewportWidth) + } + + var content string + var scrollbar string + p.viewport.SetWidth(viewportWidth) + p.viewport.SetHeight(availableHeight) + if p.viewportDirty { + p.viewport.SetContent(renderedContent) + p.viewportWidth = p.viewport.Width() + p.viewportDirty = false + } + content = p.viewport.View() + if needsScrollbar { + scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset()) + } + + // Join content with scrollbar if present. + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + + parts := []string{header} + if content != "" { + parts = append(parts, "", content) + } + parts = append(parts, "", buttons, "", helpView) + + innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...) + DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil) + return nil +} + +func (p *Permissions) renderHeader(contentWidth int) string { + t := p.com.Styles + + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary) + title = t.Dialog.Title.Render(title) + + // Tool info. + toolLine := p.renderToolName(contentWidth) + pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth) + + lines := []string{title, "", toolLine, pathLine} + + // Add tool-specific header info. + switch p.permission.ToolName { + case tools.BashToolName: + if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth)) + } + case tools.DownloadToolName: + if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth)) + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth)) + } + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: + var filePath string + switch params := p.permission.Params.(type) { + case tools.EditPermissionsParams: + filePath = params.FilePath + case tools.WritePermissionsParams: + filePath = params.FilePath + case tools.MultiEditPermissionsParams: + filePath = params.FilePath + case tools.ViewPermissionsParams: + filePath = params.FilePath + } + if filePath != "" { + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth)) + } + case tools.LSToolName: + if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth)) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (p *Permissions) renderKeyValue(key, value string, width int) string { + t := p.com.Styles + keyStyle := t.Muted + valueStyle := t.Base + + keyStr := keyStyle.Render(key) + valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value) + + return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr) +} + +func (p *Permissions) renderToolName(width int) string { + toolName := p.permission.ToolName + + // Check if this is an MCP tool (format: mcp__). + if strings.HasPrefix(toolName, "mcp_") { + parts := strings.SplitN(toolName, "_", 3) + if len(parts) == 3 { + mcpName := prettyName(parts[1]) + toolPart := prettyName(parts[2]) + toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart) + } + } + + return p.renderKeyValue("Tool", toolName, width) +} + +// prettyName converts snake_case or kebab-case to Title Case. +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + +func (p *Permissions) renderContent(width int) string { + switch p.permission.ToolName { + case tools.BashToolName: + return p.renderBashContent(width) + case tools.EditToolName: + return p.renderEditContent(width) + case tools.WriteToolName: + return p.renderWriteContent(width) + case tools.MultiEditToolName: + return p.renderMultiEditContent(width) + case tools.DownloadToolName: + return p.renderDownloadContent(width) + case tools.FetchToolName: + return p.renderFetchContent(width) + case tools.AgenticFetchToolName: + return p.renderAgenticFetchContent(width) + case tools.ViewToolName: + return p.renderViewContent(width) + case tools.LSToolName: + return p.renderLSContent(width) + default: + return p.renderDefaultContent(width) + } +} + +func (p *Permissions) renderBashContent(width int) string { + params, ok := p.permission.Params.(tools.BashPermissionsParams) + if !ok { + return "" + } + + return p.renderContentPanel(params.Command, width) +} + +func (p *Permissions) renderEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.EditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderWriteContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.WritePermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderMultiEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.MultiEditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string { + if !p.viewportDirty { + if p.isSplitMode() { + return p.splitDiffContent + } + return p.unifiedDiffContent + } + + isSplitMode := p.isSplitMode() + formatter := common.DiffFormatter(p.com.Styles). + Before(fsext.PrettyPath(filePath), oldContent). + After(fsext.PrettyPath(filePath), newContent). + XOffset(p.diffXOffset). + Width(contentWidth) + + var result string + if isSplitMode { + formatter = formatter.Split() + p.splitDiffContent = formatter.String() + result = p.splitDiffContent + } else { + formatter = formatter.Unified() + p.unifiedDiffContent = formatter.String() + result = p.unifiedDiffContent + } + + return result +} + +func (p *Permissions) renderDownloadContent(width int) string { + params, ok := p.permission.Params.(tools.DownloadPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath)) + if params.Timeout > 0 { + content += fmt.Sprintf("\nTimeout: %ds", params.Timeout) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderFetchContent(width int) string { + params, ok := p.permission.Params.(tools.FetchPermissionsParams) + if !ok { + return "" + } + + return p.renderContentPanel(params.URL, width) +} + +func (p *Permissions) renderAgenticFetchContent(width int) string { + params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams) + if !ok { + return "" + } + + var content string + if params.URL != "" { + content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt) + } else { + content = fmt.Sprintf("Prompt: %s", params.Prompt) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderViewContent(width int) string { + params, ok := p.permission.Params.(tools.ViewPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath)) + if params.Offset > 0 { + content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1) + } + if params.Limit > 0 && params.Limit != 2000 { + content += fmt.Sprintf("\nLines to read: %d", params.Limit) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderLSContent(width int) string { + params, ok := p.permission.Params.(tools.LSPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path)) + if len(params.Ignore) > 0 { + content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", ")) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderDefaultContent(width int) string { + t := p.com.Styles + var content string + // do not add the description for mcp tools + if !strings.HasPrefix(p.permission.ToolName, "mcp_") { + content = p.permission.Description + } + + // Pretty-print JSON params if available. + if p.permission.Params != nil { + var paramStr string + if str, ok := p.permission.Params.(string); ok { + paramStr = str + } else { + paramStr = fmt.Sprintf("%v", p.permission.Params) + } + + var parsed any + if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { + if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + jsonContent := string(b) + highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle) + if err == nil { + jsonContent = highlighted + } + if content != "" { + content += "\n\n" + } + content += jsonContent + } + } else if paramStr != "" { + if content != "" { + content += "\n\n" + } + content += paramStr + } + } + + if content == "" { + return "" + } + + return p.renderContentPanel(strings.TrimSpace(content), width) +} + +// renderContentPanel renders content in a panel with the full width. +func (p *Permissions) renderContentPanel(content string, width int) string { + panelStyle := p.com.Styles.Dialog.ContentPanel + return panelStyle.Width(width).Render(content) +} + +func (p *Permissions) renderButtons(contentWidth int) string { + buttons := []common.ButtonOpts{ + {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0}, + {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1}, + {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2}, + } + + content := common.ButtonGroup(p.com.Styles, buttons, " ") + + // If buttons are too wide, stack them vertically. + if lipgloss.Width(content) > contentWidth { + content = common.ButtonGroup(p.com.Styles, buttons, "\n") + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Center). + Render(content) + } + + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Right). + Render(content) +} + +func (p *Permissions) canScroll() bool { + if p.hasDiffView() { + // Diff views can always scroll. + return true + } + // For non-diff content, check if viewport has scrollable content. + return !p.viewport.AtTop() || !p.viewport.AtBottom() +} + +// ShortHelp implements [help.KeyMap]. +func (p *Permissions) ShortHelp() []key.Binding { + bindings := []key.Binding{ + p.keyMap.Choose, + p.keyMap.Select, + p.keyMap.Close, + } + + if p.canScroll() { + bindings = append(bindings, p.keyMap.Scroll) + } + + if p.hasDiffView() { + bindings = append(bindings, + p.keyMap.ToggleDiffMode, + p.keyMap.ToggleFullscreen, + ) + } + + return bindings +} + +// FullHelp implements [help.KeyMap]. +func (p *Permissions) FullHelp() [][]key.Binding { + return [][]key.Binding{p.ShortHelp()} +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go new file mode 100644 index 0000000000000000000000000000000000000000..11173f0eaddb35a0b96aad6b1bf957ec86a37044 --- /dev/null +++ b/internal/ui/dialog/quit.go @@ -0,0 +1,133 @@ +package dialog + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// QuitID is the identifier for the quit dialog. +const QuitID = "quit" + +// Quit represents a confirmation dialog for quitting the application. +type Quit struct { + com *common.Common + selectedNo bool // true if "No" button is selected + keyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close, + Quit key.Binding + } +} + +var _ Dialog = (*Quit)(nil) + +// NewQuit creates a new quit confirmation dialog. +func NewQuit(com *common.Common) *Quit { + q := &Quit{ + com: com, + selectedNo: true, + } + q.keyMap.LeftRight = key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch options"), + ) + q.keyMap.EnterSpace = key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "confirm"), + ) + q.keyMap.Yes = key.NewBinding( + key.WithKeys("y", "Y", "ctrl+c"), + key.WithHelp("y/Y/ctrl+c", "yes"), + ) + q.keyMap.No = key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n/N", "no"), + ) + q.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch options"), + ) + q.keyMap.Close = CloseKey + q.keyMap.Quit = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ) + return q +} + +// ID implements [Model]. +func (*Quit) ID() string { + return QuitID +} + +// HandleMsg implements [Model]. +func (q *Quit) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, q.keyMap.Quit): + return ActionQuit{} + case key.Matches(msg, q.keyMap.Close): + return ActionClose{} + case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): + q.selectedNo = !q.selectedNo + case key.Matches(msg, q.keyMap.EnterSpace): + if !q.selectedNo { + return ActionQuit{} + } + return ActionClose{} + case key.Matches(msg, q.keyMap.Yes): + return ActionQuit{} + case key.Matches(msg, q.keyMap.No, q.keyMap.Close): + return ActionClose{} + } + } + + return nil +} + +// Draw implements [Dialog]. +func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + const question = "Are you sure you want to quit?" + baseStyle := q.com.Styles.Base + buttonOpts := []common.ButtonOpts{ + {Text: "Yep!", Selected: !q.selectedNo, Padding: 3}, + {Text: "Nope", Selected: q.selectedNo, Padding: 3}, + } + buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ") + content := baseStyle.Render( + lipgloss.JoinVertical( + lipgloss.Center, + question, + "", + buttons, + ), + ) + + view := q.com.Styles.BorderFocus.Render(content) + DrawCenter(scr, area, view) + return nil +} + +// ShortHelp implements [help.KeyMap]. +func (q *Quit) ShortHelp() []key.Binding { + return []key.Binding{ + q.keyMap.LeftRight, + q.keyMap.EnterSpace, + } +} + +// FullHelp implements [help.KeyMap]. +func (q *Quit) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No}, + {q.keyMap.Tab, q.keyMap.Close}, + } +} diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..2a333f155cdc1499993f05411d7090793f74f54e --- /dev/null +++ b/internal/ui/dialog/reasoning.go @@ -0,0 +1,303 @@ +package dialog + +import ( + "errors" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/sahilm/fuzzy" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + // ReasoningID is the identifier for the reasoning effort dialog. + ReasoningID = "reasoning" + reasoningDialogMaxWidth = 80 + reasoningDialogMaxHeight = 12 +) + +// Reasoning represents a dialog for selecting reasoning effort. +type Reasoning struct { + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Close key.Binding + } +} + +// ReasoningItem represents a reasoning effort list item. +type ReasoningItem struct { + effort string + title string + isCurrent bool + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var ( + _ Dialog = (*Reasoning)(nil) + _ ListItem = (*ReasoningItem)(nil) +) + +// NewReasoning creates a new reasoning effort dialog. +func NewReasoning(com *common.Common) (*Reasoning, error) { + r := &Reasoning{com: com} + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + r.help = help + + r.list = list.NewFilterableList() + r.list.Focus() + + r.input = textinput.New() + r.input.SetVirtualCursor(false) + r.input.Placeholder = "Type to filter" + r.input.SetStyles(com.Styles.TextInput) + r.input.Focus() + + r.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + r.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + r.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + r.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + r.keyMap.Close = CloseKey + + if err := r.setReasoningItems(); err != nil { + return nil, err + } + + return r, nil +} + +// ID implements Dialog. +func (r *Reasoning) ID() string { + return ReasoningID +} + +// HandleMsg implements [Dialog]. +func (r *Reasoning) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, r.keyMap.Close): + return ActionClose{} + case key.Matches(msg, r.keyMap.Previous): + r.list.Focus() + if r.list.IsSelectedFirst() { + r.list.SelectLast() + r.list.ScrollToBottom() + break + } + r.list.SelectPrev() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Next): + r.list.Focus() + if r.list.IsSelectedLast() { + r.list.SelectFirst() + r.list.ScrollToTop() + break + } + r.list.SelectNext() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Select): + selectedItem := r.list.SelectedItem() + if selectedItem == nil { + break + } + reasoningItem, ok := selectedItem.(*ReasoningItem) + if !ok { + break + } + return ActionSelectReasoningEffort{Effort: reasoningItem.effort} + default: + var cmd tea.Cmd + r.input, cmd = r.input.Update(msg) + value := r.input.Value() + r.list.SetFilter(value) + r.list.ScrollToTop() + r.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (r *Reasoning) Cursor() *tea.Cursor { + return InputCursor(r.com.Styles, r.input.Cursor()) +} + +// Draw implements [Dialog]. +func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := r.com.Styles + width := max(0, min(reasoningDialogMaxWidth, area.Dx())) + height := max(0, min(reasoningDialogMaxHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + r.list.SetSize(innerWidth, height-heightOffset) + r.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Select Reasoning Effort" + inputView := t.Dialog.InputPrompt.Render(r.input.View()) + rc.AddPart(inputView) + + visibleCount := len(r.list.FilteredItems()) + if r.list.Height() >= visibleCount { + r.list.ScrollToTop() + } else { + r.list.ScrollToSelected() + } + + listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render()) + rc.AddPart(listView) + rc.Help = r.help.View(r) + + view := rc.Render() + + cur := r.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (r *Reasoning) ShortHelp() []key.Binding { + return []key.Binding{ + r.keyMap.UpDown, + r.keyMap.Select, + r.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (r *Reasoning) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + r.keyMap.Select, + r.keyMap.Next, + r.keyMap.Previous, + r.keyMap.Close, + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +func (r *Reasoning) setReasoningItems() error { + cfg := r.com.Config() + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return errors.New("agent configuration not found") + } + + selectedModel := cfg.Models[agentCfg.Model] + model := cfg.GetModelByType(agentCfg.Model) + if model == nil { + return errors.New("model configuration not found") + } + + if len(model.ReasoningLevels) == 0 { + return errors.New("no reasoning levels available") + } + + currentEffort := selectedModel.ReasoningEffort + if currentEffort == "" { + currentEffort = model.DefaultReasoningEffort + } + + caser := cases.Title(language.English) + items := make([]list.FilterableItem, 0, len(model.ReasoningLevels)) + selectedIndex := 0 + for i, effort := range model.ReasoningLevels { + item := &ReasoningItem{ + effort: effort, + title: caser.String(effort), + isCurrent: effort == currentEffort, + t: r.com.Styles, + } + items = append(items, item) + if effort == currentEffort { + selectedIndex = i + } + } + + r.list.SetItems(items...) + r.list.SetSelected(selectedIndex) + r.list.ScrollToSelected() + return nil +} + +// Filter returns the filter value for the reasoning item. +func (r *ReasoningItem) Filter() string { + return r.title +} + +// ID returns the unique identifier for the reasoning effort. +func (r *ReasoningItem) ID() string { + return r.effort +} + +// SetFocused sets the focus state of the reasoning item. +func (r *ReasoningItem) SetFocused(focused bool) { + if r.focused != focused { + r.cache = nil + } + r.focused = focused +} + +// SetMatch sets the fuzzy match for the reasoning item. +func (r *ReasoningItem) SetMatch(m fuzzy.Match) { + r.cache = nil + r.m = m +} + +// Render returns the string representation of the reasoning item. +func (r *ReasoningItem) Render(width int) string { + info := "" + if r.isCurrent { + info = "current" + } + styles := ListItemStyles{ + ItemBlurred: r.t.Dialog.NormalItem, + ItemFocused: r.t.Dialog.SelectedItem, + InfoTextBlurred: r.t.Base, + 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 new file mode 100644 index 0000000000000000000000000000000000000000..cfb0f30623c383b775c3a960134057e6c79ce9b8 --- /dev/null +++ b/internal/ui/dialog/sessions.go @@ -0,0 +1,463 @@ +package dialog + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" +) + +// SessionsID is the identifier for the session selector dialog. +const SessionsID = "session" + +type sessionsMode uint8 + +// Possible modes a session item can be in +const ( + sessionsModeNormal sessionsMode = iota + sessionsModeDeleting + sessionsModeUpdating +) + +// Session is a session selector dialog. +type Session struct { + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + selectedSessionInx int + sessions []session.Session + + sessionsMode sessionsMode + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Delete key.Binding + Rename key.Binding + ConfirmRename key.Binding + CancelRename key.Binding + ConfirmDelete key.Binding + CancelDelete key.Binding + Close key.Binding + } +} + +var _ Dialog = (*Session)(nil) + +// NewSessions creates a new Session dialog. +func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) { + s := new(Session) + s.sessionsMode = sessionsModeNormal + s.com = com + sessions, err := com.App.Sessions.List(context.TODO()) + if err != nil { + return nil, err + } + + s.sessions = sessions + for i, sess := range sessions { + if sess.ID == selectedSessionID { + s.selectedSessionInx = i + break + } + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + s.help = help + s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...) + s.list.Focus() + s.list.SetSelected(s.selectedSessionInx) + + s.input = textinput.New() + s.input.SetVirtualCursor(false) + s.input.Placeholder = "Enter session name" + s.input.SetStyles(com.Styles.TextInput) + s.input.Focus() + + s.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "choose"), + ) + s.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + s.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + s.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "choose"), + ) + s.keyMap.Delete = key.NewBinding( + key.WithKeys("ctrl+x"), + key.WithHelp("ctrl+x", "delete"), + ) + s.keyMap.Rename = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "rename"), + ) + s.keyMap.ConfirmRename = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + s.keyMap.CancelRename = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ) + s.keyMap.ConfirmDelete = key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "delete"), + ) + s.keyMap.CancelDelete = key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n", "cancel"), + ) + s.keyMap.Close = CloseKey + + return s, nil +} + +// ID implements Dialog. +func (s *Session) ID() string { + return SessionsID +} + +// HandleMsg implements Dialog. +func (s *Session) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch s.sessionsMode { + case sessionsModeDeleting: + switch { + case key.Matches(msg, s.keyMap.ConfirmDelete): + action := s.confirmDeleteSession() + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + s.list.SelectFirst() + s.list.ScrollToSelected() + return action + case key.Matches(msg, s.keyMap.CancelDelete): + s.sessionsMode = sessionsModeNormal + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + } + case sessionsModeUpdating: + switch { + case key.Matches(msg, s.keyMap.ConfirmRename): + action := s.confirmRenameSession() + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + return action + case key.Matches(msg, s.keyMap.CancelRename): + s.sessionsMode = sessionsModeNormal + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + default: + item := s.list.SelectedItem() + if item == nil { + return nil + } + if sessionItem, ok := item.(*SessionItem); ok { + return sessionItem.HandleInput(msg) + } + } + default: + switch { + case key.Matches(msg, s.keyMap.Close): + return ActionClose{} + case key.Matches(msg, s.keyMap.Rename): + s.sessionsMode = sessionsModeUpdating + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...) + case key.Matches(msg, s.keyMap.Delete): + if s.isCurrentSessionBusy() { + return ActionCmd{util.ReportWarn("Agent is busy, please wait...")} + } + s.sessionsMode = sessionsModeDeleting + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...) + case key.Matches(msg, s.keyMap.Previous): + s.list.Focus() + if s.list.IsSelectedFirst() { + s.list.SelectLast() + s.list.ScrollToBottom() + break + } + s.list.SelectPrev() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Next): + s.list.Focus() + if s.list.IsSelectedLast() { + s.list.SelectFirst() + s.list.ScrollToTop() + break + } + s.list.SelectNext() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Select): + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + value := s.input.Value() + s.list.SetFilter(value) + s.list.ScrollToTop() + s.list.SetSelected(0) + return ActionCmd{cmd} + } + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (s *Session) Cursor() *tea.Cursor { + return InputCursor(s.com.Styles, s.input.Cursor()) +} + +// Draw implements [Dialog]. +func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := s.com.Styles + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding + s.list.SetSize(innerWidth, height-heightOffset) + s.help.SetWidth(innerWidth) + + // This makes it so we do not scroll the list if we don't have to + start, end := s.list.VisibleItemIndices() + + // if selected index is outside visible range, scroll to it + if s.selectedSessionInx < start || s.selectedSessionInx > end { + s.list.ScrollToSelected() + } + + var cur *tea.Cursor + rc := NewRenderContext(t, width) + rc.Title = "Sessions" + switch s.sessionsMode { + case sessionsModeDeleting: + rc.TitleStyle = t.Dialog.Sessions.DeletingTitle + rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor + rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor + rc.ViewStyle = t.Dialog.Sessions.DeletingView + rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?")) + case sessionsModeUpdating: + 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 { + return nil + } + cur = item.Cursor() + if cur == nil { + break + } + + start, end := s.list.VisibleItemIndices() + selectedIndex := s.list.Selected() + + titleStyle := t.Dialog.Sessions.RenamingingTitle + dialogStyle := t.Dialog.Sessions.RenamingView + inputStyle := t.Dialog.InputPrompt + + // Adjust cursor position to account for dialog layout + message + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetBorderTopSize() + + lipgloss.Height(message) - 1 + + // move the cursor by one down until we see the selectedIndex + for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ { + cur.Y += 1 + } + default: + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + cur = s.Cursor() + rc.AddPart(inputView) + } + listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) + rc.AddPart(listView) + rc.Help = s.help.View(s) + + view := rc.Render() + + DrawCenterCursor(scr, area, view, cur) + return cur +} + +func (s *Session) selectedSessionItem() *SessionItem { + if item := s.list.SelectedItem(); item != nil { + return item.(*SessionItem) + } + return nil +} + +func (s *Session) confirmDeleteSession() Action { + sessionItem := s.selectedSessionItem() + s.sessionsMode = sessionsModeNormal + if sessionItem == nil { + return nil + } + + s.removeSession(sessionItem.ID()) + return ActionCmd{s.deleteSessionCmd(sessionItem.ID())} +} + +func (s *Session) removeSession(id string) { + var newSessions []session.Session + for _, sess := range s.sessions { + if sess.ID == id { + continue + } + newSessions = append(newSessions, sess) + } + s.sessions = newSessions +} + +func (s *Session) deleteSessionCmd(id string) tea.Cmd { + return func() tea.Msg { + err := s.com.App.Sessions.Delete(context.TODO(), id) + if err != nil { + return util.NewErrorMsg(err) + } + return nil + } +} + +func (s *Session) confirmRenameSession() Action { + sessionItem := s.selectedSessionItem() + s.sessionsMode = sessionsModeNormal + if sessionItem == nil { + return nil + } + + newTitle := strings.TrimSpace(sessionItem.InputValue()) + if newTitle == "" { + return nil + } + session := sessionItem.Session + session.Title = newTitle + s.updateSession(session) + return ActionCmd{s.updateSessionCmd(session)} +} + +func (s *Session) updateSession(session session.Session) { + for existingID, sess := range s.sessions { + if sess.ID == session.ID { + s.sessions[existingID] = session + break + } + } +} + +func (s *Session) updateSessionCmd(session session.Session) tea.Cmd { + return func() tea.Msg { + _, err := s.com.App.Sessions.Save(context.TODO(), session) + if err != nil { + return util.NewErrorMsg(err) + } + return nil + } +} + +func (s *Session) isCurrentSessionBusy() bool { + sessionItem := s.selectedSessionItem() + if sessionItem == nil { + return false + } + + if s.com.App.AgentCoordinator == nil { + return false + } + + return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID()) +} + +// ShortHelp implements [help.KeyMap]. +func (s *Session) ShortHelp() []key.Binding { + switch s.sessionsMode { + case sessionsModeDeleting: + return []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + case sessionsModeUpdating: + return []key.Binding{ + s.keyMap.ConfirmRename, + s.keyMap.CancelRename, + } + default: + return []key.Binding{ + s.keyMap.UpDown, + s.keyMap.Rename, + s.keyMap.Delete, + s.keyMap.Select, + s.keyMap.Close, + } + } +} + +// FullHelp implements [help.KeyMap]. +func (s *Session) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + s.keyMap.UpDown, + s.keyMap.Rename, + s.keyMap.Delete, + s.keyMap.Select, + s.keyMap.Close, + } + + switch s.sessionsMode { + case sessionsModeDeleting: + slice = []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + case sessionsModeUpdating: + slice = []key.Binding{ + s.keyMap.ConfirmRename, + s.keyMap.CancelRename, + } + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go new file mode 100644 index 0000000000000000000000000000000000000000..2532e8c19a75ef061266afd42d688016ea0ab3c9 --- /dev/null +++ b/internal/ui/dialog/sessions_item.go @@ -0,0 +1,247 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/dustin/go-humanize" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// ListItem represents a selectable and searchable item in a dialog list. +type ListItem interface { + list.FilterableItem + list.Focusable + list.MatchSettable + + // ID returns the unique identifier of the item. + ID() string +} + +// SessionItem wraps a [session.Session] to implement the [ListItem] interface. +type SessionItem struct { + session.Session + t *styles.Styles + sessionsMode sessionsMode + m fuzzy.Match + cache map[int]string + updateTitleInput textinput.Model + focused bool +} + +var _ ListItem = &SessionItem{} + +// Filter returns the filterable value of the session. +func (s *SessionItem) Filter() string { + return s.Title +} + +// ID returns the unique identifier of the session. +func (s *SessionItem) ID() string { + return s.Session.ID +} + +// SetMatch sets the fuzzy match for the session item. +func (s *SessionItem) SetMatch(m fuzzy.Match) { + s.cache = nil + s.m = m +} + +// InputValue returns the updated title value +func (s *SessionItem) InputValue() string { + return s.updateTitleInput.Value() +} + +// HandleInput forwards input message to the update title input +func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + s.updateTitleInput, cmd = s.updateTitleInput.Update(msg) + return cmd +} + +// Cursor returns the cursor of the update title input +func (s *SessionItem) Cursor() *tea.Cursor { + return s.updateTitleInput.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 := ListItemStyles{ + ItemBlurred: s.t.Dialog.NormalItem, + ItemFocused: s.t.Dialog.SelectedItem, + InfoTextBlurred: s.t.Subtle, + InfoTextFocused: s.t.Base, + } + + switch s.sessionsMode { + case sessionsModeDeleting: + styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred + styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused + case sessionsModeUpdating: + 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) + s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…") + return styles.ItemFocused.Render(s.updateTitleInput.View()) + } + } + + return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m) +} + +type ListItemStyles struct { + ItemBlurred lipgloss.Style + ItemFocused lipgloss.Style + InfoTextBlurred lipgloss.Style + InfoTextFocused lipgloss.Style +} + +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) + } + + cached, ok := cache[width] + if ok { + return cached + } + + style := t.ItemBlurred + if focused { + style = t.ItemFocused + } + + var infoText string + var infoWidth int + lineWidth := width + if len(info) > 0 { + infoText = fmt.Sprintf(" %s ", info) + if focused { + infoText = t.InfoTextFocused.Render(infoText) + } else { + infoText = t.InfoTextBlurred.Render(infoText) + } + + infoWidth = lipgloss.Width(infoText) + } + + title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "") + titleWidth := lipgloss.Width(title) + gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth)) + content := title + 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, 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 + // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] + // which only affect the underline attribute without interfering + // with other style + parts = append(parts, + ansi.NewStyle().Underline(true).String(), + ansi.Cut(title, start, stop+1), + ansi.NewStyle().Underline(false).String(), + ) + lastPos = stop + 1 + } + if lastPos < ansi.StringWidth(title) { + parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title))) + } + + content = strings.Join(parts, "") + } + + content = style.Render(content + gap + infoText) + cache[width] = content + return content +} + +// SetFocused sets the focus state of the session item. +func (s *SessionItem) SetFocused(focused bool) { + if s.focused != focused { + s.cache = nil + } + s.focused = focused +} + +// sessionItems takes a slice of [session.Session]s and convert them to a slice +// of [ListItem]s. +func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem { + items := make([]list.FilterableItem, len(sessions)) + for i, s := range sessions { + item := &SessionItem{Session: s, t: t, sessionsMode: mode} + if mode == sessionsModeUpdating { + item.updateTitleInput = textinput.New() + item.updateTitleInput.SetVirtualCursor(false) + item.updateTitleInput.Prompt = "" + inputStyle := t.TextInput + inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder + item.updateTitleInput.SetStyles(inputStyle) + item.updateTitleInput.Focus() + } + items[i] = item + } + return items +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/internal/tui/exp/diffview/Taskfile.yaml b/internal/ui/diffview/Taskfile.yaml similarity index 100% rename from internal/tui/exp/diffview/Taskfile.yaml rename to internal/ui/diffview/Taskfile.yaml diff --git a/internal/tui/exp/diffview/chroma.go b/internal/ui/diffview/chroma.go similarity index 100% rename from internal/tui/exp/diffview/chroma.go rename to internal/ui/diffview/chroma.go diff --git a/internal/tui/exp/diffview/diffview.go b/internal/ui/diffview/diffview.go similarity index 100% rename from internal/tui/exp/diffview/diffview.go rename to internal/ui/diffview/diffview.go diff --git a/internal/tui/exp/diffview/diffview_test.go b/internal/ui/diffview/diffview_test.go similarity index 99% rename from internal/tui/exp/diffview/diffview_test.go rename to internal/ui/diffview/diffview_test.go index de6eb301c9261fdbf2175ba061528b71e162ea40..266b26372dc7c519c353228590a03cbbc9e65e24 100644 --- a/internal/tui/exp/diffview/diffview_test.go +++ b/internal/ui/diffview/diffview_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/alecthomas/chroma/v2/styles" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/diffview" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) diff --git a/internal/tui/exp/diffview/split.go b/internal/ui/diffview/split.go similarity index 100% rename from internal/tui/exp/diffview/split.go rename to internal/ui/diffview/split.go diff --git a/internal/tui/exp/diffview/style.go b/internal/ui/diffview/style.go similarity index 100% rename from internal/tui/exp/diffview/style.go rename to internal/ui/diffview/style.go diff --git a/internal/tui/exp/diffview/testdata/TestDefault.after b/internal/ui/diffview/testdata/TestDefault.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.after rename to internal/ui/diffview/testdata/TestDefault.after diff --git a/internal/tui/exp/diffview/testdata/TestDefault.before b/internal/ui/diffview/testdata/TestDefault.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.before rename to internal/ui/diffview/testdata/TestDefault.before diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.after b/internal/ui/diffview/testdata/TestLineBreakIssue.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.after rename to internal/ui/diffview/testdata/TestLineBreakIssue.after diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.before b/internal/ui/diffview/testdata/TestLineBreakIssue.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.before rename to internal/ui/diffview/testdata/TestLineBreakIssue.before diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.after b/internal/ui/diffview/testdata/TestMultipleHunks.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.after rename to internal/ui/diffview/testdata/TestMultipleHunks.after diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.before b/internal/ui/diffview/testdata/TestMultipleHunks.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.before rename to internal/ui/diffview/testdata/TestMultipleHunks.before diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.after b/internal/ui/diffview/testdata/TestNarrow.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.after rename to internal/ui/diffview/testdata/TestNarrow.after diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.before b/internal/ui/diffview/testdata/TestNarrow.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.before rename to internal/ui/diffview/testdata/TestNarrow.before diff --git a/internal/tui/exp/diffview/testdata/TestTabs.after b/internal/ui/diffview/testdata/TestTabs.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.after rename to internal/ui/diffview/testdata/TestTabs.after diff --git a/internal/tui/exp/diffview/testdata/TestTabs.before b/internal/ui/diffview/testdata/TestTabs.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.before rename to internal/ui/diffview/testdata/TestTabs.before diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden b/internal/ui/diffview/testdata/TestUdiff/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden rename to internal/ui/diffview/testdata/TestUdiff/Unified.golden diff --git a/internal/tui/exp/diffview/udiff_test.go b/internal/ui/diffview/udiff_test.go similarity index 100% rename from internal/tui/exp/diffview/udiff_test.go rename to internal/ui/diffview/udiff_test.go diff --git a/internal/tui/exp/diffview/util.go b/internal/ui/diffview/util.go similarity index 100% rename from internal/tui/exp/diffview/util.go rename to internal/ui/diffview/util.go diff --git a/internal/tui/exp/diffview/util_test.go b/internal/ui/diffview/util_test.go similarity index 100% rename from internal/tui/exp/diffview/util_test.go rename to internal/ui/diffview/util_test.go diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..5af0ca7c4776cd45371d2a57e3a13dc6195b524e --- /dev/null +++ b/internal/ui/image/image.go @@ -0,0 +1,281 @@ +package image + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "image/color" + "io" + "log/slog" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/util" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" + "github.com/disintegration/imaging" + paintbrush "github.com/jordanella/go-ansi-paintbrush" +) + +// TransmittedMsg is a message indicating that an image has been transmitted to +// the terminal. +type TransmittedMsg struct { + ID string +} + +// Encoding represents the encoding format of the image. +type Encoding byte + +// Image encodings. +const ( + EncodingBlocks Encoding = iota + EncodingKitty +) + +type imageKey struct { + id string + cols int + rows int +} + +// Hash returns a hash value for the image key. +// This uses FNV-32a for simplicity and speed. +func (k imageKey) Hash() uint32 { + h := fnv.New32a() + _, _ = io.WriteString(h, k.ID()) + return h.Sum32() +} + +// ID returns a unique string representation of the image key. +func (k imageKey) ID() string { + return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows) +} + +// CellSize represents the size of a single terminal cell in pixels. +type CellSize struct { + Width, Height int +} + +type cachedImage struct { + img image.Image + cols, rows int +} + +var ( + cachedImages = map[imageKey]cachedImage{} + cachedMutex sync.RWMutex +) + +// fitImage resizes the image to fit within the specified dimensions in +// terminal cells, maintaining the aspect ratio. +func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image { + if img == nil { + return nil + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return cached.img + } + + if cs.Width == 0 || cs.Height == 0 { + return img + } + + maxWidth := cols * cs.Width + maxHeight := rows * cs.Height + + img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos) + + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + + return img +} + +// HasTransmitted checks if the image with the given ID has already been +// transmitted to the terminal. +func HasTransmitted(id string, cols, rows int) bool { + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + return ok +} + +// Transmit transmits the image data to the terminal if needed. This is used to +// cache the image on the terminal for later rendering. +func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd { + if img == nil { + return nil + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return nil + } + + cmd := func() tea.Msg { + if e != EncodingKitty { + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + return TransmittedMsg{ID: key.ID()} + } + + var buf bytes.Buffer + img := fitImage(id, img, cs, cols, rows) + bounds := img.Bounds() + imgWidth := bounds.Dx() + imgHeight := bounds.Dy() + imgID := int(key.Hash()) + if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ + ID: imgID, + Action: kitty.TransmitAndPut, + Transmission: kitty.Direct, + Format: kitty.RGBA, + ImageWidth: imgWidth, + ImageHeight: imgHeight, + Columns: cols, + Rows: rows, + VirtualPlacement: true, + Quite: 1, + Chunk: true, + ChunkFormatter: func(chunk string) string { + if tmux { + return ansi.TmuxPassthrough(chunk) + } + return chunk + }, + }); err != nil { + slog.Error("Failed to encode image for kitty graphics", "err", err) + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "failed to encode image", + } + } + + return tea.RawMsg{Msg: buf.String()} + } + + return cmd +} + +// Render renders the given image within the specified dimensions using the +// specified encoding. +func (e Encoding) Render(id string, cols, rows int) string { + key := imageKey{id: id, cols: cols, rows: rows} + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if !ok { + return "" + } + + img := cached.img + + switch e { + case EncodingBlocks: + canvas := paintbrush.New() + canvas.SetImage(img) + canvas.SetWidth(cols) + canvas.SetHeight(rows) + canvas.Weights = map[rune]float64{ + '': .95, + '': .95, + '▁': .9, + '▂': .9, + '▃': .9, + '▄': .9, + '▅': .9, + '▆': .85, + '█': .85, + '▊': .95, + '▋': .95, + '▌': .95, + '▍': .95, + '▎': .95, + '▏': .95, + '●': .95, + '◀': .95, + '▲': .95, + '▶': .95, + '▼': .9, + '○': .8, + '◉': .95, + '◧': .9, + '◨': .9, + '◩': .9, + '◪': .9, + } + canvas.Paint() + return strings.TrimSpace(canvas.GetResult()) + case EncodingKitty: + // Build Kitty graphics unicode place holders + var fg color.Color + var extra int + var r, g, b int + hashedID := key.Hash() + id := int(hashedID) + extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff + + if id <= 255 { + fg = ansi.IndexedColor(b) + } else { + fg = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: 0xff, + } + } + + fgStyle := ansi.NewStyle().ForegroundColor(fg).String() + + var buf bytes.Buffer + for y := range rows { + // As an optimization, we only write the fg color sequence id, and + // column-row data once on the first cell. The terminal will handle + // the rest. + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + buf.WriteRune(kitty.Diacritic(y)) + buf.WriteRune(kitty.Diacritic(0)) + if extra > 0 { + buf.WriteRune(kitty.Diacritic(extra)) + } + for x := 1; x < cols; x++ { + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + } + if y < rows-1 { + buf.WriteByte('\n') + } + } + + return buf.String() + + default: + return "" + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..1c66cf0f1351a40e3cadcb535803d7252785d4fb --- /dev/null +++ b/internal/ui/list/filterable.go @@ -0,0 +1,125 @@ +package list + +import ( + "github.com/sahilm/fuzzy" +) + +// FilterableItem is an item that can be filtered via a query. +type FilterableItem interface { + Item + // Filter returns the value to be used for filtering. + Filter() string +} + +// MatchSettable is an interface for items that can have their match indexes +// and match score set. +type MatchSettable interface { + SetMatch(fuzzy.Match) +} + +// FilterableList is a list that takes filterable items that can be filtered +// via a settable query. +type FilterableList struct { + *List + items []FilterableItem + query string +} + +// NewFilterableList creates a new filterable list. +func NewFilterableList(items ...FilterableItem) *FilterableList { + f := &FilterableList{ + List: NewList(), + items: items, + } + f.RegisterRenderCallback(FocusedRenderCallback(f.List)) + f.SetItems(items...) + return f +} + +// SetItems sets the list items and updates the filtered items. +func (f *FilterableList) SetItems(items ...FilterableItem) { + f.items = items + fitems := make([]Item, len(items)) + for i, item := range items { + fitems[i] = item + } + f.List.SetItems(fitems...) +} + +// AppendItems appends items to the list and updates the filtered items. +func (f *FilterableList) AppendItems(items ...FilterableItem) { + f.items = append(f.items, items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// PrependItems prepends items to the list and updates the filtered items. +func (f *FilterableList) PrependItems(items ...FilterableItem) { + f.items = append(items, f.items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// SetFilter sets the filter query and updates the list items. +func (f *FilterableList) SetFilter(q string) { + f.query = q + f.List.SetItems(f.FilteredItems()...) + f.ScrollToTop() +} + +// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering +// [FilterableItem]s. +type FilterableItemsSource []FilterableItem + +// Len returns the length of the source. +func (f FilterableItemsSource) Len() int { + return len(f) +} + +// String returns the string representation of the item at index i. +func (f FilterableItemsSource) String(i int) string { + return f[i].Filter() +} + +// FilteredItems returns the visible items after filtering. +func (f *FilterableList) FilteredItems() []Item { + if f.query == "" { + items := make([]Item, len(f.items)) + for i, item := range f.items { + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(fuzzy.Match{}) + item = ms.(FilterableItem) + } + items[i] = item + } + return items + } + + items := FilterableItemsSource(f.items) + matches := fuzzy.FindFrom(f.query, items) + matchedItems := []Item{} + resultSize := len(matches) + for i := range resultSize { + match := matches[i] + item := items[match.Index] + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(match) + item = ms.(FilterableItem) + } + matchedItems = append(matchedItems, item) + } + + return matchedItems +} + +// Render renders the filterable list. +func (f *FilterableList) Render() string { + f.List.SetItems(f.FilteredItems()...) + return f.List.Render() +} diff --git a/internal/ui/list/focus.go b/internal/ui/list/focus.go new file mode 100644 index 0000000000000000000000000000000000000000..6bdee37afa39a69d6d321b1894c6a5f221fc307d --- /dev/null +++ b/internal/ui/list/focus.go @@ -0,0 +1,13 @@ +package list + +// FocusedRenderCallback is a helper function that returns a render callback +// that marks items as focused during rendering. +func FocusedRenderCallback(list *List) RenderCallback { + return func(idx, selectedIdx int, item Item) Item { + if focusable, ok := item.(Focusable); ok { + focusable.SetFocused(list.Focused() && idx == selectedIdx) + return focusable.(Item) + } + return item + } +} diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..631181db29ce5bc3a2087de30341342f0374b229 --- /dev/null +++ b/internal/ui/list/highlight.go @@ -0,0 +1,211 @@ +package list + +import ( + "image" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/stringext" + uv "github.com/charmbracelet/ultraviolet" +) + +// DefaultHighlighter is the default highlighter function that applies inverse style. +var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell { + if c == nil { + return c + } + c.Style.Attrs |= uv.AttrReverse + return c +} + +// Highlighter represents a function that defines how to highlight text. +type Highlighter func(x, y int, c *uv.Cell) *uv.Cell + +// HighlightContent returns the content with highlighted regions based on the specified parameters. +func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string { + var sb strings.Builder + pos := image.Pt(-1, -1) + HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell { + pos.X = x + if pos.Y == -1 { + pos.Y = y + } else if y > pos.Y { + sb.WriteString(strings.Repeat("\n", y-pos.Y)) + pos.Y = y + } + sb.WriteString(c.Content) + return c + }) + if sb.Len() > 0 { + sb.WriteString("\n") + } + return sb.String() +} + +// Highlight highlights a region of text within the given content and region. +func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { + buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter) + if buf == nil { + return content + } + return buf.Render() +} + +// 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 + } + + if highlighter == nil { + highlighter = DefaultHighlighter + } + + width, height := area.Dx(), area.Dy() + buf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&buf, area) + + // Treat -1 as "end of content" + if endLine < 0 { + endLine = height - 1 + } + if endCol < 0 { + endCol = width + } + + for y := startLine; y <= endLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + colStart := 0 + if y == startLine { + colStart = min(startCol, len(line)) + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := colStart; x < colEnd; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := colEnd + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = colStart // No content on this line + } + + // Apply highlight style only to cells with content + for x := colStart; x < highlightEnd; x++ { + if !image.Pt(x, y).In(area) { + continue + } + cell := line.At(x) + if cell != nil { + line.Set(x, highlighter(x, y, cell)) + } + } + } + + return &buf +} + +// ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. +func ToHighlighter(lgStyle lipgloss.Style) Highlighter { + return func(_ int, _ int, c *uv.Cell) *uv.Cell { + if c != nil { + c.Style = ToStyle(lgStyle) + } + return c + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} + +// AdjustArea adjusts the given area rectangle by subtracting margins, borders, +// and padding from the style. +func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle { + topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding() + + return image.Rectangle{ + Min: image.Point{ + X: area.Min.X + leftMargin + leftBorder + leftPadding, + Y: area.Min.Y + topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: area.Max.X - (rightMargin + rightBorder + rightPadding), + Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding), + }, + } +} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go new file mode 100644 index 0000000000000000000000000000000000000000..7ac87212889dbc58773b409b5a4a96ec47d1fede --- /dev/null +++ b/internal/ui/list/item.go @@ -0,0 +1,61 @@ +package list + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Item represents a single item in the lazy-loaded list. +type Item interface { + // Render returns the string representation of the item for the given + // width. + Render(width int) string +} + +// RawRenderable represents an item that can provide a raw rendering +// without additional styling. +type RawRenderable interface { + // RawRender returns the raw rendered string without any additional + // styling. + RawRender(width int) string +} + +// Focusable represents an item that can be aware of focus state changes. +type Focusable interface { + // SetFocused sets the focus state of the item. + SetFocused(focused bool) +} + +// Highlightable represents an item that can highlight a portion of its content. +type Highlightable interface { + // SetHighlight highlights the content from the given start to end + // positions. Use -1 for no highlight. + SetHighlight(startLine, startCol, endLine, endCol int) + // Highlight returns the current highlight positions within the item. + Highlight() (startLine, startCol, endLine, endCol int) +} + +// MouseClickable represents an item that can handle mouse click events. +type MouseClickable interface { + // HandleMouseClick processes a mouse click event at the given coordinates. + // It returns true if the event was handled, false otherwise. + HandleMouseClick(btn ansi.MouseButton, x, y int) bool +} + +// SpacerItem is a spacer item that adds vertical space in the list. +type SpacerItem struct { + Height int +} + +// NewSpacerItem creates a new [SpacerItem] with the specified height. +func NewSpacerItem(height int) *SpacerItem { + return &SpacerItem{ + Height: max(0, height-1), + } +} + +// Render implements the Item interface for [SpacerItem]. +func (s *SpacerItem) Render(width int) string { + return strings.Repeat("\n", s.Height) +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..aec21715fcd924fde40ab9c41e9a4b6e65727ee8 --- /dev/null +++ b/internal/ui/list/list.go @@ -0,0 +1,648 @@ +package list + +import ( + "strings" +) + +// List represents a list of items that can be lazily rendered. A list is +// always rendered like a chat conversation where items are stacked vertically +// from top to bottom. +type List struct { + // Viewport size + width, height int + + // Items in the list + items []Item + + // Gap between items (0 or less means no gap) + gap int + + // show list in reverse order + reverse bool + + // Focus and selection state + focused bool + selectedIdx int // The current selected index -1 means no selection + + // offsetIdx is the index of the first visible item in the viewport. + offsetIdx int + // offsetLine is the number of lines of the item at offsetIdx that are + // scrolled out of view (above the viewport). + // It must always be >= 0. + offsetLine int + + // renderCallbacks is a list of callbacks to apply when rendering items. + renderCallbacks []func(idx, selectedIdx int, item Item) Item +} + +// renderedItem holds the rendered content and height of an item. +type renderedItem struct { + content string + height int +} + +// NewList creates a new lazy-loaded list. +func NewList(items ...Item) *List { + l := new(List) + l.items = items + l.selectedIdx = -1 + return l +} + +// RenderCallback defines a function that can modify an item before it is +// rendered. +type RenderCallback func(idx, selectedIdx int, item Item) Item + +// RegisterRenderCallback registers a callback to be called when rendering +// items. This can be used to modify items before they are rendered. +func (l *List) RegisterRenderCallback(cb RenderCallback) { + l.renderCallbacks = append(l.renderCallbacks, cb) +} + +// SetSize sets the size of the list viewport. +func (l *List) SetSize(width, height int) { + l.width = width + l.height = height +} + +// SetGap sets the gap between items. +func (l *List) SetGap(gap int) { + l.gap = gap +} + +// Gap returns the gap between items. +func (l *List) Gap() int { + return l.gap +} + +// 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 || l.offsetIdx >= len(l.items)-1 { + return true + } + + // Calculate the height from offsetIdx to the end. + var totalHeight int + 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 + } + + return totalHeight-l.offsetLine-margin <= l.height +} + +// SetReverse shows the list in reverse order. +func (l *List) SetReverse(reverse bool) { + l.reverse = reverse +} + +// Width returns the width of the list viewport. +func (l *List) Width() int { + return l.width +} + +// Height returns the height of the list viewport. +func (l *List) Height() int { + return l.height +} + +// Len returns the number of items in the list. +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) { + return renderedItem{} + } + + item := l.items[idx] + if len(l.renderCallbacks) > 0 { + for _, cb := range l.renderCallbacks { + if it := cb(idx, l.selectedIdx, item); it != nil { + item = it + } + } + } + + rendered := item.Render(l.width) + rendered = strings.TrimRight(rendered, "\n") + height := strings.Count(rendered, "\n") + 1 + ri := renderedItem{ + content: rendered, + height: height, + } + + return ri +} + +// ScrollToIndex scrolls the list to the given item index. +func (l *List) ScrollToIndex(index int) { + if index < 0 { + index = 0 + } + if index >= len(l.items) { + index = len(l.items) - 1 + } + l.offsetIdx = index + l.offsetLine = 0 +} + +// ScrollBy scrolls the list by the given number of lines. +func (l *List) ScrollBy(lines int) { + if len(l.items) == 0 || lines == 0 { + return + } + + if l.reverse { + lines = -lines + } + + if lines > 0 { + if l.AtBottom() { + // Already at bottom + return + } + + // Scroll down + l.offsetLine += lines + currentItem := l.getItem(l.offsetIdx) + for l.offsetLine >= currentItem.height { + l.offsetLine -= currentItem.height + if l.gap > 0 { + l.offsetLine = max(0, l.offsetLine-l.gap) + } + + // Move to next item + l.offsetIdx++ + if l.offsetIdx > len(l.items)-1 { + // Reached bottom + l.ScrollToBottom() + return + } + currentItem = l.getItem(l.offsetIdx) + } + + 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 + l.offsetLine += lines // lines is negative + for l.offsetLine < 0 { + // Move to previous item + l.offsetIdx-- + if l.offsetIdx < 0 { + // Reached top + l.ScrollToTop() + break + } + prevItem := l.getItem(l.offsetIdx) + totalHeight := prevItem.height + if l.gap > 0 { + totalHeight += l.gap + } + l.offsetLine += totalHeight + } + } +} + +// VisibleItemIndices finds the range of items that are visible in the viewport. +// This is used for checking if selected item is in view. +func (l *List) VisibleItemIndices() (startIdx, endIdx int) { + if len(l.items) == 0 { + return 0, 0 + } + + startIdx = l.offsetIdx + currentIdx := startIdx + visibleHeight := -l.offsetLine + + for currentIdx < len(l.items) { + item := l.getItem(currentIdx) + visibleHeight += item.height + if l.gap > 0 { + visibleHeight += l.gap + } + + if visibleHeight >= l.height { + break + } + currentIdx++ + } + + endIdx = currentIdx + if endIdx >= len(l.items) { + endIdx = len(l.items) - 1 + } + + return startIdx, endIdx +} + +// Render renders the list and returns the visible lines. +func (l *List) Render() string { + if len(l.items) == 0 { + return "" + } + + var lines []string + currentIdx := l.offsetIdx + currentOffset := l.offsetLine + + linesNeeded := l.height + + for linesNeeded > 0 && currentIdx < len(l.items) { + item := l.getItem(currentIdx) + itemLines := strings.Split(item.content, "\n") + itemHeight := len(itemLines) + + if currentOffset >= 0 && currentOffset < itemHeight { + // Add visible content lines + lines = append(lines, itemLines[currentOffset:]...) + + // Add gap if this is not the absolute last visual element (conceptually gaps are between items) + // But in the loop we can just add it and trim later + if l.gap > 0 { + for i := 0; i < l.gap; i++ { + lines = append(lines, "") + } + } + } else { + // offsetLine starts in the gap + gapOffset := currentOffset - itemHeight + gapRemaining := l.gap - gapOffset + if gapRemaining > 0 { + for range gapRemaining { + lines = append(lines, "") + } + } + } + + linesNeeded = l.height - len(lines) + currentIdx++ + currentOffset = 0 // Reset offset for subsequent items + } + + l.height = max(l.height, 0) + + if len(lines) > l.height { + lines = lines[:l.height] + } + + if l.reverse { + // Reverse the lines so the list renders bottom-to-top. + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + } + + return strings.Join(lines, "\n") +} + +// PrependItems prepends items to the list. +func (l *List) PrependItems(items ...Item) { + l.items = append(items, l.items...) + + // Keep view position relative to the content that was visible + l.offsetIdx += len(items) + + // Update selection index if valid + if l.selectedIdx != -1 { + l.selectedIdx += len(items) + } +} + +// SetItems sets the items in the list. +func (l *List) SetItems(items ...Item) { + l.setItems(true, items...) +} + +// setItems sets the items in the list. If evict is true, it clears the +// rendered item cache. +func (l *List) setItems(evict bool, items ...Item) { + l.items = items + l.selectedIdx = min(l.selectedIdx, len(l.items)-1) + l.offsetIdx = min(l.offsetIdx, len(l.items)-1) + l.offsetLine = 0 +} + +// AppendItems appends items to the list. +func (l *List) AppendItems(items ...Item) { + l.items = append(l.items, items...) +} + +// RemoveItem removes the item at the given index from the list. +func (l *List) RemoveItem(idx int) { + if idx < 0 || idx >= len(l.items) { + return + } + + // Remove the item + l.items = append(l.items[:idx], l.items[idx+1:]...) + + // Adjust selection if needed + if l.selectedIdx == idx { + l.selectedIdx = -1 + } else if l.selectedIdx > idx { + l.selectedIdx-- + } + + // Adjust offset if needed + if l.offsetIdx > idx { + l.offsetIdx-- + } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) { + l.offsetIdx = max(0, len(l.items)-1) + l.offsetLine = 0 + } +} + +// Focused returns whether the list is focused. +func (l *List) Focused() bool { + return l.focused +} + +// Focus sets the focus state of the list. +func (l *List) Focus() { + l.focused = true +} + +// Blur removes the focus state from the list. +func (l *List) Blur() { + l.focused = false +} + +// ScrollToTop scrolls the list to the top. +func (l *List) ScrollToTop() { + l.offsetIdx = 0 + l.offsetLine = 0 +} + +// ScrollToBottom scrolls the list to the bottom. +func (l *List) ScrollToBottom() { + if len(l.items) == 0 { + return + } + + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine +} + +// ScrollToSelected scrolls the list to the selected item. +func (l *List) ScrollToSelected() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + startIdx, endIdx := l.VisibleItemIndices() + if l.selectedIdx < startIdx { + // Selected item is above the visible range + l.offsetIdx = l.selectedIdx + l.offsetLine = 0 + } else if l.selectedIdx > endIdx { + // Selected item is below the visible range + // Scroll so that the selected item is at the bottom + var totalHeight int + for i := l.selectedIdx; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < l.selectedIdx { + 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() + } + } +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (l *List) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + startIdx, endIdx := l.VisibleItemIndices() + return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx +} + +// SetSelected sets the selected item index in the list. +// It returns -1 if the index is out of bounds. +func (l *List) SetSelected(index int) { + if index < 0 || index >= len(l.items) { + l.selectedIdx = -1 + } else { + l.selectedIdx = index + } +} + +// Selected returns the index of the currently selected item. It returns -1 if +// no item is selected. +func (l *List) Selected() int { + return l.selectedIdx +} + +// IsSelectedFirst returns whether the first item is selected. +func (l *List) IsSelectedFirst() bool { + return l.selectedIdx == 0 +} + +// IsSelectedLast returns whether the last item is selected. +func (l *List) IsSelectedLast() bool { + return l.selectedIdx == len(l.items)-1 +} + +// SelectPrev selects the visually previous item (moves toward visual top). +// It returns whether the selection changed. +func (l *List) SelectPrev() bool { + if l.reverse { + // In reverse, visual up = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } else { + // Normal: visual up = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } + return false +} + +// SelectNext selects the next item in the list. +// It returns whether the selection changed. +func (l *List) SelectNext() bool { + if l.reverse { + // In reverse, visual down = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } else { + // Normal: visual down = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } + return false +} + +// SelectFirst selects the first item in the list. +// It returns whether the selection changed. +func (l *List) SelectFirst() bool { + if len(l.items) == 0 { + return false + } + l.selectedIdx = 0 + return true +} + +// SelectLast selects the last item in the list (highest index). +// It returns whether the selection changed. +func (l *List) SelectLast() bool { + if len(l.items) == 0 { + return false + } + l.selectedIdx = len(l.items) - 1 + return true +} + +// WrapToStart wraps selection to the visual start (for circular navigation). +// In normal mode, this is index 0. In reverse mode, this is the highest index. +func (l *List) WrapToStart() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = len(l.items) - 1 + } else { + l.selectedIdx = 0 + } + return true +} + +// WrapToEnd wraps selection to the visual end (for circular navigation). +// In normal mode, this is the highest index. In reverse mode, this is index 0. +func (l *List) WrapToEnd() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = 0 + } else { + l.selectedIdx = len(l.items) - 1 + } + return true +} + +// SelectedItem returns the currently selected item. It may be nil if no item +// is selected. +func (l *List) SelectedItem() Item { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return nil + } + return l.items[l.selectedIdx] +} + +// SelectFirstInView selects the first item currently in view. +func (l *List) SelectFirstInView() { + startIdx, _ := l.VisibleItemIndices() + l.selectedIdx = startIdx +} + +// SelectLastInView selects the last item currently in view. +func (l *List) SelectLastInView() { + _, endIdx := l.VisibleItemIndices() + l.selectedIdx = endIdx +} + +// ItemAt returns the item at the given index. +func (l *List) ItemAt(index int) Item { + if index < 0 || index >= len(l.items) { + return nil + } + return l.items[index] +} + +// ItemIndexAtPosition returns the item at the given viewport-relative y +// coordinate. Returns the item index and the y offset within that item. It +// returns -1, -1 if no item is found. +func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) { + return l.findItemAtY(x, y) +} + +// findItemAtY finds the item at the given viewport y coordinate. +// Returns the item index and the y offset within that item. It returns -1, -1 +// if no item is found. +func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { + if y < 0 || y >= l.height { + return -1, -1 + } + + // Walk through visible items to find which one contains this y + currentIdx := l.offsetIdx + currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden + + for currentIdx < len(l.items) && currentLine < l.height { + item := l.getItem(currentIdx) + itemEndLine := currentLine + item.height + + // Check if y is within this item's visible range + if y >= currentLine && y < itemEndLine { + // Found the item, calculate itemY (offset within the item) + itemY = y - currentLine + return currentIdx, itemY + } + + // Move to next item + currentLine = itemEndLine + if l.gap > 0 { + currentLine += l.gap + } + currentIdx++ + } + + return -1, -1 +} diff --git a/internal/tui/components/logo/logo.go b/internal/ui/logo/logo.go similarity index 94% rename from internal/tui/components/logo/logo.go rename to internal/ui/logo/logo.go index 9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904..68387d4c0ba2c8914929d041e149f4b23ff3694b 100644 --- a/internal/tui/components/logo/logo.go +++ b/internal/ui/logo/logo.go @@ -8,7 +8,7 @@ import ( "charm.land/lipgloss/v2" "github.com/MakeNowJust/heredoc" - "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/slice" ) @@ -34,7 +34,7 @@ type Opts struct { // // The compact argument determines whether it renders compact for the sidebar // or wider for the main pane. -func Render(version string, compact bool, o Opts) string { +func Render(s *styles.Styles, version string, compact bool, o Opts) string { const charm = " Charm™" fg := func(c color.Color, s string) string { @@ -59,7 +59,7 @@ func Render(version string, compact bool, o Opts) string { crushWidth := lipgloss.Width(crush) b := new(strings.Builder) for r := range strings.SplitSeq(crush, "\n") { - fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) + fmt.Fprintln(b, styles.ApplyForegroundGrad(s, r, o.TitleColorA, o.TitleColorB)) } crush = b.String() @@ -117,14 +117,13 @@ func Render(version string, compact bool, o Opts) string { // SmallRender renders a smaller version of the Crush logo, suitable for // smaller windows or sidebar usage. -func SmallRender(width int) string { - t := styles.CurrentTheme() - title := t.S().Base.Foreground(t.Secondary).Render("Charm™") - title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary)) +func SmallRender(t *styles.Styles, width int) string { + title := t.Base.Foreground(t.Secondary).Render("Charm™") + title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t, "Crush", t.Secondary, t.Primary)) remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush" if remainingWidth > 0 { lines := strings.Repeat("╱", remainingWidth) - title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines)) + title = fmt.Sprintf("%s %s", title, t.Base.Foreground(t.Primary).Render(lines)) } return title } diff --git a/internal/tui/components/logo/rand.go b/internal/ui/logo/rand.go similarity index 100% rename from internal/tui/components/logo/rand.go rename to internal/ui/logo/rand.go diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..00a17ecfc5042dd42f4d24682b135667d1345386 --- /dev/null +++ b/internal/ui/model/chat.go @@ -0,0 +1,864 @@ +package model + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/common" + "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 { + com *common.Common + list *list.List + idInxMap map[string]int // Map of message IDs to their indices in the list + + // Animation visibility optimization: track animations paused due to items + // being scrolled out of view. When items become visible again, their + // animations are restarted. + pausedAnimations map[string]struct{} + + // Mouse state + mouseDown bool + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + 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 +// messages. +func NewChat(com *common.Common) *Chat { + c := &Chat{ + com: com, + idInxMap: make(map[string]int), + pausedAnimations: make(map[string]struct{}), + } + l := list.NewList() + l.SetGap(1) + l.RegisterRenderCallback(c.applyHighlightRange) + l.RegisterRenderCallback(list.FocusedRenderCallback(l)) + c.list = l + c.mouseDownItem = -1 + c.mouseDragItem = -1 + return c +} + +// Height returns the height of the chat view port. +func (m *Chat) Height() int { + return m.list.Height() +} + +// Draw renders the chat UI component to the screen and the given area. +func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { + uv.NewStyledString(m.list.Render()).Draw(scr, area) +} + +// 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. +func (m *Chat) Len() int { + return m.list.Len() +} + +// SetMessages sets the chat messages to the provided list of message items. +func (m *Chat) SetMessages(msgs ...chat.MessageItem) { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + + items := make([]list.Item, len(msgs)) + for i, msg := range msgs { + m.idInxMap[msg.ID()] = i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = i + } + } + items[i] = msg + } + m.list.SetItems(items...) + m.list.ScrollToBottom() +} + +// AppendMessages appends a new message item to the chat list. +func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { + items := make([]list.Item, len(msgs)) + indexOffset := m.list.Len() + for i, msg := range msgs { + m.idInxMap[msg.ID()] = indexOffset + i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = indexOffset + i + } + } + items[i] = msg + } + m.list.AppendItems(items...) +} + +// UpdateNestedToolIDs updates the ID map for nested tools within a container. +// Call this after modifying nested tools to ensure animations work correctly. +func (m *Chat) UpdateNestedToolIDs(containerID string) { + idx, ok := m.idInxMap[containerID] + if !ok { + return + } + + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return + } + + container, ok := item.(chat.NestedToolContainer) + if !ok { + return + } + + // Register all nested tool IDs to point to the container's index. + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = idx + } +} + +// Animate animates items in the chat list. Only propagates animation messages +// to visible items to save CPU. When items are not visible, their animation ID +// is tracked so it can be restarted when they become visible again. +func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd { + idx, ok := m.idInxMap[msg.ID] + if !ok { + return nil + } + + animatable, ok := m.list.ItemAt(idx).(chat.Animatable) + if !ok { + return nil + } + + // Check if item is currently visible. + startIdx, endIdx := m.list.VisibleItemIndices() + isVisible := idx >= startIdx && idx <= endIdx + + if !isVisible { + // Item not visible - pause animation by not propagating. + // Track it so we can restart when it becomes visible. + m.pausedAnimations[msg.ID] = struct{}{} + return nil + } + + // Item is visible - remove from paused set and animate. + delete(m.pausedAnimations, msg.ID) + return animatable.Animate(msg) +} + +// RestartPausedVisibleAnimations restarts animations for items that were paused +// due to being scrolled out of view but are now visible again. +func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd { + if len(m.pausedAnimations) == 0 { + return nil + } + + startIdx, endIdx := m.list.VisibleItemIndices() + var cmds []tea.Cmd + + for id := range m.pausedAnimations { + idx, ok := m.idInxMap[id] + if !ok { + // Item no longer exists. + delete(m.pausedAnimations, id) + continue + } + + if idx >= startIdx && idx <= endIdx { + // Item is now visible - restart its animation. + if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + delete(m.pausedAnimations, id) + } + } + + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +// Focus sets the focus state of the chat component. +func (m *Chat) Focus() { + m.list.Focus() +} + +// Blur removes the focus state from the chat component. +func (m *Chat) Blur() { + m.list.Blur() +} + +// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart +// any paused animations that are now visible. +func (m *Chat) ScrollToTopAndAnimate() tea.Cmd { + m.list.ScrollToTop() + return m.RestartPausedVisibleAnimations() +} + +// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to +// restart any paused animations that are now visible. +func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd { + m.list.ScrollToBottom() + return m.RestartPausedVisibleAnimations() +} + +// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns +// a command to restart any paused animations that are now visible. +func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd { + m.list.ScrollBy(lines) + return m.RestartPausedVisibleAnimations() +} + +// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a +// command to restart any paused animations that are now visible. +func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd { + m.list.ScrollToSelected() + return m.RestartPausedVisibleAnimations() +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (m *Chat) SelectedItemInView() bool { + return m.list.SelectedItemInView() +} + +func (m *Chat) isSelectable(index int) bool { + item := m.list.ItemAt(index) + if item == nil { + return false + } + _, ok := item.(list.Focusable) + return ok +} + +// SetSelected sets the selected message index in the chat list. +func (m *Chat) SetSelected(index int) { + m.list.SetSelected(index) + if index < 0 || index >= m.list.Len() { + return + } + for { + if m.isSelectable(m.list.Selected()) { + return + } + if m.list.SelectNext() { + continue + } + // If we're at the end and the last item isn't selectable, walk backwards + // to find the nearest selectable item. + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } + } +} + +// SelectPrev selects the previous message in the chat list. +func (m *Chat) SelectPrev() { + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectNext selects the next message in the chat list. +func (m *Chat) SelectNext() { + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectFirst selects the first message in the chat list. +func (m *Chat) SelectFirst() { + if !m.list.SelectFirst() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectLast selects the last message in the chat list. +func (m *Chat) SelectLast() { + if !m.list.SelectLast() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectFirstInView selects the first message currently in view. +func (m *Chat) SelectFirstInView() { + startIdx, endIdx := m.list.VisibleItemIndices() + for i := startIdx; i <= endIdx; i++ { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } +} + +// SelectLastInView selects the last message currently in view. +func (m *Chat) SelectLastInView() { + startIdx, endIdx := m.list.VisibleItemIndices() + for i := endIdx; i >= startIdx; i-- { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } +} + +// ClearMessages removes all messages from the chat list. +func (m *Chat) ClearMessages() { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + m.list.SetItems() + m.ClearMouse() +} + +// RemoveMessage removes a message from the chat list by its ID. +func (m *Chat) RemoveMessage(id string) { + idx, ok := m.idInxMap[id] + if !ok { + return + } + + // Remove from list + m.list.RemoveItem(idx) + + // Remove from index map + delete(m.idInxMap, id) + + // Rebuild index map for all items after the removed one + for i := idx; i < m.list.Len(); i++ { + if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok { + m.idInxMap[item.ID()] = i + } + } + + // Clean up any paused animations for this message + delete(m.pausedAnimations, id) +} + +// MessageItem returns the message item with the given ID, or nil if not found. +func (m *Chat) MessageItem(id string) chat.MessageItem { + idx, ok := m.idInxMap[id] + if !ok { + return nil + } + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return nil + } + return item +} + +// ToggleExpandedSelectedItem expands the selected message item if it is expandable. +func (m *Chat) ToggleExpandedSelectedItem() { + if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { + if !expandable.ToggleExpanded() { + m.list.ScrollToIndex(m.list.Selected()) + } + if m.list.AtBottom() { + m.list.ScrollToBottom() + } + } +} + +// HandleKeyMsg handles key events for the chat component. +func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) { + if m.list.Focused() { + if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok { + return handler.HandleKeyEvent(key) + } + } + return false, nil +} + +// HandleMouseDown handles mouse down events for the chat component. +// 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, nil + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false, nil + } + if !m.isSelectable(itemIdx) { + return false, nil + } + + // 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) + + 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, 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 { + if !expandable.ToggleExpanded() { + m.list.ScrollToIndex(m.list.Selected()) + } + } + if m.list.AtBottom() { + m.list.ScrollToBottom() + } + return handled + } + + return false +} + +// HandleMouseUp handles mouse up events for the chat component. +func (m *Chat) HandleMouseUp(x, y int) bool { + if !m.mouseDown { + return false + } + + m.mouseDown = false + return true +} + +// HandleMouseDrag handles mouse drag events for the chat component. +func (m *Chat) HandleMouseDrag(x, y int) bool { + if !m.mouseDown { + return false + } + + if m.list.Len() == 0 { + return false + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false + } + + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + return true +} + +// HasHighlight returns whether there is currently highlighted content. +func (m *Chat) HasHighlight() bool { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol) +} + +// HighlightContent returns the currently highlighted content based on the mouse +// selection. It returns an empty string if no content is highlighted. +func (m *Chat) HighlightContent() string { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol { + return "" + } + + var sb strings.Builder + for i := startItemIdx; i <= endItemIdx; i++ { + item := m.list.ItemAt(i) + if hi, ok := item.(list.Highlightable); ok { + startLine, startCol, endLine, endCol := hi.Highlight() + listWidth := m.list.Width() + var rendered string + if rr, ok := item.(list.RawRenderable); ok { + rendered = rr.RawRender(listWidth) + } else { + rendered = item.Render(listWidth) + } + sb.WriteString(list.HighlightContent( + rendered, + uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)), + startLine, + startCol, + endLine, + endCol, + )) + sb.WriteString(strings.Repeat("\n", m.list.Gap())) + } + } + + return strings.TrimSpace(sb.String()) +} + +// ClearMouse clears the current mouse interaction state. +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. +func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item { + if hi, ok := item.(list.Highlightable); ok { + // Apply highlight + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + sLine, sCol, eLine, eCol := -1, -1, -1, -1 + if idx >= startItemIdx && idx <= endItemIdx { + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = -1 + eCol = -1 + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = -1 + eCol = -1 + } + } + + hi.SetHighlight(sLine, sCol, eLine, eCol) + return hi.(list.Item) + } + + return item +} + +// getHighlightRange returns the current highlight range. +func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { + if m.mouseDownItem < 0 { + return -1, -1, -1, -1, -1, -1 + } + + downItemIdx := m.mouseDownItem + dragItemIdx := m.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) || + (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX) + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + startLine = m.mouseDownY + startCol = m.mouseDownX + endItemIdx = dragItemIdx + endLine = m.mouseDragY + endCol = m.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + startLine = m.mouseDragY + startCol = m.mouseDragX + endItemIdx = downItemIdx + endLine = m.mouseDownY + endCol = m.mouseDownX + } + + 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/filter.go b/internal/ui/model/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..b28a4d061b2e2adad0d712de014e0ccf610e0485 --- /dev/null +++ b/internal/ui/model/filter.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +var lastMouseEvent time.Time + +func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { + switch msg.(type) { + case tea.MouseWheelMsg, tea.MouseMotionMsg: + now := time.Now() + // trackpad is sending too many requests + if now.Sub(lastMouseEvent) < 15*time.Millisecond { + return nil + } + lastMouseEvent = now + } + return msg +} diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go new file mode 100644 index 0000000000000000000000000000000000000000..3d576e85d022192bb8435909915b8d1d7c5a04ee --- /dev/null +++ b/internal/ui/model/header.go @@ -0,0 +1,144 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +const ( + headerDiag = "╱" + minHeaderDiags = 3 + leftPadding = 1 + rightPadding = 1 +) + +type header struct { + // cached logo and compact logo + logo string + compactLogo string + + com *common.Common + width int + compact bool +} + +// newHeader creates a new header model. +func newHeader(com *common.Common) *header { + h := &header{ + com: com, + } + t := com.Styles + h.compactLogo = t.Header.Charm.Render("Charm™") + " " + + styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary) + " " + return h +} + +// drawHeader draws the header for the given session. +func (h *header) drawHeader( + scr uv.Screen, + area uv.Rectangle, + session *session.Session, + compact bool, + detailsOpen bool, + width int, +) { + t := h.com.Styles + if width != h.width || compact != h.compact { + h.logo = renderLogo(h.com.Styles, compact, width) + } + + h.width = width + h.compact = compact + + if !compact || session == nil || h.com.App == nil { + uv.NewStyledString(h.logo).Draw(scr, area) + return + } + + if session.ID == "" { + return + } + + var b strings.Builder + b.WriteString(h.compactLogo) + + availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags + details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth) + + remainingWidth := width - + lipgloss.Width(b.String()) - + lipgloss.Width(details) - + leftPadding - + rightPadding + + if remainingWidth > 0 { + b.WriteString(t.Header.Diagonals.Render( + strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)), + )) + b.WriteString(" ") + } + + b.WriteString(details) + + view := uv.NewStyledString( + t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())) + view.Draw(scr, area) +} + +// renderHeaderDetails renders the details section of the header. +func renderHeaderDetails( + com *common.Common, + session *session.Session, + lspClients *csync.Map[string, *lsp.Client], + detailsOpen bool, + availWidth int, +) string { + t := com.Styles + + var parts []string + + errorCount := 0 + for l := range lspClients.Seq() { + errorCount += l.GetDiagnosticCounts().Error + } + + if errorCount > 0 { + parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount))) + } + + agentCfg := config.Get().Agents[config.AgentCoder] + model := config.Get().GetModelByType(agentCfg.Model) + percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100 + formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage))) + parts = append(parts, formattedPercentage) + + const keystroke = "ctrl+d" + if detailsOpen { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close")) + } else { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open ")) + } + + dot := t.Header.Separator.Render(" • ") + metadata := strings.Join(parts, dot) + metadata = dot + metadata + + const dirTrimLimit = 4 + cfg := com.Config() + cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit) + cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…") + cwd = t.Header.WorkingDir.Render(cwd) + + return cwd + metadata +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..a42b1e7aa0ac9ac474de626b55ceb3a91824cdff --- /dev/null +++ b/internal/ui/model/keys.go @@ -0,0 +1,261 @@ +package model + +import "charm.land/bubbles/v2/key" + +type KeyMap struct { + Editor struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + 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 { + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding + TogglePills key.Binding + PillLeft key.Binding + PillRight key.Binding + Down key.Binding + Up key.Binding + UpDown key.Binding + DownOneItem key.Binding + UpOneItem key.Binding + UpDownOneItem key.Binding + PageDown key.Binding + PageUp key.Binding + HalfPageDown key.Binding + HalfPageUp key.Binding + Home key.Binding + End key.Binding + Copy key.Binding + ClearHighlight key.Binding + Expand key.Binding + } + + Initialize struct { + Yes, + No, + Enter, + Switch key.Binding + } + + // Global key maps + Quit key.Binding + Help key.Binding + Commands key.Binding + Models key.Binding + Suspend key.Binding + Sessions key.Binding + Tab key.Binding +} + +func DefaultKeyMap() KeyMap { + km := KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Help: key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), + ), + Commands: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ), + Models: key.NewBinding( + key.WithKeys("ctrl+m", "ctrl+l"), + key.WithHelp("ctrl+l", "models"), + ), + Suspend: key.NewBinding( + key.WithKeys("ctrl+z"), + key.WithHelp("ctrl+z", "suspend"), + ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ), + } + + km.Editor.AddFile = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ) + km.Editor.SendMessage = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ) + km.Editor.OpenEditor = key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "open editor"), + ) + km.Editor.Newline = key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help tex + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + km.Editor.AddImage = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ) + km.Editor.MentionFile = key.NewBinding( + 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"), + ) + km.Editor.Escape = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel delete mode"), + ) + km.Editor.DeleteAllAttachments = key.NewBinding( + 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"), + key.WithHelp("ctrl+n", "new session"), + ) + km.Chat.AddAttachment = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add attachment"), + ) + km.Chat.Cancel = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ) + km.Chat.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ) + km.Chat.Details = key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "toggle details"), + ) + km.Chat.TogglePills = key.NewBinding( + key.WithKeys("ctrl+space"), + key.WithHelp("ctrl+space", "toggle tasks"), + ) + km.Chat.PillLeft = key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/→", "switch section"), + ) + km.Chat.PillRight = key.NewBinding( + key.WithKeys("right"), + key.WithHelp("←/→", "switch section"), + ) + + km.Chat.Down = key.NewBinding( + key.WithKeys("down", "ctrl+j", "j"), + key.WithHelp("↓", "down"), + ) + km.Chat.Up = key.NewBinding( + key.WithKeys("up", "ctrl+k", "k"), + key.WithHelp("↑", "up"), + ) + km.Chat.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ) + km.Chat.UpOneItem = key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "up one item"), + ) + km.Chat.DownOneItem = key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "down one item"), + ) + km.Chat.UpDownOneItem = key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "scroll one item"), + ) + km.Chat.HalfPageDown = key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ) + km.Chat.PageDown = key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ) + km.Chat.PageUp = key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ) + km.Chat.HalfPageUp = key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ) + km.Chat.Home = key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ) + km.Chat.End = key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ) + km.Chat.Copy = key.NewBinding( + key.WithKeys("c", "y", "C", "Y"), + key.WithHelp("c/y", "copy"), + ) + km.Chat.ClearHighlight = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "clear selection"), + ) + km.Chat.Expand = key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "expand/collapse"), + ) + km.Initialize.Yes = key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "yes"), + ) + km.Initialize.No = key.NewBinding( + key.WithKeys("n", "N", "esc", "alt+esc"), + key.WithHelp("n", "no"), + ) + km.Initialize.Switch = key.NewBinding( + key.WithKeys("left", "right", "tab"), + key.WithHelp("tab", "switch"), + ) + km.Initialize.Enter = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ) + + return km +} diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go new file mode 100644 index 0000000000000000000000000000000000000000..a90ef76fdaf779e61477f5a05fd92a68d2e8a257 --- /dev/null +++ b/internal/ui/model/landing.go @@ -0,0 +1,50 @@ +package model + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// selectedLargeModel returns the currently selected large language model from +// the agent coordinator, if one exists. +func (m *UI) selectedLargeModel() *agent.Model { + if m.com.App.AgentCoordinator != nil { + model := m.com.App.AgentCoordinator.Model() + return &model + } + return nil +} + +// landingView renders the landing page view showing the current working +// directory, model information, and LSP/MCP status in a two-column layout. +func (m *UI) landingView() string { + t := m.com.Styles + width := m.layout.main.Dx() + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + + parts := []string{ + cwd, + } + + parts = append(parts, "", m.modelInfo(width)) + infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) + + _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) + + mcpLspSectionWidth := min(30, (width-1)/2) + + lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + + content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy() - 1). + PaddingTop(1). + Render( + lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content), + ) +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..ef78ebfb2c4e069901e0b4433587e948f98643d1 --- /dev/null +++ b/internal/ui/model/lsp.go @@ -0,0 +1,125 @@ +package model + +import ( + "fmt" + "maps" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +// LSPInfo wraps LSP client information with diagnostic counts by severity. +type LSPInfo struct { + app.LSPClientInfo + Diagnostics map[protocol.DiagnosticSeverity]int +} + +// lspInfo renders the LSP status section showing active LSP clients and their +// diagnostic counts. +func (m *UI) lspInfo(width, maxItems int, isSection bool) string { + t := m.com.Styles + + 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 + } + counts := client.GetDiagnosticCounts() + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: counts.Error, + protocol.SeverityWarning: counts.Warning, + protocol.SeverityHint: counts.Hint, + protocol.SeverityInformation: counts.Information, + } + + lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) + } + + title := t.Subtle.Render("LSPs") + if isSection { + title = common.Section(t, title, width) + } + list := t.Subtle.Render("None") + if len(lsps) > 0 { + list = lspList(t, lsps, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// lspDiagnostics formats diagnostic counts with appropriate icons and colors. +func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { + errs := []string{} + if diagnostics[protocol.SeverityError] > 0 { + errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, diagnostics[protocol.SeverityError]))) + } + if diagnostics[protocol.SeverityWarning] > 0 { + errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPWarningIcon, diagnostics[protocol.SeverityWarning]))) + } + if diagnostics[protocol.SeverityHint] > 0 { + errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPHintIcon, diagnostics[protocol.SeverityHint]))) + } + if diagnostics[protocol.SeverityInformation] > 0 { + errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPInfoIcon, diagnostics[protocol.SeverityInformation]))) + } + return strings.Join(errs, " ") +} + +// lspList renders a list of LSP clients with their status and diagnostics, +// truncating to maxItems if needed. +func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedLsps []string + for _, l := range lsps { + var icon string + title := l.Name + var description string + var diagnostics string + switch l.State { + case lsp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case lsp.StateReady: + icon = t.ItemOnlineIcon.String() + diagnostics = lspDiagnostics(t, l.Diagnostics) + case lsp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if l.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error())) + } + case lsp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("inactive") + default: + icon = t.ItemOfflineIcon.String() + } + renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: diagnostics, + }, width)) + } + + if len(renderedLsps) > maxItems { + visibleItems := renderedLsps[:maxItems-1] + remaining := len(renderedLsps) - maxItems + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...) +} diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..40be8619133268edbc53cf2bee863ed89a2af00f --- /dev/null +++ b/internal/ui/model/mcp.go @@ -0,0 +1,98 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// mcpInfo renders the MCP status section showing active MCP clients and their +// tool/prompt counts. +func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { + var mcps []mcp.ClientInfo + t := m.com.Styles + + for _, mcp := range m.com.Config().MCP.Sorted() { + if state, ok := m.mcpStates[mcp.Name]; ok { + mcps = append(mcps, state) + } + } + + title := t.Subtle.Render("MCPs") + if isSection { + title = common.Section(t, title, width) + } + list := t.Subtle.Render("None") + if len(mcps) > 0 { + list = mcpList(t, mcps, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// mcpCounts formats tool and prompt counts for display. +func mcpCounts(t *styles.Styles, counts mcp.Counts) string { + parts := []string{} + if counts.Tools > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools))) + } + if counts.Prompts > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts))) + } + return strings.Join(parts, " ") +} + +// mcpList renders a list of MCP clients with their status and counts, +// truncating to maxItems if needed. +func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedMcps []string + + for _, m := range mcps { + var icon string + title := m.Name + var description string + var extraContent string + + switch m.State { + case mcp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case mcp.StateConnected: + icon = t.ItemOnlineIcon.String() + extraContent = mcpCounts(t, m.Counts) + case mcp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if m.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error())) + } + case mcp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("disabled") + default: + icon = t.ItemOfflineIcon.String() + } + + renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: extraContent, + }, width)) + } + + if len(renderedMcps) > maxItems { + visibleItems := renderedMcps[:maxItems-1] + remaining := len(renderedMcps) - maxItems + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...) +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go new file mode 100644 index 0000000000000000000000000000000000000000..0a6ec0775b9f21da9bac4ed5ac2a7013457176a1 --- /dev/null +++ b/internal/ui/model/onboarding.go @@ -0,0 +1,115 @@ +package model + +import ( + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" +) + +// markProjectInitialized marks the current project as initialized in the config. +func (m *UI) markProjectInitialized() tea.Msg { + // TODO: handle error so we show it in the tui footer + err := config.MarkProjectInitialized() + if err != nil { + slog.Error(err.Error()) + } + return nil +} + +// updateInitializeView handles keyboard input for the project initialization prompt. +func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Initialize.Enter): + if m.onboarding.yesInitializeSelected { + cmds = append(cmds, m.initializeProject()) + } else { + cmds = append(cmds, m.skipInitializeProject()) + } + case key.Matches(msg, m.keyMap.Initialize.Switch): + m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected + case key.Matches(msg, m.keyMap.Initialize.Yes): + cmds = append(cmds, m.initializeProject()) + case key.Matches(msg, m.keyMap.Initialize.No): + cmds = append(cmds, m.skipInitializeProject()) + } + return cmds +} + +// initializeProject starts project initialization and transitions to the landing view. +func (m *UI) initializeProject() tea.Cmd { + // clear the session + 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) + if err != nil { + return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()} + } + return sendMessageMsg{Content: initPrompt} + } + // Mark the project as initialized + cmds = append(cmds, initialize, m.markProjectInitialized) + + return tea.Sequence(cmds...) +} + +// skipInitializeProject skips project initialization and transitions to the landing view. +func (m *UI) skipInitializeProject() tea.Cmd { + // TODO: initialize the project + m.setState(uiLanding, uiFocusEditor) + // mark the project as initialized + return m.markProjectInitialized +} + +// initializeView renders the project initialization prompt with Yes/No buttons. +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected}, + {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go new file mode 100644 index 0000000000000000000000000000000000000000..9199bc6deece64774343087bc596396b54272f4c --- /dev/null +++ b/internal/ui/model/pills.go @@ -0,0 +1,284 @@ +package model + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// pillStyle returns the appropriate style for a pill based on focus state. +func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style { + if !panelFocused || focused { + return t.Pills.Focused + } + return t.Pills.Blurred +} + +const ( + // pillHeightWithBorder is the height of a pill including its border. + pillHeightWithBorder = 3 + // maxTaskDisplayLength is the maximum length of a task name in the pill. + maxTaskDisplayLength = 40 + // maxQueueDisplayLength is the maximum length of a queue item in the list. + maxQueueDisplayLength = 60 +) + +// pillSection represents which section of the pills panel is focused. +type pillSection int + +const ( + pillSectionTodos pillSection = iota + pillSectionQueue +) + +// hasIncompleteTodos returns true if there are any non-completed todos. +func hasIncompleteTodos(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status != session.TodoStatusCompleted { + return true + } + } + return false +} + +// hasInProgressTodo returns true if there is at least one in-progress todo. +func hasInProgressTodo(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status == session.TodoStatusInProgress { + return true + } + } + return false +} + +// queuePill renders the queue count pill with gradient triangles. +func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string { + if queue <= 0 { + return "" + } + triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary) + if queue < len(triangles) { + triangles = 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) +} + +// todoPill renders the todo progress pill with optional spinner and task name. +func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string { + if !hasIncompleteTodos(todos) { + return "" + } + + completed := 0 + var currentTodo *session.Todo + for i := range todos { + switch todos[i].Status { + case session.TodoStatusCompleted: + completed++ + case session.TodoStatusInProgress: + if currentTodo == nil { + currentTodo = &todos[i] + } + } + } + + total := len(todos) + + label := t.Base.Render("To-Do") + progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total)) + + var content string + if panelFocused { + content = fmt.Sprintf("%s %s", label, progress) + } else if currentTodo != nil { + taskText := currentTodo.Content + if currentTodo.ActiveForm != "" { + taskText = currentTodo.ActiveForm + } + if len(taskText) > maxTaskDisplayLength { + taskText = taskText[:maxTaskDisplayLength-1] + "…" + } + task := t.Subtle.Render(taskText) + content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task) + } else { + content = fmt.Sprintf("%s %s", label, progress) + } + + return pillStyle(focused, panelFocused, t).Render(content) +} + +// todoList renders the expanded todo list. +func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string { + return chat.FormatTodosList(t, sessionTodos, spinnerView, width) +} + +// queueList renders the expanded queue items list. +func queueList(queueItems []string, t *styles.Styles) string { + if len(queueItems) == 0 { + return "" + } + + var lines []string + for _, item := range queueItems { + text := item + if len(text) > maxQueueDisplayLength { + text = text[:maxQueueDisplayLength-1] + "…" + } + prefix := t.Pills.QueueItemPrefix.Render() + " " + lines = append(lines, prefix+t.Muted.Render(text)) + } + + return strings.Join(lines, "\n") +} + +// togglePillsExpanded toggles the pills panel expansion state. +func (m *UI) togglePillsExpanded() tea.Cmd { + if !m.hasSession() { + return nil + } + if m.layout.pills.Dy() > 0 { + if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil { + return cmd + } + } + hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0 + if !hasPills { + return nil + } + m.pillsExpanded = !m.pillsExpanded + if m.pillsExpanded { + if hasIncompleteTodos(m.session.Todos) { + m.focusedPillSection = pillSectionTodos + } else { + m.focusedPillSection = pillSectionQueue + } + } + m.updateLayoutAndSize() + return nil +} + +// switchPillSection changes focus between todo and queue sections. +func (m *UI) switchPillSection(dir int) tea.Cmd { + if !m.pillsExpanded || !m.hasSession() { + return nil + } + hasIncompleteTodos := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos { + m.focusedPillSection = pillSectionTodos + m.updateLayoutAndSize() + return nil + } + if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue { + m.focusedPillSection = pillSectionQueue + m.updateLayoutAndSize() + return nil + } + return nil +} + +// pillsAreaHeight calculates the total height needed for the pills area. +func (m *UI) pillsAreaHeight() int { + if !m.hasSession() { + return 0 + } + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + hasPills := hasIncomplete || hasQueue + if !hasPills { + return 0 + } + + pillsAreaHeight := pillHeightWithBorder + if m.pillsExpanded { + if m.focusedPillSection == pillSectionTodos && hasIncomplete { + pillsAreaHeight += len(m.session.Todos) + } else if m.focusedPillSection == pillSectionQueue && hasQueue { + pillsAreaHeight += m.promptQueue + } + } + return pillsAreaHeight +} + +// renderPills renders the pills panel and stores it in m.pillsView. +func (m *UI) renderPills() { + m.pillsView = "" + if !m.hasSession() { + return + } + + width := m.layout.pills.Dx() + if width <= 0 { + return + } + + paddingLeft := 3 + contentWidth := max(width-paddingLeft, 0) + + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if !hasIncomplete && !hasQueue { + return + } + + t := m.com.Styles + todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos + queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue + + inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon) + if m.todoIsSpinning { + inProgressIcon = m.todoSpinner.View() + } + + var pills []string + if hasIncomplete { + pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t)) + } + if hasQueue { + pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t)) + } + + var expandedList string + if m.pillsExpanded { + if todosFocused && hasIncomplete { + expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth) + } else if queueFocused && hasQueue { + if m.com.App != nil && m.com.App.AgentCoordinator != nil { + queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID) + expandedList = queueList(queueItems, t) + } + } + } + + if len(pills) == 0 { + return + } + + pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...) + + helpDesc := "open" + if m.pillsExpanded { + helpDesc = "close" + } + helpKey := t.Pills.HelpKey.Render("ctrl+space") + helpText := t.Pills.HelpText.Render(helpDesc) + helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) + pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) + + pillsArea := pillsRow + if expandedList != "" { + pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList) + } + + m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea) +} diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go new file mode 100644 index 0000000000000000000000000000000000000000..1438d0a914556574d513d3606bb1481cde008709 --- /dev/null +++ b/internal/ui/model/session.go @@ -0,0 +1,202 @@ +package model + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/ui/util" + "github.com/charmbracelet/x/ansi" +) + +// loadSessionMsg is a message indicating that a session and its files have +// been loaded. +type loadSessionMsg struct { + session *session.Session + files []SessionFile +} + +// SessionFile tracks the first and latest versions of a file in a session, +// along with the total additions and deletions. +type SessionFile struct { + FirstVersion history.File + LatestVersion history.File + Additions int + Deletions int +} + +// loadSession loads the session along with its associated files and computes +// the diff statistics (additions and deletions) for each file in the session. +// It returns a tea.Cmd that, when executed, fetches the session data and +// returns a sessionFilesLoadedMsg containing the processed session files. +func (m *UI) loadSession(sessionID string) tea.Cmd { + return func() tea.Msg { + session, err := m.com.App.Sessions.Get(context.Background(), sessionID) + if err != nil { + return util.ReportError(err) + } + + sessionFiles, err := m.loadSessionFiles(sessionID) + if err != nil { + return util.ReportError(err) + } + + return loadSessionMsg{ + session: &session, + files: sessionFiles, + } + } +} + +func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) { + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + if err != nil { + return nil, err + } + + filesByPath := make(map[string][]history.File) + for _, f := range files { + filesByPath[f.Path] = append(filesByPath[f.Path], f) + } + sessionFiles := make([]SessionFile, 0, len(filesByPath)) + for _, versions := range filesByPath { + if len(versions) == 0 { + continue + } + + first := versions[0] + last := versions[0] + for _, v := range versions { + if v.Version < first.Version { + first = v + } + if v.Version > last.Version { + last = v + } + } + + _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path) + + sessionFiles = append(sessionFiles, SessionFile{ + FirstVersion: first, + LatestVersion: last, + Additions: additions, + Deletions: deletions, + }) + } + + slices.SortFunc(sessionFiles, func(a, b SessionFile) int { + if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt { + return -1 + } + if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt { + return 1 + } + return 0 + }) + return sessionFiles, nil +} + +// handleFileEvent processes file change events and updates the session file +// list with new or updated file information. +func (m *UI) handleFileEvent(file history.File) tea.Cmd { + if m.session == nil || file.SessionID != m.session.ID { + return nil + } + + return func() tea.Msg { + sessionFiles, err := m.loadSessionFiles(m.session.ID) + // could not load session files + if err != nil { + return util.NewErrorMsg(err) + } + + return sessionFilesUpdatesMsg{ + sessionFiles: sessionFiles, + } + } +} + +// filesInfo renders the modified files section for the sidebar, showing files +// with their addition/deletion counts. +func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string { + t := m.com.Styles + + title := t.Subtle.Render("Modified Files") + if isSection { + title = common.Section(t, "Modified Files", width) + } + list := t.Subtle.Render("None") + var filesWithChanges []SessionFile + for _, f := range m.sessionFiles { + if f.Additions == 0 && f.Deletions == 0 { + continue + } + filesWithChanges = append(filesWithChanges, f) + } + if len(filesWithChanges) > 0 { + list = fileList(t, cwd, filesWithChanges, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// fileList renders a list of files with their diff statistics, truncating to +// maxItems and showing a "...and N more" message if needed. +func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedFiles []string + filesShown := 0 + + for _, f := range filesWithChanges { + // Skip files with no changes + if filesShown >= maxItems { + break + } + + // Build stats string with colors + var statusParts []string + if f.Additions > 0 { + statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions))) + } + if f.Deletions > 0 { + statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions))) + } + extraContent := strings.Join(statusParts, " ") + + // Format file path + filePath := f.FirstVersion.Path + if rel, err := filepath.Rel(cwd, filePath); err == nil { + filePath = rel + } + filePath = fsext.DirTrim(filePath, 2) + filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…") + + line := t.Files.Path.Render(filePath) + if extraContent != "" { + line = fmt.Sprintf("%s %s", line, extraContent) + } + + renderedFiles = append(renderedFiles, line) + filesShown++ + } + + if len(filesWithChanges) > maxItems { + remaining := len(filesWithChanges) - maxItems + renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + } + + return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) +} diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go new file mode 100644 index 0000000000000000000000000000000000000000..c3498b964ca5ebbc2446ffc31855a1c225a7ab5e --- /dev/null +++ b/internal/ui/model/sidebar.go @@ -0,0 +1,161 @@ +package model + +import ( + "cmp" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// modelInfo renders the current model information including reasoning +// settings and context usage/cost for the sidebar. +func (m *UI) modelInfo(width int) string { + model := m.selectedLargeModel() + reasoningInfo := "" + providerName := "" + + if model != nil { + // Get provider name first + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + providerName = providerConfig.Name + + // Only check reasoning if model can reason + if model.CatwalkCfg.CanReason { + if model.ModelCfg.ReasoningEffort == "" { + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + } 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)) + } + } + } + } + + var modelContext *common.ModelContextInfo + if model != nil && m.session != nil { + modelContext = &common.ModelContextInfo{ + ContextUsed: m.session.CompletionTokens + m.session.PromptTokens, + Cost: m.session.Cost, + ModelContext: model.CatwalkCfg.ContextWindow, + } + } + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) +} + +// getDynamicHeightLimits will give us the num of items to show in each section based on the hight +// some items are more important than others. +func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) { + const ( + minItemsPerSection = 2 + defaultMaxFilesShown = 10 + defaultMaxLSPsShown = 8 + defaultMaxMCPsShown = 8 + minAvailableHeightLimit = 10 + ) + + // If we have very little space, use minimum values + if availableHeight < minAvailableHeightLimit { + return minItemsPerSection, minItemsPerSection, minItemsPerSection + } + + // Distribute available height among the three sections + // Give priority to files, then LSPs, then MCPs + totalSections := 3 + heightPerSection := availableHeight / totalSections + + // Calculate limits for each section, ensuring minimums + maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection)) + maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection)) + maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection)) + + // If we have extra space, give it to files first + remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs) + if remainingHeight > 0 { + extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles) + maxFiles += extraForFiles + remainingHeight -= extraForFiles + + if remainingHeight > 0 { + extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs) + maxLSPs += extraForLSPs + remainingHeight -= extraForLSPs + + if remainingHeight > 0 { + maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs) + } + } + } + + return maxFiles, maxLSPs, maxMCPs +} + +// sidebar renders the chat sidebar containing session title, working +// directory, model info, file list, LSP status, and MCP status. +func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + + const logoHeightBreakpoint = 30 + + t := m.com.Styles + width := area.Dx() + height := area.Dy() + + title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title) + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + sidebarLogo := m.sidebarLogo + if height < logoHeightBreakpoint { + sidebarLogo = logo.SmallRender(m.com.Styles, width) + } + blocks := []string{ + sidebarLogo, + title, + "", + cwd, + "", + m.modelInfo(width), + "", + } + + sidebarHeader := lipgloss.JoinVertical( + lipgloss.Left, + blocks..., + ) + + _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader))) + remainingHeight := remainingHeightArea.Dy() - 10 + maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight) + + lspSection := m.lspInfo(width, maxLSPs, true) + mcpSection := m.mcpInfo(width, maxMCPs, true) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true) + + uv.NewStyledString( + lipgloss.NewStyle(). + MaxWidth(width). + MaxHeight(height). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + sidebarHeader, + filesSection, + "", + lspSection, + "", + mcpSection, + ), + ), + ).Draw(scr, area) +} diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go new file mode 100644 index 0000000000000000000000000000000000000000..66dd4082bcc90470129b4a8ebf4ebd65e8567d6c --- /dev/null +++ b/internal/ui/model/status.go @@ -0,0 +1,114 @@ +package model + +import ( + "time" + + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// DefaultStatusTTL is the default time-to-live for status messages. +const DefaultStatusTTL = 5 * time.Second + +// Status is the status bar and help model. +type Status struct { + com *common.Common + hideHelp bool + help help.Model + helpKm help.KeyMap + msg util.InfoMsg +} + +// NewStatus creates a new status bar and help model. +func NewStatus(com *common.Common, km help.KeyMap) *Status { + s := new(Status) + s.com = com + s.help = help.New() + s.help.Styles = com.Styles.Help + s.helpKm = km + return s +} + +// SetInfoMsg sets the status info message. +func (s *Status) SetInfoMsg(msg util.InfoMsg) { + s.msg = msg +} + +// ClearInfoMsg clears the status info message. +func (s *Status) ClearInfoMsg() { + s.msg = util.InfoMsg{} +} + +// SetWidth sets the width of the status bar and help view. +func (s *Status) SetWidth(width int) { + s.help.SetWidth(width) +} + +// ShowingAll returns whether the full help view is shown. +func (s *Status) ShowingAll() bool { + return s.help.ShowAll +} + +// ToggleHelp toggles the full help view. +func (s *Status) ToggleHelp() { + s.help.ShowAll = !s.help.ShowAll +} + +// SetHideHelp sets whether the app is on the onboarding flow. +func (s *Status) SetHideHelp(hideHelp bool) { + s.hideHelp = hideHelp +} + +// Draw draws the status bar onto the screen. +func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { + if !s.hideHelp { + helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm)) + uv.NewStyledString(helpView).Draw(scr, area) + } + + // Render notifications + if s.msg.IsEmpty() { + return + } + + var indStyle lipgloss.Style + var msgStyle lipgloss.Style + switch s.msg.Type { + case util.InfoTypeError: + indStyle = s.com.Styles.Status.ErrorIndicator + msgStyle = s.com.Styles.Status.ErrorMessage + case util.InfoTypeWarn: + indStyle = s.com.Styles.Status.WarnIndicator + msgStyle = s.com.Styles.Status.WarnMessage + case util.InfoTypeUpdate: + indStyle = s.com.Styles.Status.UpdateIndicator + msgStyle = s.com.Styles.Status.UpdateMessage + case util.InfoTypeInfo: + indStyle = s.com.Styles.Status.InfoIndicator + msgStyle = s.com.Styles.Status.InfoMessage + case util.InfoTypeSuccess: + indStyle = s.com.Styles.Status.SuccessIndicator + msgStyle = s.com.Styles.Status.SuccessMessage + } + + ind := indStyle.String() + messageWidth := area.Dx() - lipgloss.Width(ind) + msg := ansi.Truncate(s.msg.Msg, messageWidth, "…") + info := msgStyle.Width(messageWidth).Render(msg) + + // Draw the info message over the help view + uv.NewStyledString(ind+info).Draw(scr, area) +} + +// clearInfoMsgCmd returns a command that clears the info message after the +// given TTL. +func clearInfoMsgCmd(ttl time.Duration) tea.Cmd { + return tea.Tick(ttl, func(time.Time) tea.Msg { + return util.ClearStatusMsg{} + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..a01fafc2905f84e31fce9ce1914bdd8274e26ad4 --- /dev/null +++ b/internal/ui/model/ui.go @@ -0,0 +1,3113 @@ +package model + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "log/slog" + "math/rand" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "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/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/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/completions" + "github.com/charmbracelet/crush/internal/ui/dialog" + "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/ui/util" + "github.com/charmbracelet/crush/internal/version" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/editor" +) + +// Compact mode breakpoints. +const ( + compactModeWidthBreakpoint = 120 + compactModeHeightBreakpoint = 30 +) + +// If pasted text has more than 2 newlines, treat it as a file attachment. +const pasteLinesThreshold = 10 + +// Session details panel max height. +const sessionDetailsMaxHeight = 20 + +// uiFocusState represents the current focus state of the UI. +type uiFocusState uint8 + +// Possible uiFocusState values. +const ( + uiFocusNone uiFocusState = iota + uiFocusEditor + uiFocusMain +) + +type uiState uint8 + +// Possible uiState values. +const ( + uiOnboarding uiState = iota + uiInitialize + uiLanding + uiChat +) + +type openEditorMsg struct { + Text string +} + +type ( + // cancelTimerExpiredMsg is sent when the cancel timer expires. + cancelTimerExpiredMsg struct{} + // userCommandsLoadedMsg is sent when user commands are loaded. + userCommandsLoadedMsg struct { + Commands []commands.CustomCommand + } + // mcpPromptsLoadedMsg is sent when mcp prompts are loaded. + mcpPromptsLoadedMsg struct { + Prompts []commands.MCPPrompt + } + // sendMessageMsg is sent to send a message. + // currently only used for mcp prompts. + sendMessageMsg struct { + Content string + Attachments []message.Attachment + } + + // closeDialogMsg is sent to close the current dialog. + closeDialogMsg struct{} + + // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. + copyChatHighlightMsg struct{} + + // sessionFilesUpdatesMsg is sent when the files for this session have been updated + sessionFilesUpdatesMsg struct { + sessionFiles []SessionFile + } +) + +// UI represents the main user interface model. +type UI struct { + com *common.Common + 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. + width int + height int + layout layout + + isTransparent bool + + focus uiFocusState + state uiState + + keyMap KeyMap + keyenh tea.KeyboardEnhancementsMsg + + dialog *dialog.Overlay + status *Status + + // isCanceling tracks whether the user has pressed escape once to cancel. + isCanceling bool + + header *header + + // sendProgressBar instructs the TUI to send progress bar updates to the + // terminal. + sendProgressBar bool + progressBarEnabled bool + + // caps hold different terminal capabilities that we query for. + caps common.Capabilities + + // Editor components + textarea textarea.Model + + // Attachment list + attachments *attachments.Attachments + + readyPlaceholder string + workingPlaceholder string + + // Completions state + completions *completions.Completions + completionsOpen bool + completionsStartIndex int + completionsQuery string + completionsPositionStart image.Point // x,y where user typed '@' + + // Chat components + chat *Chat + + // onboarding state + onboarding struct { + yesInitializeSelected bool + } + + // lsp + lspStates map[string]app.LSPClientInfo + + // mcp + mcpStates map[string]mcp.ClientInfo + + // sidebarLogo keeps a cached version of the sidebar sidebarLogo. + sidebarLogo string + + // custom commands & mcp commands + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt + + // forceCompactMode tracks whether compact mode is forced by user toggle + forceCompactMode bool + + // isCompact tracks whether we're currently in compact layout mode (either + // by user toggle or auto-switch based on window size) + isCompact bool + + // detailsOpen tracks whether the details panel is open (in compact mode) + detailsOpen bool + + // pills state + pillsExpanded bool + focusedPillSection pillSection + promptQueue int + pillsView string + + // Todo spinner + todoSpinner spinner.Model + todoIsSpinning bool + + // 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. +func New(com *common.Common) *UI { + // Editor components + ta := textarea.New() + ta.SetStyles(com.Styles.TextArea) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + + ch := NewChat(com) + + keyMap := DefaultKeyMap() + + // Completions component + comp := completions.New( + com.Styles.Completions.Normal, + com.Styles.Completions.Focused, + com.Styles.Completions.Match, + ) + + todoSpinner := spinner.New( + spinner.WithSpinner(spinner.MiniDot), + spinner.WithStyle(com.Styles.Pills.TodoSpinner), + ) + + // Attachments component + attachments := attachments.New( + attachments.NewRenderer( + com.Styles.Attachments.Normal, + com.Styles.Attachments.Deleting, + com.Styles.Attachments.Image, + com.Styles.Attachments.Text, + ), + attachments.Keymap{ + DeleteMode: keyMap.Editor.AttachmentDeleteMode, + DeleteAll: keyMap.Editor.DeleteAllAttachments, + Escape: keyMap.Editor.Escape, + }, + ) + + header := newHeader(com) + + ui := &UI{ + com: com, + dialog: dialog.NewOverlay(), + keyMap: keyMap, + textarea: ta, + chat: ch, + header: header, + completions: comp, + attachments: attachments, + todoSpinner: todoSpinner, + lspStates: make(map[string]app.LSPClientInfo), + mcpStates: make(map[string]mcp.ClientInfo), + } + + 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() { + desiredState = uiOnboarding + } else if n, _ := config.ProjectNeedsInitialization(); n { + desiredState = uiInitialize + } + + // set initial state + ui.setState(desiredState, desiredFocus) + + 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 +} + +// Init initializes the UI model. +func (m *UI) Init() tea.Cmd { + var cmds []tea.Cmd + if m.state == uiOnboarding { + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, 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) { + if state == uiLanding { + // Always turn off compact mode when going to landing + m.isCompact = false + } + 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) + } + return userCommandsLoadedMsg{Commands: customCommands} + } +} + +// loadMCPrompts loads the MCP prompts asynchronously. +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) + } + if prompts == nil { + // flag them as loaded even if there is none or an error + prompts = []commands.MCPPrompt{} + } + return mcpPromptsLoadedMsg{Prompts: prompts} + } +} + +// Update handles updates to the UI model. +func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + if m.hasSession() && m.isAgentBusy() { + queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) + if queueSize != m.promptQueue { + m.promptQueue = queueSize + 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") + } + cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) + case loadSessionMsg: + 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) + if err != nil { + cmds = append(cmds, util.ReportError(err)) + break + } + if cmd := m.setSessionMessages(msgs); cmd != nil { + cmds = append(cmds, cmd) + } + if hasInProgressTodo(m.session.Todos) { + // only start spinner if there is an in-progress todo + if m.isAgentBusy() { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + m.updateLayoutAndSize() + } + // Reload prompt history for the new session. + m.historyReset() + cmds = append(cmds, m.loadPromptHistory()) + m.updateLayoutAndSize() + case sessionFilesUpdatesMsg: + m.sessionFiles = msg.sessionFiles + + case sendMessageMsg: + cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) + + case userCommandsLoadedMsg: + m.customCommands = msg.Commands + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + commands.SetCustomCommands(m.customCommands) + } + case mcpPromptsLoadedMsg: + m.mcpPrompts = msg.Prompts + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + 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 { + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } + } + break + } + if m.session != nil && msg.Payload.ID == m.session.ID { + prevHasInProgress := hasInProgressTodo(m.session.Todos) + m.session = &msg.Payload + if !prevHasInProgress && hasInProgressTodo(m.session.Todos) { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + m.updateLayoutAndSize() + } + } + case pubsub.Event[message.Message]: + // Check if this is a child session message for an agent tool. + if m.session == nil { + break + } + if msg.Payload.SessionID != m.session.ID { + // This might be a child session message from an agent tool. + if cmd := m.handleChildSessionMessage(msg); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + switch msg.Type { + case pubsub.CreatedEvent: + cmds = append(cmds, m.appendSessionMessage(msg.Payload)) + case pubsub.UpdatedEvent: + cmds = append(cmds, m.updateSessionMessage(msg.Payload)) + case pubsub.DeletedEvent: + m.chat.RemoveMessage(msg.Payload.ID) + } + // start the spinner if there is a new message + if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + // stop the spinner if the agent is not busy anymore + if m.todoIsSpinning && !m.isAgentBusy() { + m.todoIsSpinning = false + } + // there is a number of things that could change the pills here so we want to re-render + m.renderPills() + case pubsub.Event[history.File]: + cmds = append(cmds, m.handleFileEvent(msg.Payload)) + case pubsub.Event[app.LSPEvent]: + m.lspStates = app.GetLSPStates() + case pubsub.Event[mcp.Event]: + m.mcpStates = mcp.GetStates() + // check if all mcps are initialized + initialized := true + for _, state := range m.mcpStates { + if state.State == mcp.StateStarting { + initialized = false + break + } + } + if initialized && m.mcpPrompts == nil { + cmds = append(cmds, m.loadMCPrompts()) + } + case pubsub.Event[permission.PermissionRequest]: + if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } + case pubsub.Event[permission.PermissionNotification]: + m.handlePermissionNotification(msg.Payload) + case cancelTimerExpiredMsg: + m.isCanceling = false + case tea.TerminalVersionMsg: + termVersion := strings.ToLower(msg.Name) + // Only enable progress bar for the following terminals. + if !m.sendProgressBar { + m.sendProgressBar = strings.Contains(termVersion, "ghostty") + } + return m, nil + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.updateLayoutAndSize() + case tea.KeyboardEnhancementsMsg: + m.keyenh = msg + if msg.SupportsKeyDisambiguation() { + m.keyMap.Models.SetHelp("ctrl+m", "models") + m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") + } + 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 !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) + } + } + } + } + + case tea.MouseMotionMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + switch m.state { + case uiChat: + if msg.Y <= 0 { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } else if msg.Y >= m.chat.Height()-1 { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDrag(x, y) + } + + case tea.MouseReleaseMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + 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.HandleMouseUp(x, y) && m.chat.HasHighlight() { + cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg { + if time.Since(m.lastClickTime) >= doubleClickThreshold { + return copyChatHighlightMsg{} + } + return nil + })) + } + } + case tea.MouseWheelMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + // Otherwise handle mouse wheel for chat. + switch m.state { + case uiChat: + switch msg.Button { + case tea.MouseWheelUp: + if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case tea.MouseWheelDown: + if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + } + case anim.StepMsg: + if m.state == uiChat { + if cmd := m.chat.Animate(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + case spinner.TickMsg: + if m.dialog.HasDialogs() { + // route to dialog + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning { + var cmd tea.Cmd + m.todoSpinner, cmd = m.todoSpinner.Update(msg) + if cmd != nil { + m.renderPills() + cmds = append(cmds, cmd) + } + } + + case tea.KeyPressMsg: + if cmd := m.handleKeyPressMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + case tea.PasteMsg: + if cmd := m.handlePasteMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + case openEditorMsg: + var cmd tea.Cmd + m.textarea.SetValue(msg.Text) + m.textarea.MoveToEnd() + m.textarea, cmd = m.textarea.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case util.InfoMsg: + m.status.SetInfoMsg(msg) + ttl := msg.TTL + if ttl <= 0 { + ttl = DefaultStatusTTL + } + cmds = append(cmds, clearInfoMsgCmd(ttl)) + case util.ClearStatusMsg: + m.status.ClearInfoMsg() + case completions.FilesLoadedMsg: + // Handle async file loading for completions. + if m.completionsOpen { + m.completions.SetFiles(msg.Files) + } + case uv.KittyGraphicsEvent: + if !bytes.HasPrefix(msg.Payload, []byte("OK")) { + slog.Warn("Unexpected Kitty graphics response", + "response", string(msg.Payload), + "options", msg.Options) + } + default: + if m.dialog.HasDialogs() { + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + // This logic gets triggered on any message type, but should it? + switch m.focus { + case uiFocusMain: + case uiFocusEditor: + // Textarea placeholder logic + if m.isAgentBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" + } + } + + // at this point this can only handle [message.Attachment] message, and we + // should return all cmds anyway. + _ = m.attachments.Update(msg) + return m, tea.Batch(cmds...) +} + +// setSessionMessages sets the messages for the current session in the chat +func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { + var cmds []tea.Cmd + // Build tool result map to link tool calls with their results + msgPtrs := make([]*message.Message, len(msgs)) + for i := range msgs { + msgPtrs[i] = &msgs[i] + } + toolResultMap := chat.BuildToolResultMap(msgPtrs) + if len(msgPtrs) > 0 { + m.lastUserMessageTime = msgPtrs[0].CreatedAt + } + + // Add messages to chat with linked tool results + items := make([]chat.MessageItem, 0, len(msgs)*2) + for _, msg := range msgPtrs { + switch msg.Role { + case message.User: + m.lastUserMessageTime = msg.CreatedAt + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + case message.Assistant: + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0)) + items = append(items, infoItem) + } + default: + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + } + } + + // Load nested tool calls for agent/agentic_fetch tools. + m.loadNestedToolCalls(items) + + // If the user switches between sessions while the agent is working we want + // to make sure the animations are shown. + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + m.chat.SetMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLast() + return tea.Batch(cmds...) +} + +// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools. +func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { + for _, item := range items { + nestedContainer, ok := item.(chat.NestedToolContainer) + if !ok { + continue + } + toolItem, ok := item.(chat.ToolMessageItem) + if !ok { + continue + } + + tc := toolItem.ToolCall() + messageID := toolItem.MessageID() + + // Get the agent tool session ID. + agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID) + + // Fetch nested messages. + nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID) + if err != nil || len(nestedMsgs) == 0 { + continue + } + + // Build tool result map for nested messages. + nestedMsgPtrs := make([]*message.Message, len(nestedMsgs)) + for i := range nestedMsgs { + nestedMsgPtrs[i] = &nestedMsgs[i] + } + nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs) + + // Extract nested tool items. + var nestedTools []chat.ToolMessageItem + for _, nestedMsg := range nestedMsgPtrs { + nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap) + for _, nestedItem := range nestedItems { + if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok { + // Mark nested tools as simple (compact) rendering. + if simplifiable, ok := nestedToolItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + nestedTools = append(nestedTools, nestedToolItem) + } + } + } + + // Recursively load nested tool calls for any agent tools within. + nestedMessageItems := make([]chat.MessageItem, len(nestedTools)) + for i, nt := range nestedTools { + nestedMessageItems[i] = nt + } + m.loadNestedToolCalls(nestedMessageItems) + + // Set nested tools on the parent. + nestedContainer.SetNestedTools(nestedTools) + } +} + +// appendSessionMessage appends a new message to the current session in the chat +// 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 + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case message.Assistant: + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + 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 atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + case message.Tool: + for _, tr := range msg.ToolResults() { + toolItem := m.chat.MessageItem(tr.ToolCallID) + if toolItem == nil { + // we should have an item! + continue + } + 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 +func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + existingItem := m.chat.MessageItem(msg.ID) + atBottom := m.chat.list.AtBottom() + + if existingItem != nil { + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetMessage(&msg) + } + } + + shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg) + // if the message of the assistant does not have any response just tool calls we need to remove it + if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil { + m.chat.RemoveMessage(msg.ID) + if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil { + m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID)) + } + } + + if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil { + newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + m.chat.AppendMessages(newInfoItem) + } + } + + var items []chat.MessageItem + for _, tc := range msg.ToolCalls() { + existingToolItem := m.chat.MessageItem(tc.ID) + if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok { + existingToolCall := toolItem.ToolCall() + // only update if finished state changed or input changed + // to avoid clearing the cache + if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input { + toolItem.SetToolCall(tc) + } + } + if existingToolItem == nil { + items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false)) + } + } + + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + m.chat.AppendMessages(items...) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + + return tea.Batch(cmds...) +} + +// handleChildSessionMessage handles messages from child sessions (agent tools). +func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { + var cmds []tea.Cmd + + atBottom := m.chat.list.AtBottom() + // Only process messages with tool calls or results. + if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { + return nil + } + + // Check if this is an agent tool session and parse it. + childSessionID := event.Payload.SessionID + _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID) + if !ok { + return nil + } + + // Find the parent agent tool item. + var agentItem chat.NestedToolContainer + for i := 0; i < m.chat.Len(); i++ { + item := m.chat.MessageItem(toolCallID) + if item == nil { + continue + } + if agent, ok := item.(chat.NestedToolContainer); ok { + if toolMessageItem, ok := item.(chat.ToolMessageItem); ok { + if toolMessageItem.ToolCall().ID == toolCallID { + // Verify this agent belongs to the correct parent message. + // We can't directly check parentMessageID on the item, so we trust the session parsing. + agentItem = agent + break + } + } + } + } + + if agentItem == nil { + return nil + } + + // Get existing nested tools. + nestedTools := agentItem.NestedTools() + + // Update or create nested tool calls. + for _, tc := range event.Payload.ToolCalls() { + found := false + for _, existingTool := range nestedTools { + if existingTool.ToolCall().ID == tc.ID { + existingTool.SetToolCall(tc) + found = true + break + } + } + if !found { + // Create a new nested tool item. + nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false) + if simplifiable, ok := nestedItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + if animatable, ok := nestedItem.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + nestedTools = append(nestedTools, nestedItem) + } + } + + // Update nested tool results. + for _, tr := range event.Payload.ToolResults() { + for _, nestedTool := range nestedTools { + if nestedTool.ToolCall().ID == tr.ToolCallID { + nestedTool.SetResult(&tr) + break + } + } + } + + // Update the agent item with the new nested tools. + agentItem.SetNestedTools(nestedTools) + + // Update the chat so it updates the index map for animations to work as expected + m.chat.UpdateNestedToolIDs(toolCallID) + + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + + return tea.Batch(cmds...) +} + +func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + action := m.dialog.Update(msg) + if action == nil { + return tea.Batch(cmds...) + } + + isOnboarding := m.state == uiOnboarding + + switch msg := action.(type) { + // Generic dialog messages + case dialog.ActionClose: + if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) { + break + } + + m.dialog.CloseFrontDialog() + + if isOnboarding { + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + } + + if m.focus == uiFocusEditor { + cmds = append(cmds, m.textarea.Focus()) + } + case dialog.ActionCmd: + if msg.Cmd != nil { + cmds = append(cmds, msg.Cmd) + } + + // Session dialog messages + case dialog.ActionSelectSession: + m.dialog.CloseDialog(dialog.SessionsID) + cmds = append(cmds, m.loadSession(msg.Session.ID)) + + // Open dialog message + case dialog.ActionOpenDialog: + m.dialog.CloseDialog(dialog.CommandsID) + if cmd := m.openDialog(msg.DialogID); cmd != nil { + cmds = append(cmds, cmd) + } + + // Command dialog messages + case dialog.ActionToggleYoloMode: + yolo := !m.com.App.Permissions.SkipRequests() + m.com.App.Permissions.SetSkipRequests(yolo) + m.setEditorPrompt(yolo) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionNewSession: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionSummarize: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + cmds = append(cmds, func() tea.Msg { + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + return util.ReportError(err)() + } + return nil + }) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleHelp: + m.status.ToggleHelp() + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionExternalEditor: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleCompactMode: + cmds = append(cmds, m.toggleCompactMode()) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleThinking: + cmds = append(cmds, func() tea.Msg { + cfg := m.com.Config() + if cfg == nil { + return util.ReportError(errors.New("configuration not found"))() + } + + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return util.ReportError(errors.New("agent configuration not found"))() + } + + currentModel := cfg.Models[agentCfg.Model] + currentModel.Think = !currentModel.Think + if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + return util.ReportError(err)() + } + m.com.App.UpdateAgentModel(context.TODO()) + status := "disabled" + if currentModel.Think { + status = "enabled" + } + return util.NewInfoMsg("Thinking mode " + status) + }) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionQuit: + cmds = append(cmds, tea.Quit) + case dialog.ActionInitializeProject: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + cmds = append(cmds, m.initializeProject()) + m.dialog.CloseDialog(dialog.CommandsID) + + case dialog.ActionSelectModel: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, util.ReportError(errors.New("configuration not found"))) + break + } + + var ( + providerID = msg.Model.Provider + isCopilot = providerID == string(catwalk.InferenceProviderCopilot) + isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok } + ) + + // Attempt to import GitHub Copilot tokens from VSCode if available. + if isCopilot && !isConfigured() { + config.Get().ImportCopilot() + } + + if !isConfigured() { + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + + if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + cmds = append(cmds, util.ReportError(err)) + } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { + // Ensure small model is set is unset. + smallModel := m.com.App.GetDefaultSmallModel(providerID) + if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + + cmds = append(cmds, func() tea.Msg { + if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil { + return util.ReportError(err) + } + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + + return util.NewInfoMsg(modelMsg) + }) + + m.dialog.CloseDialog(dialog.APIKeyInputID) + m.dialog.CloseDialog(dialog.OAuthID) + m.dialog.CloseDialog(dialog.ModelsID) + + if isOnboarding { + m.setState(uiLanding, uiFocusEditor) + m.com.Config().SetupAgents() + if err := m.com.App.InitCoderAgent(context.TODO()); err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + case dialog.ActionSelectReasoningEffort: + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, util.ReportError(errors.New("configuration not found"))) + break + } + + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + cmds = append(cmds, util.ReportError(errors.New("agent configuration not found"))) + break + } + + currentModel := cfg.Models[agentCfg.Model] + currentModel.ReasoningEffort = msg.Effort + if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + cmds = append(cmds, util.ReportError(err)) + break + } + + cmds = append(cmds, func() tea.Msg { + m.com.App.UpdateAgentModel(context.TODO()) + return util.NewInfoMsg("Reasoning effort set to " + msg.Effort) + }) + m.dialog.CloseDialog(dialog.ReasoningID) + case dialog.ActionPermissionResponse: + m.dialog.CloseDialog(dialog.PermissionsID) + switch msg.Action { + case dialog.PermissionAllow: + m.com.App.Permissions.Grant(msg.Permission) + case dialog.PermissionAllowForSession: + m.com.App.Permissions.GrantPersistent(msg.Permission) + case dialog.PermissionDeny: + m.com.App.Permissions.Deny(msg.Permission) + } + + case dialog.ActionFilePickerSelected: + cmds = append(cmds, tea.Sequence( + msg.Cmd(), + func() tea.Msg { + m.dialog.CloseDialog(dialog.FilePickerID) + return nil + }, + )) + + case dialog.ActionRunCustomCommand: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + argsDialog := dialog.NewArguments( + m.com, + "Custom Command Arguments", + "", + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + content := msg.Content + if msg.Args != nil { + content = substituteArgs(content, msg.Args) + } + cmds = append(cmds, m.sendMessage(content)) + m.dialog.CloseFrontDialog() + case dialog.ActionRunMCPPrompt: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + title := msg.Title + if title == "" { + title = "MCP Prompt Arguments" + } + argsDialog := dialog.NewArguments( + m.com, + title, + msg.Description, + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args)) + default: + cmds = append(cmds, util.CmdHandler(msg)) + } + + return tea.Batch(cmds...) +} + +// substituteArgs replaces $ARG_NAME placeholders in content with actual values. +func substituteArgs(content string, args map[string]string) string { + for name, value := range args { + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + return content +} + +func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + var ( + dlg dialog.Dialog + cmd tea.Cmd + + isOnboarding = m.state == uiOnboarding + ) + + switch provider.ID { + case "hyper": + dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType) + case catwalk.InferenceProviderCopilot: + dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType) + default: + dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType) + } + + if m.dialog.ContainsDialog(dlg.ID()) { + m.dialog.BringToFront(dlg.ID()) + return nil + } + + m.dialog.OpenDialog(dlg) + return cmd +} + +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + var cmds []tea.Cmd + + handleGlobalKeys := func(msg tea.KeyPressMsg) bool { + switch { + case key.Matches(msg, m.keyMap.Help): + m.status.ToggleHelp() + m.updateLayoutAndSize() + return true + case key.Matches(msg, m.keyMap.Commands): + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Models): + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Sessions): + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact: + m.detailsOpen = !m.detailsOpen + m.updateLayoutAndSize() + return true + case key.Matches(msg, m.keyMap.Chat.TogglePills): + if m.state == uiChat && m.hasSession() { + if cmd := m.togglePillsExpanded(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Chat.PillLeft): + 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 && m.focus != uiFocusEditor { + if cmd := m.switchPillSection(1); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Suspend): + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) + return true + } + cmds = append(cmds, tea.Suspend) + return true + } + return false + } + + if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) { + // Always handle quit keys first + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) + } + + // Route all messages to dialog if one is open. + if m.dialog.HasDialogs() { + return m.handleDialogMsg(msg) + } + + // Handle cancel key when agent is busy. + if key.Matches(msg, m.keyMap.Chat.Cancel) { + if m.isAgentBusy() { + if cmd := m.cancelAgent(); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) + } + } + + switch m.state { + case uiOnboarding: + return tea.Batch(cmds...) + case uiInitialize: + cmds = append(cmds, m.updateInitializeView(msg)...) + return tea.Batch(cmds...) + case uiChat, uiLanding: + switch m.focus { + case uiFocusEditor: + // Handle completions if open. + if m.completionsOpen { + if msg, ok := m.completions.Update(msg); ok { + switch msg := msg.(type) { + case completions.SelectionMsg: + // Handle file completion selection. + if item, ok := msg.Value.(completions.FileCompletionValue); ok { + cmds = append(cmds, m.insertFileCompletion(item.Path)) + } + if !msg.Insert { + m.closeCompletions() + } + case completions.ClosedMsg: + m.completionsOpen = false + } + return tea.Batch(cmds...) + } + } + + if ok := m.attachments.Update(msg); ok { + return tea.Batch(cmds...) + } + + switch { + case key.Matches(msg, m.keyMap.Editor.AddImage): + if cmd := m.openFilesDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + + case key.Matches(msg, m.keyMap.Editor.SendMessage): + value := m.textarea.Value() + if before, ok := strings.CutSuffix(value, "\\"); ok { + // If the last character is a backslash, remove it and add a newline. + m.textarea.SetValue(before) + break + } + + // Otherwise, send the message + m.textarea.Reset() + + value = strings.TrimSpace(value) + if value == "exit" || value == "quit" { + return m.openQuitDialog() + } + + attachments := m.attachments.List() + m.attachments.Reset() + if len(value) == 0 && !message.ContainsTextAttachment(attachments) { + return nil + } + + m.randomizePlaceholders() + m.historyReset() + + return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory()) + case key.Matches(msg, m.keyMap.Chat.NewSession): + if !m.hasSession() { + break + } + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Tab): + if m.state != uiLanding { + m.setState(m.state, uiFocusMain) + m.textarea.Blur() + m.chat.Focus() + m.chat.SetSelected(m.chat.Len() - 1) + } + case key.Matches(msg, m.keyMap.Editor.OpenEditor): + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + m.closeCompletions() + 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. + break + } + + // Check for @ trigger before passing to textarea. + curValue := m.textarea.Value() + curIdx := len(curValue) + + // Trigger completions on @. + if msg.String() == "@" && !m.completionsOpen { + // Only show if beginning of prompt or after whitespace. + if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) { + m.completionsOpen = true + m.completionsQuery = "" + m.completionsStartIndex = curIdx + m.completionsPositionStart = m.completionsPosition() + depth, limit := m.com.Config().Options.TUI.Completions.Limits() + cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) + } + } + + // remove the details if they are open when user starts typing + if m.detailsOpen { + m.detailsOpen = false + m.updateLayoutAndSize() + } + + ta, cmd := m.textarea.Update(msg) + 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() != "@" { + newValue := m.textarea.Value() + newIdx := len(newValue) + + // Close completions if cursor moved before start. + if newIdx <= m.completionsStartIndex { + m.closeCompletions() + } else if msg.String() == "space" { + // Close on space. + m.closeCompletions() + } else { + // Extract current word and filter. + word := m.textareaWord() + if strings.HasPrefix(word, "@") { + m.completionsQuery = word[1:] + m.completions.Filter(m.completionsQuery) + } else if m.completionsOpen { + m.closeCompletions() + } + } + } + } + case uiFocusMain: + switch { + case key.Matches(msg, m.keyMap.Tab): + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) + m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.NewSession): + if !m.hasSession() { + break + } + if m.isAgentBusy() { + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.focus = uiFocusEditor + 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): + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case key.Matches(msg, m.keyMap.Chat.Down): + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case key.Matches(msg, m.keyMap.Chat.UpOneItem): + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Chat.DownOneItem): + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Chat.HalfPageUp): + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.HalfPageDown): + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.PageUp): + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.PageDown): + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.Home): + if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirst() + case key.Matches(msg, m.keyMap.Chat.End): + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLast() + default: + if ok, cmd := m.chat.HandleKeyMsg(msg); ok { + cmds = append(cmds, cmd) + } else { + handleGlobalKeys(msg) + } + } + default: + handleGlobalKeys(msg) + } + default: + handleGlobalKeys(msg) + } + + return tea.Batch(cmds...) +} + +// drawHeader draws the header section of the UI. +func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) { + m.header.drawHeader( + scr, + area, + m.session, + m.isCompact, + m.detailsOpen, + m.width, + ) +} + +// Draw implements [uv.Drawable] and draws the UI model. +func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + layout := m.generateLayout(area.Dx(), area.Dy()) + + if m.layout != layout { + m.layout = layout + m.updateSize() + } + + // Clear the screen first + screen.Clear(scr) + + switch m.state { + case uiOnboarding: + m.drawHeader(scr, layout.header) + + // NOTE: Onboarding flow will be rendered as dialogs below, but + // positioned at the bottom left of the screen. + + case uiInitialize: + m.drawHeader(scr, layout.header) + + main := uv.NewStyledString(m.initializeView()) + main.Draw(scr, layout.main) + + case uiLanding: + m.drawHeader(scr, layout.header) + main := uv.NewStyledString(m.landingView()) + main.Draw(scr, layout.main) + + editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx())) + editor.Draw(scr, layout.editor) + + case uiChat: + if m.isCompact { + m.drawHeader(scr, layout.header) + } else { + m.drawSidebar(scr, layout.sidebar) + } + + m.chat.Draw(scr, layout.main) + if layout.pills.Dy() > 0 && m.pillsView != "" { + uv.NewStyledString(m.pillsView).Draw(scr, layout.pills) + } + + editorWidth := scr.Bounds().Dx() + if !m.isCompact { + editorWidth -= layout.sidebar.Dx() + } + editor := uv.NewStyledString(m.renderEditorView(editorWidth)) + editor.Draw(scr, layout.editor) + + // Draw details overlay in compact mode when open + if m.isCompact && m.detailsOpen { + m.drawSessionDetails(scr, layout.sessionDetails) + } + } + + isOnboarding := m.state == uiOnboarding + + // Add status and help layer + m.status.SetHideHelp(isOnboarding) + m.status.Draw(scr, layout.status) + + // Draw completions popup if open + if !isOnboarding && m.completionsOpen && m.completions.HasItems() { + w, h := m.completions.Size() + x := m.completionsPositionStart.X + y := m.completionsPositionStart.Y - h + + screenW := area.Dx() + if x+w > screenW { + x = screenW - w + } + x = max(0, x) + y = max(0, y) + + completionsView := uv.NewStyledString(m.completions.Render()) + completionsView.Draw(scr, image.Rectangle{ + Min: image.Pt(x, y), + Max: image.Pt(x+w, y+h), + }) + } + + // Debugging rendering (visually see when the tui rerenders) + if os.Getenv("CRUSH_UI_DEBUG") == "true" { + debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) + debug := uv.NewStyledString(debugView.String()) + debug.Draw(scr, image.Rectangle{ + Min: image.Pt(4, 1), + Max: image.Pt(8, 3), + }) + } + + // This needs to come last to overlay on top of everything. We always pass + // the full screen bounds because the dialogs will position themselves + // accordingly. + if m.dialog.HasDialogs() { + return m.dialog.Draw(scr, scr.Bounds()) + } + + switch m.focus { + case uiFocusEditor: + if m.layout.editor.Dy() <= 0 { + // Don't show cursor if editor is not visible + return nil + } + if m.detailsOpen && m.isCompact { + // Don't show cursor if details overlay is open + return nil + } + + if m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + // Offset for attachment row if present. + if len(m.attachments.List()) > 0 { + cur.Y++ + } + return cur + } + } + return nil +} + +// View renders the UI model's view. +func (m *UI) View() tea.View { + var v tea.View + v.AltScreen = true + if !m.isTransparent { + v.BackgroundColor = m.com.Styles.Background + } + v.MouseMode = tea.MouseModeCellMotion + v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) + + canvas := uv.NewScreenBuffer(m.width, m.height) + v.Cursor = m.Draw(canvas, canvas.Bounds()) + + content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines + contentLines := strings.Split(content, "\n") + for i, line := range contentLines { + // Trim trailing spaces for concise rendering + contentLines[i] = strings.TrimRight(line, " ") + } + + content = strings.Join(contentLines, "\n") + + v.Content = content + 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)) + } + + return v +} + +// ShortHelp implements [help.KeyMap]. +func (m *UI) ShortHelp() []key.Binding { + var binds []key.Binding + k := &m.keyMap + tab := k.Tab + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.Value() == "" { + commands.SetHelp("/ or ctrl+p", "commands") + } + + switch m.state { + case uiInitialize: + binds = append(binds, k.Quit) + case uiChat: + // Show cancel binding if agent is busy. + if m.isAgentBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, cancelBinding) + } + + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + binds = append(binds, + tab, + commands, + k.Models, + ) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + k.Editor.Newline, + ) + case uiFocusMain: + binds = append(binds, + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + k.Chat.Copy, + ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, k.Chat.PillLeft) + } + } + default: + // TODO: other states + // if m.session == nil { + // no session selected + binds = append(binds, + commands, + k.Models, + k.Editor.Newline, + ) + } + + binds = append(binds, + k.Quit, + k.Help, + ) + + return binds +} + +// FullHelp implements [help.KeyMap]. +func (m *UI) FullHelp() [][]key.Binding { + var binds [][]key.Binding + k := &m.keyMap + help := k.Help + help.SetHelp("ctrl+g", "less") + hasAttachments := len(m.attachments.List()) > 0 + hasSession := m.hasSession() + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.Value() == "" { + commands.SetHelp("/ or ctrl+p", "commands") + } + + switch m.state { + case uiInitialize: + binds = append(binds, + []key.Binding{ + k.Quit, + }) + case uiChat: + // Show cancel binding if agent is busy. + if m.isAgentBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, []key.Binding{cancelBinding}) + } + + mainBinds := []key.Binding{} + tab := k.Tab + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + mainBinds = append(mainBinds, + tab, + commands, + k.Models, + k.Sessions, + ) + if hasSession { + mainBinds = append(mainBinds, k.Chat.NewSession) + } + + binds = append(binds, mainBinds) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + case uiFocusMain: + binds = append(binds, + []key.Binding{ + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + }, + []key.Binding{ + k.Chat.HalfPageUp, + k.Chat.HalfPageDown, + k.Chat.Home, + k.Chat.End, + }, + []key.Binding{ + k.Chat.Copy, + k.Chat.ClearHighlight, + }, + ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, []key.Binding{k.Chat.PillLeft}) + } + } + default: + if m.session == nil { + // no session selected + binds = append(binds, + []key.Binding{ + commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + binds = append(binds, + []key.Binding{ + help, + }, + ) + } + } + + binds = append(binds, + []key.Binding{ + help, + k.Quit, + }, + ) + + return binds +} + +// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states. +func (m *UI) toggleCompactMode() tea.Cmd { + m.forceCompactMode = !m.forceCompactMode + + err := m.com.Config().SetCompactMode(m.forceCompactMode) + if err != nil { + return util.ReportError(err) + } + + m.updateLayoutAndSize() + + return nil +} + +// 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 m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint { + m.isCompact = true + } else { + m.isCompact = false + } + } + + m.layout = m.generateLayout(m.width, m.height) + m.updateSize() +} + +// updateSize updates the sizes of UI components based on the current layout. +func (m *UI) updateSize() { + // Set status width + m.status.SetWidth(m.layout.status.Dx()) + + m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + m.renderPills() + + // Handle different app states + switch m.state { + case uiChat: + if !m.isCompact { + m.cacheSidebarLogo(m.layout.sidebar.Dx()) + } + } +} + +// generateLayout calculates the layout rectangles for all UI components based +// on the current UI state and terminal dimensions. +func (m *UI) generateLayout(w, h int) layout { + // The screen area we're working with + area := image.Rect(0, 0, w, h) + + // The help height + helpHeight := 1 + // The editor height + editorHeight := 5 + // The sidebar width + sidebarWidth := 30 + // The header height + const landingHeaderHeight = 4 + + var helpKeyMap help.KeyMap = m + if m.status != nil && m.status.ShowingAll() { + for _, row := range helpKeyMap.FullHelp() { + helpHeight = max(helpHeight, len(row)) + } + } + + // Add app margins + appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight)) + appRect.Min.Y += 1 + appRect.Max.Y -= 1 + helpRect.Min.Y -= 1 + appRect.Min.X += 1 + appRect.Max.X -= 1 + + if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) { + // extra padding on left and right for these states + appRect.Min.X += 1 + appRect.Max.X -= 1 + } + + layout := layout{ + area: area, + status: helpRect, + } + + // Handle different app states + switch m.state { + case uiOnboarding, uiInitialize: + // Layout + // + // header + // ------ + // main + // ------ + // help + + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) + layout.header = headerRect + layout.main = mainRect + + case uiLanding: + // Layout + // + // header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + // Remove extra padding from editor (but keep it for header and main) + editorRect.Min.X -= 1 + editorRect.Max.X += 1 + layout.header = headerRect + layout.main = mainRect + layout.editor = editorRect + + case uiChat: + if m.isCompact { + // Layout + // + // compact-header + // ------ + // main + // ------ + // editor + // ------ + // help + const compactHeaderHeight = 1 + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight)) + detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header + sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight)) + layout.sessionDetails = sessionDetailsArea + layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header + // Add one line gap between header and main content + mainRect.Min.Y += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + layout.header = headerRect + pillsHeight := m.pillsAreaHeight() + if pillsHeight > 0 { + pillsHeight = min(pillsHeight, mainRect.Dy()) + chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) + layout.main = chatRect + layout.pills = pillsRect + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 + layout.editor = editorRect + } else { + // Layout + // + // ------|--- + // main | + // ------| side + // editor| + // ---------- + // help + + mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + // Add padding left + sideRect.Min.X += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + layout.sidebar = sideRect + pillsHeight := m.pillsAreaHeight() + if pillsHeight > 0 { + pillsHeight = min(pillsHeight, mainRect.Dy()) + chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) + layout.main = chatRect + layout.pills = pillsRect + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 + layout.editor = editorRect + } + } + + if !layout.editor.Empty() { + // Add editor margins 1 top and bottom + if len(m.attachments.List()) == 0 { + layout.editor.Min.Y += 1 + } + layout.editor.Max.Y -= 1 + } + + return layout +} + +// layout defines the positioning of UI elements. +type layout struct { + // area is the overall available area. + area uv.Rectangle + + // header is the header shown in special cases + // e.x when the sidebar is collapsed + // or when in the landing page + // or in init/config + header uv.Rectangle + + // main is the area for the main pane. (e.x chat, configure, landing) + main uv.Rectangle + + // pills is the area for the pills panel. + pills uv.Rectangle + + // editor is the area for the editor pane. + editor uv.Rectangle + + // sidebar is the area for the sidebar. + sidebar uv.Rectangle + + // status is the area for the status view. + status uv.Rectangle + + // session details is the area for the session details overlay in compact mode. + sessionDetails uv.Rectangle +} + +func (m *UI) openEditor(value string) tea.Cmd { + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return util.ReportError(err) + } + defer tmpfile.Close() //nolint:errcheck + if _, err := tmpfile.WriteString(value); err != nil { + return util.ReportError(err) + } + cmd, err := editor.Command( + "crush", + tmpfile.Name(), + editor.AtPosition( + m.textarea.Line()+1, + m.textarea.Column()+1, + ), + ) + if err != nil { + return util.ReportError(err) + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return util.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return util.ReportError(err) + } + if len(content) == 0 { + return util.ReportWarn("Message is empty") + } + os.Remove(tmpfile.Name()) + return openEditorMsg{ + Text: strings.TrimSpace(string(content)), + } + }) +} + +// setEditorPrompt configures the textarea prompt function based on whether +// yolo mode is enabled. +func (m *UI) setEditorPrompt(yolo bool) { + if yolo { + m.textarea.SetPromptFunc(4, m.yoloPromptFunc) + return + } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) +} + +// normalPromptFunc returns the normal editor prompt style (" > " on first +// line, "::: " on subsequent lines). +func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return " > " + } + return "::: " + } + if info.Focused { + return t.EditorPromptNormalFocused.Render() + } + return t.EditorPromptNormalBlurred.Render() +} + +// yoloPromptFunc returns the yolo mode editor prompt style with warning icon +// and colored dots. +func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.EditorPromptYoloIconFocused.Render() + } else { + return t.EditorPromptYoloIconBlurred.Render() + } + } + if info.Focused { + return t.EditorPromptYoloDotsFocused.Render() + } + return t.EditorPromptYoloDotsBlurred.Render() +} + +// closeCompletions closes the completions popup and resets state. +func (m *UI) closeCompletions() { + m.completionsOpen = false + m.completionsQuery = "" + m.completionsStartIndex = 0 + m.completions.Close() +} + +// insertFileCompletion inserts the selected file path into the textarea, +// replacing the @query, and adds the file as an attachment. +func (m *UI) insertFileCompletion(path string) tea.Cmd { + value := m.textarea.Value() + word := m.textareaWord() + + // Find the @ and query to replace. + if m.completionsStartIndex > len(value) { + return nil + } + + // Build the new value: everything before @, the path, everything after query. + endIdx := min(m.completionsStartIndex+len(word), len(value)) + + newValue := value[:m.completionsStartIndex] + path + value[endIdx:] + m.textarea.SetValue(newValue) + m.textarea.MoveToEnd() + m.textarea.InsertRune(' ') + + return func() tea.Msg { + absPath, _ := filepath.Abs(path) + + 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 + } + + return message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + } + } +} + +// completionsPosition returns the X and Y position for the completions popup. +func (m *UI) completionsPosition() image.Point { + cur := m.textarea.Cursor() + if cur == nil { + return image.Point{ + X: m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y, + } + } + return image.Point{ + X: cur.X + m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y + cur.Y, + } +} + +// textareaWord returns the current word at the cursor position. +func (m *UI) textareaWord() string { + return m.textarea.Word() +} + +// isWhitespace returns true if the byte is a whitespace character. +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// isAgentBusy returns true if the agent coordinator exists and is currently +// busy processing a request. +func (m *UI) isAgentBusy() bool { + return m.com.App != nil && + m.com.App.AgentCoordinator != nil && + m.com.App.AgentCoordinator.IsBusy() +} + +// hasSession returns true if there is an active session with a valid ID. +func (m *UI) hasSession() bool { + return m.session != nil && m.session.ID != "" +} + +// mimeOf detects the MIME type of the given content. +func mimeOf(content []byte) string { + mimeBufferSize := min(512, len(content)) + return http.DetectContentType(content[:mimeBufferSize]) +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +// randomizePlaceholders selects random placeholder text for the textarea's +// ready and working states. +func (m *UI) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +} + +// renderEditorView renders the editor view with attachments if any. +func (m *UI) renderEditorView(width int) string { + if len(m.attachments.List()) == 0 { + return m.textarea.View() + } + return lipgloss.JoinVertical( + lipgloss.Top, + m.attachments.Render(width), + m.textarea.View(), + ) +} + +// cacheSidebarLogo renders and caches the sidebar logo at the specified width. +func (m *UI) cacheSidebarLogo(width int) { + m.sidebarLogo = renderLogo(m.com.Styles, true, width) +} + +// sendMessage sends a message with the given content and attachments. +func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd { + if m.com.App.AgentCoordinator == nil { + return util.ReportError(fmt.Errorf("coder agent is not initialized")) + } + + var cmds []tea.Cmd + if !m.hasSession() { + newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") + if err != nil { + return util.ReportError(err) + } + if m.forceCompactMode { + m.isCompact = true + } + if newSession.ID != "" { + 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. + sessionID := m.session.ID + cmds = append(cmds, func() tea.Msg { + _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...) + if err != nil { + isCancelErr := errors.Is(err, context.Canceled) + isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) + if isCancelErr || isPermissionErr { + return nil + } + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: err.Error(), + } + } + return nil + }) + return tea.Batch(cmds...) +} + +const cancelTimerDuration = 2 * time.Second + +// cancelTimerCmd creates a command that expires the cancel timer. +func cancelTimerCmd() tea.Cmd { + return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg { + return cancelTimerExpiredMsg{} + }) +} + +// cancelAgent handles the cancel key press. The first press sets isCanceling to true +// and starts a timer. The second press (before the timer expires) actually +// cancels the agent. +func (m *UI) cancelAgent() tea.Cmd { + if !m.hasSession() { + return nil + } + + coordinator := m.com.App.AgentCoordinator + if coordinator == nil { + return nil + } + + if m.isCanceling { + // Second escape press - actually cancel the agent. + m.isCanceling = false + coordinator.Cancel(m.session.ID) + // Stop the spinning todo indicator. + m.todoIsSpinning = false + m.renderPills() + return nil + } + + // Check if there are queued prompts - if so, clear the queue. + if coordinator.QueuedPrompts(m.session.ID) > 0 { + coordinator.ClearQueue(m.session.ID) + return nil + } + + // First escape press - set canceling state and start timer. + m.isCanceling = true + return cancelTimerCmd() +} + +// openDialog opens a dialog by its ID. +func (m *UI) openDialog(id string) tea.Cmd { + var cmds []tea.Cmd + switch id { + case dialog.SessionsID: + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ModelsID: + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.CommandsID: + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ReasoningID: + if cmd := m.openReasoningDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.QuitID: + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + default: + // Unknown dialog + break + } + return tea.Batch(cmds...) +} + +// openQuitDialog opens the quit confirmation dialog. +func (m *UI) openQuitDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.QuitID) { + // Bring to front + m.dialog.BringToFront(dialog.QuitID) + return nil + } + + quitDialog := dialog.NewQuit(m.com) + m.dialog.OpenDialog(quitDialog) + return nil +} + +// openModelsDialog opens the models dialog. +func (m *UI) openModelsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ModelsID) { + // Bring to front + m.dialog.BringToFront(dialog.ModelsID) + return nil + } + + isOnboarding := m.state == uiOnboarding + modelsDialog, err := dialog.NewModels(m.com, isOnboarding) + if err != nil { + return util.ReportError(err) + } + + m.dialog.OpenDialog(modelsDialog) + + return nil +} + +// openCommandsDialog opens the commands dialog. +func (m *UI) openCommandsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.CommandsID) { + // Bring to front + m.dialog.BringToFront(dialog.CommandsID) + return nil + } + + sessionID := "" + if m.session != nil { + sessionID = m.session.ID + } + + commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts) + if err != nil { + return util.ReportError(err) + } + + m.dialog.OpenDialog(commands) + + return nil +} + +// openReasoningDialog opens the reasoning effort dialog. +func (m *UI) openReasoningDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ReasoningID) { + m.dialog.BringToFront(dialog.ReasoningID) + return nil + } + + reasoningDialog, err := dialog.NewReasoning(m.com) + if err != nil { + return util.ReportError(err) + } + + m.dialog.OpenDialog(reasoningDialog) + return nil +} + +// openSessionsDialog opens the sessions dialog. If the dialog is already open, +// it brings it to the front. Otherwise, it will list all the sessions and open +// the dialog. +func (m *UI) openSessionsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.SessionsID) { + // Bring to front + m.dialog.BringToFront(dialog.SessionsID) + return nil + } + + selectedSessionID := "" + if m.session != nil { + selectedSessionID = m.session.ID + } + + dialog, err := dialog.NewSessions(m.com, selectedSessionID) + if err != nil { + return util.ReportError(err) + } + + m.dialog.OpenDialog(dialog) + return nil +} + +// openFilesDialog opens the file picker dialog. +func (m *UI) openFilesDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.FilePickerID) { + // Bring to front + m.dialog.BringToFront(dialog.FilePickerID) + return nil + } + + filePicker, cmd := dialog.NewFilePicker(m.com) + filePicker.SetImageCapabilities(&m.caps) + m.dialog.OpenDialog(filePicker) + + return cmd +} + +// openPermissionsDialog opens the permissions dialog for a permission request. +func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { + // Close any existing permissions dialog first. + m.dialog.CloseDialog(dialog.PermissionsID) + + // Get diff mode from config. + var opts []dialog.PermissionsOption + if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" { + opts = append(opts, dialog.WithDiffMode(diffMode == "split")) + } + + permDialog := dialog.NewPermissions(m.com, perm, opts...) + m.dialog.OpenDialog(permDialog) + return nil +} + +// handlePermissionNotification updates tool items when permission state changes. +func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) { + toolItem := m.chat.MessageItem(notification.ToolCallID) + if toolItem == nil { + return + } + + if permItem, ok := toolItem.(chat.ToolMessageItem); ok { + if notification.Granted { + permItem.SetStatus(chat.ToolStatusRunning) + } else { + permItem.SetStatus(chat.ToolStatusAwaitingPermission) + } + } +} + +// newSession clears the current session state and prepares for a new session. +// The actual session creation happens when the user sends their first message. +// Returns a command to reload prompt history. +func (m *UI) newSession() tea.Cmd { + if !m.hasSession() { + return nil + } + + m.session = nil + m.sessionFiles = nil + 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. +func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { + if m.dialog.HasDialogs() { + return m.handleDialogMsg(msg) + } + + if m.focus != uiFocusEditor { + return nil + } + + if strings.Count(msg.Content, "\n") > pasteLinesThreshold { + return func() tea.Msg { + content := []byte(msg.Content) + if int64(len(content)) > common.MaxAttachmentSize { + return util.ReportWarn("Paste is too big (>5mb)") + } + name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + return message.Attachment{ + FileName: name, + FilePath: name, + MimeType: mimeType, + Content: content, + } + } + } + + // 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 { + if len(paths) == 0 { + return false + } + for _, path := range paths { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } + + 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 !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 util.ReportError(err) + } + if fileInfo.IsDir() { + return util.ReportWarn("Cannot attach a directory") + } + if fileInfo.Size() > common.MaxAttachmentSize { + return util.ReportWarn("File is too big (>5mb)") + } + + content, err := os.ReadFile(path) + if err != nil { + return util.ReportError(err) + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } + } +} + +var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) + +func (m *UI) pasteIdx() int { + result := 0 + for _, at := range m.attachments.List() { + found := pasteRE.FindStringSubmatch(at.FileName) + if len(found) == 0 { + continue + } + idx, err := strconv.Atoi(found[1]) + if err == nil { + result = max(result, idx) + } + } + return result + 1 +} + +// drawSessionDetails draws the session details in compact mode. +func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + + s := m.com.Styles + + width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize() + height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize() + + title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title) + blocks := []string{ + title, + "", + m.modelInfo(width), + "", + } + + detailsHeader := lipgloss.JoinVertical( + lipgloss.Left, + blocks..., + ) + + version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version) + + remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version) + + const maxSectionWidth = 50 + sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces + maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing + + lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false) + mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false) + sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection) + uv.NewStyledString( + s.CompactDetails.View. + Width(area.Dx()). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + detailsHeader, + sections, + version, + ), + ), + ).Draw(scr, area) +} + +func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd { + load := func() tea.Msg { + prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments) + if err != nil { + // TODO: make this better + return util.ReportError(err)() + } + + if prompt == "" { + return nil + } + return sendMessageMsg{ + Content: prompt, + } + } + + var cmds []tea.Cmd + if cmd := m.dialog.StartLoading(); cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, load, func() tea.Msg { + return closeDialogMsg{} + }) + + return tea.Sequence(cmds...) +} + +func (m *UI) copyChatHighlight() tea.Cmd { + text := m.chat.HighlightContent() + return common.CopyToClipboardWithCallback( + text, + "Selected text copied to clipboard", + func() tea.Msg { + m.chat.ClearMouse() + return nil + }, + ) +} + +// renderLogo renders the Crush logo with the given styles and dimensions. +func renderLogo(t *styles.Styles, compact bool, width int) string { + return logo.Render(t, version.Version, compact, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: width, + }) +} diff --git a/internal/ui/styles/grad.go b/internal/ui/styles/grad.go new file mode 100644 index 0000000000000000000000000000000000000000..866a00fa501b48caa2a69f559efd7d45964cec97 --- /dev/null +++ b/internal/ui/styles/grad.go @@ -0,0 +1,117 @@ +package styles + +import ( + "fmt" + "image/color" + "strings" + + "github.com/lucasb-eyer/go-colorful" + "github.com/rivo/uniseg" +) + +// ForegroundGrad returns a slice of strings representing the input string +// rendered with a horizontal gradient foreground from color1 to color2. Each +// string in the returned slice corresponds to a grapheme cluster in the input +// string. If bold is true, the rendered strings will be bolded. +func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string { + if input == "" { + return []string{""} + } + if len(input) == 1 { + style := t.Base.Foreground(color1) + if bold { + style.Bold(true) + } + return []string{style.Render(input)} + } + var clusters []string + gr := uniseg.NewGraphemes(input) + for gr.Next() { + clusters = append(clusters, string(gr.Runes())) + } + + ramp := blendColors(len(clusters), color1, color2) + for i, c := range ramp { + style := t.Base.Foreground(c) + if bold { + style.Bold(true) + } + clusters[i] = style.Render(clusters[i]) + } + return clusters +} + +// ApplyForegroundGrad renders a given string with a horizontal gradient +// foreground. +func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, input, false, color1, color2) + for _, c := range clusters { + fmt.Fprint(&o, c) + } + return o.String() +} + +// ApplyBoldForegroundGrad renders a given string with a horizontal gradient +// foreground. +func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, input, true, color1, color2) + for _, c := range clusters { + fmt.Fprint(&o, c) + } + return o.String() +} + +// blendColors returns a slice of colors blended between the given keys. +// Blending is done in Hcl to stay in gamut. +func blendColors(size int, stops ...color.Color) []color.Color { + if len(stops) < 2 { + return nil + } + + stopsPrime := make([]colorful.Color, len(stops)) + for i, k := range stops { + stopsPrime[i], _ = colorful.MakeColor(k) + } + + numSegments := len(stopsPrime) - 1 + blended := make([]color.Color, 0, size) + + // Calculate how many colors each segment should have. + segmentSizes := make([]int, numSegments) + baseSize := size / numSegments + remainder := size % numSegments + + // Distribute the remainder across segments. + for i := range numSegments { + segmentSizes[i] = baseSize + if i < remainder { + segmentSizes[i]++ + } + } + + // Generate colors for each segment. + for i := range numSegments { + c1 := stopsPrime[i] + c2 := stopsPrime[i+1] + segmentSize := segmentSizes[i] + + for j := range segmentSize { + var t float64 + if segmentSize > 1 { + t = float64(j) / float64(segmentSize-1) + } + c := c1.BlendHcl(c2, t) + blended = append(blended, c) + } + } + + return blended +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..d28dd1b462ffa6e7bc6bc2c1a34b4ef66d513ef7 --- /dev/null +++ b/internal/ui/styles/styles.go @@ -0,0 +1,1386 @@ +package styles + +import ( + "image/color" + "strings" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2/ansi" + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/crush/internal/ui/diffview" + "github.com/charmbracelet/x/exp/charmtone" +) + +const ( + CheckIcon string = "✓" + SpinnerIcon string = "⋯" + LoadingIcon string = "⟳" + ModelIcon string = "◇" + + ArrowRightIcon string = "→" + + ToolPending string = "●" + ToolSuccess string = "✓" + ToolError string = "×" + + RadioOn string = "◉" + RadioOff string = "○" + + BorderThin string = "│" + BorderThick string = "▌" + + SectionSeparator string = "─" + + TodoCompletedIcon string = "✓" + TodoPendingIcon string = "•" + TodoInProgressIcon string = "→" + + ImageIcon string = "■" + TextIcon string = "≡" + + ScrollbarThumb string = "┃" + ScrollbarTrack string = "│" + + LSPErrorIcon string = "E" + LSPWarningIcon string = "W" + LSPInfoIcon string = "I" + LSPHintIcon string = "H" +) + +const ( + defaultMargin = 2 + defaultListIndent = 2 +) + +type Styles struct { + WindowTooSmall lipgloss.Style + + // Reusable text styles + Base lipgloss.Style + Muted lipgloss.Style + HalfMuted lipgloss.Style + Subtle lipgloss.Style + + // Tags + TagBase lipgloss.Style + TagError lipgloss.Style + TagInfo lipgloss.Style + + // Header + Header struct { + Charm lipgloss.Style // Style for "Charm™" label + Diagonals lipgloss.Style // Style for diagonal separators (╱) + Percentage lipgloss.Style // Style for context percentage + Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d") + KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close") + WorkingDir lipgloss.Style // Style for current working directory + Separator lipgloss.Style // Style for separator dots (•) + } + + CompactDetails struct { + View lipgloss.Style + Version lipgloss.Style + Title lipgloss.Style + } + + // Panels + PanelMuted lipgloss.Style + PanelBase lipgloss.Style + + // Line numbers for code blocks + LineNumber lipgloss.Style + + // Message borders + FocusedMessageBorder lipgloss.Border + + // Tool calls + ToolCallPending lipgloss.Style + ToolCallError lipgloss.Style + ToolCallSuccess lipgloss.Style + ToolCallCancelled lipgloss.Style + EarlyStateMessage lipgloss.Style + + // Text selection + TextSelection lipgloss.Style + + // LSP and MCP status indicators + ItemOfflineIcon lipgloss.Style + ItemBusyIcon lipgloss.Style + ItemErrorIcon lipgloss.Style + ItemOnlineIcon lipgloss.Style + + // Markdown & Chroma + Markdown ansi.StyleConfig + PlainMarkdown ansi.StyleConfig + + // Inputs + TextInput textinput.Styles + TextArea textarea.Styles + + // Help + Help help.Styles + + // Diff + Diff diffview.Style + + // FilePicker + FilePicker filepicker.Styles + + // Buttons + ButtonFocus lipgloss.Style + ButtonBlur lipgloss.Style + + // Borders + BorderFocus lipgloss.Style + BorderBlur lipgloss.Style + + // Editor + EditorPromptNormalFocused lipgloss.Style + EditorPromptNormalBlurred lipgloss.Style + EditorPromptYoloIconFocused lipgloss.Style + EditorPromptYoloIconBlurred lipgloss.Style + EditorPromptYoloDotsFocused lipgloss.Style + EditorPromptYoloDotsBlurred lipgloss.Style + + // Radio + RadioOn lipgloss.Style + RadioOff lipgloss.Style + + // Background + Background color.Color + + // Logo + LogoFieldColor color.Color + LogoTitleColorA color.Color + LogoTitleColorB color.Color + LogoCharmColor color.Color + LogoVersionColor color.Color + + // Colors - semantic colors for tool rendering. + Primary color.Color + Secondary color.Color + Tertiary color.Color + BgBase color.Color + BgBaseLighter color.Color + BgSubtle color.Color + BgOverlay color.Color + FgBase color.Color + FgMuted color.Color + FgHalfMuted color.Color + FgSubtle color.Color + Border color.Color + BorderColor color.Color // Border focus color + Error color.Color + Warning color.Color + Info color.Color + White color.Color + BlueLight color.Color + Blue color.Color + BlueDark color.Color + GreenLight color.Color + Green color.Color + GreenDark color.Color + Red color.Color + RedDark color.Color + Yellow color.Color + + // Section Title + Section struct { + Title lipgloss.Style + Line lipgloss.Style + } + + // Initialize + Initialize struct { + Header lipgloss.Style + Content lipgloss.Style + Accent lipgloss.Style + } + + // LSP + LSP struct { + ErrorDiagnostic lipgloss.Style + WarningDiagnostic lipgloss.Style + HintDiagnostic lipgloss.Style + InfoDiagnostic lipgloss.Style + } + + // Files + Files struct { + Path lipgloss.Style + Additions lipgloss.Style + Deletions lipgloss.Style + } + + // Chat + Chat struct { + // Message item styles + Message struct { + UserBlurred lipgloss.Style + UserFocused lipgloss.Style + AssistantBlurred lipgloss.Style + AssistantFocused lipgloss.Style + NoContent lipgloss.Style + Thinking lipgloss.Style + ErrorTag lipgloss.Style + ErrorTitle lipgloss.Style + ErrorDetails lipgloss.Style + ToolCallFocused lipgloss.Style + ToolCallCompact lipgloss.Style + ToolCallBlurred lipgloss.Style + SectionHeader lipgloss.Style + + // Thinking section styles + ThinkingBox lipgloss.Style // Background for thinking content + ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint + ThinkingFooterTitle lipgloss.Style // "Thought for" text + ThinkingFooterDuration lipgloss.Style // Duration value + AssistantInfoIcon lipgloss.Style + AssistantInfoModel lipgloss.Style + AssistantInfoProvider lipgloss.Style + AssistantInfoDuration lipgloss.Style + } + } + + // Tool - styles for tool call rendering + Tool struct { + // Icon styles with tool status + IconPending lipgloss.Style // Pending operation icon + IconSuccess lipgloss.Style // Successful operation icon + IconError lipgloss.Style // Error operation icon + IconCancelled lipgloss.Style // Cancelled operation icon + + // Tool name styles + NameNormal lipgloss.Style // Normal tool name + NameNested lipgloss.Style // Nested tool name + + // Parameter list styles + ParamMain lipgloss.Style // Main parameter + ParamKey lipgloss.Style // Parameter keys + + // Content rendering styles + ContentLine lipgloss.Style // Individual content line with background and width + ContentTruncation lipgloss.Style // Truncation message "… (N lines)" + ContentCodeLine lipgloss.Style // Code line with background and width + ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase + ContentCodeBg color.Color // Background color for syntax highlighting + Body lipgloss.Style // Body content padding (PaddingLeft(2)) + + // Deprecated - kept for backward compatibility + ContentBg lipgloss.Style // Content background + ContentText lipgloss.Style // Content text + ContentLineNumber lipgloss.Style // Line numbers in code + + // State message styles + StateWaiting lipgloss.Style // "Waiting for tool response..." + StateCancelled lipgloss.Style // "Canceled." + + // Error styles + ErrorTag lipgloss.Style // ERROR tag + ErrorMessage lipgloss.Style // Error message text + + // Diff styles + DiffTruncation lipgloss.Style // Diff truncation message with padding + + // Multi-edit note styles + NoteTag lipgloss.Style // NOTE tag (yellow background) + NoteMessage lipgloss.Style // Note message text + + // Job header styles (for bash jobs) + JobIconPending lipgloss.Style // Pending job icon (green dark) + JobIconError lipgloss.Style // Error job icon (red dark) + JobIconSuccess lipgloss.Style // Success job icon (green) + JobToolName lipgloss.Style // Job tool name "Bash" (blue) + JobAction lipgloss.Style // Action text (Start, Output, Kill) + JobPID lipgloss.Style // PID text + JobDescription lipgloss.Style // Description text + + // Agent task styles + AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) + AgentPrompt lipgloss.Style // Agent prompt text + + // Agentic fetch styles + AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold) + + // Todo styles + TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5") + TodoCompletedIcon lipgloss.Style // Completed todo icon + TodoInProgressIcon lipgloss.Style // In-progress todo icon + TodoPendingIcon lipgloss.Style // Pending todo icon + + // MCP tools + MCPName lipgloss.Style // The mcp name + MCPToolName lipgloss.Style // The mcp tool name + MCPArrow lipgloss.Style // The mcp arrow icon + } + + // Dialog styles + Dialog struct { + Title lipgloss.Style + TitleText lipgloss.Style + TitleError lipgloss.Style + TitleAccent lipgloss.Style + // View is the main content area style. + View lipgloss.Style + PrimaryText lipgloss.Style + SecondaryText lipgloss.Style + // HelpView is the line that contains the help. + HelpView lipgloss.Style + Help struct { + Ellipsis lipgloss.Style + ShortKey lipgloss.Style + ShortDesc lipgloss.Style + ShortSeparator lipgloss.Style + FullKey lipgloss.Style + FullDesc lipgloss.Style + FullSeparator lipgloss.Style + } + + NormalItem lipgloss.Style + SelectedItem lipgloss.Style + InputPrompt lipgloss.Style + + List lipgloss.Style + + Spinner lipgloss.Style + + // ContentPanel is used for content blocks with subtle background. + ContentPanel lipgloss.Style + + // Scrollbar styles for scrollable content. + ScrollbarThumb lipgloss.Style + ScrollbarTrack lipgloss.Style + + // Arguments + Arguments struct { + Content lipgloss.Style + Description lipgloss.Style + InputLabelBlurred lipgloss.Style + InputLabelFocused lipgloss.Style + InputRequiredMarkBlurred lipgloss.Style + InputRequiredMarkFocused lipgloss.Style + } + + Commands struct{} + + ImagePreview lipgloss.Style + + Sessions struct { + // styles for when we are in delete mode + DeletingView lipgloss.Style + DeletingItemFocused lipgloss.Style + DeletingItemBlurred lipgloss.Style + DeletingTitle lipgloss.Style + DeletingMessage lipgloss.Style + DeletingTitleGradientFromColor color.Color + DeletingTitleGradientToColor color.Color + + // styles for when we are in update mode + RenamingView lipgloss.Style + RenamingingItemFocused lipgloss.Style + RenamingItemBlurred lipgloss.Style + RenamingingTitle lipgloss.Style + RenamingingMessage lipgloss.Style + RenamingTitleGradientFromColor color.Color + RenamingTitleGradientToColor color.Color + RenamingPlaceholder lipgloss.Style + } + } + + // Status bar and help + Status struct { + Help lipgloss.Style + + ErrorIndicator lipgloss.Style + WarnIndicator lipgloss.Style + InfoIndicator lipgloss.Style + UpdateIndicator lipgloss.Style + SuccessIndicator lipgloss.Style + + ErrorMessage lipgloss.Style + WarnMessage lipgloss.Style + InfoMessage lipgloss.Style + UpdateMessage lipgloss.Style + SuccessMessage lipgloss.Style + } + + // Completions popup styles + Completions struct { + Normal lipgloss.Style + Focused lipgloss.Style + Match lipgloss.Style + } + + // Attachments styles + Attachments struct { + Normal lipgloss.Style + Image lipgloss.Style + Text lipgloss.Style + Deleting lipgloss.Style + } + + // Pills styles for todo/queue pills + Pills struct { + Base lipgloss.Style // Base pill style with padding + Focused lipgloss.Style // Focused pill with visible border + Blurred lipgloss.Style // Blurred pill with hidden border + QueueItemPrefix lipgloss.Style // Prefix for queue list items + HelpKey lipgloss.Style // Keystroke hint style + HelpText lipgloss.Style // Help action text style + Area lipgloss.Style // Pills area container + TodoSpinner lipgloss.Style // Todo spinner style + } +} + +// ChromaTheme converts the current markdown chroma styles to a chroma +// StyleEntries map. +func (s *Styles) ChromaTheme() chroma.StyleEntries { + rules := s.Markdown.CodeBlock + + return chroma.StyleEntries{ + chroma.Text: chromaStyle(rules.Chroma.Text), + chroma.Error: chromaStyle(rules.Chroma.Error), + chroma.Comment: chromaStyle(rules.Chroma.Comment), + chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc), + chroma.Keyword: chromaStyle(rules.Chroma.Keyword), + chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved), + chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace), + chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType), + chroma.Operator: chromaStyle(rules.Chroma.Operator), + chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation), + chroma.Name: chromaStyle(rules.Chroma.Name), + chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin), + chroma.NameTag: chromaStyle(rules.Chroma.NameTag), + chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute), + chroma.NameClass: chromaStyle(rules.Chroma.NameClass), + chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant), + chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator), + chroma.NameException: chromaStyle(rules.Chroma.NameException), + chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction), + chroma.NameOther: chromaStyle(rules.Chroma.NameOther), + chroma.Literal: chromaStyle(rules.Chroma.Literal), + chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber), + chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate), + chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString), + chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape), + chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted), + chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph), + chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted), + chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong), + chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading), + chroma.Background: chromaStyle(rules.Chroma.Background), + } +} + +// DialogHelpStyles returns the styles for dialog help. +func (s *Styles) DialogHelpStyles() help.Styles { + return help.Styles(s.Dialog.Help) +} + +// DefaultStyles returns the default styles for the UI. +func DefaultStyles() Styles { + var ( + primary = charmtone.Charple + secondary = charmtone.Dolly + tertiary = charmtone.Bok + // accent = charmtone.Zest + + // Backgrounds + bgBase = charmtone.Pepper + bgBaseLighter = charmtone.BBQ + bgSubtle = charmtone.Charcoal + bgOverlay = charmtone.Iron + + // Foregrounds + fgBase = charmtone.Ash + fgMuted = charmtone.Squid + fgHalfMuted = charmtone.Smoke + fgSubtle = charmtone.Oyster + // fgSelected = charmtone.Salt + + // Borders + border = charmtone.Charcoal + borderFocus = charmtone.Charple + + // Status + error = charmtone.Sriracha + warning = charmtone.Zest + info = charmtone.Malibu + + // Colors + white = charmtone.Butter + + blueLight = charmtone.Sardine + blue = charmtone.Malibu + blueDark = charmtone.Damson + + // yellow = charmtone.Mustard + yellow = charmtone.Mustard + // citron = charmtone.Citron + + greenLight = charmtone.Bok + green = charmtone.Julep + greenDark = charmtone.Guac + // greenLight = charmtone.Bok + + red = charmtone.Coral + redDark = charmtone.Sriracha + // redLight = charmtone.Salmon + // cherry = charmtone.Cherry + ) + + normalBorder := lipgloss.NormalBorder() + + base := lipgloss.NewStyle().Foreground(fgBase) + + s := Styles{} + + s.Background = bgBase + + // Populate color fields + s.Primary = primary + s.Secondary = secondary + s.Tertiary = tertiary + s.BgBase = bgBase + s.BgBaseLighter = bgBaseLighter + s.BgSubtle = bgSubtle + s.BgOverlay = bgOverlay + s.FgBase = fgBase + s.FgMuted = fgMuted + s.FgHalfMuted = fgHalfMuted + s.FgSubtle = fgSubtle + s.Border = border + s.BorderColor = borderFocus + s.Error = error + s.Warning = warning + s.Info = info + s.White = white + s.BlueLight = blueLight + s.Blue = blue + s.BlueDark = blueDark + s.GreenLight = greenLight + s.Green = green + s.GreenDark = greenDark + s.Red = red + s.RedDark = redDark + s.Yellow = yellow + + s.TextInput = textinput.Styles{ + Focused: textinput.StyleState{ + Text: base, + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + Suggestion: base.Foreground(fgSubtle), + }, + Blurred: textinput.StyleState{ + Text: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + Suggestion: base.Foreground(fgSubtle), + }, + Cursor: textinput.CursorStyle{ + Color: secondary, + Shape: tea.CursorBlock, + Blink: true, + }, + } + + s.TextArea = textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base, + LineNumber: base.Foreground(fgSubtle), + CursorLine: base, + CursorLineNumber: base.Foreground(fgSubtle), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + }, + Blurred: textarea.StyleState{ + Base: base, + Text: base.Foreground(fgMuted), + LineNumber: base.Foreground(fgMuted), + CursorLine: base, + CursorLineNumber: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + }, + Cursor: textarea.CursorStyle{ + Color: secondary, + Shape: tea.CursorBlock, + Blink: true, + }, + } + + s.Markdown = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + // BlockPrefix: "\n", + // BlockSuffix: "\n", + Color: stringPtr(charmtone.Smoke.Hex()), + }, + // Margin: uintPtr(defaultMargin), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Color: stringPtr(charmtone.Malibu.Hex()), + Bold: boolPtr(true), + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr(charmtone.Zest.Hex()), + BackgroundColor: stringPtr(charmtone.Charple.Hex()), + Bold: boolPtr(true), + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: stringPtr(charmtone.Guac.Hex()), + Bold: boolPtr(false), + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + HorizontalRule: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Charcoal.Hex()), + Format: "\n--------\n", + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{}, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Zinc.Hex()), + Underline: boolPtr(true), + }, + LinkText: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + Bold: boolPtr(true), + }, + Image: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cheeky.Hex()), + Underline: boolPtr(true), + }, + ImageText: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Squid.Hex()), + Format: "Image: {{.text}} →", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr(charmtone.Coral.Hex()), + BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Charcoal.Hex()), + }, + Margin: uintPtr(defaultMargin), + }, + Chroma: &ansi.Chroma{ + Text: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Smoke.Hex()), + }, + Error: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Butter.Hex()), + BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), + }, + Comment: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Oyster.Hex()), + }, + CommentPreproc: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Bengal.Hex()), + }, + Keyword: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Malibu.Hex()), + }, + KeywordReserved: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Pony.Hex()), + }, + KeywordNamespace: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Pony.Hex()), + }, + KeywordType: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guppy.Hex()), + }, + Operator: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Salmon.Hex()), + }, + Punctuation: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Zest.Hex()), + }, + Name: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Smoke.Hex()), + }, + NameBuiltin: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cheeky.Hex()), + }, + NameTag: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Mauve.Hex()), + }, + NameAttribute: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Hazy.Hex()), + }, + NameClass: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Salt.Hex()), + Underline: boolPtr(true), + Bold: boolPtr(true), + }, + NameDecorator: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Citron.Hex()), + }, + NameFunction: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + }, + LiteralNumber: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Julep.Hex()), + }, + LiteralString: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cumin.Hex()), + }, + LiteralStringEscape: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Bok.Hex()), + }, + GenericDeleted: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Coral.Hex()), + }, + GenericEmph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + GenericInserted: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + }, + GenericStrong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + GenericSubheading: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Squid.Hex()), + }, + Background: ansi.StylePrimitive{ + BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + }, + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + }, + } + + // PlainMarkdown style - muted colors on subtle background for thinking content. + plainBg := stringPtr(bgBaseLighter.Hex()) + plainFg := stringPtr(fgMuted.Hex()) + s.PlainMarkdown = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + HorizontalRule: ansi.StylePrimitive{ + Format: "\n--------\n", + Color: plainFg, + BackgroundColor: plainBg, + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + LinkText: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Image: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + ImageText: ansi.StylePrimitive{ + Format: "Image: {{.text}} →", + Color: plainFg, + BackgroundColor: plainBg, + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Margin: uintPtr(defaultMargin), + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + Color: plainFg, + BackgroundColor: plainBg, + }, + } + + s.Help = help.Styles{ + ShortKey: base.Foreground(fgMuted), + ShortDesc: base.Foreground(fgSubtle), + ShortSeparator: base.Foreground(border), + Ellipsis: base.Foreground(border), + FullKey: base.Foreground(fgMuted), + FullDesc: base.Foreground(fgSubtle), + FullSeparator: base.Foreground(border), + } + + s.Diff = diffview.Style{ + DividerLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + }, + MissingLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Background(bgBaseLighter), + }, + EqualLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(bgBase), + Code: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(bgBase), + }, + InsertLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#629657")). + Background(lipgloss.Color("#2b322a")), + Symbol: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#629657")). + Background(lipgloss.Color("#323931")), + Code: lipgloss.NewStyle(). + Background(lipgloss.Color("#323931")), + }, + DeleteLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a45c59")). + Background(lipgloss.Color("#312929")), + Symbol: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a45c59")). + Background(lipgloss.Color("#383030")), + Code: lipgloss.NewStyle(). + Background(lipgloss.Color("#383030")), + }, + } + + s.FilePicker = filepicker.Styles{ + DisabledCursor: base.Foreground(fgMuted), + Cursor: base.Foreground(fgBase), + Symlink: base.Foreground(fgSubtle), + Directory: base.Foreground(primary), + File: base.Foreground(fgBase), + DisabledFile: base.Foreground(fgMuted), + DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted), + Permission: base.Foreground(fgMuted), + Selected: base.Background(primary).Foreground(fgBase), + FileSize: base.Foreground(fgMuted), + EmptyDirectory: base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"), + } + + // borders + s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick} + + // text presets + s.Base = lipgloss.NewStyle().Foreground(fgBase) + s.Muted = lipgloss.NewStyle().Foreground(fgMuted) + s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle) + + s.WindowTooSmall = s.Muted + + // tag presets + s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white) + s.TagError = s.TagBase.Background(redDark) + s.TagInfo = s.TagBase.Background(blueLight) + + // Compact header styles + s.Header.Charm = base.Foreground(secondary) + s.Header.Diagonals = base.Foreground(primary) + s.Header.Percentage = s.Muted + s.Header.Keystroke = s.Muted + s.Header.KeystrokeTip = s.Subtle + s.Header.WorkingDir = s.Muted + s.Header.Separator = s.Subtle + + s.CompactDetails.Title = s.Base + s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.CompactDetails.Version = s.Muted + + // panels + s.PanelMuted = s.Muted.Background(bgBaseLighter) + s.PanelBase = lipgloss.NewStyle().Background(bgBase) + + // code line number + s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) + + // Tool calls + s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending) + s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError) + s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess) + // Cancelled uses muted tone but same glyph as pending + s.ToolCallCancelled = s.Muted.SetString(ToolPending) + s.EarlyStateMessage = s.Subtle.PaddingLeft(2) + + // Tool rendering styles + s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending) + s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess) + s.Tool.IconError = base.Foreground(redDark).SetString(ToolError) + s.Tool.IconCancelled = s.Muted.SetString(ToolPending) + + s.Tool.NameNormal = base.Foreground(blue) + s.Tool.NameNested = base.Foreground(fgHalfMuted) + + s.Tool.ParamMain = s.Subtle + s.Tool.ParamKey = s.Subtle + + // 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).PaddingLeft(2) + s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2) + s.Tool.ContentCodeBg = bgBase + s.Tool.Body = base.PaddingLeft(2) + + // Deprecated - kept for backward compatibility + s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) + s.Tool.ContentText = s.Muted + s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) + + s.Tool.StateWaiting = base.Foreground(fgSubtle) + s.Tool.StateCancelled = base.Foreground(fgSubtle) + + s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white) + s.Tool.ErrorMessage = base.Foreground(fgHalfMuted) + + // Diff and multi-edit styles + s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2) + s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white) + s.Tool.NoteMessage = base.Foreground(fgHalfMuted) + + // Job header styles + s.Tool.JobIconPending = base.Foreground(greenDark) + s.Tool.JobIconError = base.Foreground(redDark) + s.Tool.JobIconSuccess = base.Foreground(green) + s.Tool.JobToolName = base.Foreground(blue) + s.Tool.JobAction = base.Foreground(blueDark) + s.Tool.JobPID = s.Muted + s.Tool.JobDescription = s.Subtle + + // Agent task styles + s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white) + s.Tool.AgentPrompt = s.Muted + + // Agentic fetch styles + s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border) + + // Todo styles + s.Tool.TodoRatio = base.Foreground(blueDark) + s.Tool.TodoCompletedIcon = base.Foreground(green) + s.Tool.TodoInProgressIcon = base.Foreground(greenDark) + s.Tool.TodoPendingIcon = base.Foreground(fgMuted) + + // MCP styles + s.Tool.MCPName = base.Foreground(blue) + s.Tool.MCPToolName = base.Foreground(blueDark) + s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon) + + // Buttons + s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) + s.ButtonBlur = s.Base.Background(bgSubtle) + + // Borders + s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) + + // Editor + s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ") + s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted) + s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") + s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) + s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::") + s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + + s.RadioOn = s.HalfMuted.SetString(RadioOn) + s.RadioOff = s.HalfMuted.SetString(RadioOff) + + // Logo colors + s.LogoFieldColor = primary + s.LogoTitleColorA = secondary + s.LogoTitleColorB = primary + s.LogoCharmColor = secondary + s.LogoVersionColor = primary + + // Section + s.Section.Title = s.Subtle + s.Section.Line = s.Base.Foreground(charmtone.Charcoal) + + // Initialize + s.Initialize.Header = s.Base + s.Initialize.Content = s.Muted + s.Initialize.Accent = s.Base.Foreground(greenDark) + + // LSP and MCP status. + s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") + s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron) + s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral) + s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac) + + // LSP + s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark) + s.LSP.WarningDiagnostic = s.Base.Foreground(warning) + s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) + s.LSP.InfoDiagnostic = s.Base.Foreground(info) + + // Files + s.Files.Path = s.Muted + s.Files.Additions = s.Base.Foreground(greenDark) + s.Files.Deletions = s.Base.Foreground(redDark) + + // Chat + messageFocussedBorder := lipgloss.Border{ + Left: "▌", + } + + s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase) + s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(normalBorder) + s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(messageFocussedBorder) + s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2) + s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(greenDark).BorderStyle(messageFocussedBorder) + s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10) + s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1). + Background(red).Foreground(white) + s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + + // Message item styles + s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1). + BorderStyle(messageFocussedBorder). + BorderLeft(true). + BorderForeground(greenDark) + s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) + // No padding or border for compact tool calls within messages + s.Chat.Message.ToolCallCompact = s.Muted + s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) + s.Chat.Message.AssistantInfoIcon = s.Subtle + s.Chat.Message.AssistantInfoModel = s.Muted + s.Chat.Message.AssistantInfoProvider = s.Subtle + s.Chat.Message.AssistantInfoDuration = s.Subtle + + // Thinking section styles + s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter) + s.Chat.Message.ThinkingTruncationHint = s.Muted + s.Chat.Message.ThinkingFooterTitle = s.Muted + s.Chat.Message.ThinkingFooterDuration = s.Subtle + + // Text selection. + s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + + // Dialog styles + s.Dialog.Title = base.Padding(0, 1).Foreground(primary) + s.Dialog.TitleText = base.Foreground(primary) + s.Dialog.TitleError = base.Foreground(red) + s.Dialog.TitleAccent = base.Foreground(green).Bold(true) + s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary) + s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle) + s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left) + s.Dialog.Help.ShortKey = base.Foreground(fgMuted) + s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle) + s.Dialog.Help.ShortSeparator = base.Foreground(border) + s.Dialog.Help.Ellipsis = base.Foreground(border) + s.Dialog.Help.FullKey = base.Foreground(fgMuted) + s.Dialog.Help.FullDesc = base.Foreground(fgSubtle) + s.Dialog.Help.FullSeparator = base.Foreground(border) + s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase) + s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) + s.Dialog.InputPrompt = base.Margin(1, 1) + + s.Dialog.List = base.Margin(0, 0, 1, 0) + s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2) + s.Dialog.Spinner = base.Foreground(secondary) + s.Dialog.ScrollbarThumb = base.Foreground(secondary) + s.Dialog.ScrollbarTrack = base.Foreground(border) + + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle) + + s.Dialog.Arguments.Content = base.Padding(1) + s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3) + s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted) + s.Dialog.Arguments.InputLabelFocused = base.Bold(true) + s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*") + s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*") + + s.Dialog.Sessions.DeletingTitle = s.Dialog.Title.Foreground(red) + s.Dialog.Sessions.DeletingView = s.Dialog.View.BorderForeground(red) + s.Dialog.Sessions.DeletingMessage = s.Base.Padding(1) + 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).Foreground(charmtone.Butter) + + 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!") + s.Status.InfoIndicator = s.Status.SuccessIndicator + s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!") + s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING") + s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR") + s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1) + s.Status.InfoMessage = s.Status.SuccessMessage + s.Status.UpdateMessage = s.Status.SuccessMessage + s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning) + s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark) + + // Completions styles + s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase) + s.Completions.Focused = base.Background(primary).Foreground(white) + s.Completions.Match = base.Underline(true) + + // Attachments styles + attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1) + s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon) + s.Attachments.Text = attachmentIconStyle.SetString(TextIcon) + s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase) + s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase) + + // Pills styles + s.Pills.Base = base.Padding(0, 1) + s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay) + s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) + s.Pills.QueueItemPrefix = s.Muted.SetString(" •") + s.Pills.HelpKey = s.Muted + s.Pills.HelpText = s.Subtle + s.Pills.Area = base + s.Pills.TodoSpinner = base.Foreground(greenDark) + + return s +} + +// Helper functions for style pointers +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 strings.Builder + + if style.Color != nil { + s.WriteString(*style.Color) + } + if style.BackgroundColor != nil { + if s.Len() > 0 { + s.WriteString(" ") + } + s.WriteString("bg:") + s.WriteString(*style.BackgroundColor) + } + if style.Italic != nil && *style.Italic { + if s.Len() > 0 { + s.WriteString(" ") + } + s.WriteString("italic") + } + if style.Bold != nil && *style.Bold { + if s.Len() > 0 { + s.WriteString(" ") + } + s.WriteString("bold") + } + if style.Underline != nil && *style.Underline { + if s.Len() > 0 { + s.WriteString(" ") + } + s.WriteString("underline") + } + + return s.String() +} diff --git a/internal/uiutil/uiutil.go b/internal/ui/util/util.go similarity index 72% rename from internal/uiutil/uiutil.go rename to internal/ui/util/util.go index efd89dda69f780b354777916b459675154780372..7a53df7d1e4e676b3b142de9ec74deff614c8af2 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/ui/util/util.go @@ -1,7 +1,5 @@ -// Package uiutil provides utility functions for UI message handling. -// TODO: Move to internal/ui/ once the new UI migration -// is finalized. -package uiutil +// Package util provides utility functions for UI message handling. +package util import ( "context" @@ -26,10 +24,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd { func ReportError(err error) tea.Cmd { slog.Error("Error reported", "error", err) - return CmdHandler(InfoMsg{ - Type: InfoTypeError, - Msg: err.Error(), - }) + return CmdHandler(NewErrorMsg(err)) } type InfoType int @@ -42,18 +37,33 @@ const ( InfoTypeUpdate ) -func ReportInfo(info string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewInfoMsg(info string) InfoMsg { + return InfoMsg{ Type: InfoTypeInfo, Msg: info, - }) + } } -func ReportWarn(warn string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewWarnMsg(warn string) InfoMsg { + return InfoMsg{ Type: InfoTypeWarn, Msg: warn, - }) + } +} + +func NewErrorMsg(err error) InfoMsg { + return InfoMsg{ + Type: InfoTypeError, + Msg: err.Error(), + } +} + +func ReportInfo(info string) tea.Cmd { + return CmdHandler(NewInfoMsg(info)) +} + +func ReportWarn(warn string) tea.Cmd { + return CmdHandler(NewWarnMsg(warn)) } type ( @@ -65,6 +75,12 @@ type ( ClearStatusMsg struct{} ) +// IsEmpty checks if the [InfoMsg] is empty. +func (m InfoMsg) IsEmpty() bool { + var zero InfoMsg + return m == zero +} + // ExecShell parses a shell command string and executes it with exec.Command. // Uses shell.Fields for proper handling of shell syntax like quotes and // arguments while preserving TTY handling for terminal editors. diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go deleted file mode 100644 index c571dacd1989c518347e3a773b36d6d5fd2b8878..0000000000000000000000000000000000000000 --- a/internal/uicmd/uicmd.go +++ /dev/null @@ -1,313 +0,0 @@ -// Package uicmd provides functionality to load and handle custom commands -// from markdown files and MCP prompts. -// TODO: Move this into internal/ui after refactoring. -package uicmd - -import ( - "cmp" - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type CommandType uint - -func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } - -const ( - SystemCommands CommandType = iota - UserCommands - MCPPrompts -) - -// Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Shortcut string // Optional shortcut for the command - Handler func(cmd Command) tea.Cmd -} - -// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. -type ShowArgumentsDialogMsg struct { - CommandID string - Description string - ArgNames []string - OnSubmit func(args map[string]string) tea.Cmd -} - -// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. -type CloseArgumentsDialogMsg struct { - Submit bool - CommandID string - Content string - Args map[string]string -} - -const ( - userCommandPrefix = "user:" - projectCommandPrefix = "project:" -) - -var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) - -type commandLoader struct { - sources []commandSource -} - -type commandSource struct { - path string - prefix string -} - -func LoadCustomCommands() ([]Command, error) { - return LoadCustomCommandsFromConfig(config.Get()) -} - -func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) { - if cfg == nil { - return nil, fmt.Errorf("config not loaded") - } - - loader := &commandLoader{ - sources: buildCommandSources(cfg), - } - - return loader.loadAll() -} - -func buildCommandSources(cfg *config.Config) []commandSource { - var sources []commandSource - - // XDG config directory - if dir := getXDGCommandsDir(); dir != "" { - sources = append(sources, commandSource{ - path: dir, - prefix: userCommandPrefix, - }) - } - - // Home directory - if home := home.Dir(); home != "" { - sources = append(sources, commandSource{ - path: filepath.Join(home, ".crush", "commands"), - prefix: userCommandPrefix, - }) - } - - // Project directory - sources = append(sources, commandSource{ - path: filepath.Join(cfg.Options.DataDirectory, "commands"), - prefix: projectCommandPrefix, - }) - - return sources -} - -func getXDGCommandsDir() string { - xdgHome := os.Getenv("XDG_CONFIG_HOME") - if xdgHome == "" { - if home := home.Dir(); home != "" { - xdgHome = filepath.Join(home, ".config") - } - } - if xdgHome != "" { - return filepath.Join(xdgHome, "crush", "commands") - } - return "" -} - -func (l *commandLoader) loadAll() ([]Command, error) { - var commands []Command - - for _, source := range l.sources { - if cmds, err := l.loadFromSource(source); err == nil { - commands = append(commands, cmds...) - } - } - - return commands, nil -} - -func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) { - if err := ensureDir(source.path); err != nil { - return nil, err - } - - var commands []Command - - err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { - return err - } - - cmd, err := l.loadCommand(path, source.path, source.prefix) - if err != nil { - return nil // Skip invalid files - } - - commands = append(commands, cmd) - return nil - }) - - return commands, err -} - -func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) { - content, err := os.ReadFile(path) - if err != nil { - return Command{}, err - } - - id := buildCommandID(path, baseDir, prefix) - desc := fmt.Sprintf("Custom command from %s", filepath.Base(path)) - - return Command{ - ID: id, - Title: id, - Description: desc, - Handler: createCommandHandler(id, desc, string(content)), - }, nil -} - -func buildCommandID(path, baseDir, prefix string) string { - relPath, _ := filepath.Rel(baseDir, path) - parts := strings.Split(relPath, string(filepath.Separator)) - - // Remove .md extension from last part - if len(parts) > 0 { - lastIdx := len(parts) - 1 - parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) - } - - return prefix + strings.Join(parts, ":") -} - -func createCommandHandler(id, desc, content string) func(Command) tea.Cmd { - return func(cmd Command) tea.Cmd { - args := extractArgNames(content) - - if len(args) == 0 { - return util.CmdHandler(CommandRunCustomMsg{ - Content: content, - }) - } - return util.CmdHandler(ShowArgumentsDialogMsg{ - CommandID: id, - Description: desc, - ArgNames: args, - OnSubmit: func(args map[string]string) tea.Cmd { - return execUserPrompt(content, args) - }, - }) - } -} - -func execUserPrompt(content string, args map[string]string) tea.Cmd { - return func() tea.Msg { - for name, value := range args { - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) - } - return CommandRunCustomMsg{ - Content: content, - } - } -} - -func extractArgNames(content string) []string { - matches := namedArgPattern.FindAllStringSubmatch(content, -1) - if len(matches) == 0 { - return nil - } - - seen := make(map[string]bool) - var args []string - - for _, match := range matches { - arg := match[1] - if !seen[arg] { - seen[arg] = true - args = append(args, arg) - } - } - - return args -} - -func ensureDir(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - return os.MkdirAll(path, 0o755) - } - return nil -} - -func isMarkdownFile(name string) bool { - return strings.HasSuffix(strings.ToLower(name), ".md") -} - -type CommandRunCustomMsg struct { - Content string -} - -func LoadMCPPrompts() []Command { - var commands []Command - for mcpName, prompts := range mcp.Prompts() { - for _, prompt := range prompts { - key := mcpName + ":" + prompt.Name - commands = append(commands, Command{ - ID: key, - Title: cmp.Or(prompt.Title, prompt.Name), - Description: prompt.Description, - Handler: createMCPPromptHandler(mcpName, prompt.Name, prompt), - }) - } - } - - return commands -} - -func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd { - return func(cmd Command) tea.Cmd { - if len(prompt.Arguments) == 0 { - return execMCPPrompt(mcpName, promptName, nil) - } - return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{ - Prompt: prompt, - OnSubmit: func(args map[string]string) tea.Cmd { - return execMCPPrompt(mcpName, promptName, args) - }, - }) - } -} - -func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args) - if err != nil { - return util.ReportError(err) - } - - return chat.SendMsg{ - Text: strings.Join(result, " "), - } - } -} - -type ShowMCPPromptArgumentsDialogMsg struct { - Prompt *mcp.Prompt - OnSubmit func(arg map[string]string) tea.Cmd -} diff --git a/schema.json b/schema.json index d92c398cc84704e69975f501c5f96e26ebfa7d1e..c8d2482079f294b6499810c34c312f0e1729d929 100644 --- a/schema.json +++ b/schema.json @@ -63,16 +63,6 @@ "type": "object", "description": "Model configurations for different model types" }, - "recent_models": { - "additionalProperties": { - "items": { - "$ref": "#/$defs/SelectedModel" - }, - "type": "array" - }, - "type": "object", - "description": "Recently used models sorted by most recent first" - }, "providers": { "additionalProperties": { "$ref": "#/$defs/ProviderConfig" @@ -102,10 +92,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "tools" - ] + "type": "object" }, "LSPConfig": { "properties": { @@ -169,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": { @@ -445,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, @@ -647,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, @@ -708,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