on main help sign in
.github/cla-signatures.json+24
since splitsnapshots
unifiedsplit
@@ -2071,6 +2071,30 @@
2071 "created_at": "2026-07-01T08:13:03Z",
2071 "created_at": "2026-07-01T08:13:03Z",
2072 "repoId": 987670088,
2072 "repoId": 987670088,
2073 "pullRequestNo": 3235
2073 "pullRequestNo": 3235
2074+ },
2075+ {
2076+ "name": "Osamaali313",
2077+ "id": 86572800,
2078+ "comment_id": 4870561813,
2079+ "created_at": "2026-07-02T21:19:15Z",
2080+ "repoId": 987670088,
2081+ "pullRequestNo": 3244
2082+ },
2083+ {
2084+ "name": "lockp111",
2085+ "id": 28383475,
2086+ "comment_id": 4878248994,
2087+ "created_at": "2026-07-03T17:12:26Z",
2088+ "repoId": 987670088,
2089+ "pullRequestNo": 3247
2090+ },
2091+ {
2092+ "name": "TheRAWagent",
2093+ "id": 77039476,
2094+ "comment_id": 4879276761,
2095+ "created_at": "2026-07-03T20:52:00Z",
2096+ "repoId": 987670088,
2097+ "pullRequestNo": 3251
2074 }
2098 }
2075 ]
2099 ]
2076}
2100}
.github/workflows/security.yml+5−5
@@ -30,11 +30,11 @@ jobs:
30 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
30 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
31 with:
31 with:
32 persist-credentials: false
32 persist-credentials: false
33− - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
33+ - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
34 with:
34 with:
35 languages: ${{ matrix.language }}
35 languages: ${{ matrix.language }}
36− - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
36+ - uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
37− - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
37+ - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
38
38
39 grype:
39 grype:
40 runs-on: ubuntu-latest
40 runs-on: ubuntu-latest
@@ -52,7 +52,7 @@ jobs:
52 path: "."
52 path: "."
53 fail-build: true
53 fail-build: true
54 severity-cutoff: critical
54 severity-cutoff: critical
55− - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
55+ - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
56 with:
56 with:
57 sarif_file: ${{ steps.scan.outputs.sarif }}
57 sarif_file: ${{ steps.scan.outputs.sarif }}
58
58
@@ -73,7 +73,7 @@ jobs:
73 - name: Run govulncheck
73 - name: Run govulncheck
74 run: |
74 run: |
75 govulncheck -C . -format sarif ./... > results.sarif
75 govulncheck -C . -format sarif ./... > results.sarif
76− - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
76+ - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
77 with:
77 with:
78 sarif_file: results.sarif
78 sarif_file: results.sarif
79
79
.github/workflows/snapshot.yml+1−1
@@ -25,7 +25,7 @@ jobs:
25 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
25 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
26 with:
26 with:
27 go-version-file: go.mod
27 go-version-file: go.mod
28− - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
28+ - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
29 with:
29 with:
30 version: "nightly"
30 version: "nightly"
31 distribution: goreleaser-pro
31 distribution: goreleaser-pro
.gitignore+1
@@ -44,6 +44,7 @@ Thumbs.db
44
44
45# Nix/direnv
45# Nix/direnv
46.direnv/
46.direnv/
47+/result
47
48
48**/.crush/**
49**/.crush/**
49
50
.golangci.yml+12−4
@@ -2,6 +2,7 @@ version: "2"
2linters:
2linters:
3 enable:
3 enable:
4 - bodyclose
4 - bodyclose
5+ - errcheck
5 # - exhaustive
6 # - exhaustive
6 # - goconst
7 # - goconst
7 # - godot
8 # - godot
@@ -9,6 +10,7 @@ linters:
9 # - gomoddirectives
10 # - gomoddirectives
10 - goprintffuncname
11 - goprintffuncname
11 # - gosec
12 # - gosec
13+ - ineffassign
12 - misspell
14 - misspell
13 # - nakedret
15 # - nakedret
14 # - nestif
16 # - nestif
@@ -23,17 +25,23 @@ linters:
23 - tparallel
25 - tparallel
24 # - unconvert
26 # - unconvert
25 # - unparam
27 # - unparam
28+ - unused
26 - whitespace
29 - whitespace
27 # - wrapcheck
30 # - wrapcheck
28− disable:
29− - errcheck
30− - ineffassign
31− - unused
32 exclusions:
31 exclusions:
33 generated: lax
32 generated: lax
34 presets:
33 presets:
35 - common-false-positives
34 - common-false-positives
35+ # Ignores the idiomatic-to-drop error family (*Close, *Flush,
36+ # Fprint*, os.Remove/RemoveAll, os.Setenv, stdout/stderr writes) so
37+ # errcheck only flags genuinely meaningful dropped errors.
38+ - std-error-handling
36 rules:
39 rules:
40+ # Dropped errors in tests (setup writes, httptest encoders) are
41+ # low-signal — test failures surface them anyway.
42+ - path: _test\.go
43+ linters:
44+ - errcheck
37 - text: '(slog|log)\.\w+'
45 - text: '(slog|log)\.\w+'
38 linters:
46 linters:
39 - noctx
47 - noctx
README.md+5
@@ -1,5 +1,8 @@
1# Crush
1# Crush
2
2
3+> [!NOTE]
4+> This is a personal fork of [charmbracelet/crush](https://github.com/charmbracelet/crush) with UI/UX tweaks and performance improvements.
5+
3<p align="center">
6<p align="center">
4 <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/cf8ca3ce-8b02-43f0-9d0f-5a331488da4b" /></a><br />
7 <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/cf8ca3ce-8b02-43f0-9d0f-5a331488da4b" /></a><br />
5 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
8 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
@@ -194,6 +197,7 @@ That said, you can also set environment variables for preferred providers.
194| `OPENROUTER_API_KEY` | OpenRouter |
197| `OPENROUTER_API_KEY` | OpenRouter |
195| `IONET_API_KEY` | io.net |
198| `IONET_API_KEY` | io.net |
196| `ALIBABA_SINGAPORE_API_KEY` | Alibaba (Singapore) |
199| `ALIBABA_SINGAPORE_API_KEY` | Alibaba (Singapore) |
200+| `ALIBABA_US_API_KEY` | Alibaba (United States) |
197| `GROQ_API_KEY` | Groq |
201| `GROQ_API_KEY` | Groq |
198| `AVIAN_API_KEY` | Avian |
202| `AVIAN_API_KEY` | Avian |
199| `OPENCODE_API_KEY` | OpenCode Zen & Go |
203| `OPENCODE_API_KEY` | OpenCode Zen & Go |
@@ -207,6 +211,7 @@ That said, you can also set environment variables for preferred providers.
207| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
211| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
208| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
212| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
209| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
213| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
214+| `MOONSHOT_API_KEY` | Moonshot |
210
215
211### Subscriptions
216### Subscriptions
212
217
flake.lock+3−4
@@ -20,16 +20,15 @@
20 },
20 },
21 "nixpkgs": {
21 "nixpkgs": {
22 "locked": {
22 "locked": {
23− "lastModified": 1775423009,
23+ "lastModified": 1782964654,
24− "narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
24+ "narHash": "sha256-oqyac50OyRdni5ZK9H7ZuAJoR3LzgsOdUcbBlUsk9lg=",
25 "owner": "NixOS",
25 "owner": "NixOS",
26 "repo": "nixpkgs",
26 "repo": "nixpkgs",
27− "rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
27+ "rev": "3130a1cf8f7c794ea5a4760c3fa4c745d64ec269",
28 "type": "github"
28 "type": "github"
29 },
29 },
30 "original": {
30 "original": {
31 "owner": "NixOS",
31 "owner": "NixOS",
32− "ref": "nixos-unstable",
33 "repo": "nixpkgs",
32 "repo": "nixpkgs",
34 "type": "github"
33 "type": "github"
35 }
34 }
flake.nix+32−1
@@ -2,7 +2,7 @@
2 description = "Crush development environment";
2 description = "Crush development environment";
3
3
4 inputs = {
4 inputs = {
5− nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5+ nixpkgs.url = "github:NixOS/nixpkgs";
6 flake-utils.url = "github:numtide/flake-utils";
6 flake-utils.url = "github:numtide/flake-utils";
7 };
7 };
8
8
@@ -14,7 +14,38 @@
14 flake-utils.lib.eachDefaultSystem (
14 flake-utils.lib.eachDefaultSystem (
15 system: let
15 system: let
16 pkgs = nixpkgs.legacyPackages.${system};
16 pkgs = nixpkgs.legacyPackages.${system};
17+ version = "devel";
17 in {
18 in {
19+ packages.default = pkgs.buildGoModule {
20+ pname = "crush";
21+ inherit version;
22+
23+ src = ./.;
24+
25+ vendorHash = null;
26+
27+ # Match the Go toolchain used in the dev shell.
28+ go = pkgs.go_1_26;
29+
30+ env.CGO_ENABLED = 0;
31+
32+ ldflags = [
33+ "-s"
34+ "-w"
35+ "-X github.com/charmbracelet/crush/internal/version.Version=${version}"
36+ ];
37+
38+ # The test suite requires network access and external tools.
39+ doCheck = false;
40+
41+ meta = {
42+ description = "Terminal-based AI coding assistant";
43+ homepage = "https://github.com/charmbracelet/crush";
44+ license = pkgs.lib.licenses.mit;
45+ mainProgram = "crush";
46+ };
47+ };
48+
18 devShells.default = pkgs.mkShell {
49 devShells.default = pkgs.mkShell {
19 buildInputs = with pkgs; [
50 buildInputs = with pkgs; [
20 # Go toolchain
51 # Go toolchain
go.mod+11−11
@@ -3,13 +3,13 @@ module github.com/charmbracelet/crush
3go 1.26.4
3go 1.26.4
4
4
5require (
5require (
6− charm.land/bubbles/v2 v2.1.0
6+ charm.land/bubbles/v2 v2.1.1
7− charm.land/bubbletea/v2 v2.0.7
7+ charm.land/bubbletea/v2 v2.0.8
8− charm.land/catwalk v0.45.8
8+ charm.land/catwalk v0.49.0
9 charm.land/fang/v2 v2.0.1
9 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.34.0
10+ charm.land/fantasy v0.35.0
11 charm.land/glamour/v2 v2.0.1
11 charm.land/glamour/v2 v2.0.1
12− charm.land/lipgloss/v2 v2.0.4
12+ charm.land/lipgloss/v2 v2.0.5
13 charm.land/log/v2 v2.0.0
13 charm.land/log/v2 v2.0.0
14 charm.land/x/vcr v0.1.1
14 charm.land/x/vcr v0.1.1
15 github.com/JohannesKaufmann/html-to-markdown v1.6.0
15 github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -22,7 +22,7 @@ require (
22 github.com/charlievieth/fastwalk v1.0.14
22 github.com/charlievieth/fastwalk v1.0.14
23 github.com/charmbracelet/colorprofile v0.4.3
23 github.com/charmbracelet/colorprofile v0.4.3
24 github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
24 github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
25− github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654
25+ github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
26 github.com/charmbracelet/x/ansi v0.11.7
26 github.com/charmbracelet/x/ansi v0.11.7
27 github.com/charmbracelet/x/editor v0.2.0
27 github.com/charmbracelet/x/editor v0.2.0
28 github.com/charmbracelet/x/etag v0.2.0
28 github.com/charmbracelet/x/etag v0.2.0
@@ -51,8 +51,8 @@ require (
51 github.com/ncruces/go-sqlite3 v0.35.1
51 github.com/ncruces/go-sqlite3 v0.35.1
52 github.com/nxadm/tail v1.4.11
52 github.com/nxadm/tail v1.4.11
53 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
53 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
54− github.com/posthog/posthog-go v1.17.2
54+ github.com/posthog/posthog-go v1.17.5
55− github.com/pressly/goose/v3 v3.27.1
55+ github.com/pressly/goose/v3 v3.27.2
56 github.com/qjebbs/go-jsons v1.0.0-alpha.6
56 github.com/qjebbs/go-jsons v1.0.0-alpha.6
57 github.com/rivo/uniseg v0.4.7
57 github.com/rivo/uniseg v0.4.7
58 github.com/sahilm/fuzzy v0.1.3
58 github.com/sahilm/fuzzy v0.1.3
@@ -103,7 +103,7 @@ require (
103 github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
103 github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
104 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
104 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
105 github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
105 github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
106− github.com/aws/smithy-go v1.27.2 // indirect
106+ github.com/aws/smithy-go v1.27.3 // indirect
107 github.com/aymerick/douceur v0.2.0 // indirect
107 github.com/aymerick/douceur v0.2.0 // indirect
108 github.com/bahlo/generic-list-go v0.2.0 // indirect
108 github.com/bahlo/generic-list-go v0.2.0 // indirect
109 github.com/buger/jsonparser v1.1.2 // indirect
109 github.com/buger/jsonparser v1.1.2 // indirect
@@ -153,7 +153,7 @@ require (
153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154 github.com/klauspost/pgzip v1.2.6 // indirect
154 github.com/klauspost/pgzip v1.2.6 // indirect
155 github.com/mailru/easyjson v0.7.7 // indirect
155 github.com/mailru/easyjson v0.7.7 // indirect
156− github.com/mattn/go-runewidth v0.0.23 // indirect
156+ github.com/mattn/go-runewidth v0.0.24 // indirect
157 github.com/mfridman/interpolate v0.0.2 // indirect
157 github.com/mfridman/interpolate v0.0.2 // indirect
158 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
158 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
159 github.com/mitchellh/mapstructure v1.5.0 // indirect
159 github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -207,7 +207,7 @@ require (
207 golang.org/x/time v0.15.0 // indirect
207 golang.org/x/time v0.15.0 // indirect
208 golang.org/x/tools v0.45.0 // indirect
208 golang.org/x/tools v0.45.0 // indirect
209 google.golang.org/api v0.286.0 // indirect
209 google.golang.org/api v0.286.0 // indirect
210− google.golang.org/genai v1.61.0 // indirect
210+ google.golang.org/genai v1.62.0 // indirect
211 google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
211 google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
212 google.golang.org/grpc v1.81.1 // indirect
212 google.golang.org/grpc v1.81.1 // indirect
213 google.golang.org/protobuf v1.36.11 // indirect
213 google.golang.org/protobuf v1.36.11 // indirect
go.sum+22−22
@@ -1,17 +1,17 @@
1−charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
1+charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
2−charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
2+charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
3−charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
3+charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
4−charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
4+charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
5−charm.land/catwalk v0.45.8 h1:fnd86JrpIDcuH8+QrTl5ZDChrwbHNMjM0GAMr2jFW5g=
5+charm.land/catwalk v0.49.0 h1:pXcHh9H76VvwGqPYMZhJe5vRqMLpzGsv0anJArKw70g=
6−charm.land/catwalk v0.45.8/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A=
6+charm.land/catwalk v0.49.0/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.34.0 h1:InhMjeJ0u7tuJCFWSWV1hvojpDIIUMD5ur1ds333Qcw=
9+charm.land/fantasy v0.35.0 h1:iFES7cnt3/o20T01+y2PDkHj5dHEWRFQH0vSXYzV8xo=
10−charm.land/fantasy v0.34.0/go.mod h1:DAZjJ5w98fOJkkDWKpG2jKqDg58Kvri/5I0ZdD31Hjo=
10+charm.land/fantasy v0.35.0/go.mod h1:rUqUhunz+c6k66L0hHd51CrXB3SSzPNKkwBrzIe6XnY=
11charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
11charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
12charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
12charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
13−charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
13+charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
14−charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
14+charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
15charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
15charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
16charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
16charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
17charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
17charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
@@ -86,8 +86,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMb
86github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
86github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
87github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
87github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
88github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
88github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
89−github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
89+github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
90−github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
90+github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
91github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
91github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
92github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
92github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
93github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
93github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -108,8 +108,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
108github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
108github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
109github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be h1:pg+OWlIkk9HOe/8P5J95aKe2wGDzFUiiyFOUpwR30B4=
109github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be h1:pg+OWlIkk9HOe/8P5J95aKe2wGDzFUiiyFOUpwR30B4=
110github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
110github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
111−github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
111+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
112−github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
112+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
113github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
113github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
114github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
114github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
115github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
115github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
@@ -290,8 +290,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
290github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
290github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
291github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
291github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
292github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
292github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
293−github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
293+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
294−github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
294+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
295github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
295github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
296github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
296github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
297github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
297github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -341,10 +341,10 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
341github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
341github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
342github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
342github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
343github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
343github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
344−github.com/posthog/posthog-go v1.17.2 h1:w0TaCAd+Z3WoEgVyR/nlcXlqNN2tpoBfIyxuGssDgCE=
344+github.com/posthog/posthog-go v1.17.5 h1:mrLiAdyiQpl8Yeyg23iShyAztJMaUrYzsBjKeH52Aak=
345−github.com/posthog/posthog-go v1.17.2/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU=
345+github.com/posthog/posthog-go v1.17.5/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU=
346−github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
346+github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8=
347−github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
347+github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM=
348github.com/qjebbs/go-jsons v1.0.0-alpha.6 h1:9pMjdrCT7rj9CDIM0FXf9Ilie8PEy9g1b4qMkCnedjQ=
348github.com/qjebbs/go-jsons v1.0.0-alpha.6 h1:9pMjdrCT7rj9CDIM0FXf9Ilie8PEy9g1b4qMkCnedjQ=
349github.com/qjebbs/go-jsons v1.0.0-alpha.6/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
349github.com/qjebbs/go-jsons v1.0.0-alpha.6/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
350github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
350github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -559,8 +559,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
559gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
559gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
560google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
560google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
561google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
561google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
562−google.golang.org/genai v1.61.0 h1:wCyNGiaC9q5A59B80zuEtNBhq3ypEvICFkZYOfK7IO0=
562+google.golang.org/genai v1.62.0 h1:PaBju84orf4Vbcc6OfHe4vxhxhjwulKTgOpEc3iIc00=
563−google.golang.org/genai v1.61.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
563+google.golang.org/genai v1.62.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
564google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
564google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
565google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
565google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
566google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
566google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
internal/agent/agent.go+52−31
@@ -157,6 +157,7 @@ type sessionAgent struct {
157 systemPromptPrefix *csync.Value[string]
157 systemPromptPrefix *csync.Value[string]
158 systemPrompt *csync.Value[string]
158 systemPrompt *csync.Value[string]
159 tools *csync.Slice[fantasy.AgentTool]
159 tools *csync.Slice[fantasy.AgentTool]
160+ toolsGen atomic.Uint64 // bumped by SetTools; lets PrepareStep skip re-copying unchanged tools
160
161
161 isSubAgent bool
162 isSubAgent bool
162 sessions session.Service
163 sessions session.Service
@@ -348,7 +349,7 @@ func (a *sessionAgent) sessionMu(sessionID string) *sync.Mutex {
348func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
349func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
349 existing, ok := a.messageQueue.Get(call.SessionID)
350 existing, ok := a.messageQueue.Get(call.SessionID)
350 if !ok {
351 if !ok {
351− existing = []SessionAgentCall{}
352+ existing = make([]SessionAgentCall, 0, 4)
352 }
353 }
353 queued := call
354 queued := call
354 if call.Accepted != nil {
355 if call.Accepted != nil {
@@ -642,6 +643,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
642
643
643 // Copy mutable fields under lock to avoid races with SetTools/SetModels.
644 // Copy mutable fields under lock to avoid races with SetTools/SetModels.
644 agentTools := a.tools.Copy()
645 agentTools := a.tools.Copy()
646+ // Per-step tool snapshot reused across PrepareStep calls; only
647+ // re-copied when SetTools bumps toolsGen (see PrepareStep below).
648+ var (
649+ stepTools []fantasy.AgentTool
650+ stepToolsGen uint64
651+ stepToolsInit bool
652+ )
645 largeModel := a.largeModel.Get()
653 largeModel := a.largeModel.Get()
646 systemPrompt := a.systemPrompt.Get()
654 systemPrompt := a.systemPrompt.Get()
647 promptPrefix := a.systemPromptPrefix.Get()
655 promptPrefix := a.systemPromptPrefix.Get()
@@ -806,7 +814,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
806 }
814 }
807
815
808 // Use latest tools (updated by SetTools when MCP tools change).
816 // Use latest tools (updated by SetTools when MCP tools change).
817+ // Reuse the previous step's copy unless SetTools has bumped the
818+ // generation, so an unchanged tool set isn't re-copied every step.
819+ if gen := a.toolsGen.Load(); !stepToolsInit || gen != stepToolsGen {
809− prepared.Tools = a.tools.Copy()
820+ stepTools = a.tools.Copy()
821+ stepToolsGen = gen
822+ stepToolsInit = true
823+ }
824+ prepared.Tools = stepTools
810
825
811 // Drain queued follow-up prompts for this step. Calls covered
826 // Drain queued follow-up prompts for this step. Calls covered
812 // by a cancel recorded while they sat in the queue are dropped:
827 // by a cancel recorded while they sat in the queue are dropped:
@@ -851,9 +866,12 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
851 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
866 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
852 }
867 }
853
868
854− sessionLock.Lock()
869+ // Save a reference to the prepared messages for token
870+ // estimation in OnStepFinish. No clone needed here — the
871+ // messages are not modified between PrepareStep and
872+ // OnStepFinish (which run sequentially), and the clone is
873+ // only needed when the provider returns zero usage.
855− stepMessages = cloneFantasyMessages(prepared.Messages)
874+ stepMessages = prepared.Messages
856− sessionLock.Unlock()
857
875
858 var assistantMsg message.Message
876 var assistantMsg message.Message
859 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
877 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
@@ -1062,7 +1080,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
1062 currentAssistant.FinishThinking()
1080 currentAssistant.FinishThinking()
1063 toolCalls := currentAssistant.ToolCalls()
1081 toolCalls := currentAssistant.ToolCalls()
1064 // INFO: we use the cleanup context here because the genCtx has been cancelled.
1082 // INFO: we use the cleanup context here because the genCtx has been cancelled.
1083+ // Only load messages from the current assistant message onward — tool
1084+ // results for this turn's tool calls can only exist after it.
1065− msgs, createErr := a.messages.List(cleanupCtx, currentAssistant.SessionID)
1085+ msgs, createErr := a.messages.ListAfter(cleanupCtx, currentAssistant.SessionID, currentAssistant.ID)
1066 if createErr != nil {
1086 if createErr != nil {
1067 return nil, createErr
1087 return nil, createErr
1068 }
1088 }
@@ -1434,7 +1454,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
1434 return qErr
1454 return qErr
1435}
1455}
1436
1456
1457+// cacheControlOptions is computed once for the process lifetime: the env check
1458+// is constant and callers only read (never mutate) the returned map, so the
1459+// same shared value can be reused across the ~3 calls per step.
1437−func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
1460+var cacheControlOptions = sync.OnceValue(func() fantasy.ProviderOptions {
1438 if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
1461 if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
1439 return fantasy.ProviderOptions{}
1462 return fantasy.ProviderOptions{}
1440 }
1463 }
@@ -1449,6 +1472,10 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
1449 CacheControl: anthropic.CacheControl{Type: "ephemeral"},
1472 CacheControl: anthropic.CacheControl{Type: "ephemeral"},
1450 },
1473 },
1451 }
1474 }
1475+})
1476+
1477+func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
1478+ return cacheControlOptions()
1452}
1479}
1453
1480
1454func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
1481func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
@@ -1469,7 +1496,7 @@ func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentC
1469}
1496}
1470
1497
1471func (a *sessionAgent) preparePrompt(msgs []message.Message, supportsImages bool, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
1498func (a *sessionAgent) preparePrompt(msgs []message.Message, supportsImages bool, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
1472− var history []fantasy.Message
1499+ history := make([]fantasy.Message, 0, len(msgs)+1)
1473 if !a.isSubAgent {
1500 if !a.isSubAgent {
1474 history = append(history, fantasy.NewUserMessage(
1501 history = append(history, fantasy.NewUserMessage(
1475 fmt.Sprintf(
1502 fmt.Sprintf(
@@ -1569,7 +1596,7 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
1569 if len(aiMsgs) == 0 {
1596 if len(aiMsgs) == 0 {
1570 return fantasy.Message{}, false
1597 return fantasy.Message{}, false
1571 }
1598 }
1572− var validParts []fantasy.MessagePart
1599+ validParts := make([]fantasy.MessagePart, 0, len(aiMsgs[0].Content))
1573 for _, part := range aiMsgs[0].Content {
1600 for _, part := range aiMsgs[0].Content {
1574 tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1601 tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1575 if !ok {
1602 if !ok {
@@ -1601,7 +1628,7 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
1601// conversation. Returns the message and true if any synthetic results were
1628// conversation. Returns the message and true if any synthetic results were
1602// produced.
1629// produced.
1603func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
1630func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
1604− var syntheticParts []fantasy.MessagePart
1631+ syntheticParts := make([]fantasy.MessagePart, 0, len(m.ToolCalls()))
1605 for _, tc := range m.ToolCalls() {
1632 for _, tc := range m.ToolCalls() {
1606 if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
1633 if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
1607 continue
1634 continue
@@ -1628,23 +1655,20 @@ func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs
1628}
1655}
1629
1656
1630func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
1657func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
1631− msgs, err := a.messages.List(ctx, session.ID)
1632− if err != nil {
1633− return nil, fmt.Errorf("failed to list messages: %w", err)
1634− }
1635−
1636 if session.SummaryMessageID != "" {
1658 if session.SummaryMessageID != "" {
1637− summaryMsgIndex := -1
1638− for i, msg := range msgs {
1639− if msg.ID == session.SummaryMessageID {
1659+ msgs, err := a.messages.ListAfter(ctx, session.ID, session.SummaryMessageID)
1640− summaryMsgIndex = i
1660+ if err != nil {
1641− break
1661+ return nil, fmt.Errorf("failed to list messages after summary: %w", err)
1642− }
1643 }
1662 }
1644− if summaryMsgIndex != -1 {
1663+ if len(msgs) > 0 {
1645− msgs = msgs[summaryMsgIndex:]
1646 msgs[0].Role = message.User
1664 msgs[0].Role = message.User
1647 }
1665 }
1666+ return msgs, nil
1667+ }
1668+
1669+ msgs, err := a.messages.List(ctx, session.ID)
1670+ if err != nil {
1671+ return nil, fmt.Errorf("failed to list messages: %w", err)
1648 }
1672 }
1649 return msgs, nil
1673 return msgs, nil
1650}
1674}
@@ -1975,14 +1999,10 @@ func (a *sessionAgent) CancelAll() {
1975}
1999}
1976
2000
1977func (a *sessionAgent) IsBusy() bool {
2001func (a *sessionAgent) IsBusy() bool {
1978− var busy bool
2002+ // activeRequests only ever holds non-nil cancel funcs (Set with a
1979− for cancelFunc := range a.activeRequests.Seq() {
2003+ // real func, removed via Del), so a length check is exact — and it
1980− if cancelFunc != nil {
2004+ // avoids cloning the whole map, which Seq() does on every call.
1981− busy = true
2005+ return a.activeRequests.Len() > 0
1982− break
1983− }
1984− }
1985− return busy
1986}
2006}
1987
2007
1988func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
2008func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
@@ -2017,6 +2037,7 @@ func (a *sessionAgent) SetModels(large Model, small Model) {
2017
2037
2018func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
2038func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
2019 a.tools.SetSlice(tools)
2039 a.tools.SetSlice(tools)
2040+ a.toolsGen.Add(1)
2020}
2041}
2021
2042
2022func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
2043func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
internal/agent/coordinator.go+183−11
@@ -15,6 +15,8 @@ import (
15 "path/filepath"
15 "path/filepath"
16 "slices"
16 "slices"
17 "strings"
17 "strings"
18+ "sync"
19+ "sync/atomic"
18
20
19 "charm.land/catwalk/pkg/catwalk"
21 "charm.land/catwalk/pkg/catwalk"
20 "charm.land/fantasy"
22 "charm.land/fantasy"
@@ -22,7 +24,9 @@ import (
22 "github.com/charmbracelet/crush/internal/agent/notify"
24 "github.com/charmbracelet/crush/internal/agent/notify"
23 "github.com/charmbracelet/crush/internal/agent/prompt"
25 "github.com/charmbracelet/crush/internal/agent/prompt"
24 "github.com/charmbracelet/crush/internal/agent/tools"
26 "github.com/charmbracelet/crush/internal/agent/tools"
27+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
25 "github.com/charmbracelet/crush/internal/config"
28 "github.com/charmbracelet/crush/internal/config"
29+ "github.com/charmbracelet/crush/internal/csync"
26 "github.com/charmbracelet/crush/internal/discover"
30 "github.com/charmbracelet/crush/internal/discover"
27 "github.com/charmbracelet/crush/internal/event"
31 "github.com/charmbracelet/crush/internal/event"
28 "github.com/charmbracelet/crush/internal/filetracker"
32 "github.com/charmbracelet/crush/internal/filetracker"
@@ -123,6 +127,32 @@ type coordinator struct {
123 activeSkills []*skills.Skill // Post-filter: active skills only.
127 activeSkills []*skills.Skill // Post-filter: active skills only.
124 skillTracker *skills.Tracker
128 skillTracker *skills.Tracker
125
129
130+ // providerClients caches built provider clients keyed by their fully
131+ // resolved configuration. UpdateModels rebuilds models every turn;
132+ // without this, each turn discards the provider's HTTP client and its
133+ // keep-alive connection pool, forcing a fresh TLS handshake. The key
134+ // captures every input buildProvider depends on, so any config/model/
135+ // credential change misses the cache and rebuilds.
136+ providerClients *csync.Map[string, fantasy.Provider]
137+
138+ // toolsCache avoids rebuilding the tool set on every UpdateModels call.
139+ // The cache is invalidated when the config pointer changes (reload or
140+ // mutation) or when mcpToolsGen is bumped (MCP server connect/disconnect
141+ // or tools/list_changed notification).
142+ toolsCacheMu sync.Mutex
143+ toolsCache []fantasy.AgentTool
144+ toolsCacheCfg *config.Config
145+ toolsCacheMCP uint64
146+ mcpToolsGen atomic.Uint64
147+
148+ // providerOptsCache avoids repeating the 3× JSON marshal + merge +
149+ // unmarshal in getProviderOptions on every turn when the model and
150+ // config have not changed. Keyed by config pointer + model CatwalkCfg
151+ // ID + provider name.
152+ providerOptsCacheMu sync.Mutex
153+ providerOptsCache fantasy.ProviderOptions
154+ providerOptsCacheKey string
155+
126 readyWg errgroup.Group
156 readyWg errgroup.Group
127}
157}
128
158
@@ -166,6 +196,8 @@ func NewCoordinator(
166 allSkills: allSkills,
196 allSkills: allSkills,
167 activeSkills: activeSkills,
197 activeSkills: activeSkills,
168 skillTracker: skillTracker,
198 skillTracker: skillTracker,
199+
200+ providerClients: csync.NewMap[string, fantasy.Provider](),
169 }
201 }
170
202
171 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
203 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
@@ -185,6 +217,17 @@ func NewCoordinator(
185 }
217 }
186 c.currentAgent = agent
218 c.currentAgent = agent
187 c.agents[config.AgentCoder] = agent
219 c.agents[config.AgentCoder] = agent
220+
221+ // Subscribe to MCP tool-list changes so the tools cache is invalidated
222+ // when MCP servers connect/disconnect or announce new tools.
223+ go func() {
224+ for ev := range mcp.SubscribeEvents(ctx) {
225+ if ev.Payload.Type == mcp.EventToolsListChanged || ev.Payload.Type == mcp.EventStateChanged {
226+ c.mcpToolsGen.Add(1)
227+ }
228+ }
229+ }()
230+
188 return c, nil
231 return c, nil
189}
232}
190
233
@@ -235,7 +278,7 @@ func (c *coordinator) run(ctx context.Context, accept *AcceptedRun, sessionID st
235 return nil, errModelProviderNotConfigured
278 return nil, errModelProviderNotConfigured
236 }
279 }
237
280
238− mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
281+ mergedOptions, temp, topP, topK, freqPenalty, presPenalty := c.cachedMergeCallOptions(model, providerCfg)
239
282
240 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
283 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
241 // NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
284 // NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
@@ -407,6 +450,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
407 options[openai.Name] = parsed
450 options[openai.Name] = parsed
408 }
451 }
409 }
452 }
453+
410 case anthropic.Name, bedrock.Name:
454 case anthropic.Name, bedrock.Name:
411 var (
455 var (
412 _, hasEffort = mergedOptions["effort"]
456 _, hasEffort = mergedOptions["effort"]
@@ -415,7 +459,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
415 )
459 )
416
460
417 switch providerCfg.ID {
461 switch providerCfg.ID {
418− case string(catwalk.InferenceProviderAlibabaSingapore):
462+ case string(catwalk.InferenceProviderAlibabaSingapore), string(catwalk.InferenceProviderAlibabaUS):
419 switch {
463 switch {
420 case !hasEffort && shouldSetEffort:
464 case !hasEffort && shouldSetEffort:
421 extraBody["reasoning_effort"] = reasoningEffort
465 extraBody["reasoning_effort"] = reasoningEffort
@@ -454,6 +498,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
454 if err == nil {
498 if err == nil {
455 options[openrouter.Name] = parsed
499 options[openrouter.Name] = parsed
456 }
500 }
501+
457 case vercel.Name:
502 case vercel.Name:
458 _, hasReasoning := mergedOptions["reasoning"]
503 _, hasReasoning := mergedOptions["reasoning"]
459 if !hasReasoning && shouldSetEffort {
504 if !hasReasoning && shouldSetEffort {
@@ -466,6 +511,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
466 if err == nil {
511 if err == nil {
467 options[vercel.Name] = parsed
512 options[vercel.Name] = parsed
468 }
513 }
514+
469 case google.Name:
515 case google.Name:
470 _, hasReasoning := mergedOptions["thinking_config"]
516 _, hasReasoning := mergedOptions["thinking_config"]
471 if !hasReasoning {
517 if !hasReasoning {
@@ -485,6 +531,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
485 if err == nil {
531 if err == nil {
486 options[google.Name] = parsed
532 options[google.Name] = parsed
487 }
533 }
534+
488 case openaicompat.Name, hyper.Name:
535 case openaicompat.Name, hyper.Name:
489 extraBody := make(map[string]any)
536 extraBody := make(map[string]any)
490
537
@@ -513,16 +560,14 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
513 extraBody["reasoning"] = map[string]string{"effort": "none"}
560 extraBody["reasoning"] = map[string]string{"effort": "none"}
514 }
561 }
515 }
562 }
563+
516 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
564 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
517 if model.ModelCfg.Think || reasoningEffort != "" {
565 if model.ModelCfg.Think || reasoningEffort != "" {
518− extraBody["thinking"] = map[string]any{
566+ extraBody["thinking"] = map[string]any{"type": "enabled"}
519− "type": "enabled",
520− }
521 } else {
567 } else {
522− extraBody["thinking"] = map[string]any{
568+ extraBody["thinking"] = map[string]any{"type": "disabled"}
523− "type": "disabled",
524− }
525 }
569 }
570+
526 case string(catwalk.InferenceProviderFireworks):
571 case string(catwalk.InferenceProviderFireworks):
527 // NOTE: Fireworks break if we set both `reasoning_effort` and `thinking`.
572 // NOTE: Fireworks break if we set both `reasoning_effort` and `thinking`.
528 if reasoningEffort == "" {
573 if reasoningEffort == "" {
@@ -532,9 +577,15 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
532 extraBody["thinking"] = map[string]any{"type": "disabled"}
577 extraBody["thinking"] = map[string]any{"type": "disabled"}
533 }
578 }
534 }
579 }
580+
581+ case string(catwalk.InferenceProviderBaseten):
582+ extraBody["chat_template_args"] = map[string]any{
583+ "enable_thinking": model.ModelCfg.Think || reasoningEffort != "",
584+ }
585+
535− case string(catwalk.InferenceProviderAlibabaSingapore):
586+ case string(catwalk.InferenceProviderAlibabaSingapore), string(catwalk.InferenceProviderAlibabaUS):
536 if model.CatwalkCfg.CanReason {
587 if model.CatwalkCfg.CanReason {
537− extraBody["enable_thinking"] = model.ModelCfg.Think
588+ extraBody["enable_thinking"] = model.ModelCfg.Think || reasoningEffort != ""
538 }
589 }
539 }
590 }
540
591
@@ -544,6 +595,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
544 if err == nil {
595 if err == nil {
545 options[openaicompat.Name] = parsed
596 options[openaicompat.Name] = parsed
546 }
597 }
598+
547 default:
599 default:
548 // Known custom providers (litellm, ollama, omlx) are
600 // Known custom providers (litellm, ollama, omlx) are
549 // openai-compat under the hood.
601 // openai-compat under the hood.
@@ -568,6 +620,39 @@ func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderO
568 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
620 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
569}
621}
570
622
623+// cachedMergeCallOptions wraps mergeCallOptions with a memoization cache.
624+// The provider options JSON merge is expensive (3× marshal + merge +
625+// unmarshal) and the inputs rarely change between turns. The cache key
626+// combines the config pointer identity with the model's catwalk ID and
627+// provider name so any config reload or model switch misses the cache.
628+func (c *coordinator) cachedMergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
629+ key := fmt.Sprintf("%p|%s|%s", c.cfg.Config(), model.CatwalkCfg.ID, model.ModelCfg.Provider)
630+
631+ c.providerOptsCacheMu.Lock()
632+ if c.providerOptsCacheKey == key {
633+ opts := c.providerOptsCache
634+ c.providerOptsCacheMu.Unlock()
635+ // Recompute the scalar overrides (cheap) since they don't need
636+ // the JSON merge — they're just cmp.Or calls.
637+ temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
638+ topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
639+ topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
640+ freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
641+ presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
642+ return opts, temp, topP, topK, freqPenalty, presPenalty
643+ }
644+ c.providerOptsCacheMu.Unlock()
645+
646+ opts, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, cfg)
647+
648+ c.providerOptsCacheMu.Lock()
649+ c.providerOptsCache = opts
650+ c.providerOptsCacheKey = key
651+ c.providerOptsCacheMu.Unlock()
652+
653+ return opts, temp, topP, topK, freqPenalty, presPenalty
654+}
655+
571func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
656func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
572 large, small, err := c.buildAgentModels(ctx, isSubAgent)
657 large, small, err := c.buildAgentModels(ctx, isSubAgent)
573 if err != nil {
658 if err != nil {
@@ -1034,6 +1119,74 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
1034 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
1119 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
1035 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
1120 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
1036
1121
1122+ // Reuse a previously built client when nothing that affects it has
1123+ // changed, so the HTTP keep-alive pool survives across turns. The key
1124+ // is derived from the resolved inputs above (plus the debug flag); the
1125+ // per-provider adjustments inside the switch below are deterministic
1126+ // functions of these, so identical inputs always yield an identical
1127+ // client.
1128+ cacheKey := c.providerCacheKey(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1129+ if p, ok := c.providerClients.Get(cacheKey); ok {
1130+ return p, nil
1131+ }
1132+ provider, err := c.buildProviderUncached(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1133+ if err != nil {
1134+ return nil, err
1135+ }
1136+ c.providerClients.Set(cacheKey, provider)
1137+ return provider, nil
1138+}
1139+
1140+// providerCacheKey builds a stable key from every input buildProvider's
1141+// switch consumes, so any credential, endpoint, header, or option change
1142+// produces a distinct key (and thus a rebuilt client).
1143+func (c *coordinator) providerCacheKey(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) string {
1144+ var b strings.Builder
1145+ b.WriteString(providerCfg.ID)
1146+ b.WriteByte(0)
1147+ b.WriteString(string(providerCfg.Type))
1148+ b.WriteByte(0)
1149+ // The opencode providers pick their client type from the model, so the
1150+ // model must participate in the key.
1151+ b.WriteString(model.Model)
1152+ b.WriteByte(0)
1153+ b.WriteString(apiKey)
1154+ b.WriteByte(0)
1155+ // Bedrock falls back to AWS_BEARER_TOKEN_BEDROCK when no apiKey is
1156+ // configured, and that token is baked into the built client. Fold it
1157+ // into the key so a rotated token rebuilds the client instead of
1158+ // reusing a stale one.
1159+ if apiKey == "" && providerCfg.Type == bedrock.Name {
1160+ b.WriteString(os.Getenv("AWS_BEARER_TOKEN_BEDROCK"))
1161+ }
1162+ b.WriteByte(0)
1163+ b.WriteString(baseURL)
1164+ b.WriteByte(0)
1165+ for _, k := range slices.Sorted(maps.Keys(headers)) {
1166+ b.WriteString(k)
1167+ b.WriteByte('=')
1168+ b.WriteString(headers[k])
1169+ b.WriteByte(1)
1170+ }
1171+ b.WriteByte(0)
1172+ if extra, err := json.Marshal(providerCfg.ExtraBody); err == nil {
1173+ b.Write(extra)
1174+ }
1175+ b.WriteByte(0)
1176+ if extra, err := json.Marshal(providerCfg.ExtraParams); err == nil {
1177+ b.Write(extra)
1178+ }
1179+ b.WriteByte(0)
1180+ if isSubAgent {
1181+ b.WriteByte('s')
1182+ }
1183+ if c.cfg.Config().Options.Debug {
1184+ b.WriteByte('d')
1185+ }
1186+ return b.String()
1187+}
1188+
1189+func (c *coordinator) buildProviderUncached(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) (fantasy.Provider, error) {
1037 switch providerCfg.ID {
1190 switch providerCfg.ID {
1038 case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
1191 case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
1039 if opencodeMessagesModels[model.Model] {
1192 if opencodeMessagesModels[model.Model] {
@@ -1133,15 +1286,34 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
1133 }
1286 }
1134 c.currentAgent.SetModels(large, small)
1287 c.currentAgent.SetModels(large, small)
1135
1288
1289+ cfg := c.cfg.Config()
1136− agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
1290+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1137 if !ok {
1291 if !ok {
1138 return errCoderAgentNotConfigured
1292 return errCoderAgentNotConfigured
1139 }
1293 }
1140
1294
1295+ // Fast path: reuse cached tools when neither the config nor the MCP
1296+ // tool set has changed since the last build.
1297+ mcpGen := c.mcpToolsGen.Load()
1298+ c.toolsCacheMu.Lock()
1299+ if c.toolsCache != nil && c.toolsCacheCfg == cfg && c.toolsCacheMCP == mcpGen {
1300+ c.currentAgent.SetTools(c.toolsCache)
1301+ c.toolsCacheMu.Unlock()
1302+ return nil
1303+ }
1304+ c.toolsCacheMu.Unlock()
1305+
1141 tools, err := c.buildTools(ctx, agentCfg, false)
1306 tools, err := c.buildTools(ctx, agentCfg, false)
1142 if err != nil {
1307 if err != nil {
1143 return err
1308 return err
1144 }
1309 }
1310+
1311+ c.toolsCacheMu.Lock()
1312+ c.toolsCache = tools
1313+ c.toolsCacheCfg = cfg
1314+ c.toolsCacheMCP = mcpGen
1315+ c.toolsCacheMu.Unlock()
1316+
1145 c.currentAgent.SetTools(tools)
1317 c.currentAgent.SetTools(tools)
1146 return nil
1318 return nil
1147}
1319}
internal/agent/hyper/provider.go+1−1
@@ -88,7 +88,7 @@ func FetchCredits(ctx context.Context, apiKey string) (int, error) {
88 if err != nil {
88 if err != nil {
89 return 0, fmt.Errorf("failed to make request: %w", err)
89 return 0, fmt.Errorf("failed to make request: %w", err)
90 }
90 }
91− defer resp.Body.Close() //nolint:errcheck
91+ defer resp.Body.Close()
92
92
93 if resp.StatusCode != http.StatusOK {
93 if resp.StatusCode != http.StatusOK {
94 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
94 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
internal/agent/loop_detection.go+17−17
@@ -1,8 +1,7 @@
1package agent
1package agent
2
2
3import (
3import (
4− "crypto/sha256"
4+ "hash/fnv"
5− "encoding/hex"
6 "io"
5 "io"
7
6
8 "charm.land/fantasy"
7 "charm.land/fantasy"
@@ -22,11 +21,11 @@ func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int
22 }
21 }
23
22
24 window := steps[len(steps)-windowSize:]
23 window := steps[len(steps)-windowSize:]
25− counts := make(map[string]int)
24+ counts := make(map[uint64]int, windowSize)
26
25
27 for _, step := range window {
26 for _, step := range window {
28 sig := getToolInteractionSignature(step.Content)
27 sig := getToolInteractionSignature(step.Content)
29− if sig == "" {
28+ if sig == 0 {
30 continue
29 continue
31 }
30 }
32 counts[sig]++
31 counts[sig]++
@@ -40,34 +39,35 @@ func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int
40
39
41// getToolInteractionSignature computes a hash signature for the tool
40// getToolInteractionSignature computes a hash signature for the tool
42// interactions in a single step's content. It pairs tool calls with their
41// interactions in a single step's content. It pairs tool calls with their
43−// results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
42+// results (matched by ToolCallID) and returns an FNV-1a hash. FNV is ~10x
43+// faster than SHA-256 and collision risk is negligible for loop detection
44−// If the step contains no tool calls, it returns "".
44+// over a 10-step window. If the step contains no tool calls, it returns 0.
45−func getToolInteractionSignature(content fantasy.ResponseContent) string {
45+func getToolInteractionSignature(content fantasy.ResponseContent) uint64 {
46 toolCalls := content.ToolCalls()
46 toolCalls := content.ToolCalls()
47 if len(toolCalls) == 0 {
47 if len(toolCalls) == 0 {
48− return ""
48+ return 0
49 }
49 }
50
50
51 // Index tool results by their ToolCallID for fast lookup.
51 // Index tool results by their ToolCallID for fast lookup.
52− resultsByID := make(map[string]fantasy.ToolResultContent)
52+ resultsByID := make(map[string]fantasy.ToolResultContent, len(toolCalls))
53 for _, tr := range content.ToolResults() {
53 for _, tr := range content.ToolResults() {
54 resultsByID[tr.ToolCallID] = tr
54 resultsByID[tr.ToolCallID] = tr
55 }
55 }
56
56
57− h := sha256.New()
57+ h := fnv.New64a()
58 for _, tc := range toolCalls {
58 for _, tc := range toolCalls {
59 output := ""
59 output := ""
60 if tr, ok := resultsByID[tc.ToolCallID]; ok {
60 if tr, ok := resultsByID[tc.ToolCallID]; ok {
61 output = toolResultOutputString(tr.Result)
61 output = toolResultOutputString(tr.Result)
62 }
62 }
63− io.WriteString(h, tc.ToolName)
63+ _, _ = io.WriteString(h, tc.ToolName)
64− io.WriteString(h, "\x00")
64+ h.Write([]byte{0})
65− io.WriteString(h, tc.Input)
65+ _, _ = io.WriteString(h, tc.Input)
66− io.WriteString(h, "\x00")
66+ h.Write([]byte{0})
67− io.WriteString(h, output)
67+ _, _ = io.WriteString(h, output)
68− io.WriteString(h, "\x00")
68+ h.Write([]byte{0})
69 }
69 }
70− return hex.EncodeToString(h.Sum(nil))
70+ return h.Sum64()
71}
71}
72
72
73// toolResultOutputString converts a ToolResultOutputContent to a stable string
73// toolResultOutputString converts a ToolResultOutputContent to a stable string
internal/agent/loop_detection_test.go+8−8
@@ -145,18 +145,18 @@ func TestHasRepeatedToolCalls(t *testing.T) {
145func TestGetToolInteractionSignature(t *testing.T) {
145func TestGetToolInteractionSignature(t *testing.T) {
146 t.Run("empty content returns empty string", func(t *testing.T) {
146 t.Run("empty content returns empty string", func(t *testing.T) {
147 sig := getToolInteractionSignature(fantasy.ResponseContent{})
147 sig := getToolInteractionSignature(fantasy.ResponseContent{})
148− if sig != "" {
148+ if sig != 0 {
149− t.Errorf("expected empty string, got %q", sig)
149+ t.Errorf("expected zero signature, got %d", sig)
150 }
150 }
151 })
151 })
152
152
153− t.Run("text only content returns empty string", func(t *testing.T) {
153+ t.Run("text only content returns zero signature", func(t *testing.T) {
154 content := fantasy.ResponseContent{
154 content := fantasy.ResponseContent{
155 fantasy.TextContent{Text: "hello"},
155 fantasy.TextContent{Text: "hello"},
156 }
156 }
157 sig := getToolInteractionSignature(content)
157 sig := getToolInteractionSignature(content)
158− if sig != "" {
158+ if sig != 0 {
159− t.Errorf("expected empty string, got %q", sig)
159+ t.Errorf("expected zero signature, got %d", sig)
160 }
160 }
161 })
161 })
162
162
@@ -166,8 +166,8 @@ func TestGetToolInteractionSignature(t *testing.T) {
166 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
166 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
167 }
167 }
168 sig := getToolInteractionSignature(content)
168 sig := getToolInteractionSignature(content)
169− if sig == "" {
169+ if sig == 0 {
170− t.Error("expected non-empty signature")
170+ t.Error("expected non-zero signature")
171 }
171 }
172 })
172 })
173
173
@@ -183,7 +183,7 @@ func TestGetToolInteractionSignature(t *testing.T) {
183 sig1 := getToolInteractionSignature(content1)
183 sig1 := getToolInteractionSignature(content1)
184 sig2 := getToolInteractionSignature(content2)
184 sig2 := getToolInteractionSignature(content2)
185 if sig1 != sig2 {
185 if sig1 != sig2 {
186− t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
186+ t.Errorf("expected same signature for same interactions, got %d and %d", sig1, sig2)
187 }
187 }
188 })
188 })
189
189
internal/agent/prompt/prompt.go+1−1
@@ -115,7 +115,7 @@ func processContextPath(p string, store *config.ConfigStore) []ContextFile {
115 return contexts
115 return contexts
116 }
116 }
117 if info.IsDir() {
117 if info.IsDir() {
118− filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
118+ _ = filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
119 if err != nil {
119 if err != nil {
120 return err
120 return err
121 }
121 }
internal/agent/tools/bash.go+4−16
@@ -206,19 +206,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
206 // Determine working directory
206 // Determine working directory
207 execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
207 execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
208
208
209− isSafeReadOnly := false
209+ isSafeReadOnly := isSafeReadOnly(params.Command)
210− cmdLower := strings.ToLower(params.Command)
211−
212− if !containsCommandChaining(params.Command) {
213− for _, safe := range safeCommands {
214− if strings.HasPrefix(cmdLower, safe) {
215− if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
216− isSafeReadOnly = true
217− break
218− }
219− }
220− }
221− }
222
210
223 sessionID := GetSessionFromContext(ctx)
211 sessionID := GetSessionFromContext(ctx)
224 if sessionID == "" {
212 if sessionID == "" {
@@ -262,7 +250,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
262
250
263 if done {
251 if done {
264 // Command failed or completed very quickly
252 // Command failed or completed very quickly
265− bgManager.Remove(bgShell.ID)
253+ _ = bgManager.Remove(bgShell.ID)
266
254
267 interrupted := shell.IsInterrupt(execErr)
255 interrupted := shell.IsInterrupt(execErr)
268 exitCode := shell.ExitCode(execErr)
256 exitCode := shell.ExitCode(execErr)
@@ -337,7 +325,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
337 case <-ctx.Done():
325 case <-ctx.Done():
338 // Incoming context was cancelled before we moved to background
326 // Incoming context was cancelled before we moved to background
339 // Kill the shell and return error
327 // Kill the shell and return error
340− bgManager.Kill(bgShell.ID)
328+ _ = bgManager.Kill(bgShell.ID)
341 return fantasy.ToolResponse{}, ctx.Err()
329 return fantasy.ToolResponse{}, ctx.Err()
342 }
330 }
343 }
331 }
@@ -346,7 +334,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
346 // Command completed within threshold - return synchronously
334 // Command completed within threshold - return synchronously
347 // Remove from background manager since we're returning directly
335 // Remove from background manager since we're returning directly
348 // Don't call Kill() as it cancels the context and corrupts the exit code
336 // Don't call Kill() as it cancels the context and corrupts the exit code
349− bgManager.Remove(bgShell.ID)
337+ _ = bgManager.Remove(bgShell.ID)
350
338
351 interrupted := shell.IsInterrupt(execErr)
339 interrupted := shell.IsInterrupt(execErr)
352 exitCode := shell.ExitCode(execErr)
340 exitCode := shell.ExitCode(execErr)
internal/agent/tools/download.go+3−3
@@ -67,7 +67,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
67 downloadDescription(),
67 downloadDescription(),
68 func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
68 func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
69 if params.URL == "" {
69 if params.URL == "" {
70− return fantasy.NewTextErrorResponse("URL parameter is required"), nil
70+ return fantasy.NewTextErrorResponse("url parameter is required"), nil
71 }
71 }
72
72
73 if params.FilePath == "" {
73 if params.FilePath == "" {
@@ -75,7 +75,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
75 }
75 }
76
76
77 if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
77 if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
78− return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
78+ return fantasy.NewTextErrorResponse("url must start with http:// or https://"), nil
79 }
79 }
80
80
81 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
81 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
@@ -131,7 +131,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
131 defer resp.Body.Close()
131 defer resp.Body.Close()
132
132
133 if resp.StatusCode != http.StatusOK {
133 if resp.StatusCode != http.StatusOK {
134− return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
134+ return fantasy.NewTextErrorResponse(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
135 }
135 }
136
136
137 // Create parent directories if they don't exist
137 // Create parent directories if they don't exist
internal/agent/tools/edit.go+4−1
@@ -352,7 +352,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
352
352
353 sessionID := GetSessionFromContext(edit.ctx)
353 sessionID := GetSessionFromContext(edit.ctx)
354 if sessionID == "" {
354 if sessionID == "" {
355− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
355+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing a file")
356 }
356 }
357
357
358 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
358 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
@@ -381,6 +381,9 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
381
381
382 if replaceAll {
382 if replaceAll {
383 newContent = strings.ReplaceAll(oldContent, oldString, newString)
383 newContent = strings.ReplaceAll(oldContent, oldString, newString)
384+ if newContent == oldContent {
385+ return oldStringNotFoundErr, nil
386+ }
384 } else {
387 } else {
385 index := strings.Index(oldContent, oldString)
388 index := strings.Index(oldContent, oldString)
386 if index == -1 {
389 if index == -1 {
internal/agent/tools/fetch.go+12−12
@@ -60,21 +60,21 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
60 fetchDescription(),
60 fetchDescription(),
61 func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
61 func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
62 if params.URL == "" {
62 if params.URL == "" {
63− return fantasy.NewTextErrorResponse("URL parameter is required"), nil
63+ return fantasy.NewTextErrorResponse("url parameter is required"), nil
64 }
64 }
65
65
66 format := strings.ToLower(params.Format)
66 format := strings.ToLower(params.Format)
67 if format != "text" && format != "markdown" && format != "html" {
67 if format != "text" && format != "markdown" && format != "html" {
68− return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
68+ return fantasy.NewTextErrorResponse("format must be one of: text, markdown, html"), nil
69 }
69 }
70
70
71 if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
71 if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
72− return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
72+ return fantasy.NewTextErrorResponse("url must start with http:// or https://"), nil
73 }
73 }
74
74
75 sessionID := GetSessionFromContext(ctx)
75 sessionID := GetSessionFromContext(ctx)
76 if sessionID == "" {
76 if sessionID == "" {
77− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
77+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for fetching content")
78 }
78 }
79
79
80 p, err := permissions.Request(
80 p, err := permissions.Request(
@@ -124,19 +124,19 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
124 defer resp.Body.Close()
124 defer resp.Body.Close()
125
125
126 if resp.StatusCode != http.StatusOK {
126 if resp.StatusCode != http.StatusOK {
127− return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
127+ return fantasy.NewTextErrorResponse(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
128 }
128 }
129
129
130 body, err := io.ReadAll(io.LimitReader(resp.Body, MaxFetchSize))
130 body, err := io.ReadAll(io.LimitReader(resp.Body, MaxFetchSize))
131 if err != nil {
131 if err != nil {
132− return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
132+ return fantasy.NewTextErrorResponse("failed to read response body: " + err.Error()), nil
133 }
133 }
134
134
135 content := string(body)
135 content := string(body)
136
136
137 validUTF8 := utf8.ValidString(content)
137 validUTF8 := utf8.ValidString(content)
138 if !validUTF8 {
138 if !validUTF8 {
139− return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil
139+ return fantasy.NewTextErrorResponse("response content is not valid UTF-8"), nil
140 }
140 }
141 contentType := resp.Header.Get("Content-Type")
141 contentType := resp.Header.Get("Content-Type")
142
142
@@ -145,7 +145,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
145 if strings.Contains(contentType, "text/html") {
145 if strings.Contains(contentType, "text/html") {
146 text, err := extractTextFromHTML(content)
146 text, err := extractTextFromHTML(content)
147 if err != nil {
147 if err != nil {
148− return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
148+ return fantasy.NewTextErrorResponse("failed to extract text from HTML: " + err.Error()), nil
149 }
149 }
150 content = text
150 content = text
151 }
151 }
@@ -154,7 +154,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
154 if strings.Contains(contentType, "text/html") {
154 if strings.Contains(contentType, "text/html") {
155 markdown, err := convertHTMLToMarkdown(content)
155 markdown, err := convertHTMLToMarkdown(content)
156 if err != nil {
156 if err != nil {
157− return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
157+ return fantasy.NewTextErrorResponse("failed to convert HTML to Markdown: " + err.Error()), nil
158 }
158 }
159 content = markdown
159 content = markdown
160 }
160 }
@@ -166,14 +166,14 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
166 if strings.Contains(contentType, "text/html") {
166 if strings.Contains(contentType, "text/html") {
167 doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
167 doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
168 if err != nil {
168 if err != nil {
169− return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
169+ return fantasy.NewTextErrorResponse("failed to parse HTML: " + err.Error()), nil
170 }
170 }
171 body, err := doc.Find("body").Html()
171 body, err := doc.Find("body").Html()
172 if err != nil {
172 if err != nil {
173− return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
173+ return fantasy.NewTextErrorResponse("failed to extract body from HTML: " + err.Error()), nil
174 }
174 }
175 if body == "" {
175 if body == "" {
176− return fantasy.NewTextErrorResponse("No body content found in HTML"), nil
176+ return fantasy.NewTextErrorResponse("no body content found in HTML"), nil
177 }
177 }
178 content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
178 content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
179 }
179 }
internal/agent/tools/grep.go+11−4
@@ -235,6 +235,8 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
235 }
235 }
236
236
237 var matches []grepMatch
237 var matches []grepMatch
238+ // modTime is only used for sorting; stat each unique path once.
239+ modTimes := make(map[string]time.Time)
238 for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
240 for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
239 if len(line) == 0 {
241 if len(line) == 0 {
240 continue
242 continue
@@ -247,13 +249,18 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
247 continue
249 continue
248 }
250 }
249 for _, m := range match.Data.Submatches {
251 for _, m := range match.Data.Submatches {
252+ modTime, ok := modTimes[match.Data.Path.Text]
253+ if !ok {
250− fi, err := os.Stat(match.Data.Path.Text)
254+ fi, err := os.Stat(match.Data.Path.Text)
251− if err != nil {
255+ if err != nil {
252− continue // Skip files we can't access
256+ continue // Skip files we can't access
257+ }
258+ modTime = fi.ModTime()
259+ modTimes[match.Data.Path.Text] = modTime
253 }
260 }
254 matches = append(matches, grepMatch{
261 matches = append(matches, grepMatch{
255 path: match.Data.Path.Text,
262 path: match.Data.Path.Text,
256− modTime: fi.ModTime(),
263+ modTime: modTime,
257 lineNum: match.Data.LineNumber,
264 lineNum: match.Data.LineNumber,
258 charNum: m.Start + 1, // ensure 1-based
265 charNum: m.Start + 1, // ensure 1-based
259 lineText: strings.TrimSpace(match.Data.Lines.Text),
266 lineText: strings.TrimSpace(match.Data.Lines.Text),
internal/agent/tools/ls.go+1−1
@@ -163,7 +163,7 @@ func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolL
163 output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
163 output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
164 }
164 }
165 if depth > 0 {
165 if depth > 0 {
166− output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
166+ output += fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
167 }
167 }
168 return output + "\n" + printTree(tree, searchPath), metadata, nil
168 return output + "\n" + printTree(tree, searchPath), metadata, nil
169}
169}
internal/agent/tools/mcp-tools.go+2−2
@@ -99,7 +99,7 @@ func (m *Tool) Info() fantasy.ToolInfo {
99func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
99func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
100 sessionID := GetSessionFromContext(ctx)
100 sessionID := GetSessionFromContext(ctx)
101 if sessionID == "" {
101 if sessionID == "" {
102− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
102+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing MCP tool")
103 }
103 }
104
104
105 // Skip permission for whitelisted Docker MCP tools.
105 // Skip permission for whitelisted Docker MCP tools.
@@ -134,7 +134,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
134 case "image", "media":
134 case "image", "media":
135 if !GetSupportsImagesFromContext(ctx) {
135 if !GetSupportsImagesFromContext(ctx) {
136 modelName := GetModelNameFromContext(ctx)
136 modelName := GetModelNameFromContext(ctx)
137− return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
137+ return fantasy.NewTextErrorResponse(fmt.Sprintf("this model (%s) does not support image data.", modelName)), nil
138 }
138 }
139
139
140 var response fantasy.ToolResponse
140 var response fantasy.ToolResponse
internal/agent/tools/mcp/init.go+22−3
@@ -55,11 +55,19 @@ func (s *ClientSession) Close() error {
55var (
55var (
56 sessions = csync.NewMap[string, *ClientSession]()
56 sessions = csync.NewMap[string, *ClientSession]()
57 states = csync.NewMap[string, ClientInfo]()
57 states = csync.NewMap[string, ClientInfo]()
58+ // sessionLastOK records the last time a session was confirmed alive
59+ // (successful ping or freshly created). Used to skip redundant pings
60+ // within livenessTTL before each MCP tool call.
61+ sessionLastOK = csync.NewMap[string, time.Time]()
58− broker = pubsub.NewBroker[Event]()
62+ broker = pubsub.NewBroker[Event]()
59− initOnce sync.Once
63+ initOnce sync.Once
60− initDone = make(chan struct{})
64+ initDone = make(chan struct{})
61)
65)
62
66
67+// livenessTTL is how long a session is trusted to be alive after a successful
68+// ping/create before getOrRenewClient pings it again.
69+const livenessTTL = 30 * time.Second
70+
63// State represents the current state of an MCP client
71// State represents the current state of an MCP client
64type State int
72type State int
65
73
@@ -260,6 +268,7 @@ func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m con
260 toolCount := updateTools(cfg, name, tools)
268 toolCount := updateTools(cfg, name, tools)
261 updatePrompts(name, prompts)
269 updatePrompts(name, prompts)
262 sessions.Set(name, session)
270 sessions.Set(name, session)
271+ sessionLastOK.Set(name, time.Now())
263
272
264 updateState(name, StateConnected, nil, session, Counts{
273 updateState(name, StateConnected, nil, session, Counts{
265 Tools: toolCount,
274 Tools: toolCount,
@@ -280,6 +289,7 @@ func DisableSingle(cfg *config.ConfigStore, name string) error {
280 slog.Warn("Error closing MCP session", "name", name, "error", err)
289 slog.Warn("Error closing MCP session", "name", name, "error", err)
281 }
290 }
282 sessions.Del(name)
291 sessions.Del(name)
292+ sessionLastOK.Del(name)
283 }
293 }
284
294
285 // Clear tools and prompts for this MCP.
295 // Clear tools and prompts for this MCP.
@@ -299,6 +309,11 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
299 return nil, fmt.Errorf("mcp '%s' not available", name)
309 return nil, fmt.Errorf("mcp '%s' not available", name)
300 }
310 }
301
311
312+ // Skip the ping if this session was confirmed alive very recently.
313+ if last, ok := sessionLastOK.Get(name); ok && time.Since(last) < livenessTTL {
314+ return sess, nil
315+ }
316+
302 m := cfg.Config().MCP[name]
317 m := cfg.Config().MCP[name]
303 state, _ := states.Get(name)
318 state, _ := states.Get(name)
304
319
@@ -307,8 +322,10 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
307 defer cancel()
322 defer cancel()
308 err := sess.Ping(pingCtx, nil)
323 err := sess.Ping(pingCtx, nil)
309 if err == nil {
324 if err == nil {
325+ sessionLastOK.Set(name, time.Now())
310 return sess, nil
326 return sess, nil
311 }
327 }
328+ sessionLastOK.Del(name)
312 updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, state.Counts)
329 updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, state.Counts)
313
330
314 sess, err = createSession(ctx, name, m, cfg.Resolver())
331 sess, err = createSession(ctx, name, m, cfg.Resolver())
@@ -318,6 +335,7 @@ func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string)
318
335
319 updateState(name, StateConnected, nil, sess, state.Counts)
336 updateState(name, StateConnected, nil, sess, state.Counts)
320 sessions.Set(name, sess)
337 sessions.Set(name, sess)
338+ sessionLastOK.Set(name, time.Now())
321 return sess, nil
339 return sess, nil
322}
340}
323
341
@@ -335,6 +353,7 @@ func updateState(name string, state State, err error, client *ClientSession, cou
335 info.ConnectedAt = time.Now()
353 info.ConnectedAt = time.Now()
336 case StateError:
354 case StateError:
337 sessions.Del(name)
355 sessions.Del(name)
356+ sessionLastOK.Del(name)
338 }
357 }
339 states.Set(name, info)
358 states.Set(name, info)
340
359
internal/agent/tools/mcp/tools.go+14
@@ -206,6 +206,20 @@ func ensureRawBytes(data []byte) []byte {
206}
206}
207
207
208func normalizeBase64Input(data []byte) []byte {
208func normalizeBase64Input(data []byte) []byte {
209+ // Fast path: if the data is all-ASCII and contains no whitespace, there is
210+ // nothing for strings.Fields to strip, so avoid copying the (potentially
211+ // multi-MB) payload. Bytes >= 0x80 could be part of a non-ASCII Unicode
212+ // space that strings.Fields would strip, so fall back to the slow path.
213+ fast := true
214+ for _, b := range data {
215+ if b >= 0x80 || b == ' ' || b == '\t' || b == '\n' || b == '\v' || b == '\f' || b == '\r' {
216+ fast = false
217+ break
218+ }
219+ }
220+ if fast {
221+ return data
222+ }
209 normalized := strings.Join(strings.Fields(string(data)), "")
223 normalized := strings.Join(strings.Fields(string(data)), "")
210 return []byte(normalized)
224 return []byte(normalized)
211}
225}
internal/agent/tools/multiedit.go−1
@@ -445,7 +445,6 @@ func applyEditToContent(content string, edit MultiEditOperation) (string, error)
445 }
445 }
446
446
447 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
447 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
448− replacementCount = 1
449 }
448 }
450
449
451 return newContent, nil
450 return newContent, nil
internal/agent/tools/references.go−8
@@ -24,10 +24,6 @@ type ReferencesParams struct {
24 Path string `json:"path,omitempty" description:"The directory to search in. Use a directory/file to narrow down the symbol search. Defaults to the current working directory."`
24 Path string `json:"path,omitempty" description:"The directory to search in. Use a directory/file to narrow down the symbol search. Defaults to the current working directory."`
25}
25}
26
26
27−type referencesTool struct {
28− lspManager *lsp.Manager
29−}
30−
31const ReferencesToolName = "lsp_references"
27const ReferencesToolName = "lsp_references"
32
28
33//go:embed references.md
29//go:embed references.md
@@ -91,10 +87,6 @@ func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
91 )
87 )
92}
88}
93
89
94−func (r *referencesTool) Name() string {
95− return ReferencesToolName
96−}
97−
98func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) {
90func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) {
99 absPath, err := filepath.Abs(match.path)
91 absPath, err := filepath.Abs(match.path)
100 if err != nil {
92 if err != nil {
internal/agent/tools/safe.go+95−22
@@ -2,10 +2,16 @@ package tools
2
2
3import (
3import (
4 "runtime"
4 "runtime"
5− "slices"
6 "strings"
5 "strings"
6+
7+ "mvdan.cc/sh/v3/syntax"
7)
8)
8
9
10+// safeCommands are read-only commands that can run without a permission
11+// prompt. Entries may be multi-word (e.g. "git log") to scope a
12+// subcommand. Do NOT add wrapper commands that execute an arbitrary
13+// argument (env, time, timeout, nohup, nice, xargs, sh, bash, find -exec,
14+// ...): those would let an unapproved command ride in on a safe prefix.
9var safeCommands = []string{
15var safeCommands = []string{
10 // Bash builtins and core utils
16 // Bash builtins and core utils
11 "cal",
17 "cal",
@@ -13,7 +19,6 @@ var safeCommands = []string{
13 "df",
19 "df",
14 "du",
20 "du",
15 "echo",
21 "echo",
16− "env",
17 "free",
22 "free",
18 "groups",
23 "groups",
19 "hostname",
24 "hostname",
@@ -21,14 +26,10 @@ var safeCommands = []string{
21 "kill",
26 "kill",
22 "killall",
27 "killall",
23 "ls",
28 "ls",
24− "nice",
25− "nohup",
26 "printenv",
29 "printenv",
27 "ps",
30 "ps",
28 "pwd",
31 "pwd",
29 "set",
32 "set",
30− "time",
31− "timeout",
32 "top",
33 "top",
33 "type",
34 "type",
34 "uname",
35 "uname",
@@ -58,22 +59,6 @@ var safeCommands = []string{
58 "git tag",
59 "git tag",
59}
60}
60
61
61−var chainingMetacharacters = []string{
62− ";",
63− "|",
64− "&&",
65− "$(",
66− "`",
67−}
68−
69−// containsCommandChaining reports whether s contains shell metacharacters
70−// that enable command chaining or substitution.
71−func containsCommandChaining(s string) bool {
72− return slices.ContainsFunc(chainingMetacharacters, func(c string) bool {
73− return strings.Contains(s, c)
74− })
75−}
76−
77func init() {
62func init() {
78 if runtime.GOOS == "windows" {
63 if runtime.GOOS == "windows" {
79 safeCommands = append(
64 safeCommands = append(
@@ -87,4 +72,92 @@ func init() {
87 "where",
72 "where",
88 )
73 )
89 }
74 }
75+
76+ // Precompute the tokenized form of each safe command once, so
77+ // isSafeReadOnly doesn't re-split on every call.
78+ safeCommandTokens = make([][]string, len(safeCommands))
79+ for i, safeCmd := range safeCommands {
80+ safeCommandTokens[i] = strings.Fields(safeCmd)
81+ }
82+}
83+
84+// safeCommandTokens holds the tokenized form of safeCommands, populated in
85+// init after any platform-specific entries are appended.
86+var safeCommandTokens [][]string
87+
88+// isSafeReadOnly reports whether command is a single, simple, read-only
89+// command that can run without a permission prompt.
90+//
91+// It parses the command and only returns true when the whole input is
92+// exactly one simple command (no pipes, chaining, subshells, blocks, or
93+// negation), with no redirections, no backgrounding, no inline variable
94+// assignments (which could inject e.g. LD_PRELOAD), and no command or
95+// process substitution anywhere. The command name (and any subcommand
96+// tokens) must be plain literals matching an entry in safeCommands.
97+//
98+// This replaces raw-string prefix matching, which let wrapper commands
99+// and redirects/chaining smuggle unapproved commands past the gate
100+// (e.g. "time rm -rf x", "echo x > file", "echo hi & rm -rf x").
101+func isSafeReadOnly(command string) bool {
102+ parser := syntax.NewParser()
103+ file, err := parser.Parse(strings.NewReader(command), "")
104+ if err != nil {
105+ return false
106+ }
107+
108+ // Exactly one statement, and it must be a plain foreground command.
109+ if len(file.Stmts) != 1 {
110+ return false
111+ }
112+ stmt := file.Stmts[0]
113+ if stmt.Background || stmt.Negated || len(stmt.Redirs) > 0 {
114+ return false
115+ }
116+ call, ok := stmt.Cmd.(*syntax.CallExpr)
117+ if !ok || len(call.Args) == 0 || len(call.Assigns) > 0 {
118+ return false
119+ }
120+
121+ // Reject any command/process substitution anywhere in the statement:
122+ // those execute arbitrary commands the allowlist never sees.
123+ safe := true
124+ syntax.Walk(file, func(node syntax.Node) bool {
125+ switch node.(type) {
126+ case *syntax.CmdSubst, *syntax.ProcSubst:
127+ safe = false
128+ return false
129+ }
130+ return true
131+ })
132+ if !safe {
133+ return false
134+ }
135+
136+ // Extract literal argv. Non-literal words (quoting, parameter
137+ // expansion, globbing) yield "" via Word.Lit(); the command name and
138+ // any matched subcommand tokens must be plain literals.
139+ argv := make([]string, len(call.Args))
140+ for i, w := range call.Args {
141+ argv[i] = w.Lit()
142+ }
143+ if argv[0] == "" {
144+ return false
145+ }
146+
147+ for _, tokens := range safeCommandTokens {
148+ if len(argv) < len(tokens) {
149+ continue
150+ }
151+ match := true
152+ for i, tok := range tokens {
153+ if argv[i] != tok {
154+ match = false
155+ break
156+ }
157+ }
158+ if match {
159+ return true
160+ }
161+ }
162+ return false
90}
163}
internal/agent/tools/safe_test.go+51−24
@@ -6,7 +6,7 @@ import (
6 "github.com/stretchr/testify/assert"
6 "github.com/stretchr/testify/assert"
7)
7)
8
8
9−func TestContainsCommandChaining(t *testing.T) {
9+func TestIsSafeReadOnly(t *testing.T) {
10 t.Parallel()
10 t.Parallel()
11
11
12 tests := []struct {
12 tests := []struct {
@@ -14,34 +14,61 @@ func TestContainsCommandChaining(t *testing.T) {
14 input string
14 input string
15 expected bool
15 expected bool
16 }{
16 }{
17+ // Plain safe commands.
17− {"plain ls", "ls -la", false},
18+ {"plain ls", "ls -la", true},
18− {"plain echo", "echo hello world", false},
19+ {"plain echo", "echo hello world", true},
19− {"plain pwd", "pwd", false},
20+ {"plain pwd", "pwd", true},
20− {"plain git status", "git status", false},
21+ {"git status", "git status", true},
21− {"ls with redirect", "ls > /tmp/out", false},
22+ {"git log with flags", "git log --oneline", true},
23+ {"git config --get", "git config --get user.name", true},
24+ {"echo with param expansion", "echo $HOME", true},
25+ {"simple kill", "kill 1234", true},
26+
27+ // Not on the allowlist.
28+ {"rm", "rm -rf /tmp/x", false},
29+ {"unknown", "frobnicate", false},
30+ {"git push not allowed", "git push", false},
31+ {"prefix is not a match (lsof)", "lsof", false},
32+
33+ // Chaining / multiple statements.
22− {"ls with pipe", "ls | grep foo", true},
34+ {"pipe", "ls | grep foo", false},
23− {"ls with double ampersand", "ls && echo done", true},
35+ {"and", "ls && echo done", false},
24− {"ls with semicolon", "ls; echo done", true},
25− {"ls with pipe pipe", "ls || echo fail", true},
36+ {"or", "ls || echo fail", false},
26− {"ls with backticks", "ls `echo foo`", true},
37+ {"semicolon", "ls; echo done", false},
27− {"ls with subshell", "ls $(echo foo)", true},
38+ {"newline", "echo hi\nrm -rf x", false},
28− {"ls with background ampersand", "ls & echo done", false},
39+ {"background then rm", "echo hi & rm -rf x", false},
29− {"rm -rf with && ls (rm first)", "rm -rf / && ls", true},
40+ {"rm first with and", "rm -rf / && ls", false},
30− {"redirect with ampersand gt", "ls &> /dev/null", false},
41+
31− {"redirect with gt ampersand", "ls >& /dev/null", false},
42+ // Substitution.
32− {"simple kill", "kill 1234", false},
43+ {"command subst", "ls $(rm -rf x)", false},
33− {"kill with pipe", "kill 1234 | echo foo", true},
44+ {"backticks", "ls `rm -rf x`", false},
34− {"git log", "git log --oneline", false},
45+ {"param default with subst", "echo ${x:-$(rm -rf x)}", false},
35− {"git log with pipe", "git log | head", true},
46+
36− {"empty string", "", false},
47+ // Redirection.
37− {"dollar sign in argument", "echo $HOME", false},
48+ {"redirect out", "echo x > /tmp/out", false},
49+ {"redirect append", "echo x >> ~/.bashrc", false},
50+ {"redirect in", "cat < /etc/passwd", false},
51+
52+ // Wrapper commands must not smuggle an unapproved command through.
53+ {"time wrapper", "time rm -rf x", false},
54+ {"env wrapper", "env FOO=bar rm -rf x", false},
55+ {"timeout wrapper", "timeout 10 rm -rf x", false},
56+ {"nohup wrapper", "nohup rm -rf x", false},
57+ {"nice wrapper", "nice rm -rf x", false},
58+
59+ // Inline assignment (e.g. LD_PRELOAD) is not safe.
60+ {"inline assignment", "LD_PRELOAD=/evil.so ls", false},
61+
62+ // Malformed / empty.
63+ {"empty", "", false},
64+ {"unbalanced quote", "echo \"unterminated", false},
38 }
65 }
39
66
40 for _, tt := range tests {
67 for _, tt := range tests {
41 t.Run(tt.name, func(t *testing.T) {
68 t.Run(tt.name, func(t *testing.T) {
42 t.Parallel()
69 t.Parallel()
43− got := containsCommandChaining(tt.input)
70+ got := isSafeReadOnly(tt.input)
44− assert.Equal(t, tt.expected, got, "containsCommandChaining(%q)", tt.input)
71+ assert.Equal(t, tt.expected, got, "isSafeReadOnly(%q)", tt.input)
45 })
72 })
46 }
73 }
47}
74}
internal/agent/tools/sourcegraph.go+4−4
@@ -64,7 +64,7 @@ func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
64 sourcegraphDescription(),
64 sourcegraphDescription(),
65 func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
65 func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
66 if params.Query == "" {
66 if params.Query == "" {
67− return fantasy.NewTextErrorResponse("Query parameter is required"), nil
67+ return fantasy.NewTextErrorResponse("query parameter is required"), nil
68 }
68 }
69
69
70 if params.Count <= 0 {
70 if params.Count <= 0 {
@@ -129,10 +129,10 @@ func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
129 if resp.StatusCode != http.StatusOK {
129 if resp.StatusCode != http.StatusOK {
130 body, _ := io.ReadAll(resp.Body)
130 body, _ := io.ReadAll(resp.Body)
131 if len(body) > 0 {
131 if len(body) > 0 {
132− return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
132+ return fantasy.NewTextErrorResponse(fmt.Sprintf("request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
133 }
133 }
134
134
135− return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
135+ return fantasy.NewTextErrorResponse(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
136 }
136 }
137 body, err := io.ReadAll(resp.Body)
137 body, err := io.ReadAll(resp.Body)
138 if err != nil {
138 if err != nil {
@@ -146,7 +146,7 @@ func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
146
146
147 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
147 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
148 if err != nil {
148 if err != nil {
149− return fantasy.NewTextErrorResponse("Failed to format results: " + err.Error()), nil
149+ return fantasy.NewTextErrorResponse("failed to format results: " + err.Error()), nil
150 }
150 }
151
151
152 return fantasy.NewTextResponse(formattedResults), nil
152 return fantasy.NewTextResponse(formattedResults), nil
internal/agent/tools/view.go+25−24
@@ -12,6 +12,7 @@ import (
12 "net/http"
12 "net/http"
13 "os"
13 "os"
14 "path/filepath"
14 "path/filepath"
15+ "strconv"
15 "strings"
16 "strings"
16 "time"
17 "time"
17 "unicode/utf8"
18 "unicode/utf8"
@@ -176,19 +177,19 @@ func NewViewTool(
176 }
177 }
177
178
178 if len(suggestions) > 0 {
179 if len(suggestions) > 0 {
179− return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
180+ return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s\n\nDid you mean one of these?\n%s",
180 filePath, strings.Join(suggestions, "\n"))), nil
181 filePath, strings.Join(suggestions, "\n"))), nil
181 }
182 }
182 }
183 }
183
184
184− return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
185+ return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
185 }
186 }
186 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
187 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
187 }
188 }
188
189
189 // Check if it's a directory
190 // Check if it's a directory
190 if fileInfo.IsDir() {
191 if fileInfo.IsDir() {
191− return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
192+ return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
192 }
193 }
193
194
194 // Set default limit if not provided (no limit for SKILL.md files)
195 // Set default limit if not provided (no limit for SKILL.md files)
@@ -203,12 +204,12 @@ func NewViewTool(
203 isSupportedImage, mimeType := getImageMimeType(filePath)
204 isSupportedImage, mimeType := getImageMimeType(filePath)
204 if isSupportedImage {
205 if isSupportedImage {
205 if fileInfo.Size() > MaxViewSize {
206 if fileInfo.Size() > MaxViewSize {
206− return fantasy.NewTextErrorResponse(fmt.Sprintf("Image file is too large (%d bytes). Maximum size is %d bytes",
207+ return fantasy.NewTextErrorResponse(fmt.Sprintf("image file is too large (%d bytes). Maximum size is %d bytes",
207 fileInfo.Size(), MaxViewSize)), nil
208 fileInfo.Size(), MaxViewSize)), nil
208 }
209 }
209 if !GetSupportsImagesFromContext(ctx) {
210 if !GetSupportsImagesFromContext(ctx) {
210 modelName := GetModelNameFromContext(ctx)
211 modelName := GetModelNameFromContext(ctx)
211− return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
212+ return fantasy.NewTextErrorResponse(fmt.Sprintf("this model (%s) does not support image data.", modelName)), nil
212 }
213 }
213
214
214 imageData, readErr := os.ReadFile(filePath)
215 imageData, readErr := os.ReadFile(filePath)
@@ -236,13 +237,13 @@ func NewViewTool(
236 if err != nil {
237 if err != nil {
237 var tooLarge contentTooLargeError
238 var tooLarge contentTooLargeError
238 if errors.As(err, &tooLarge) {
239 if errors.As(err, &tooLarge) {
239− return fantasy.NewTextErrorResponse(fmt.Sprintf("Content section is too large (%d bytes). Maximum size is %d bytes",
240+ return fantasy.NewTextErrorResponse(fmt.Sprintf("content section is too large (%d bytes). Maximum size is %d bytes",
240 tooLarge.Size, tooLarge.Max)), nil
241 tooLarge.Size, tooLarge.Max)), nil
241 }
242 }
242 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
243 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
243 }
244 }
244 if !utf8.ValidString(content) {
245 if !utf8.ValidString(content) {
245− return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
246+ return fantasy.NewTextErrorResponse("file content is not valid UTF-8"), nil
246 }
247 }
247
248
248 openInLSPs(ctx, lspManager, filePath)
249 openInLSPs(ctx, lspManager, filePath)
@@ -252,7 +253,7 @@ func NewViewTool(
252
253
253 if hasMore {
254 if hasMore {
254 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
255 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
255− params.Offset+len(strings.Split(content, "\n")))
256+ params.Offset+strings.Count(content, "\n")+1)
256 }
257 }
257 output += "\n</file>\n"
258 output += "\n</file>\n"
258 output += getDiagnostics(filePath, lspManager)
259 output += getDiagnostics(filePath, lspManager)
@@ -284,24 +285,24 @@ func addLineNumbers(content string, startLine int) string {
284 return ""
285 return ""
285 }
286 }
286
287
287− lines := strings.Split(content, "\n")
288+ var b strings.Builder
288−
289− var result []string
290− for i, line := range lines {
289+ for i, line := range strings.Split(content, "\n") {
290+ if i > 0 {
291+ b.WriteByte('\n')
292+ }
291 line = strings.TrimSuffix(line, "\r")
293 line = strings.TrimSuffix(line, "\r")
292
294
293− lineNum := i + startLine
295+ numStr := strconv.Itoa(i + startLine)
294− numStr := fmt.Sprintf("%d", lineNum)
296+ // Right-align to a width of 6 (numbers with >=6 digits are unpadded).
295−
296− if len(numStr) >= 6 {
297+ for pad := 6 - len(numStr); pad > 0; pad-- {
297− result = append(result, fmt.Sprintf("%s|%s", numStr, line))
298+ b.WriteByte(' ')
298− } else {
299− paddedNum := fmt.Sprintf("%6s", numStr)
300− result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
301 }
299 }
300+ b.WriteString(numStr)
301+ b.WriteByte('|')
302+ b.WriteString(line)
302 }
303 }
303
304
304− return strings.Join(result, "\n")
305+ return b.String()
305}
306}
306
307
307func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
308func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
@@ -447,12 +448,12 @@ func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.T
447
448
448 data, err := fs.ReadFile(builtinFS, embeddedPath)
449 data, err := fs.ReadFile(builtinFS, embeddedPath)
449 if err != nil {
450 if err != nil {
450− return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
451+ return fantasy.NewTextErrorResponse(fmt.Sprintf("builtin file not found: %s", params.FilePath)), nil
451 }
452 }
452
453
453 content := string(data)
454 content := string(data)
454 if !utf8.ValidString(content) {
455 if !utf8.ValidString(content) {
455− return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
456+ return fantasy.NewTextErrorResponse("file content is not valid UTF-8"), nil
456 }
457 }
457
458
458 limit := params.Limit
459 limit := params.Limit
@@ -461,7 +462,7 @@ func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.T
461 }
462 }
462
463
463 lines := strings.Split(content, "\n")
464 lines := strings.Split(content, "\n")
464− offset := min(params.Offset, len(lines))
465+ offset := max(0, min(params.Offset, len(lines)))
465 lines = lines[offset:]
466 lines = lines[offset:]
466
467
467 hasMore := len(lines) > limit
468 hasMore := len(lines) > limit
internal/agent/tools/view_test.go+2−2
@@ -151,7 +151,7 @@ func TestViewToolBlocksOversizedReturnedSections(t *testing.T) {
151 })
151 })
152
152
153 require.True(t, resp.IsError)
153 require.True(t, resp.IsError)
154− require.Contains(t, resp.Content, "Content section is too large")
154+ require.Contains(t, resp.Content, "content section is too large")
155}
155}
156
156
157func TestViewToolBlocksOversizedImages(t *testing.T) {
157func TestViewToolBlocksOversizedImages(t *testing.T) {
@@ -169,7 +169,7 @@ func TestViewToolBlocksOversizedImages(t *testing.T) {
169 })
169 })
170
170
171 require.True(t, resp.IsError)
171 require.True(t, resp.IsError)
172− require.Contains(t, resp.Content, "Image file is too large")
172+ require.Contains(t, resp.Content, "image file is too large")
173}
173}
174
174
175func TestReadTextFileEnforcesMaxContentSize(t *testing.T) {
175func TestReadTextFileEnforcesMaxContentSize(t *testing.T) {
internal/agent/tools/web_fetch.go+4−4
@@ -45,7 +45,7 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
45
45
46 content, err := FetchURLAndConvert(ctx, client, params.URL)
46 content, err := FetchURLAndConvert(ctx, client, params.URL)
47 if err != nil {
47 if err != nil {
48− return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
48+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to fetch URL: %s", err)), nil
49 }
49 }
50
50
51 hasLargeContent := len(content) > LargeContentThreshold
51 hasLargeContent := len(content) > LargeContentThreshold
@@ -54,16 +54,16 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
54 if hasLargeContent {
54 if hasLargeContent {
55 tempFile, err := os.CreateTemp(workingDir, "page-*.md")
55 tempFile, err := os.CreateTemp(workingDir, "page-*.md")
56 if err != nil {
56 if err != nil {
57− return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
57+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to create temporary file: %s", err)), nil
58 }
58 }
59 tempFilePath := tempFile.Name()
59 tempFilePath := tempFile.Name()
60
60
61 if _, err := tempFile.WriteString(content); err != nil {
61 if _, err := tempFile.WriteString(content); err != nil {
62 _ = tempFile.Close() // Best effort close
62 _ = tempFile.Close() // Best effort close
63− return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
63+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write content to file: %s", err)), nil
64 }
64 }
65 if err := tempFile.Close(); err != nil {
65 if err := tempFile.Close(); err != nil {
66− return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to close temporary file: %s", err)), nil
66+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to close temporary file: %s", err)), nil
67 }
67 }
68
68
69 fmt.Fprintf(&result, "Fetched content from %s (large page)\n\n", params.URL)
69 fmt.Fprintf(&result, "Fetched content from %s (large page)\n\n", params.URL)
internal/agent/tools/web_search.go+1−1
@@ -53,7 +53,7 @@ func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
53 results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults)
53 results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults)
54 slog.Debug("Web search completed", "query", params.Query, "results", len(results), "err", err)
54 slog.Debug("Web search completed", "query", params.Query, "results", len(results), "err", err)
55 if err != nil {
55 if err != nil {
56− return fantasy.NewTextErrorResponse("Failed to search: " + err.Error()), nil
56+ return fantasy.NewTextErrorResponse("failed to search: " + err.Error()), nil
57 }
57 }
58
58
59 return fantasy.NewTextResponse(formatSearchResults(results)), nil
59 return fantasy.NewTextResponse(formatSearchResults(results)), nil
internal/agent/tools/write.go+15−14
@@ -66,21 +66,25 @@ func NewWriteTool(
66 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
66 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
67
67
68 fileInfo, err := os.Stat(filePath)
68 fileInfo, err := os.Stat(filePath)
69+ oldContent := ""
69 if err == nil {
70 if err == nil {
70 if fileInfo.IsDir() {
71 if fileInfo.IsDir() {
71− return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
72+ return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
72 }
73 }
73
74
74 modTime := fileInfo.ModTime().Truncate(time.Second)
75 modTime := fileInfo.ModTime().Truncate(time.Second)
75 lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
76 lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
76 if modTime.After(lastRead) {
77 if modTime.After(lastRead) {
77− 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.",
78+ 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.",
78 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
79 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
79 }
80 }
80
81
81− oldContent, readErr := os.ReadFile(filePath)
82+ oldBytes, readErr := os.ReadFile(filePath)
82− if readErr == nil && string(oldContent) == params.Content {
83+ if readErr == nil {
84+ oldContent = string(oldBytes)
85+ if oldContent == params.Content {
83− return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
86+ return fantasy.NewTextErrorResponse(fmt.Sprintf("file %s already contains the exact content. No changes made.", filePath)), nil
87+ }
84 }
88 }
85 } else if !os.IsNotExist(err) {
89 } else if !os.IsNotExist(err) {
86 return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
90 return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
@@ -91,20 +95,17 @@ func NewWriteTool(
91 return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
95 return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
92 }
96 }
93
97
94− oldContent := ""
95− if fileInfo != nil && !fileInfo.IsDir() {
96− oldBytes, readErr := os.ReadFile(filePath)
97− if readErr == nil {
98− oldContent = string(oldBytes)
99− }
100− }
101−
102 diff, additions, removals := diff.GenerateDiff(
98 diff, additions, removals := diff.GenerateDiff(
103 oldContent,
99 oldContent,
104 params.Content,
100 params.Content,
105 strings.TrimPrefix(filePath, workingDir),
101 strings.TrimPrefix(filePath, workingDir),
106 )
102 )
107
103
104+ description := fmt.Sprintf("Create file %s", filePath)
105+ if fileInfo != nil && !fileInfo.IsDir() {
106+ description = fmt.Sprintf("Overwrite file %s", filePath)
107+ }
108+
108 p, err := permissions.Request(
109 p, err := permissions.Request(
109 ctx,
110 ctx,
110 permission.CreatePermissionRequest{
111 permission.CreatePermissionRequest{
@@ -113,7 +114,7 @@ func NewWriteTool(
113 ToolCallID: call.ID,
114 ToolCallID: call.ID,
114 ToolName: WriteToolName,
115 ToolName: WriteToolName,
115 Action: "write",
116 Action: "write",
116− Description: fmt.Sprintf("Create file %s", filePath),
117+ Description: description,
117 Params: WritePermissionsParams{
118 Params: WritePermissionsParams{
118 FilePath: filePath,
119 FilePath: filePath,
119 OldContent: oldContent,
120 OldContent: oldContent,
internal/agent/usage_fallback.go−9
@@ -33,15 +33,6 @@ func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fan
33 }, true
33 }, true
34}
34}
35
35
36−func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
37− cloned := make([]fantasy.Message, len(messages))
38− for i, msg := range messages {
39− cloned[i] = msg
40− cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
41− }
42− return cloned
43−}
44−
45func estimateMessageTokens(messages []fantasy.Message) int64 {
36func estimateMessageTokens(messages []fantasy.Message) int64 {
46 var tokens int64
37 var tokens int64
47 for _, msg := range messages {
38 for _, msg := range messages {
internal/app/app.go+48−6
@@ -74,8 +74,13 @@ type App struct {
74 tuiWG *sync.WaitGroup
74 tuiWG *sync.WaitGroup
75
75
76 // global context and cleanup functions
76 // global context and cleanup functions
77− globalCtx context.Context
77+ globalCtx context.Context
78− cleanupFuncs []func(context.Context) error
78+ cleanupFuncs []func(context.Context) error
79+ // bgCancel cancels the context passed to the fire-and-forget
80+ // goroutines launched from New (update check, MCP init, LSP tracking);
81+ // bgWG tracks them so Shutdown can join them before closing the DB.
82+ bgCancel context.CancelFunc
83+ bgWG sync.WaitGroup
79 agentNotifications *pubsub.Broker[notify.Notification]
84 agentNotifications *pubsub.Broker[notify.Notification]
80 // runCompletions is the authoritative per-run completion signal,
85 // runCompletions is the authoritative per-run completion signal,
81 // emitted once per top-level agent turn after all message
86 // emitted once per top-level agent turn after all message
@@ -134,10 +139,16 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
134 slog.Warn("Clipboard initialization failed", "error", err)
139 slog.Warn("Clipboard initialization failed", "error", err)
135 }
140 }
136
141
142+ // Background goroutines run on a cancelable context so Shutdown can
143+ // stop and join them (see goBackground / Shutdown) rather than leaking
144+ // them past teardown.
145+ bgCtx, bgCancel := context.WithCancel(ctx)
146+ app.bgCancel = bgCancel
147+
137 // Check for updates in the background.
148 // Check for updates in the background.
138− go app.checkForUpdates(ctx)
149+ app.goBackground(func() { app.checkForUpdates(bgCtx) })
139
150
140− go mcp.Initialize(ctx, app.Permissions, store)
151+ app.goBackground(func() { mcp.Initialize(bgCtx, app.Permissions, store) })
141
152
142 // Start herdr integration when running inside a herdr pane.
153 // Start herdr integration when running inside a herdr pane.
143 app.herdrClient = herdr.Init()
154 app.herdrClient = herdr.Init()
@@ -175,11 +186,22 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
175 client.SetDiagnosticsCallback(updateLSPDiagnostics)
186 client.SetDiagnosticsCallback(updateLSPDiagnostics)
176 updateLSPState(name, client.GetServerState(), nil, client, 0)
187 updateLSPState(name, client.GetServerState(), nil, client, 0)
177 })
188 })
178− go app.LSPManager.TrackConfigured()
189+ app.goBackground(app.LSPManager.TrackConfigured)
179
190
180 return app, nil
191 return app, nil
181}
192}
182
193
194+// goBackground runs fn in a goroutine tracked by bgWG so Shutdown can join
195+// it. Callers whose work is cancelable should close over the app's
196+// background context (see New) so bgCancel actually interrupts them.
197+func (app *App) goBackground(fn func()) {
198+ app.bgWG.Add(1)
199+ go func() {
200+ defer app.bgWG.Done()
201+ fn()
202+ }()
203+}
204+
183// Config returns the pure-data configuration.
205// Config returns the pure-data configuration.
184func (app *App) Config() *config.Config {
206func (app *App) Config() *config.Config {
185 return app.config.Config()
207 return app.config.Config()
@@ -320,7 +342,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
320 }
342 }
321
343
322 // force update of agent models before running so mcp tools are loaded
344 // force update of agent models before running so mcp tools are loaded
323− app.AgentCoordinator.UpdateModels(ctx)
345+ if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
346+ slog.Warn("failed to update agent models", "error", err)
347+ }
324
348
325 defer stopSpinner()
349 defer stopSpinner()
326
350
@@ -690,6 +714,24 @@ func (app *App) Shutdown() {
690 }
714 }
691 }
715 }
692
716
717+ // Cancel and join the fire-and-forget goroutines from New before the
718+ // DB/MCP cleanup below, so none keep touching a closing store, DB, or
719+ // MCP registry. Bounded by shutdownCtx so a goroutine that ignores
720+ // cancellation can't hang shutdown.
721+ if app.bgCancel != nil {
722+ app.bgCancel()
723+ }
724+ bgJoined := make(chan struct{})
725+ go func() {
726+ app.bgWG.Wait()
727+ close(bgJoined)
728+ }()
729+ select {
730+ case <-bgJoined:
731+ case <-shutdownCtx.Done():
732+ slog.Warn("Timed out waiting for background goroutines on shutdown")
733+ }
734+
693 // Now run remaining cleanup tasks in parallel.
735 // Now run remaining cleanup tasks in parallel.
694 var wg sync.WaitGroup
736 var wg sync.WaitGroup
695
737
internal/backend/backend.go+18
@@ -649,6 +649,24 @@ func (w *Workspace) AttachedClientsForSession(sessionID string) int {
649 return n
649 return n
650}
650}
651
651
652+// AttachedClientCountsBySession returns, for every session with at least
653+// one attached client, the number of clients currently viewing it. Only
654+// clients with a live SSE stream (streams > 0) are counted; hold-only
655+// clients (streams == 0) do not contribute. It acquires [clientsMu] once
656+// and iterates the client set a single time, so callers that need counts
657+// for many sessions avoid an O(sessions x clients) scan.
658+func (w *Workspace) AttachedClientCountsBySession() map[string]int {
659+ w.clientsMu.Lock()
660+ defer w.clientsMu.Unlock()
661+ counts := make(map[string]int)
662+ for _, cs := range w.clients {
663+ if cs.streams > 0 {
664+ counts[cs.currentSessionID]++
665+ }
666+ }
667+ return counts
668+}
669+
652// GetWorkspaceProto returns the proto representation of a workspace.
670// GetWorkspaceProto returns the proto representation of a workspace.
653func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
671func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
654 ws, err := b.GetWorkspace(id)
672 ws, err := b.GetWorkspace(id)
internal/backend/config_test.go+7−9
@@ -81,15 +81,13 @@ func TestRemoveConfigField_PublishesConfigChanged(t *testing.T) {
81
81
82func TestUpdatePreferredModel_PublishesConfigChanged(t *testing.T) {
82func TestUpdatePreferredModel_PublishesConfigChanged(t *testing.T) {
83 if raceEnabled {
83 if raceEnabled {
84− // UpdatePreferredModel writes config.Models concurrently
84+ // The backend package has pre-existing, flaky data races under
85− // with the agent coordinator's async sub-agent builder
85+ // -race in its agent-run/attach/hold lifecycle that surface only
86− // that reads it via buildAgentModels. That race is
86+ // under full-package timing pressure. This test spins up the full
87− // pre-existing in the codebase and unrelated to this
87+ // coordinator, so it is skipped under -race to keep CI stable
88− // item; ConfigStore mutations are not currently
88+ // until that concurrency debt is addressed. The mutator → publish
89− // synchronized against background readers in [app.App].
90− // The mutator → publish wiring is unit-tested via
91− // publishConfigChanged regardless.
89+ // wiring is unit-tested via publishConfigChanged regardless.
92− t.Skip("skipped under -race: pre-existing race between ConfigStore writes and agent coordinator startup")
90+ t.Skip("skipped under -race: pre-existing backend lifecycle races")
93 }
91 }
94 b, ws, evc := newPublishingWorkspace(t)
92 b, ws, evc := newPublishingWorkspace(t)
95
93
internal/client/proto.go+2−4
@@ -661,11 +661,9 @@ func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, erro
661 return &cfg, nil
661 return &cfg, nil
662}
662}
663
663
664−func jsonBody(v any) *bytes.Buffer {
664+func jsonBody(v any) io.Reader {
665− b := new(bytes.Buffer)
666 m, _ := json.Marshal(v)
665 m, _ := json.Marshal(v)
667− b.Write(m)
666+ return bytes.NewReader(m)
668− return b
669}
667}
670
668
671// SaveSession updates a session in a workspace, returning a proto type.
669// SaveSession updates a session in a workspace, returning a proto type.
internal/cmd/logs.go+3−3
@@ -95,7 +95,7 @@ func followLogs(ctx context.Context, logsFile string, tailLines int) error {
95 lines = lines[len(lines)-tailLines:]
95 lines = lines[len(lines)-tailLines:]
96 }
96 }
97 }
97 }
98− t.Stop()
98+ _ = t.Stop()
99
99
100 for _, line := range lines {
100 for _, line := range lines {
101 printLogLine(line)
101 printLogLine(line)
@@ -115,7 +115,7 @@ func followLogs(ctx context.Context, logsFile string, tailLines int) error {
115 if err != nil {
115 if err != nil {
116 return fmt.Errorf("failed to tail log file: %v", err)
116 return fmt.Errorf("failed to tail log file: %v", err)
117 }
117 }
118− defer t.Stop()
118+ defer func() { _ = t.Stop() }()
119
119
120 for {
120 for {
121 select {
121 select {
@@ -140,7 +140,7 @@ func showLogs(logsFile string, tailLines int) error {
140 if err != nil {
140 if err != nil {
141 return fmt.Errorf("failed to tail log file: %v", err)
141 return fmt.Errorf("failed to tail log file: %v", err)
142 }
142 }
143− defer t.Stop()
143+ defer func() { _ = t.Stop() }()
144
144
145 var lines []string
145 var lines []string
146 for line := range t.Lines {
146 for line := range t.Lines {
internal/cmd/root.go+1−26
@@ -3,7 +3,6 @@ package cmd
3import (
3import (
4 "bytes"
4 "bytes"
5 "context"
5 "context"
6− _ "embed"
7 "errors"
6 "errors"
8 "fmt"
7 "fmt"
9 "io"
8 "io"
@@ -125,7 +124,7 @@ crush --continue
125 event.AppInitialized()
124 event.AppInitialized()
126
125
127 com := common.DefaultCommon(ws)
126 com := common.DefaultCommon(ws)
128− model := ui.New(com, sessionID, continueLast)
127+ model := ui.New(cmd.Context(), com, sessionID, continueLast)
129
128
130 inputFilter := ui.NewFilter()
129 inputFilter := ui.NewFilter()
131 var env uv.Environ = os.Environ()
130 var env uv.Environ = os.Environ()
@@ -840,27 +839,3 @@ func ResolveCwd(cmd *cobra.Command) (string, error) {
840 }
839 }
841 return cwd, nil
840 return cwd, nil
842}
841}
843−
844−func createDotCrushDir(dir string) error {
845− if err := os.MkdirAll(dir, 0o700); err != nil {
846− return fmt.Errorf("failed to create data directory: %q %w", dir, err)
847− }
848−
849− gitIgnorePath := filepath.Join(dir, ".gitignore")
850− content, err := os.ReadFile(gitIgnorePath)
851−
852− // create or update if old version
853− if os.IsNotExist(err) || string(content) == oldGitIgnore {
854− if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
855− return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
856− }
857− }
858−
859− return nil
860−}
861−
862−//go:embed gitignore/old
863−var oldGitIgnore string
864−
865−//go:embed gitignore/default
866−var defaultGitIgnore string
internal/cmd/session.go+1−1
@@ -485,7 +485,7 @@ func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess sessi
485
485
486 first := true
486 first := true
487 for _, msg := range msgs {
487 for _, msg := range msgs {
488− items := chat.ExtractMessageItems(&styles, msg, toolResults)
488+ items := chat.ExtractMessageItems(&styles, msg, toolResults, "")
489 for _, item := range items {
489 for _, item := range items {
490 if !first {
490 if !first {
491 fmt.Fprintln(&buf)
491 fmt.Fprintln(&buf)
internal/config/config.go+5−2
@@ -221,8 +221,11 @@ type LSPConfig struct {
221type TUIOptions struct {
221type TUIOptions struct {
222 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
222 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
223 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
223 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
224− // Here we can add themes later or any TUI related options
224+ DiffTool string `json:"diff_tool,omitempty" jsonschema:"description=External diff tool for edit diffs in the TUI,enum=builtin,enum=difftastic,default=builtin"`
225− //
225+ // Theme selects the TUI color scheme. "auto" (the default) picks light
226+ // or dark based on the detected terminal background; "dark"/"light"
227+ // force a specific scheme.
228+ Theme string `json:"theme,omitempty" jsonschema:"description=TUI color scheme,enum=auto,enum=dark,enum=light,default=auto"`
226
229
227 Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
230 Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
228 Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
231 Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
internal/config/hyper.go+1−1
@@ -106,7 +106,7 @@ func (r realHyperClient) Get(ctx context.Context, etag string) (catwalk.Provider
106 if err != nil {
106 if err != nil {
107 return result, fmt.Errorf("failed to make request: %w", err)
107 return result, fmt.Errorf("failed to make request: %w", err)
108 }
108 }
109− defer resp.Body.Close() //nolint:errcheck
109+ defer resp.Body.Close()
110
110
111 if resp.StatusCode == http.StatusNotModified {
111 if resp.StatusCode == http.StatusNotModified {
112 return result, catwalk.ErrNotModified
112 return result, catwalk.ErrNotModified
internal/config/load.go+17−1
@@ -292,7 +292,9 @@ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env
292 // state is kept consistent by the Providers.Del call below; any
292 // state is kept consistent by the Providers.Del call below; any
293 // concurrent reload that races with this write will also see the
293 // concurrent reload that races with this write will also see the
294 // removal because it re-reads from disk.
294 // removal because it re-reads from disk.
295− store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
295+ if err := store.RemoveConfigField(ScopeGlobal, "providers.anthropic"); err != nil {
296+ slog.Warn("failed to remove anthropic provider config field", "error", err)
297+ }
296 c.Providers.Del(string(p.ID))
298 c.Providers.Del(string(p.ID))
297 continue
299 continue
298 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
300 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -969,12 +971,26 @@ func hasAWSCredentials(env env.Env) bool {
969 return false
971 return false
970}
972}
971
973
974+// migrateDisableNotificationsOnce guards migrateDisableNotifications so the
975+// one-shot migration for the removed disable_notifications field runs at most
976+// once per process. Without this, every config reload re-read both config
977+// files and re-ran gjson scans over them even though the migration is only
978+// meaningful on first run.
979+var migrateDisableNotificationsOnce sync.Once
980+
972// migrateDisableNotifications migrates the deprecated disable_notifications
981// migrateDisableNotifications migrates the deprecated disable_notifications
973// field to notification_style. It checks both the user config (~/.config) and
982// field to notification_style. It checks both the user config (~/.config) and
974// data config (~/.local) files. If disable_notifications is true, it sets
983// data config (~/.local) files. If disable_notifications is true, it sets
975// notification_style to "disabled" in the data file. Regardless of value, it
984// notification_style to "disabled" in the data file. Regardless of value, it
976// removes disable_notifications from any file that contains it.
985// removes disable_notifications from any file that contains it.
986+//
987+// It is safe to call from both Load and the reload path: the actual work runs
988+// at most once per process via migrateDisableNotificationsOnce.
977func migrateDisableNotifications() {
989func migrateDisableNotifications() {
990+ migrateDisableNotificationsOnce.Do(migrateDisableNotificationsImpl)
991+}
992+
993+func migrateDisableNotificationsImpl() {
978 globalConfig := GlobalConfig()
994 globalConfig := GlobalConfig()
979 dataConfig := GlobalConfigData()
995 dataConfig := GlobalConfigData()
980
996
internal/config/resolve.go+11
@@ -3,6 +3,7 @@ package config
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "strings"
6 "time"
7 "time"
7
8
8 "github.com/charmbracelet/crush/internal/env"
9 "github.com/charmbracelet/crush/internal/env"
@@ -94,6 +95,16 @@ func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
94 return "", fmt.Errorf("invalid value format: %s", value)
95 return "", fmt.Errorf("invalid value format: %s", value)
95 }
96 }
96
97
98+ // Fast path: a value with no shell-expansion metacharacters expands to
99+ // itself, so we can skip materialising the process environment via
100+ // r.env.Env() (which copies all of os.Environ()) and the whole shell
101+ // round-trip. The metacharacter set mirrors shell.ExpandValue's own
102+ // fast-path check ($, backtick, backslash, and both quote styles); any
103+ // value lacking all of them is a literal and cannot expand.
104+ if !strings.ContainsAny(value, "$`\\'\"") {
105+ return value, nil
106+ }
107+
97 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
108 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
98 defer cancel()
109 defer cancel()
99
110
internal/config/store.go+41−18
@@ -133,19 +133,28 @@ func (s *ConfigStore) WorkingDir() string {
133
133
134// Resolver returns the variable resolver.
134// Resolver returns the variable resolver.
135func (s *ConfigStore) Resolver() VariableResolver {
135func (s *ConfigStore) Resolver() VariableResolver {
136+ s.configMu.RLock()
137+ defer s.configMu.RUnlock()
136 return s.resolver
138 return s.resolver
137}
139}
138
140
139// Resolve resolves a variable reference using the configured resolver.
141// Resolve resolves a variable reference using the configured resolver.
140func (s *ConfigStore) Resolve(key string) (string, error) {
142func (s *ConfigStore) Resolve(key string) (string, error) {
143+ // Snapshot the resolver under the lock, then resolve without it:
144+ // ResolveValue may be slow and can call back into the store.
145+ s.configMu.RLock()
146+ resolver := s.resolver
147+ s.configMu.RUnlock()
141− if s.resolver == nil {
148+ if resolver == nil {
142 return "", fmt.Errorf("no variable resolver configured")
149 return "", fmt.Errorf("no variable resolver configured")
143 }
150 }
144− return s.resolver.ResolveValue(key)
151+ return resolver.ResolveValue(key)
145}
152}
146
153
147// KnownProviders returns the list of known providers.
154// KnownProviders returns the list of known providers.
148func (s *ConfigStore) KnownProviders() []catwalk.Provider {
155func (s *ConfigStore) KnownProviders() []catwalk.Provider {
156+ s.configMu.RLock()
157+ defer s.configMu.RUnlock()
149 return s.knownProviders
158 return s.knownProviders
150}
159}
151
160
@@ -161,6 +170,8 @@ func (s *ConfigStore) Overrides() *RuntimeOverrides {
161
170
162// LoadedPaths returns the config file paths that were successfully loaded.
171// LoadedPaths returns the config file paths that were successfully loaded.
163func (s *ConfigStore) LoadedPaths() []string {
172func (s *ConfigStore) LoadedPaths() []string {
173+ s.configMu.RLock()
174+ defer s.configMu.RUnlock()
164 return slices.Clone(s.loadedPaths)
175 return slices.Clone(s.loadedPaths)
165}
176}
166
177
@@ -233,10 +244,13 @@ func (s *ConfigStore) atomicWrite(scope Scope, fn func(current []byte) ([]byte,
233func (s *ConfigStore) configPath(scope Scope) (string, error) {
244func (s *ConfigStore) configPath(scope Scope) (string, error) {
234 switch scope {
245 switch scope {
235 case ScopeWorkspace:
246 case ScopeWorkspace:
247+ s.configMu.RLock()
248+ workspacePath := s.workspacePath
249+ s.configMu.RUnlock()
236− if s.workspacePath == "" {
250+ if workspacePath == "" {
237 return "", ErrNoWorkspaceConfig
251 return "", ErrNoWorkspaceConfig
238 }
252 }
239− return s.workspacePath, nil
253+ return workspacePath, nil
240 default:
254 default:
241 return s.globalDataPath, nil
255 return s.globalDataPath, nil
242 }
256 }
@@ -484,8 +498,11 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
484 return nil
498 return nil
485 }
499 }
486
500
501+ s.configMu.RLock()
502+ knownProviders := s.knownProviders
503+ s.configMu.RUnlock()
487 var foundProvider *catwalk.Provider
504 var foundProvider *catwalk.Provider
488− for _, p := range s.knownProviders {
505+ for _, p := range knownProviders {
489 if string(p.ID) == providerID {
506 if string(p.ID) == providerID {
490 foundProvider = &p
507 foundProvider = &p
491 break
508 break
@@ -760,7 +777,7 @@ func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
760 }
777 }
761
778
762 slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
779 slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
763− token, err := copilot.RefreshToken(context.TODO(), diskToken)
780+ token, err := copilot.RefreshToken(context.Background(), diskToken)
764 if err != nil {
781 if err != nil {
765 slog.Error("Unable to import GitHub Copilot token", "error", err)
782 slog.Error("Unable to import GitHub Copilot token", "error", err)
766 return nil, false
783 return nil, false
@@ -885,8 +902,11 @@ func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
885 }
902 }
886
903
887 // Also track workspace and global config paths if set
904 // Also track workspace and global config paths if set
905+ s.configMu.RLock()
906+ workspacePath := s.workspacePath
907+ s.configMu.RUnlock()
888− if s.workspacePath != "" {
908+ if workspacePath != "" {
889− abs, err := filepath.Abs(s.workspacePath)
909+ abs, err := filepath.Abs(workspacePath)
890 if err == nil {
910 if err == nil {
891 seen[abs] = struct{}{}
911 seen[abs] = struct{}{}
892 }
912 }
@@ -906,7 +926,9 @@ func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
906 slices.Sort(s.trackedConfigPaths)
926 slices.Sort(s.trackedConfigPaths)
907
927
908 // Capture initial snapshots
928 // Capture initial snapshots
909− s.RefreshStalenessSnapshot()
929+ if err := s.RefreshStalenessSnapshot(); err != nil {
930+ slog.Warn("failed to refresh staleness snapshot", "error", err)
931+ }
910}
932}
911
933
912// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
934// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
@@ -966,9 +988,6 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
966 return fmt.Errorf("invalid hook configuration on reload: %w", err)
988 return fmt.Errorf("invalid hook configuration on reload: %w", err)
967 }
989 }
968
990
969− // Preserve runtime overrides
970− overrides := s.overrides
971−
972 // Reconfigure providers
991 // Reconfigure providers
973 env := env.New()
992 env := env.New()
974 resolver := NewShellVariableResolver(env)
993 resolver := NewShellVariableResolver(env)
@@ -986,16 +1005,19 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
986 oldLoadedPaths := s.loadedPaths
1005 oldLoadedPaths := s.loadedPaths
987 oldResolver := s.resolver
1006 oldResolver := s.resolver
988 oldKnownProviders := s.knownProviders
1007 oldKnownProviders := s.knownProviders
989− oldOverrides := s.overrides
990 oldWorkspacePath := s.workspacePath
1008 oldWorkspacePath := s.workspacePath
991
1009
992− // Update store state BEFORE running model/agent setup (so they see new config)
1010+ // Update store state BEFORE running model/agent setup (so they see new
993− s.setConfig(cfg)
1011+ // config). All reload-mutated fields are published atomically under
1012+ // configMu so the lock-free public readers (Resolver, KnownProviders,
1013+ // LoadedPaths, configPath) never observe a torn update.
1014+ s.configMu.Lock()
1015+ s.config = cfg
994 s.loadedPaths = loadedPaths
1016 s.loadedPaths = loadedPaths
995 s.resolver = resolver
1017 s.resolver = resolver
996 s.knownProviders = providers
1018 s.knownProviders = providers
997− s.overrides = overrides
998 s.workspacePath = workspacePath
1019 s.workspacePath = workspacePath
1020+ s.configMu.Unlock()
999
1021
1000 // Mirror startup flow: setup models and agents against NEW config.
1022 // Mirror startup flow: setup models and agents against NEW config.
1001 var setupErr error
1023 var setupErr error
@@ -1014,12 +1036,13 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
1014
1036
1015 // Rollback on setup failure
1037 // Rollback on setup failure
1016 if setupErr != nil {
1038 if setupErr != nil {
1017− s.setConfig(oldConfig)
1039+ s.configMu.Lock()
1040+ s.config = oldConfig
1018 s.loadedPaths = oldLoadedPaths
1041 s.loadedPaths = oldLoadedPaths
1019 s.resolver = oldResolver
1042 s.resolver = oldResolver
1020 s.knownProviders = oldKnownProviders
1043 s.knownProviders = oldKnownProviders
1021− s.overrides = oldOverrides
1022 s.workspacePath = oldWorkspacePath
1044 s.workspacePath = oldWorkspacePath
1045+ s.configMu.Unlock()
1023 return setupErr
1046 return setupErr
1024 }
1047 }
1025
1048
internal/csync/maps.go+20−5
@@ -75,15 +75,30 @@ func (m *Map[K, V]) Len() int {
75 return len(m.inner)
75 return len(m.inner)
76}
76}
77
77
78−// GetOrSet gets and returns the key if it exists, otherwise, it executes the
78+// GetOrSet gets and returns the value for key if it exists, otherwise it
79−// given function, set its return value for the given key, and returns it.
79+// executes fn, stores its return value for key, and returns it.
80+//
81+// The stored value and the returned value are always the same: if two
82+// goroutines race on a missing key, exactly one fn result is stored and
83+// every caller receives that stored value (the losers discard their own
84+// fn result). This keeps singleton values (a mutex, a client, a cache
85+// entry others mutate) consistent. fn may run more than once under
86+// contention, so it must be side-effect-free and safe to call redundantly.
80func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
87func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
81− got, ok := m.Get(key)
88+ if got, ok := m.Get(key); ok {
82− if ok {
83 return got
89 return got
84 }
90 }
91+ // Compute outside the lock so a slow fn (I/O, parsing) does not
92+ // serialize other map users.
85 value := fn()
93 value := fn()
86− m.Set(key, value)
94+ m.mu.Lock()
95+ defer m.mu.Unlock()
96+ // Re-check under the write lock: another goroutine may have won the
97+ // race between our Get above and acquiring the lock.
98+ if got, ok := m.inner[key]; ok {
99+ return got
100+ }
101+ m.inner[key] = value
87 return value
102 return value
88}
103}
89
104
internal/db/db.go+24−4
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4
4
5package db
5package db
6
6
@@ -11,10 +11,10 @@ import (
11)
11)
12
12
13type DBTX interface {
13type DBTX interface {
14− ExecContext(context.Context, string, ...any) (sql.Result, error)
14+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
15 PrepareContext(context.Context, string) (*sql.Stmt, error)
15 PrepareContext(context.Context, string) (*sql.Stmt, error)
16− QueryContext(context.Context, string, ...any) (*sql.Rows, error)
16+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
17− QueryRowContext(context.Context, string, ...any) *sql.Row
17+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
18}
18}
19
19
20func New(db DBTX) *Queries {
20func New(db DBTX) *Queries {
@@ -66,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
66 if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
66 if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
67 return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
67 return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
68 }
68 }
69+ if q.getMaxFileVersionByPathStmt, err = db.PrepareContext(ctx, getMaxFileVersionByPath); err != nil {
70+ return nil, fmt.Errorf("error preparing query GetMaxFileVersionByPath: %w", err)
71+ }
69 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
72 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
70 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
73 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
71 }
74 }
@@ -108,6 +111,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
108 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
111 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
109 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
112 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
110 }
113 }
114+ if q.listMessagesBySessionAfterStmt, err = db.PrepareContext(ctx, listMessagesBySessionAfter); err != nil {
115+ return nil, fmt.Errorf("error preparing query ListMessagesBySessionAfter: %w", err)
116+ }
111 if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
117 if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
112 return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
118 return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
113 }
119 }
@@ -210,6 +216,11 @@ func (q *Queries) Close() error {
210 err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
216 err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
211 }
217 }
212 }
218 }
219+ if q.getMaxFileVersionByPathStmt != nil {
220+ if cerr := q.getMaxFileVersionByPathStmt.Close(); cerr != nil {
221+ err = fmt.Errorf("error closing getMaxFileVersionByPathStmt: %w", cerr)
222+ }
223+ }
213 if q.getMessageStmt != nil {
224 if q.getMessageStmt != nil {
214 if cerr := q.getMessageStmt.Close(); cerr != nil {
225 if cerr := q.getMessageStmt.Close(); cerr != nil {
215 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
226 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -280,6 +291,11 @@ func (q *Queries) Close() error {
280 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
291 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
281 }
292 }
282 }
293 }
294+ if q.listMessagesBySessionAfterStmt != nil {
295+ if cerr := q.listMessagesBySessionAfterStmt.Close(); cerr != nil {
296+ err = fmt.Errorf("error closing listMessagesBySessionAfterStmt: %w", cerr)
297+ }
298+ }
283 if q.listNewFilesStmt != nil {
299 if q.listNewFilesStmt != nil {
284 if cerr := q.listNewFilesStmt.Close(); cerr != nil {
300 if cerr := q.listNewFilesStmt.Close(); cerr != nil {
285 err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
301 err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
@@ -378,6 +394,7 @@ type Queries struct {
378 getFileReadStmt *sql.Stmt
394 getFileReadStmt *sql.Stmt
379 getHourDayHeatmapStmt *sql.Stmt
395 getHourDayHeatmapStmt *sql.Stmt
380 getLastSessionStmt *sql.Stmt
396 getLastSessionStmt *sql.Stmt
397+ getMaxFileVersionByPathStmt *sql.Stmt
381 getMessageStmt *sql.Stmt
398 getMessageStmt *sql.Stmt
382 getRecentActivityStmt *sql.Stmt
399 getRecentActivityStmt *sql.Stmt
383 getSessionByIDStmt *sql.Stmt
400 getSessionByIDStmt *sql.Stmt
@@ -392,6 +409,7 @@ type Queries struct {
392 listFilesBySessionStmt *sql.Stmt
409 listFilesBySessionStmt *sql.Stmt
393 listLatestSessionFilesStmt *sql.Stmt
410 listLatestSessionFilesStmt *sql.Stmt
394 listMessagesBySessionStmt *sql.Stmt
411 listMessagesBySessionStmt *sql.Stmt
412+ listMessagesBySessionAfterStmt *sql.Stmt
395 listNewFilesStmt *sql.Stmt
413 listNewFilesStmt *sql.Stmt
396 listSessionReadFilesStmt *sql.Stmt
414 listSessionReadFilesStmt *sql.Stmt
397 listSessionsStmt *sql.Stmt
415 listSessionsStmt *sql.Stmt
@@ -421,6 +439,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
421 getFileReadStmt: q.getFileReadStmt,
439 getFileReadStmt: q.getFileReadStmt,
422 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
440 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
423 getLastSessionStmt: q.getLastSessionStmt,
441 getLastSessionStmt: q.getLastSessionStmt,
442+ getMaxFileVersionByPathStmt: q.getMaxFileVersionByPathStmt,
424 getMessageStmt: q.getMessageStmt,
443 getMessageStmt: q.getMessageStmt,
425 getRecentActivityStmt: q.getRecentActivityStmt,
444 getRecentActivityStmt: q.getRecentActivityStmt,
426 getSessionByIDStmt: q.getSessionByIDStmt,
445 getSessionByIDStmt: q.getSessionByIDStmt,
@@ -435,6 +454,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
435 listFilesBySessionStmt: q.listFilesBySessionStmt,
454 listFilesBySessionStmt: q.listFilesBySessionStmt,
436 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
455 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
437 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
456 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
457+ listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt,
438 listNewFilesStmt: q.listNewFilesStmt,
458 listNewFilesStmt: q.listNewFilesStmt,
439 listSessionReadFilesStmt: q.listSessionReadFilesStmt,
459 listSessionReadFilesStmt: q.listSessionReadFilesStmt,
440 listSessionsStmt: q.listSessionsStmt,
460 listSessionsStmt: q.listSessionsStmt,
internal/db/files.sql.go+21−5
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4// source: files.sql
4// source: files.sql
5
5
6package db
6package db
@@ -122,6 +122,21 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
122 return i, err
122 return i, err
123}
123}
124
124
125+const getMaxFileVersionByPath = `-- name: GetMaxFileVersionByPath :one
126+SELECT version
127+FROM files
128+WHERE path = ?
129+ORDER BY version DESC, created_at DESC
130+LIMIT 1
131+`
132+
133+func (q *Queries) GetMaxFileVersionByPath(ctx context.Context, path string) (int64, error) {
134+ row := q.queryRow(ctx, q.getMaxFileVersionByPathStmt, getMaxFileVersionByPath, path)
135+ var version int64
136+ err := row.Scan(&version)
137+ return version, err
138+}
139+
125const listFilesByPath = `-- name: ListFilesByPath :many
140const listFilesByPath = `-- name: ListFilesByPath :many
126SELECT id, session_id, path, content, version, created_at, updated_at
141SELECT id, session_id, path, content, version, created_at, updated_at
127FROM files
142FROM files
@@ -202,11 +217,12 @@ const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
202SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
217SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
203FROM files f
218FROM files f
204INNER JOIN (
219INNER JOIN (
205− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
220+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
206− FROM files
221+ FROM files f2
222+ WHERE f2.session_id = ?1
207− GROUP BY path
223+ GROUP BY f2.path
208) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
224) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
209−WHERE f.session_id = ?
225+WHERE f.session_id = ?1
210ORDER BY f.path
226ORDER BY f.path
211`
227`
212
228
internal/db/messages.sql.go+50−2
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4// source: messages.sql
4// source: messages.sql
5
5
6package db
6package db
@@ -38,7 +38,8 @@ type CreateMessageParams struct {
38}
38}
39
39
40func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
40func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
41+ row := q.queryRow(
41− row := q.queryRow(ctx, q.createMessageStmt, createMessage,
42+ ctx, q.createMessageStmt, createMessage,
42 arg.ID,
43 arg.ID,
43 arg.SessionID,
44 arg.SessionID,
44 arg.Role,
45 arg.Role,
@@ -189,6 +190,53 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
189 return items, nil
190 return items, nil
190}
191}
191
192
193+const listMessagesBySessionAfter = `-- name: ListMessagesBySessionAfter :many
194+SELECT m.id, m.session_id, m.role, m.parts, m.model, m.created_at, m.updated_at, m.finished_at, m.provider, m.is_summary_message
195+FROM messages m
196+WHERE m.session_id = ?
197+ AND m.created_at >= (SELECT m2.created_at FROM messages m2 WHERE m2.id = ?)
198+ORDER BY m.created_at ASC
199+`
200+
201+type ListMessagesBySessionAfterParams struct {
202+ SessionID string `json:"session_id"`
203+ ID string `json:"id"`
204+}
205+
206+func (q *Queries) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) {
207+ rows, err := q.query(ctx, q.listMessagesBySessionAfterStmt, listMessagesBySessionAfter, arg.SessionID, arg.ID)
208+ if err != nil {
209+ return nil, err
210+ }
211+ defer rows.Close()
212+ items := []Message{}
213+ for rows.Next() {
214+ var i Message
215+ if err := rows.Scan(
216+ &i.ID,
217+ &i.SessionID,
218+ &i.Role,
219+ &i.Parts,
220+ &i.Model,
221+ &i.CreatedAt,
222+ &i.UpdatedAt,
223+ &i.FinishedAt,
224+ &i.Provider,
225+ &i.IsSummaryMessage,
226+ ); err != nil {
227+ return nil, err
228+ }
229+ items = append(items, i)
230+ }
231+ if err := rows.Close(); err != nil {
232+ return nil, err
233+ }
234+ if err := rows.Err(); err != nil {
235+ return nil, err
236+ }
237+ return items, nil
238+}
239+
192const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many
240const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many
193SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
241SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
194FROM messages
242FROM messages
internal/db/migrations/20260701000000_add_composite_indexes.sqlnew file+12
@@ -0,0 +1,12 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+-- Add composite indexes to speed up per-session ordered lookups.
4+CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages (session_id, created_at);
5+CREATE INDEX IF NOT EXISTS idx_files_session_version_created ON files (session_id, version, created_at);
6+-- +goose StatementEnd
7+
8+-- +goose Down
9+-- +goose StatementBegin
10+DROP INDEX IF EXISTS idx_messages_session_created;
11+DROP INDEX IF EXISTS idx_files_session_version_created;
12+-- +goose StatementEnd
internal/db/migrations/20260702000000_add_hot_path_indexes.sqlnew file+14
@@ -0,0 +1,14 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+-- Add indexes for hot-path queries that previously required full table scans.
4+CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions (updated_at DESC);
5+CREATE INDEX IF NOT EXISTS idx_messages_role_created ON messages (role, created_at DESC);
6+CREATE INDEX IF NOT EXISTS idx_files_path_version ON files (path, version DESC);
7+-- +goose StatementEnd
8+
9+-- +goose Down
10+-- +goose StatementBegin
11+DROP INDEX IF EXISTS idx_sessions_updated_at;
12+DROP INDEX IF EXISTS idx_messages_role_created;
13+DROP INDEX IF EXISTS idx_files_path_version;
14+-- +goose StatementEnd
internal/db/models.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4
4
5package db
5package db
6
6
internal/db/querier.go+5−3
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4
4
5package db
5package db
6
6
@@ -23,6 +23,7 @@ type Querier interface {
23 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
23 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
24 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
24 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
25 GetLastSession(ctx context.Context) (Session, error)
25 GetLastSession(ctx context.Context) (Session, error)
26+ GetMaxFileVersionByPath(ctx context.Context, path string) (int64, error)
26 GetMessage(ctx context.Context, id string) (Message, error)
27 GetMessage(ctx context.Context, id string) (Message, error)
27 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
28 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
28 GetSessionByID(ctx context.Context, id string) (Session, error)
29 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -37,15 +38,16 @@ type Querier interface {
37 ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
38 ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
38 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
39 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
39 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
40 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
41+ ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error)
40 ListNewFiles(ctx context.Context) ([]File, error)
42 ListNewFiles(ctx context.Context) ([]File, error)
41 ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
43 ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
42 ListSessions(ctx context.Context) ([]Session, error)
44 ListSessions(ctx context.Context) ([]Session, error)
43 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
45 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
44 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
46 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
45− RenameSession(ctx context.Context, arg RenameSessionParams) error
47+ RenameSession(ctx context.Context, arg RenameSessionParams) (Session, error)
46 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
48 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
47 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
49 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
48− UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
50+ UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) (Session, error)
49}
51}
50
52
51var _ Querier = (*Queries)(nil)
53var _ Querier = (*Queries)(nil)
internal/db/read_files.sql.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4// source: read_files.sql
4// source: read_files.sql
5
5
6package db
6package db
internal/db/sessions.sql.go+39−9
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4// source: sessions.sql
4// source: sessions.sql
5
5
6package db
6package db
@@ -176,11 +176,12 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
176 return items, nil
176 return items, nil
177}
177}
178
178
179−const renameSession = `-- name: RenameSession :exec
179+const renameSession = `-- name: RenameSession :one
180UPDATE sessions
180UPDATE sessions
181SET
181SET
182 title = ?
182 title = ?
183WHERE id = ?
183WHERE id = ?
184+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
184`
185`
185
186
186type RenameSessionParams struct {
187type RenameSessionParams struct {
@@ -188,9 +189,23 @@ type RenameSessionParams struct {
188 ID string `json:"id"`
189 ID string `json:"id"`
189}
190}
190
191
191−func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) error {
192+func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) (Session, error) {
192− _, err := q.exec(ctx, q.renameSessionStmt, renameSession, arg.Title, arg.ID)
193+ row := q.queryRow(ctx, q.renameSessionStmt, renameSession, arg.Title, arg.ID)
194+ var i Session
195+ err := row.Scan(
196+ &i.ID,
197+ &i.ParentSessionID,
198+ &i.Title,
199+ &i.MessageCount,
200+ &i.PromptTokens,
201+ &i.CompletionTokens,
202+ &i.Cost,
203+ &i.UpdatedAt,
204+ &i.CreatedAt,
205+ &i.SummaryMessageID,
206+ &i.Todos,
207+ )
193− return err
208+ return i, err
194}
209}
195
210
196const updateSession = `-- name: UpdateSession :one
211const updateSession = `-- name: UpdateSession :one
@@ -243,7 +258,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
243 return i, err
258 return i, err
244}
259}
245
260
246−const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :exec
261+const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :one
247UPDATE sessions
262UPDATE sessions
248SET
263SET
249 title = ?,
264 title = ?,
@@ -252,6 +267,7 @@ SET
252 cost = cost + ?,
267 cost = cost + ?,
253 updated_at = strftime('%s', 'now')
268 updated_at = strftime('%s', 'now')
254WHERE id = ?
269WHERE id = ?
270+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
255`
271`
256
272
257type UpdateSessionTitleAndUsageParams struct {
273type UpdateSessionTitleAndUsageParams struct {
@@ -262,13 +278,27 @@ type UpdateSessionTitleAndUsageParams struct {
262 ID string `json:"id"`
278 ID string `json:"id"`
263}
279}
264
280
265−func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error {
281+func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) (Session, error) {
266− _, err := q.exec(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
282+ row := q.queryRow(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
267 arg.Title,
283 arg.Title,
268 arg.PromptTokens,
284 arg.PromptTokens,
269 arg.CompletionTokens,
285 arg.CompletionTokens,
270 arg.Cost,
286 arg.Cost,
271 arg.ID,
287 arg.ID,
272 )
288 )
289+ var i Session
290+ err := row.Scan(
291+ &i.ID,
292+ &i.ParentSessionID,
293+ &i.Title,
294+ &i.MessageCount,
295+ &i.PromptTokens,
296+ &i.CompletionTokens,
297+ &i.Cost,
298+ &i.UpdatedAt,
299+ &i.CreatedAt,
300+ &i.SummaryMessageID,
301+ &i.Todos,
302+ )
273− return err
303+ return i, err
274}
304}
internal/db/sql/files.sql+12−4
@@ -22,6 +22,13 @@ FROM files
22WHERE path = ?
22WHERE path = ?
23ORDER BY version DESC, created_at DESC;
23ORDER BY version DESC, created_at DESC;
24
24
25+-- name: GetMaxFileVersionByPath :one
26+SELECT version
27+FROM files
28+WHERE path = ?
29+ORDER BY version DESC, created_at DESC
30+LIMIT 1;
31+
25-- name: CreateFile :one
32-- name: CreateFile :one
26INSERT INTO files (
33INSERT INTO files (
27 id,
34 id,
@@ -48,11 +55,12 @@ WHERE session_id = ?;
48SELECT f.*
55SELECT f.*
49FROM files f
56FROM files f
50INNER JOIN (
57INNER JOIN (
51− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
58+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
52− FROM files
59+ FROM files f2
60+ WHERE f2.session_id = @session_id
53− GROUP BY path
61+ GROUP BY f2.path
54) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
62) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
55−WHERE f.session_id = ?
63+WHERE f.session_id = @session_id
56ORDER BY f.path;
64ORDER BY f.path;
57
65
58-- name: ListNewFiles :many
66-- name: ListNewFiles :many
internal/db/sql/messages.sql+7
@@ -9,6 +9,13 @@ FROM messages
9WHERE session_id = ?
9WHERE session_id = ?
10ORDER BY created_at ASC;
10ORDER BY created_at ASC;
11
11
12+-- name: ListMessagesBySessionAfter :many
13+SELECT m.*
14+FROM messages m
15+WHERE m.session_id = ?
16+ AND m.created_at >= (SELECT m2.created_at FROM messages m2 WHERE m2.id = ?)
17+ORDER BY m.created_at ASC;
18+
12-- name: CreateMessage :one
19-- name: CreateMessage :one
13INSERT INTO messages (
20INSERT INTO messages (
14 id,
21 id,
internal/db/sql/sessions.sql+6−4
@@ -52,7 +52,7 @@ SET
52WHERE id = ?
52WHERE id = ?
53RETURNING *;
53RETURNING *;
54
54
55−-- name: UpdateSessionTitleAndUsage :exec
55+-- name: UpdateSessionTitleAndUsage :one
56UPDATE sessions
56UPDATE sessions
57SET
57SET
58 title = ?,
58 title = ?,
@@ -60,14 +60,16 @@ SET
60 completion_tokens = completion_tokens + ?,
60 completion_tokens = completion_tokens + ?,
61 cost = cost + ?,
61 cost = cost + ?,
62 updated_at = strftime('%s', 'now')
62 updated_at = strftime('%s', 'now')
63−WHERE id = ?;
63+WHERE id = ?
64+RETURNING *;
64
65
65
66
66−-- name: RenameSession :exec
67+-- name: RenameSession :one
67UPDATE sessions
68UPDATE sessions
68SET
69SET
69 title = ?
70 title = ?
70−WHERE id = ?;
71+WHERE id = ?
72+RETURNING *;
71
73
72-- name: DeleteSession :exec
74-- name: DeleteSession :exec
73DELETE FROM sessions
75DELETE FROM sessions
internal/db/stats.sql.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
4// source: stats.sql
4// source: stats.sql
5
5
6package db
6package db
internal/event/event.go+15−2
@@ -7,6 +7,7 @@ import (
7 "path/filepath"
7 "path/filepath"
8 "reflect"
8 "reflect"
9 "runtime"
9 "runtime"
10+ "sync"
10 "time"
11 "time"
11
12
12 "github.com/charmbracelet/crush/internal/version"
13 "github.com/charmbracelet/crush/internal/version"
@@ -25,7 +26,10 @@ const (
25var (
26var (
26 client posthog.Client
27 client posthog.Client
27
28
29+ // basePropsMu guards baseProps: the Set* mutators reassign it during
30+ // startup while send() reads it concurrently from event goroutines.
31+ basePropsMu sync.RWMutex
28− baseProps = posthog.NewProperties().
32+ baseProps = posthog.NewProperties().
29 Set("GOOS", runtime.GOOS).
33 Set("GOOS", runtime.GOOS).
30 Set("GOARCH", runtime.GOARCH).
34 Set("GOARCH", runtime.GOARCH).
31 Set("TERM", os.Getenv("TERM")).
35 Set("TERM", os.Getenv("TERM")).
@@ -36,14 +40,20 @@ var (
36)
40)
37
41
38func SetNonInteractive(nonInteractive bool) {
42func SetNonInteractive(nonInteractive bool) {
43+ basePropsMu.Lock()
44+ defer basePropsMu.Unlock()
39 baseProps = baseProps.Set(nonInteractiveAttrName, nonInteractive)
45 baseProps = baseProps.Set(nonInteractiveAttrName, nonInteractive)
40}
46}
41
47
42func SetContinueBySessionID(continueBySessionID bool) {
48func SetContinueBySessionID(continueBySessionID bool) {
49+ basePropsMu.Lock()
50+ defer basePropsMu.Unlock()
43 baseProps = baseProps.Set(continueSessionByIDAttrName, continueBySessionID)
51 baseProps = baseProps.Set(continueSessionByIDAttrName, continueBySessionID)
44}
52}
45
53
46func SetContinueLastSession(continueLastSession bool) {
54func SetContinueLastSession(continueLastSession bool) {
55+ basePropsMu.Lock()
56+ defer basePropsMu.Unlock()
47 baseProps = baseProps.Set(continueLastSessionAttrName, continueLastSession)
57 baseProps = baseProps.Set(continueLastSessionAttrName, continueLastSession)
48}
58}
49
59
@@ -81,10 +91,13 @@ func send(event string, props ...any) {
81 if client == nil {
91 if client == nil {
82 return
92 return
83 }
93 }
94+ basePropsMu.RLock()
95+ properties := pairsToProps(props...).Merge(baseProps)
96+ basePropsMu.RUnlock()
84 err := client.Enqueue(posthog.Capture{
97 err := client.Enqueue(posthog.Capture{
85 DistinctId: distinctId,
98 DistinctId: distinctId,
86 Event: event,
99 Event: event,
87− Properties: pairsToProps(props...).Merge(baseProps),
100+ Properties: properties,
88 })
101 })
89 if err != nil {
102 if err != nil {
90 slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
103 slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
internal/filetracker/service.go+19−2
@@ -7,6 +7,7 @@ import (
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9 "path/filepath"
9 "path/filepath"
10+ "sync"
10 "time"
11 "time"
11
12
12 "github.com/charmbracelet/crush/internal/db"
13 "github.com/charmbracelet/crush/internal/db"
@@ -58,9 +59,25 @@ func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time
58 return time.Unix(readFile.ReadAt, 0)
59 return time.Unix(readFile.ReadAt, 0)
59}
60}
60
61
62+// workingDir caches the process working directory. The working directory
63+// cannot legitimately change during normal operation, so resolving it once
64+// avoids an os.Getwd() syscall on every RecordRead/LastReadTime call.
65+var (
66+ workingDirOnce sync.Once
67+ workingDir string
68+ workingDirErr error
69+)
70+
71+func getwd() (string, error) {
72+ workingDirOnce.Do(func() {
73+ workingDir, workingDirErr = os.Getwd()
74+ })
75+ return workingDir, workingDirErr
76+}
77+
61func relpath(path string) string {
78func relpath(path string) string {
62 path = filepath.Clean(path)
79 path = filepath.Clean(path)
63− basepath, err := os.Getwd()
80+ basepath, err := getwd()
64 if err != nil {
81 if err != nil {
65 slog.Warn("Error getting basepath", "error", err)
82 slog.Warn("Error getting basepath", "error", err)
66 return path
83 return path
@@ -80,7 +97,7 @@ func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string
80 return nil, fmt.Errorf("listing read files: %w", err)
97 return nil, fmt.Errorf("listing read files: %w", err)
81 }
98 }
82
99
83− basepath, err := os.Getwd()
100+ basepath, err := getwd()
84 if err != nil {
101 if err != nil {
85 return nil, fmt.Errorf("getting working directory: %w", err)
102 return nil, fmt.Errorf("getting working directory: %w", err)
86 }
103 }
internal/fsext/fileutil.go+23−21
@@ -22,6 +22,29 @@ type FileInfo struct {
22 ModTime time.Time
22 ModTime time.Time
23}
23}
24
24
25+// commonIgnoredDirs is a set of directory names that SkipHidden always ignores.
26+// Hoisted to package scope so it is built once instead of on every call.
27+var commonIgnoredDirs = map[string]bool{
28+ ".crush": true,
29+ "node_modules": true,
30+ "vendor": true,
31+ "dist": true,
32+ "build": true,
33+ "target": true,
34+ ".git": true,
35+ ".idea": true,
36+ ".vscode": true,
37+ "__pycache__": true,
38+ "bin": true,
39+ "obj": true,
40+ "out": true,
41+ "coverage": true,
42+ "logs": true,
43+ "generated": true,
44+ "bower_components": true,
45+ "jspm_packages": true,
46+}
47+
25func SkipHidden(path string) bool {
48func SkipHidden(path string) bool {
26 // Check for hidden files (starting with a dot)
49 // Check for hidden files (starting with a dot)
27 base := filepath.Base(path)
50 base := filepath.Base(path)
@@ -29,27 +52,6 @@ func SkipHidden(path string) bool {
29 return true
52 return true
30 }
53 }
31
54
32− commonIgnoredDirs := map[string]bool{
33− ".crush": true,
34− "node_modules": true,
35− "vendor": true,
36− "dist": true,
37− "build": true,
38− "target": true,
39− ".git": true,
40− ".idea": true,
41− ".vscode": true,
42− "__pycache__": true,
43− "bin": true,
44− "obj": true,
45− "out": true,
46− "coverage": true,
47− "logs": true,
48− "generated": true,
49− "bower_components": true,
50− "jspm_packages": true,
51− }
52−
53 parts := strings.SplitSeq(path, string(os.PathSeparator))
55 parts := strings.SplitSeq(path, string(os.PathSeparator))
54 for part := range parts {
56 for part := range parts {
55 if commonIgnoredDirs[part] {
57 if commonIgnoredDirs[part] {
internal/fsext/lookup.go+2−7
@@ -258,7 +258,7 @@ func canonicalize(path string) string {
258
258
259// probeEnt checks if entity at given path exists and belongs to given owner
259// probeEnt checks if entity at given path exists and belongs to given owner
260func probeEnt(fspath string, owner int) error {
260func probeEnt(fspath string, owner int) error {
261− _, err := os.Stat(fspath)
261+ info, err := os.Stat(fspath)
262 if err != nil {
262 if err != nil {
263 return fmt.Errorf("cannot stat %s: %w", fspath, err)
263 return fmt.Errorf("cannot stat %s: %w", fspath, err)
264 }
264 }
@@ -268,12 +268,7 @@ func probeEnt(fspath string, owner int) error {
268 return nil
268 return nil
269 }
269 }
270
270
271− fowner, err := Owner(fspath)
271+ if ownerFromInfo(info) != owner {
272− if err != nil {
273− return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
274− }
275−
276− if fowner != owner {
277 return os.ErrPermission
272 return os.ErrPermission
278 }
273 }
279
274
internal/fsext/owner_others.go+8−5
@@ -14,11 +14,14 @@ func Owner(path string) (int, error) {
14 if err != nil {
14 if err != nil {
15 return 0, err
15 return 0, err
16 }
16 }
17− var uid int
17+ return ownerFromInfo(info), nil
18+}
19+
20+// ownerFromInfo derives the owner user ID from an already-obtained
21+// os.FileInfo, avoiding a redundant stat.
22+func ownerFromInfo(info os.FileInfo) int {
18 if stat, ok := info.Sys().(*syscall.Stat_t); ok {
23 if stat, ok := info.Sys().(*syscall.Stat_t); ok {
19− uid = int(stat.Uid)
24+ return int(stat.Uid)
20− } else {
21− uid = os.Getuid()
22 }
25 }
23− return uid, nil
26+ return os.Getuid()
24}
27}
internal/fsext/owner_windows.go+7
@@ -13,3 +13,10 @@ func Owner(path string) (int, error) {
13 }
13 }
14 return -1, nil
14 return -1, nil
15}
15}
16+
17+// ownerFromInfo derives the owner user ID from an already-obtained
18+// os.FileInfo, avoiding a redundant stat. Windows does not resolve
19+// ownership, so it always returns the bypass sentinel.
20+func ownerFromInfo(_ os.FileInfo) int {
21+ return -1
22+}
internal/herdr/client.go+6−2
@@ -158,7 +158,9 @@ func (c *Client) registerInitial() {
158 }
158 }
159 c.mu.Lock()
159 c.mu.Lock()
160 defer c.mu.Unlock()
160 defer c.mu.Unlock()
161− c.snd.send(c.newRequestLocked("pane.report_agent", "init", stateIdle))
161+ if err := c.snd.send(c.newRequestLocked("pane.report_agent", "init", stateIdle)); err != nil {
162+ slog.Warn("failed to send herdr agent report", "error", err)
163+ }
162}
164}
163
165
164// Close releases the agent's authority on the pane and shuts down
166// Close releases the agent's authority on the pane and shuts down
@@ -297,7 +299,9 @@ func (c *Client) reportLocked(state string) {
297 return
299 return
298 }
300 }
299 c.state = state
301 c.state = state
300− c.snd.send(c.newRequestLocked("pane.report_agent", "report", state))
302+ if err := c.snd.send(c.newRequestLocked("pane.report_agent", "report", state)); err != nil {
303+ slog.Warn("failed to send herdr agent report", "error", err)
304+ }
301}
305}
302
306
303// reportRequest is the JSON-RPC envelope sent to herdr.
307// reportRequest is the JSON-RPC envelope sent to herdr.
internal/history/file.go+20−15
@@ -3,6 +3,7 @@ package history
3import (
3import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6+ "errors"
6 "fmt"
7 "fmt"
7 "strings"
8 "strings"
8
9
@@ -63,20 +64,22 @@ func (s *service) Create(ctx context.Context, sessionID, path, content string) (
63// number. If no previous versions exist for the path, it creates the initial
64// number. If no previous versions exist for the path, it creates the initial
64// version. The provided content is stored as the new version.
65// version. The provided content is stored as the new version.
65func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
66func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
66− // Get the latest version for this path
67+ // Get the latest version for this path. Only the version column is read
67− files, err := s.q.ListFilesByPath(ctx, path)
68+ // (via ORDER BY version DESC LIMIT 1) to avoid loading every version's
69+ // full content just to compute the max. Versions start at InitialVersion
70+ // (0), so MAX cannot distinguish "no rows" from "only the initial
71+ // version"; sql.ErrNoRows is the reliable signal for "no previous
72+ // version", in which case we create the initial version.
73+ maxVersion, err := s.q.GetMaxFileVersionByPath(ctx, path)
68 if err != nil {
74 if err != nil {
75+ if errors.Is(err, sql.ErrNoRows) {
76+ // No previous versions, create initial
77+ return s.Create(ctx, sessionID, path, content)
78+ }
69 return File{}, err
79 return File{}, err
70 }
80 }
71
81
72− if len(files) == 0 {
73− // No previous versions, create initial
74− return s.Create(ctx, sessionID, path, content)
75− }
76−
77− // Get the latest version
78− latestFile := files[0] // Files are ordered by version DESC, created_at DESC
79− nextVersion := latestFile.Version + 1
82+ nextVersion := maxVersion + 1
80
83
81 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
84 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
82}
85}
@@ -108,7 +111,7 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
108 })
111 })
109 if txErr != nil {
112 if txErr != nil {
110 // Rollback the transaction
113 // Rollback the transaction
111− tx.Rollback()
114+ _ = tx.Rollback()
112
115
113 // Check if this is a uniqueness constraint violation
116 // Check if this is a uniqueness constraint violation
114 if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
117 if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
@@ -191,15 +194,17 @@ func (s *service) Delete(ctx context.Context, id string) error {
191}
194}
192
195
193func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
196func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
197+ // Fetch all files first so we can publish per-file DeletedEvent.
194 files, err := s.ListBySession(ctx, sessionID)
198 files, err := s.ListBySession(ctx, sessionID)
195 if err != nil {
199 if err != nil {
196 return err
200 return err
197 }
201 }
202+ // Single batch DELETE instead of N individual deletes.
203+ if err := s.q.DeleteSessionFiles(ctx, sessionID); err != nil {
204+ return err
205+ }
198 for _, file := range files {
206 for _, file := range files {
199− err = s.Delete(ctx, file.ID)
207+ s.Publish(pubsub.DeletedEvent, file)
200− if err != nil {
201− return err
202− }
203 }
208 }
204 return nil
209 return nil
205}
210}
internal/lsp/client.go+5
@@ -53,6 +53,11 @@ type Client struct {
53 // Diagnostic cache
53 // Diagnostic cache
54 diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
54 diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
55
55
56+ // diagnosticsTotal is a running total of diagnostics across all files,
57+ // maintained incrementally as diagnostics are set to avoid recounting
58+ // every file on each publishDiagnostics notification.
59+ diagnosticsTotal atomic.Int64
60+
56 // Cached diagnostic counts to avoid map copy on every UI render.
61 // Cached diagnostic counts to avoid map copy on every UI render.
57 diagCountsCache DiagnosticCounts
62 diagCountsCache DiagnosticCounts
58 diagCountsVersion uint64
63 diagCountsVersion uint64
internal/lsp/handlers.go+6−6
@@ -108,13 +108,13 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
108 return
108 return
109 }
109 }
110
110
111+ // Maintain the running total incrementally: subtract the previous
112+ // diagnostics for this URI (if any) and add the new ones. This avoids
113+ // recounting every file's diagnostics on each notification.
114+ previous, _ := client.diagnostics.Get(diagParams.URI)
111 client.diagnostics.Set(diagParams.URI, diagParams.Diagnostics)
115 client.diagnostics.Set(diagParams.URI, diagParams.Diagnostics)
112−
116+ delta := len(diagParams.Diagnostics) - len(previous)
113− // Calculate total diagnostic count
117+ totalCount := int(client.diagnosticsTotal.Add(int64(delta)))
114− totalCount := 0
115− for _, diagnostics := range client.diagnostics.Seq2() {
116− totalCount += len(diagnostics)
117− }
118
118
119 // Trigger callback if set
119 // Trigger callback if set
120 if client.onDiagnosticsChanged != nil {
120 if client.onDiagnosticsChanged != nil {
internal/lsp/manager.go+28−8
@@ -27,6 +27,7 @@ const unavailableRetryDelay = 30 * time.Second
27type Manager struct {
27type Manager struct {
28 clients *csync.Map[string, *Client]
28 clients *csync.Map[string, *Client]
29 unavailable *csync.Map[string, time.Time]
29 unavailable *csync.Map[string, time.Time]
30+ rootMarkers *csync.Map[string, bool]
30 cfg *config.ConfigStore
31 cfg *config.ConfigStore
31 manager *powernapconfig.Manager
32 manager *powernapconfig.Manager
32 callback func(name string, client *Client)
33 callback func(name string, client *Client)
@@ -36,7 +37,9 @@ type Manager struct {
36// NewManager creates a new LSP manager service.
37// NewManager creates a new LSP manager service.
37func NewManager(cfg *config.ConfigStore) *Manager {
38func NewManager(cfg *config.ConfigStore) *Manager {
38 manager := powernapconfig.NewManager()
39 manager := powernapconfig.NewManager()
39− manager.LoadDefaults()
40+ if err := manager.LoadDefaults(); err != nil {
41+ slog.Warn("failed to load LSP defaults", "error", err)
42+ }
40
43
41 // Merge user-configured LSPs into the manager.
44 // Merge user-configured LSPs into the manager.
42 for name, clientConfig := range cfg.Config().LSP {
45 for name, clientConfig := range cfg.Config().LSP {
@@ -63,6 +66,7 @@ func NewManager(cfg *config.ConfigStore) *Manager {
63 return &Manager{
66 return &Manager{
64 clients: csync.NewMap[string, *Client](),
67 clients: csync.NewMap[string, *Client](),
65 unavailable: csync.NewMap[string, time.Time](),
68 unavailable: csync.NewMap[string, time.Time](),
69+ rootMarkers: csync.NewMap[string, bool](),
66 cfg: cfg,
70 cfg: cfg,
67 manager: manager,
71 manager: manager,
68 callback: func(string, *Client) {}, // default no-op callback
72 callback: func(string, *Client) {}, // default no-op callback
@@ -168,6 +172,14 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
168 }
172 }
169 }
173 }
170
174
175+ // Cheap, pure string check first: if this server does not handle the
176+ // file type at all, skip the expensive PATH lookup and workspace
177+ // root-marker glob below. This gates the costly I/O on the zero-I/O
178+ // filetype match, without changing whether the server ultimately starts.
179+ if !handlesFiletype(server.Command, server.FileTypes, filepath) {
180+ return
181+ }
182+
171 if !isUserConfigured {
183 if !isUserConfigured {
172 if s.recentlyUnavailable(name) {
184 if s.recentlyUnavailable(name) {
173 return
185 return
@@ -184,8 +196,8 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
184 }
196 }
185 }
197 }
186
198
187− // this is the slowest bit, so we do it last.
199+ // hasRootMarkers is the slowest bit (a workspace glob), so we do it last.
188− if !handles(server, filepath, s.cfg.WorkingDir()) {
200+ if !s.hasRootMarkers(s.cfg.WorkingDir(), server.RootMarkers) {
189 // nothing to do
201 // nothing to do
190 return
202 return
191 }
203 }
@@ -329,6 +341,19 @@ func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
329 return false
341 return false
330}
342}
331
343
344+// hasRootMarkers reports whether dir contains any of the given root
345+// markers. The result is invariant for a fixed (dir, markers) pair, so it
346+// is memoized to avoid re-running the workspace glob on every file view.
347+func (s *Manager) hasRootMarkers(dir string, markers []string) bool {
348+ if len(markers) == 0 {
349+ return true
350+ }
351+ key := dir + "\x00" + strings.Join(markers, "\x00")
352+ return s.rootMarkers.GetOrSet(key, func() bool {
353+ return hasRootMarkers(dir, markers)
354+ })
355+}
356+
332func hasRootMarkers(dir string, markers []string) bool {
357func hasRootMarkers(dir string, markers []string) bool {
333 if len(markers) == 0 {
358 if len(markers) == 0 {
334 return true
359 return true
@@ -345,11 +370,6 @@ func hasRootMarkers(dir string, markers []string) bool {
345 return false
370 return false
346}
371}
347
372
348−func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
349− return handlesFiletype(server.Command, server.FileTypes, filePath) &&
350− hasRootMarkers(workDir, server.RootMarkers)
351−}
352−
353// KillAll force-kills all the LSP clients.
373// KillAll force-kills all the LSP clients.
354//
374//
355// This is generally faster than [Manager.StopAll] because it doesn't wait for
375// This is generally faster than [Manager.StopAll] because it doesn't wait for
internal/lsp/util/edit.go+41−34
@@ -4,6 +4,7 @@ import (
4 "bytes"
4 "bytes"
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7+ "slices"
7 "sort"
8 "sort"
8 "strings"
9 "strings"
9
10
@@ -37,28 +38,40 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit, encodin
37 // Split into lines without the endings
38 // Split into lines without the endings
38 lines := strings.Split(string(content), lineEnding)
39 lines := strings.Split(string(content), lineEnding)
39
40
40− // Check for overlapping edits
41+ // Sort edits once by ascending start position (end position as a stable
41− for i, edit1 := range edits {
42+ // tiebreaker). This lets us both detect overlaps in a single linear pass
42− for j := i + 1; j < len(edits); j++ {
43+ // and apply edits from the bottom of the file upward.
43− if rangesOverlap(edit1.Range, edits[j].Range) {
44− return fmt.Errorf("overlapping edits detected between edit %d and %d", i, j)
45− }
46− }
47− }
48−
49− // Sort edits in reverse order
50 sortedEdits := make([]protocol.TextEdit, len(edits))
44 sortedEdits := make([]protocol.TextEdit, len(edits))
51 copy(sortedEdits, edits)
45 copy(sortedEdits, edits)
52 sort.Slice(sortedEdits, func(i, j int) bool {
46 sort.Slice(sortedEdits, func(i, j int) bool {
47+ a, b := sortedEdits[i].Range, sortedEdits[j].Range
53− if sortedEdits[i].Range.Start.Line != sortedEdits[j].Range.Start.Line {
48+ if a.Start.Line != b.Start.Line {
54− return sortedEdits[i].Range.Start.Line > sortedEdits[j].Range.Start.Line
49+ return a.Start.Line < b.Start.Line
55 }
50 }
56− return sortedEdits[i].Range.Start.Character > sortedEdits[j].Range.Start.Character
51+ if a.Start.Character != b.Start.Character {
52+ return a.Start.Character < b.Start.Character
53+ }
54+ if a.End.Line != b.End.Line {
55+ return a.End.Line < b.End.Line
56+ }
57+ return a.End.Character < b.End.Character
57 })
58 })
58
59
59− // Apply each edit
60+ // Detect overlapping edits in a single linear pass. Because the edits are
61+ // sorted by ascending start, any overlap manifests between two adjacent
62+ // edits, so checking neighbors is sufficient.
63+ for i := 1; i < len(sortedEdits); i++ {
64+ if rangesOverlap(sortedEdits[i-1].Range, sortedEdits[i].Range) {
65+ return fmt.Errorf("overlapping edits detected between edit %d and %d", i-1, i)
66+ }
67+ }
68+
69+ // Apply edits from the bottom of the file upward so that earlier edits'
70+ // line/character offsets are not shifted by later ones. applyTextEdit
71+ // splices the affected range in place instead of rebuilding the whole
72+ // slice per edit.
60− for _, edit := range sortedEdits {
73+ for i := len(sortedEdits) - 1; i >= 0; i-- {
61− newLines, err := applyTextEdit(lines, edit, encoding)
74+ newLines, err := applyTextEdit(lines, sortedEdits[i], encoding)
62 if err != nil {
75 if err != nil {
63 return fmt.Errorf("failed to apply edit: %w", err)
76 return fmt.Errorf("failed to apply edit: %w", err)
64 }
77 }
@@ -118,12 +131,6 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
118 endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character)
131 endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character)
119 }
132 }
120
133
121− // Create result slice with initial capacity
122− result := make([]string, 0, len(lines))
123−
124− // Copy lines before edit
125− result = append(result, lines[:startLine]...)
126−
127 // Get the prefix of the start line
134 // Get the prefix of the start line
128 startLineContent := lines[startLine]
135 startLineContent := lines[startLine]
129 if startChar < 0 || startChar > len(startLineContent) {
136 if startChar < 0 || startChar > len(startLineContent) {
@@ -138,10 +145,11 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
138 }
145 }
139 suffix := endLineContent[endChar:]
146 suffix := endLineContent[endChar:]
140
147
141− // Handle the edit
148+ // Build the replacement lines for the edited range [startLine, endLine].
149+ var region []string
142 if edit.NewText == "" {
150 if edit.NewText == "" {
143 if prefix+suffix != "" {
151 if prefix+suffix != "" {
144− result = append(result, prefix+suffix)
152+ region = []string{prefix + suffix}
145 }
153 }
146 } else {
154 } else {
147 // Split new text into lines, being careful not to add extra newlines
155 // Split new text into lines, being careful not to add extra newlines
@@ -150,21 +158,20 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
150
158
151 if len(newLines) == 1 {
159 if len(newLines) == 1 {
152 // Single line change
160 // Single line change
153− result = append(result, prefix+newLines[0]+suffix)
161+ region = []string{prefix + newLines[0] + suffix}
154 } else {
162 } else {
155 // Multi-line change
163 // Multi-line change
164+ region = make([]string, 0, len(newLines))
156− result = append(result, prefix+newLines[0])
165+ region = append(region, prefix+newLines[0])
157− result = append(result, newLines[1:len(newLines)-1]...)
166+ region = append(region, newLines[1:len(newLines)-1]...)
158− result = append(result, newLines[len(newLines)-1]+suffix)
167+ region = append(region, newLines[len(newLines)-1]+suffix)
159 }
168 }
160 }
169 }
161
170
162− // Add remaining lines
171+ // Splice the replacement in place instead of rebuilding the whole slice.
163− if endLine+1 < len(lines) {
172+ // Callers apply edits from the bottom of the file upward, so mutating the
164− result = append(result, lines[endLine+1:]...)
173+ // shared slice does not disturb not-yet-applied (earlier) edits.
165− }
174+ return slices.Replace(lines, startLine, endLine+1, region...), nil
166−
167− return result, nil
168}
175}
169
176
170// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
177// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
internal/message/content.go+69−23
@@ -171,6 +171,18 @@ type Message struct {
171 CreatedAt int64
171 CreatedAt int64
172 UpdatedAt int64
172 UpdatedAt int64
173 IsSummaryMessage bool
173 IsSummaryMessage bool
174+
175+ // textBuf and reasoningBuf accumulate streaming deltas in amortized
176+ // O(1) so building a long response is O(n) rather than O(n²) (the
177+ // naive `existing + delta` reallocated and copied the whole
178+ // accumulated string on every token). They are nil until the first
179+ // delta, unexported (never persisted), and deliberately dropped by
180+ // Clone: only the streaming message that owns them ever appends, so
181+ // no other goroutine touches the builder. Snapshots observe an
182+ // immutable prefix of the builder's append-only buffer, which is
183+ // race-free to read concurrently.
184+ textBuf *strings.Builder
185+ reasoningBuf *strings.Builder
174}
186}
175
187
176func (m *Message) Content() TextContent {
188func (m *Message) Content() TextContent {
@@ -221,6 +233,21 @@ func (m *Message) ToolCalls() []ToolCall {
221 return toolCalls
233 return toolCalls
222}
234}
223
235
236+// toolCallStats returns the number of tool-call parts and how many of
237+// them are finished, in a single pass without allocating (unlike
238+// ToolCalls, which builds a slice). Used on the streaming hot path.
239+func (m *Message) toolCallStats() (count, finished int) {
240+ for _, part := range m.Parts {
241+ if c, ok := part.(ToolCall); ok {
242+ count++
243+ if c.Finished {
244+ finished++
245+ }
246+ }
247+ }
248+ return count, finished
249+}
250+
224func (m *Message) ToolResults() []ToolResult {
251func (m *Message) ToolResults() []ToolResult {
225 toolResults := make([]ToolResult, 0)
252 toolResults := make([]ToolResult, 0)
226 for _, part := range m.Parts {
253 for _, part := range m.Parts {
@@ -266,37 +293,51 @@ func (m *Message) IsThinking() bool {
266}
293}
267
294
268func (m *Message) AppendContent(delta string) {
295func (m *Message) AppendContent(delta string) {
269− found := false
296+ if m.textBuf == nil {
297+ m.textBuf = &strings.Builder{}
298+ // Seed from any pre-existing text part (e.g. a restored message);
299+ // streaming messages start empty so this is normally a no-op.
270− for i, part := range m.Parts {
300+ for _, part := range m.Parts {
271− if c, ok := part.(TextContent); ok {
301+ if c, ok := part.(TextContent); ok {
272− m.Parts[i] = TextContent{Text: c.Text + delta}
302+ m.textBuf.WriteString(c.Text)
273− found = true
303+ break
304+ }
274 }
305 }
275 }
306 }
276− if !found {
307+ m.textBuf.WriteString(delta)
308+ text := m.textBuf.String()
309+ for i, part := range m.Parts {
310+ if _, ok := part.(TextContent); ok {
277− m.Parts = append(m.Parts, TextContent{Text: delta})
311+ m.Parts[i] = TextContent{Text: text}
312+ return
313+ }
278 }
314 }
315+ m.Parts = append(m.Parts, TextContent{Text: text})
279}
316}
280
317
281func (m *Message) AppendReasoningContent(delta string) {
318func (m *Message) AppendReasoningContent(delta string) {
282− found := false
319+ if m.reasoningBuf == nil {
320+ m.reasoningBuf = &strings.Builder{}
283− for i, part := range m.Parts {
321+ for _, part := range m.Parts {
284− if c, ok := part.(ReasoningContent); ok {
322+ if c, ok := part.(ReasoningContent); ok {
285− m.Parts[i] = ReasoningContent{
323+ m.reasoningBuf.WriteString(c.Thinking)
286− Thinking: c.Thinking + delta,
324+ break
287− Signature: c.Signature,
288− StartedAt: c.StartedAt,
289− FinishedAt: c.FinishedAt,
290 }
325 }
291− found = true
292 }
326 }
293 }
327 }
294− if !found {
328+ m.reasoningBuf.WriteString(delta)
329+ thinking := m.reasoningBuf.String()
330+ for i, part := range m.Parts {
295− m.Parts = append(m.Parts, ReasoningContent{
331+ if c, ok := part.(ReasoningContent); ok {
296− Thinking: delta,
332+ c.Thinking = thinking
297− StartedAt: time.Now().Unix(),
333+ m.Parts[i] = c
334+ return
298− })
335+ }
299 }
336 }
337+ m.Parts = append(m.Parts, ReasoningContent{
338+ Thinking: thinking,
339+ StartedAt: time.Now().Unix(),
340+ })
300}
341}
301
342
302func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
343func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
@@ -450,6 +491,10 @@ func (m *Message) Clone() Message {
450 clone := *m
491 clone := *m
451 clone.Parts = make([]ContentPart, len(m.Parts))
492 clone.Parts = make([]ContentPart, len(m.Parts))
452 copy(clone.Parts, m.Parts)
493 copy(clone.Parts, m.Parts)
494+ // Snapshots never stream; drop the accumulation builders so only the
495+ // owning message ever appends to them (keeps them single-goroutine).
496+ clone.textBuf = nil
497+ clone.reasoningBuf = nil
453 return clone
498 return clone
454}
499}
455
500
@@ -502,8 +547,9 @@ func (m *Message) ToAIMessage() []fantasy.Message {
502 case User:
547 case User:
503 var parts []fantasy.MessagePart
548 var parts []fantasy.MessagePart
504 text := strings.TrimSpace(m.Content().Text)
549 text := strings.TrimSpace(m.Content().Text)
550+ binaryContent := m.BinaryContent()
505 var textAttachments []Attachment
551 var textAttachments []Attachment
506− for _, content := range m.BinaryContent() {
552+ for _, content := range binaryContent {
507 if !strings.HasPrefix(content.MIMEType, "text/") {
553 if !strings.HasPrefix(content.MIMEType, "text/") {
508 continue
554 continue
509 }
555 }
@@ -526,7 +572,7 @@ func (m *Message) ToAIMessage() []fantasy.Message {
526 if text != "" {
572 if text != "" {
527 parts = append(parts, fantasy.TextPart{Text: text})
573 parts = append(parts, fantasy.TextPart{Text: text})
528 }
574 }
529− for _, content := range m.BinaryContent() {
575+ for _, content := range binaryContent {
530 // skip text attachements
576 // skip text attachements
531 if strings.HasPrefix(content.MIMEType, "text/") {
577 if strings.HasPrefix(content.MIMEType, "text/") {
532 continue
578 continue
internal/message/message.go+102−56
@@ -49,6 +49,7 @@ type Service interface {
49 Update(ctx context.Context, message Message) error
49 Update(ctx context.Context, message Message) error
50 Get(ctx context.Context, id string) (Message, error)
50 Get(ctx context.Context, id string) (Message, error)
51 List(ctx context.Context, sessionID string) ([]Message, error)
51 List(ctx context.Context, sessionID string) ([]Message, error)
52+ ListAfter(ctx context.Context, sessionID, afterMessageID string) ([]Message, error)
52 ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
53 ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
53 ListAllUserMessages(ctx context.Context) ([]Message, error)
54 ListAllUserMessages(ctx context.Context) ([]Message, error)
54 Delete(ctx context.Context, id string) error
55 Delete(ctx context.Context, id string) error
@@ -85,6 +86,11 @@ type pendingState struct {
85 // but other flushers must back off.
86 // but other flushers must back off.
86 flushing bool
87 flushing bool
87
88
89+ // flushDone is non-nil exactly while flushing is true. It is closed
90+ // (under service.mu) when the in-flight flush completes, so a sync
91+ // caller can wait on it instead of polling.
92+ flushDone chan struct{}
93+
88 // timer is the active debounce timer, or nil if no flush is
94 // timer is the active debounce timer, or nil if no flush is
89 // scheduled. Stopped and reset when a terminal update preempts
95 // scheduled. Stopped and reset when a terminal update preempts
90 // the debounce window.
96 // the debounce window.
@@ -196,18 +202,34 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
196}
202}
197
203
198func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
204func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
205+ // Fetch all messages first so we can publish per-message DeletedEvent
206+ // for each (the UI uses them to remove individual chat entries).
199 messages, err := s.List(ctx, sessionID)
207 messages, err := s.List(ctx, sessionID)
200 if err != nil {
208 if err != nil {
201 return err
209 return err
202 }
210 }
211+
212+ // Single batch DELETE instead of N individual deletes.
213+ if err := s.q.DeleteSessionMessages(ctx, sessionID); err != nil {
214+ return err
215+ }
216+
217+ // Drop any pending coalesced state and publish events for the
218+ // already-fetched messages — no redundant Get queries needed.
219+ s.mu.Lock()
203− for _, message := range messages {
220+ for _, msg := range messages {
204− if message.SessionID == sessionID {
221+ if p, ok := s.pending[msg.ID]; ok {
205− err = s.Delete(ctx, message.ID)
206− if err != nil {
222+ if p.timer != nil {
207− return err
223+ p.timer.Stop()
208 }
224 }
225+ delete(s.pending, msg.ID)
209 }
226 }
210 }
227 }
228+ s.mu.Unlock()
229+
230+ for _, msg := range messages {
231+ s.Publish(pubsub.DeletedEvent, msg.Clone())
232+ }
211 return nil
233 return nil
212}
234}
213
235
@@ -324,9 +346,17 @@ func (s *service) flushOne(ctx context.Context, id string, syncCaller bool) erro
324 s.mu.Unlock()
346 s.mu.Unlock()
325 return nil
347 return nil
326 }
348 }
349+ // Wait for the in-flight flush to finish rather than
350+ // polling. flushDone is closed under s.mu when flushing
351+ // clears, and we capture it under the same lock, so the
352+ // wakeup can never be missed.
353+ done := p.flushDone
327 s.mu.Unlock()
354 s.mu.Unlock()
328− // Brief yield; in-flight write should land in <1ms typical.
355+ select {
329− time.Sleep(time.Millisecond)
356+ case <-done:
357+ case <-ctx.Done():
358+ return ctx.Err()
359+ }
330 continue
360 continue
331 }
361 }
332 if !p.dirty {
362 if !p.dirty {
@@ -349,6 +379,7 @@ func (s *service) flushOne(ctx context.Context, id string, syncCaller bool) erro
349 }
379 }
350 isTerminal := shouldFlushNow(prev, &snap)
380 isTerminal := shouldFlushNow(prev, &snap)
351 p.flushing = true
381 p.flushing = true
382+ p.flushDone = make(chan struct{})
352 p.dirty = false
383 p.dirty = false
353 s.mu.Unlock()
384 s.mu.Unlock()
354
385
@@ -356,6 +387,10 @@ func (s *service) flushOne(ctx context.Context, id string, syncCaller bool) erro
356
387
357 s.mu.Lock()
388 s.mu.Lock()
358 p.flushing = false
389 p.flushing = false
390+ if p.flushDone != nil {
391+ close(p.flushDone)
392+ p.flushDone = nil
393+ }
359 if err == nil {
394 if err == nil {
360 p.lastFlushed = snap
395 p.lastFlushed = snap
361 p.hasFlushed = true
396 p.hasFlushed = true
@@ -420,25 +455,20 @@ func shouldFlushNow(prev, next *Message) bool {
420 return true
455 return true
421 }
456 }
422
457
423− var prevCalls []ToolCall
458+ var prevCount, prevFinished int
424 var prevReasoningFinishedAt int64
459 var prevReasoningFinishedAt int64
425 if prev != nil {
460 if prev != nil {
426− prevCalls = prev.ToolCalls()
461+ prevCount, prevFinished = prev.toolCallStats()
427 prevReasoningFinishedAt = prev.ReasoningContent().FinishedAt
462 prevReasoningFinishedAt = prev.ReasoningContent().FinishedAt
428 }
463 }
429− nextCalls := next.ToolCalls()
464+ nextCount, nextFinished := next.toolCallStats()
430− if len(nextCalls) != len(prevCalls) {
465+ // The tool-call set grew, or a call transitioned to finished. Tool
466+ // calls never un-finish and the count only grows, so comparing the
467+ // finished counts is equivalent to the old per-index Finished check
468+ // but allocation-free (a call's Input only matters once Finished).
469+ if nextCount != prevCount || nextFinished != prevFinished {
431 return true
470 return true
432 }
471 }
433− for i := range nextCalls {
434− // Bounds-safe: lengths are equal here.
435− if nextCalls[i].Finished != prevCalls[i].Finished {
436− return true
437− }
438− // A tool call's input only matters once it has landed (Finished
439− // flips true). Earlier deltas to Input are debounced with the
440− // rest of the streaming state.
441− }
442 if next.ReasoningContent().FinishedAt > 0 && prevReasoningFinishedAt == 0 {
472 if next.ReasoningContent().FinishedAt > 0 && prevReasoningFinishedAt == 0 {
443 return true
473 return true
444 }
474 }
@@ -468,6 +498,24 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
468 return messages, nil
498 return messages, nil
469}
499}
470
500
501+func (s *service) ListAfter(ctx context.Context, sessionID, afterMessageID string) ([]Message, error) {
502+ dbMessages, err := s.q.ListMessagesBySessionAfter(ctx, db.ListMessagesBySessionAfterParams{
503+ SessionID: sessionID,
504+ ID: afterMessageID,
505+ })
506+ if err != nil {
507+ return nil, err
508+ }
509+ messages := make([]Message, len(dbMessages))
510+ for i, dbMessage := range dbMessages {
511+ messages[i], err = s.fromDBItem(dbMessage)
512+ if err != nil {
513+ return nil, err
514+ }
515+ }
516+ return messages, nil
517+}
518+
471func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) {
519func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) {
472 dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID)
520 dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID)
473 if err != nil {
521 if err != nil {
@@ -570,76 +618,74 @@ func marshalParts(parts []ContentPart) ([]byte, error) {
570}
618}
571
619
572func unmarshalParts(data []byte) ([]ContentPart, error) {
620func unmarshalParts(data []byte) ([]ContentPart, error) {
573− temp := []json.RawMessage{}
621+ // Single-pass unmarshal into a wrapper with Data as json.RawMessage,
622+ // avoiding the intermediate []json.RawMessage + per-element re-decode.
623+ var temp []struct {
624+ Type partType `json:"type"`
625+ Data json.RawMessage `json:"data"`
626+ }
574
627
575 if err := json.Unmarshal(data, &temp); err != nil {
628 if err := json.Unmarshal(data, &temp); err != nil {
576 return nil, err
629 return nil, err
577 }
630 }
578
631
579− parts := make([]ContentPart, 0)
632+ parts := make([]ContentPart, 0, len(temp))
580−
581− for _, rawPart := range temp {
582− var wrapper struct {
583− Type partType `json:"type"`
584− Data json.RawMessage `json:"data"`
585− }
586−
587− if err := json.Unmarshal(rawPart, &wrapper); err != nil {
588− return nil, err
589− }
590
633
634+ for _, wrapper := range temp {
635+ var part ContentPart
591 switch wrapper.Type {
636 switch wrapper.Type {
592 case reasoningType:
637 case reasoningType:
593− part := ReasoningContent{}
638+ p := ReasoningContent{}
594− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
639+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
595 return nil, err
640 return nil, err
596 }
641 }
597− parts = append(parts, part)
642+ part = p
598 case textType:
643 case textType:
599− part := TextContent{}
644+ p := TextContent{}
600− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
645+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
601 return nil, err
646 return nil, err
602 }
647 }
603− parts = append(parts, part)
648+ part = p
604 case imageURLType:
649 case imageURLType:
605− part := ImageURLContent{}
650+ p := ImageURLContent{}
606− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
651+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
607 return nil, err
652 return nil, err
608 }
653 }
609− parts = append(parts, part)
654+ part = p
610 case binaryType:
655 case binaryType:
611− part := BinaryContent{}
656+ p := BinaryContent{}
612− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
657+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
613 return nil, err
658 return nil, err
614 }
659 }
615− parts = append(parts, part)
660+ part = p
616 case toolCallType:
661 case toolCallType:
617− part := ToolCall{}
662+ p := ToolCall{}
618− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
663+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
619 return nil, err
664 return nil, err
620 }
665 }
621− parts = append(parts, part)
666+ part = p
622 case toolResultType:
667 case toolResultType:
623− part := ToolResult{}
668+ p := ToolResult{}
624− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
669+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
625 return nil, err
670 return nil, err
626 }
671 }
627− parts = append(parts, part)
672+ part = p
628 case finishType:
673 case finishType:
629− part := Finish{}
674+ p := Finish{}
630− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
675+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
631 return nil, err
676 return nil, err
632 }
677 }
633− parts = append(parts, part)
678+ part = p
634 case shellCommandType:
679 case shellCommandType:
635− part := ShellCommand{}
680+ p := ShellCommand{}
636− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
681+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
637 return nil, err
682 return nil, err
638 }
683 }
639− parts = append(parts, part)
684+ part = p
640 default:
685 default:
641 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
686 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
642 }
687 }
688+ parts = append(parts, part)
643 }
689 }
644
690
645 return parts, nil
691 return parts, nil
internal/oauth/copilot/client.go+22−7
@@ -17,14 +17,20 @@ var assistantRolePattern = regexp.MustCompile(`"role"\s*:\s*"assistant"`)
17// NewClient creates a new HTTP client with a custom transport that adds the
17// NewClient creates a new HTTP client with a custom transport that adds the
18// X-Initiator header based on message history in the request body.
18// X-Initiator header based on message history in the request body.
19func NewClient(isSubAgent, debug bool) *http.Client {
19func NewClient(isSubAgent, debug bool) *http.Client {
20+ t := &initiatorTransport{debug: debug, isSubAgent: isSubAgent}
21+ if debug {
22+ // Build the debug HTTP client once and reuse it on every RoundTrip.
23+ t.debugClient = log.NewHTTPClient()
24+ }
20 return &http.Client{
25 return &http.Client{
21− Transport: &initiatorTransport{debug: debug, isSubAgent: isSubAgent},
26+ Transport: t,
22 }
27 }
23}
28}
24
29
25type initiatorTransport struct {
30type initiatorTransport struct {
26− debug bool
31+ debug bool
27− isSubAgent bool
32+ isSubAgent bool
33+ debugClient *http.Client
28}
34}
29
35
30func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
36func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -37,8 +43,17 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
37 if req == nil {
43 if req == nil {
38 return nil, fmt.Errorf("HTTP request is nil")
44 return nil, fmt.Errorf("HTTP request is nil")
39 }
45 }
46+ // Sub-agents are always attributed to the agent initiator regardless of
47+ // body content, so short-circuit before reading/cloning the body.
48+ if t.isSubAgent {
49+ req.Header.Set(xInitiatorHeader, agentInitiator)
50+ slog.Debug("Setting X-Initiator header to agent (sub-agent)")
51+ return t.roundTrip(req)
52+ }
40− if req.Body == http.NoBody {
53+ if req.Body == nil || req.Body == http.NoBody {
41− // No body to inspect; default to user.
54+ // No body to inspect; default to user. A nil Body is valid for
55+ // bodyless requests (e.g. GET), and is distinct from http.NoBody,
56+ // so both must be handled before reading below.
42 req.Header.Set(xInitiatorHeader, userInitiator)
57 req.Header.Set(xInitiatorHeader, userInitiator)
43 slog.Debug("Setting X-Initiator header to user (no request body)")
58 slog.Debug("Setting X-Initiator header to user (no request body)")
44 return t.roundTrip(req)
59 return t.roundTrip(req)
@@ -60,7 +75,7 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
60 // Check for assistant messages using regex to handle whitespace
75 // Check for assistant messages using regex to handle whitespace
61 // variations in the JSON while avoiding full unmarshalling overhead.
76 // variations in the JSON while avoiding full unmarshalling overhead.
62 initiator := userInitiator
77 initiator := userInitiator
63− if assistantRolePattern.Match(bodyBytes) || t.isSubAgent {
78+ if assistantRolePattern.Match(bodyBytes) {
64 slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)")
79 slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)")
65 initiator = agentInitiator
80 initiator = agentInitiator
66 } else {
81 } else {
@@ -73,7 +88,7 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
73
88
74func (t *initiatorTransport) roundTrip(req *http.Request) (*http.Response, error) {
89func (t *initiatorTransport) roundTrip(req *http.Request) (*http.Response, error) {
75 if t.debug {
90 if t.debug {
76− return log.NewHTTPClient().Transport.RoundTrip(req)
91+ return t.debugClient.Transport.RoundTrip(req)
77 }
92 }
78 return http.DefaultTransport.RoundTrip(req)
93 return http.DefaultTransport.RoundTrip(req)
79}
94}
internal/oauth/copilot/client_test.gonew file+70
@@ -0,0 +1,70 @@
1+package copilot
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestInitiatorTransportSetsHeader(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ // body builds the request body; nil means a bodyless request.
16+ body func() (int, string)
17+ want string
18+ }{
19+ {
20+ name: "nil body defaults to user",
21+ body: func() (int, string) { return 0, "" }, // req.Body == nil
22+ want: "user",
23+ },
24+ {
25+ name: "NoBody defaults to user",
26+ body: func() (int, string) { return -1, "" }, // req.Body == http.NoBody
27+ want: "user",
28+ },
29+ {
30+ name: "user-only history stays user",
31+ body: func() (int, string) { return 1, `{"messages":[{"role":"user"}]}` },
32+ want: "user",
33+ },
34+ {
35+ name: "assistant history becomes agent",
36+ body: func() (int, string) { return 1, `{"messages":[{"role":"assistant"}]}` },
37+ want: "agent",
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ var got string
44+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ got = r.Header.Get("X-Initiator")
46+ }))
47+ defer srv.Close()
48+
49+ kind, payload := tt.body()
50+ var req *http.Request
51+ var err error
52+ switch kind {
53+ case 0:
54+ req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
55+ case -1:
56+ req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, http.NoBody)
57+ default:
58+ req, err = http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader(payload))
59+ }
60+ require.NoError(t, err)
61+
62+ client := &http.Client{Transport: &initiatorTransport{}}
63+ resp, err := client.Do(req)
64+ require.NoError(t, err)
65+ resp.Body.Close()
66+
67+ require.Equal(t, tt.want, got)
68+ })
69+ }
70+}
internal/permission/permission.go+55−47
@@ -104,10 +104,10 @@ type permissionService struct {
104 skip atomic.Bool
104 skip atomic.Bool
105 allowedTools []string
105 allowedTools []string
106
106
107− // used to make sure we only process one request at a time
107+ // requestMu serializes the setup of a permission request (up to the
108+ // point the pending entry is registered); it is released before the
109+ // human wait so parallel requests don't queue behind one another.
108− requestMu sync.Mutex
110+ requestMu sync.Mutex
109− activeRequest *PermissionRequest
110− activeRequestMu sync.Mutex
111}
111}
112
112
113// resolve atomically removes the pending request entry for the given
113// resolve atomically removes the pending request entry for the given
@@ -146,12 +146,6 @@ func (s *permissionService) resolve(permission PermissionRequest, granted, denie
146 // per request because Take removes the entry under the map lock,
146 // per request because Take removes the entry under the map lock,
147 // so this send never blocks.
147 // so this send never blocks.
148 respCh <- granted
148 respCh <- granted
149−
150− s.activeRequestMu.Lock()
151− if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
152− s.activeRequest = nil
153− }
154− s.activeRequestMu.Unlock()
155 return true
149 return true
156}
150}
157
151
@@ -201,26 +195,17 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
201 return true, nil
195 return true, nil
202 }
196 }
203
197
204− s.requestMu.Lock()
198+ // Set up the request under requestMu, then release it *before*
205− defer s.requestMu.Unlock()
199+ // blocking on the human response. Holding requestMu across the wait
206−
200+ // would serialize every permission prompt process-wide, so parallel
207− // tell the UI that a permission was requested
201+ // sub-agents each needing approval would queue behind one another.
208− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
202+ // Per-request resolution is scoped by the pendingRequests map (keyed
209− ToolCallID: opts.ToolCallID,
203+ // by request ID), so the wait needs no global lock.
210− })
204+ // Resolve the target path and build the request before taking
211−
205+ // requestMu. os.Stat is a blocking syscall and none of this setup
212− s.autoApproveSessionsMu.RLock()
206+ // touches shared state (workingDir is immutable, uuid.New is
213− autoApprove := s.autoApproveSessions[opts.SessionID]
207+ // self-contained), so it does not need to serialize with other
214− s.autoApproveSessionsMu.RUnlock()
208+ // in-flight requests.
215−
216− if autoApprove {
217− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
218− ToolCallID: opts.ToolCallID,
219− Granted: true,
220− })
221− return true, nil
222− }
223−
224 fileInfo, err := os.Stat(opts.Path)
209 fileInfo, err := os.Stat(opts.Path)
225 dir := opts.Path
210 dir := opts.Path
226 if err == nil {
211 if err == nil {
@@ -245,29 +230,52 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
245 Params: opts.Params,
230 Params: opts.Params,
246 }
231 }
247
232
248− if _, ok := s.sessionPermissions.Get(PermissionKey{
233+ respCh, requestID, resolved, granted := func() (chan bool, string, bool, bool) {
249− SessionID: permission.SessionID,
234+ s.requestMu.Lock()
250− ToolName: permission.ToolName,
235+ defer s.requestMu.Unlock()
251− Action: permission.Action,
236+
252− Path: permission.Path,
237+ // tell the UI that a permission was requested
253− }); ok {
254 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
238 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
255 ToolCallID: opts.ToolCallID,
239 ToolCallID: opts.ToolCallID,
256− Granted: true,
257 })
240 })
258− return true, nil
259− }
260
241
261− s.activeRequestMu.Lock()
242+ s.autoApproveSessionsMu.RLock()
243+ autoApprove := s.autoApproveSessions[opts.SessionID]
244+ s.autoApproveSessionsMu.RUnlock()
245+
246+ if autoApprove {
247+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
248+ ToolCallID: opts.ToolCallID,
249+ Granted: true,
250+ })
251+ return nil, "", true, true
252+ }
253+
254+ if _, ok := s.sessionPermissions.Get(PermissionKey{
255+ SessionID: permission.SessionID,
256+ ToolName: permission.ToolName,
257+ Action: permission.Action,
262− s.activeRequest = &permission
258+ Path: permission.Path,
263− s.activeRequestMu.Unlock()
259+ }); ok {
260+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
261+ ToolCallID: opts.ToolCallID,
262+ Granted: true,
263+ })
264+ return nil, "", true, true
265+ }
266+
267+ respCh := make(chan bool, 1)
268+ s.pendingRequests.Set(permission.ID, respCh)
264
269
265− respCh := make(chan bool, 1)
270+ // Publish the request
266− s.pendingRequests.Set(permission.ID, respCh)
271+ s.Publish(pubsub.CreatedEvent, permission)
267− defer s.pendingRequests.Del(permission.ID)
272+ return respCh, permission.ID, false, false
273+ }()
268
274
269− // Publish the request
275+ if resolved {
270− s.Publish(pubsub.CreatedEvent, permission)
276+ return granted, nil
277+ }
278+ defer s.pendingRequests.Del(requestID)
271
279
272 select {
280 select {
273 case <-ctx.Done():
281 case <-ctx.Done():
internal/pubsub/broker.go+69−27
@@ -83,6 +83,12 @@ func (b *Broker[T]) SetMustDeliverTimeout(d time.Duration) {
83}
83}
84
84
85func (b *Broker[T]) Shutdown() {
85func (b *Broker[T]) Shutdown() {
86+ b.mu.Lock()
87+ defer b.mu.Unlock()
88+
89+ // Close b.done under the lock so concurrent Shutdown calls (and the
90+ // per-subscriber goroutines, which also close under the lock) cannot
91+ // double-close it and panic.
86 select {
92 select {
87 case <-b.done: // Already closed
93 case <-b.done: // Already closed
88 return
94 return
@@ -90,9 +96,6 @@ func (b *Broker[T]) Shutdown() {
90 close(b.done)
96 close(b.done)
91 }
97 }
92
98
93− b.mu.Lock()
94− defer b.mu.Unlock()
95−
96 for ch := range b.subs {
99 for ch := range b.subs {
97 delete(b.subs, ch)
100 delete(b.subs, ch)
98 close(ch)
101 close(ch)
@@ -118,11 +121,20 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
118 b.subCount++
121 b.subCount++
119
122
120 go func() {
123 go func() {
124+ // Wake on either the subscriber's context ending or the broker
125+ // shutting down. Without the b.done case, a subscriber with a
126+ // long-lived context would park here forever after Shutdown,
127+ // leaking the goroutine.
128+ select {
121− <-ctx.Done()
129+ case <-ctx.Done():
130+ case <-b.done:
131+ }
122
132
123 b.mu.Lock()
133 b.mu.Lock()
124 defer b.mu.Unlock()
134 defer b.mu.Unlock()
125
135
136+ // If the broker shut down, it already closed this channel under
137+ // the lock; don't close it again.
126 select {
138 select {
127 case <-b.done:
139 case <-b.done:
128 return
140 return
@@ -179,10 +191,16 @@ func (b *Broker[T]) Publish(t EventType, payload T) {
179 case sub <- event:
191 case sub <- event:
180 default:
192 default:
181 // Channel is full, subscriber is slow — skip this event.
193 // Channel is full, subscriber is slow — skip this event.
182− // Lossy by design; counted and logged so saturation is
194+ // Lossy by design; counted so saturation is observable via
183− // observable.
195+ // DropCount. The warning is throttled to power-of-two drop
196+ // counts (1, 2, 4, 8, …): saturation is exactly when this
197+ // path runs hottest, and an unthrottled per-event log — while
198+ // holding the RLock — would turn a slow subscriber into a
199+ // log-storm that slows every publisher further.
184− b.dropCount.Add(1)
200+ if n := b.dropCount.Add(1); n&(n-1) == 0 {
185− slog.Warn("Pubsub buffer full; dropping event", "type", t)
201+ slog.Warn("Pubsub buffer full; dropping events",
202+ "type", t, "dropped_total", n)
203+ }
186 }
204 }
187 }
205 }
188}
206}
@@ -199,6 +217,19 @@ func (b *Broker[T]) Publish(t EventType, payload T) {
199// after timeout — recovery is the subscriber's responsibility (e.g. a
217// after timeout — recovery is the subscriber's responsibility (e.g. a
200// re-fetch on the next session-visible event).
218// re-fetch on the next session-visible event).
201func (b *Broker[T]) PublishMustDeliver(ctx context.Context, t EventType, payload T) {
219func (b *Broker[T]) PublishMustDeliver(ctx context.Context, t EventType, payload T) {
220+ // Hold the read lock across the bounded-blocking sends below. Both
221+ // Shutdown and per-subscriber teardown close subscriber channels under
222+ // the write lock, so holding RLock here is what guarantees a send can
223+ // never race a close. The earlier lock-free snapshot released b.mu
224+ // before sending, which let Shutdown/unsubscribe close a channel
225+ // mid-send — a data race the race detector flags (the recover() that
226+ // used to sit in mustDeliver only masked the resulting panic).
227+ //
228+ // The cost is bounded and paid only under saturation: a stalled
229+ // subscriber can delay Subscribe/Shutdown by at most (number of
230+ // subscribers × mustDeliverTimeout). With this app's handful of
231+ // subscribers that ceiling is a few hundred milliseconds, and only
232+ // when a subscriber's buffer is already full.
202 b.mu.RLock()
233 b.mu.RLock()
203 defer b.mu.RUnlock()
234 defer b.mu.RUnlock()
204
235
@@ -210,27 +241,38 @@ func (b *Broker[T]) PublishMustDeliver(ctx context.Context, t EventType, payload
210
241
211 event := Event[T]{Type: t, Payload: payload}
242 event := Event[T]{Type: t, Payload: payload}
212 timeout := b.mustDeliverTimeout
243 timeout := b.mustDeliverTimeout
213−
214 for sub := range b.subs {
244 for sub := range b.subs {
215− // Fast path: non-blocking send.
245+ if b.mustDeliver(ctx, sub, event, timeout) {
216− select {
246+ // Context cancelled; stop delivering to the remaining subs.
217− case sub <- event:
218− continue
219− default:
220− }
221−
222− // Slow path: bounded blocking send.
223− timer := time.NewTimer(timeout)
224− select {
225− case sub <- event:
226− timer.Stop()
227− case <-timer.C:
228− b.mustDeliverDropCount.Add(1)
229− slog.Error("PublishMustDeliver timed out delivering event",
230− "type", t, "timeout", timeout)
231− case <-ctx.Done():
232− timer.Stop()
233 return
247 return
234 }
248 }
235 }
249 }
236}
250}
251+
252+// mustDeliver performs one bounded-blocking send. It reports whether the
253+// context was cancelled (so the caller stops delivering to remaining
254+// subscribers). The caller holds b.mu.RLock, and every subscriber-channel
255+// close happens under b.mu's write lock, so the channel cannot be closed
256+// concurrently with this send.
257+func (b *Broker[T]) mustDeliver(ctx context.Context, sub chan Event[T], event Event[T], timeout time.Duration) (ctxDone bool) {
258+ // Fast path: non-blocking send.
259+ select {
260+ case sub <- event:
261+ return false
262+ default:
263+ }
264+
265+ // Slow path: bounded blocking send.
266+ timer := time.NewTimer(timeout)
267+ defer timer.Stop()
268+ select {
269+ case sub <- event:
270+ case <-timer.C:
271+ b.mustDeliverDropCount.Add(1)
272+ slog.Error("PublishMustDeliver timed out delivering event",
273+ "type", event.Type, "timeout", timeout)
274+ case <-ctx.Done():
275+ return true
276+ }
277+ return false
278+}
internal/pubsub/broker_test.gonew file+117
@@ -0,0 +1,117 @@
1+package pubsub
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+)
9+
10+// TestConcurrentShutdown ensures concurrent Shutdown calls don't panic on a
11+// double-close of the done channel.
12+func TestConcurrentShutdown(t *testing.T) {
13+ t.Parallel()
14+ b := NewBroker[int]()
15+ ctx, cancel := context.WithCancel(context.Background())
16+ defer cancel()
17+ for range 8 {
18+ b.Subscribe(ctx)
19+ }
20+
21+ var wg sync.WaitGroup
22+ for range 16 {
23+ wg.Add(1)
24+ go func() {
25+ defer wg.Done()
26+ b.Shutdown()
27+ }()
28+ }
29+ wg.Wait()
30+}
31+
32+// TestPublishMustDeliverDuringShutdown ensures sending outside the lock is
33+// safe when a subscriber channel is closed concurrently.
34+func TestPublishMustDeliverDuringShutdown(t *testing.T) {
35+ t.Parallel()
36+ b := NewBrokerWithOptions[int](1)
37+ b.SetMustDeliverTimeout(time.Millisecond)
38+
39+ ctx, cancel := context.WithCancel(context.Background())
40+ defer cancel()
41+ for range 8 {
42+ b.Subscribe(ctx) // never drained, so buffers fill and sends block
43+ }
44+
45+ var wg sync.WaitGroup
46+ wg.Add(2)
47+ go func() {
48+ defer wg.Done()
49+ for range 50 {
50+ b.PublishMustDeliver(context.Background(), CreatedEvent, 1)
51+ }
52+ }()
53+ go func() {
54+ defer wg.Done()
55+ b.Shutdown()
56+ }()
57+ wg.Wait()
58+}
59+
60+// TestPublishMustDeliverDuringUnsubscribe ensures a send can't race a
61+// per-subscriber teardown: each subscriber is cancelled individually while
62+// PublishMustDeliver is delivering to it. Run under -race, this fails if the
63+// broker ever sends on a channel a teardown goroutine is concurrently closing.
64+func TestPublishMustDeliverDuringUnsubscribe(t *testing.T) {
65+ t.Parallel()
66+ b := NewBrokerWithOptions[int](1)
67+ b.SetMustDeliverTimeout(time.Millisecond)
68+ defer b.Shutdown()
69+
70+ var wg sync.WaitGroup
71+ wg.Add(1)
72+ go func() {
73+ defer wg.Done()
74+ for range 200 {
75+ b.PublishMustDeliver(context.Background(), CreatedEvent, 1)
76+ }
77+ }()
78+
79+ for range 8 {
80+ wg.Add(1)
81+ go func() {
82+ defer wg.Done()
83+ // Per-subscriber context: cancelling it tears the subscriber
84+ // down (removing + closing its channel) concurrently with the
85+ // publisher above. Never drained, so its buffer saturates and
86+ // sends block, widening the race window.
87+ ctx, cancel := context.WithCancel(context.Background())
88+ b.Subscribe(ctx)
89+ cancel()
90+ }()
91+ }
92+ wg.Wait()
93+}
94+
95+// TestSubscribeGoroutineExitsOnShutdown ensures the per-subscriber goroutine
96+// unblocks on Shutdown even when its context outlives the broker.
97+func TestSubscribeGoroutineExitsOnShutdown(t *testing.T) {
98+ t.Parallel()
99+ b := NewBroker[int]()
100+ // Background context: without the b.done wakeup this goroutine would
101+ // leak forever after Shutdown.
102+ ch := b.Subscribe(context.Background())
103+ b.Shutdown()
104+
105+ select {
106+ case _, ok := <-ch:
107+ if ok {
108+ t.Fatal("expected channel to be closed after Shutdown")
109+ }
110+ case <-time.After(time.Second):
111+ t.Fatal("subscriber channel not closed after Shutdown")
112+ }
113+
114+ if got := b.GetSubscriberCount(); got != 0 {
115+ t.Fatalf("subscriber count = %d, want 0", got)
116+ }
117+}
internal/server/proto.go+13−3
@@ -3,7 +3,6 @@ package server
3import (
3import (
4 "encoding/json"
4 "encoding/json"
5 "errors"
5 "errors"
6− "fmt"
7 "net/http"
6 "net/http"
8
7
9 "github.com/charmbracelet/crush/internal/backend"
8 "github.com/charmbracelet/crush/internal/backend"
@@ -306,7 +305,12 @@ func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.R
306 continue
305 continue
307 }
306 }
308
307
309− fmt.Fprintf(w, "data: %s\n\n", data)
308+ // Write the SSE frame in three chunks to avoid the
309+ // []byte->string copy that a "%s"-formatted write would
310+ // incur on the (potentially large) marshaled payload.
311+ _, _ = w.Write([]byte("data: "))
312+ _, _ = w.Write(data)
313+ _, _ = w.Write([]byte("\n\n"))
310 flusher.Flush()
314 flusher.Flush()
311 }
315 }
312 }
316 }
@@ -382,11 +386,17 @@ func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http
382 return
386 return
383 }
387 }
384 ws, _ := c.backend.GetWorkspace(id)
388 ws, _ := c.backend.GetWorkspace(id)
389+ // Snapshot the client->session attachment counts once instead of
390+ // re-scanning every client for each session (O(sessions x clients)).
391+ var attachedCounts map[string]int
392+ if ws != nil {
393+ attachedCounts = ws.AttachedClientCountsBySession()
394+ }
385 result := make([]proto.Session, len(sessions))
395 result := make([]proto.Session, len(sessions))
386 for i, s := range sessions {
396 for i, s := range sessions {
387 result[i] = sessionToProto(s)
397 result[i] = sessionToProto(s)
388 result[i].IsBusy = isSessionBusy(ws, s.ID)
398 result[i].IsBusy = isSessionBusy(ws, s.ID)
389− result[i].AttachedClients = attachedClients(ws, s.ID)
399+ result[i].AttachedClients = attachedCounts[s.ID]
390 }
400 }
391 jsonEncode(w, result)
401 jsonEncode(w, result)
392}
402}
internal/session/session.go+13−18
@@ -27,7 +27,7 @@ const (
27// HashID returns the XXH3 hash of a session ID (UUID) as a hex string.
27// HashID returns the XXH3 hash of a session ID (UUID) as a hex string.
28func HashID(id string) string {
28func HashID(id string) string {
29 h := xxh3.New()
29 h := xxh3.New()
30− h.WriteString(id)
30+ _, _ = h.WriteString(id)
31 return fmt.Sprintf("%x", h.Sum(nil))
31 return fmt.Sprintf("%x", h.Sum(nil))
32}
32}
33
33
@@ -223,29 +223,35 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
223// UpdateTitleAndUsage updates only the title and usage fields atomically.
223// UpdateTitleAndUsage updates only the title and usage fields atomically.
224// This is safer than fetching, modifying, and saving the entire session.
224// This is safer than fetching, modifying, and saving the entire session.
225func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
225func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
226− if err := s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
226+ dbSession, err := s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
227 ID: sessionID,
227 ID: sessionID,
228 Title: title,
228 Title: title,
229 PromptTokens: promptTokens,
229 PromptTokens: promptTokens,
230 CompletionTokens: completionTokens,
230 CompletionTokens: completionTokens,
231 Cost: cost,
231 Cost: cost,
232+ })
232− }); err != nil {
233+ if err != nil {
233 return err
234 return err
234 }
235 }
235− s.publishSessionUpdate(ctx, sessionID)
236+ session := s.fromDBItem(dbSession)
237+ s.applyEstimatedUsageState(&session)
238+ s.Publish(pubsub.UpdatedEvent, session)
236 return nil
239 return nil
237}
240}
238
241
239// Rename updates only the title of a session without touching updated_at or
242// Rename updates only the title of a session without touching updated_at or
240// usage fields.
243// usage fields.
241func (s *service) Rename(ctx context.Context, id string, title string) error {
244func (s *service) Rename(ctx context.Context, id string, title string) error {
242− if err := s.q.RenameSession(ctx, db.RenameSessionParams{
245+ dbSession, err := s.q.RenameSession(ctx, db.RenameSessionParams{
243 ID: id,
246 ID: id,
244 Title: title,
247 Title: title,
248+ })
245− }); err != nil {
249+ if err != nil {
246 return err
250 return err
247 }
251 }
248− s.publishSessionUpdate(ctx, id)
252+ session := s.fromDBItem(dbSession)
253+ s.applyEstimatedUsageState(&session)
254+ s.Publish(pubsub.UpdatedEvent, session)
249 return nil
255 return nil
250}
256}
251
257
@@ -262,17 +268,6 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
262 return sessions, nil
268 return sessions, nil
263}
269}
264
270
265−// publishSessionUpdate re-fetches a session and publishes an UpdatedEvent so
266−// that UI subscribers reflect title or usage changes.
267−func (s *service) publishSessionUpdate(ctx context.Context, sessionID string) {
268− session, err := s.Get(ctx, sessionID)
269− if err != nil {
270− slog.Error("Failed to re-fetch session for event publish", "error", err, "sessionID", sessionID)
271− return
272− }
273− s.Publish(pubsub.UpdatedEvent, session)
274−}
275−
276func (s *service) applyEstimatedUsageState(session *Session) {
271func (s *service) applyEstimatedUsageState(session *Session) {
277 s.estimatedUsageMu.RLock()
272 s.estimatedUsageMu.RLock()
278 session.EstimatedUsage = s.estimatedUsage[session.ID]
273 session.EstimatedUsage = s.estimatedUsage[session.ID]
internal/shell/background.go+1−1
@@ -185,7 +185,7 @@ func (m *BackgroundShellManager) Cleanup() int {
185 }
185 }
186
186
187 for _, id := range toRemove {
187 for _, id := range toRemove {
188− m.Remove(id)
188+ _ = m.Remove(id)
189 }
189 }
190
190
191 return len(toRemove)
191 return len(toRemove)
internal/skills/embed.go+1−1
@@ -32,7 +32,7 @@ func DiscoverBuiltinWithStates() ([]*Skill, []*SkillState) {
32 var discovered []*Skill
32 var discovered []*Skill
33 var states []*SkillState
33 var states []*SkillState
34
34
35− fs.WalkDir(builtinFS, "builtin", func(path string, d fs.DirEntry, err error) error {
35+ _ = fs.WalkDir(builtinFS, "builtin", func(path string, d fs.DirEntry, err error) error {
36 if err != nil {
36 if err != nil {
37 return nil
37 return nil
38 }
38 }
internal/skills/skills.go+30−1
@@ -34,6 +34,26 @@ var (
34 latestStatesMu sync.RWMutex
34 latestStatesMu sync.RWMutex
35)
35)
36
36
37+// ignoredWalkDirs is a set of directory names that skill discovery never
38+// descends into. Pruning these avoids walking large dependency/VCS trees that
39+// cannot contain user skills.
40+var ignoredWalkDirs = map[string]bool{
41+ "node_modules": true,
42+ ".git": true,
43+ ".svn": true,
44+ ".hg": true,
45+ "vendor": true,
46+ "dist": true,
47+ "build": true,
48+ "target": true,
49+ ".idea": true,
50+ ".vscode": true,
51+ "__pycache__": true,
52+ ".cache": true,
53+ "bin": true,
54+ "obj": true,
55+}
56+
37// Skill represents a parsed SKILL.md file.
57// Skill represents a parsed SKILL.md file.
38type Skill struct {
58type Skill struct {
39 Name string `yaml:"name" json:"name"`
59 Name string `yaml:"name" json:"name"`
@@ -249,7 +269,16 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
249 addState("", path, StateError, err)
269 addState("", path, StateError, err)
250 return nil
270 return nil
251 }
271 }
272+ if d.IsDir() {
273+ // Prune large/irrelevant directory trees so discovery does
274+ // not descend into dependency and VCS directories. Never
275+ // prune the walk root itself.
276+ if path != base && ignoredWalkDirs[d.Name()] {
277+ return filepath.SkipDir
278+ }
279+ return nil
280+ }
252− if d.IsDir() || d.Name() != SkillFileName {
281+ if d.Name() != SkillFileName {
253 return nil
282 return nil
254 }
283 }
255 mu.Lock()
284 mu.Lock()
internal/ui/attachments/attachments.go+2−2
@@ -132,8 +132,8 @@ func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width
132 )
132 )
133 }
133 }
134
134
135− if i == fits && len(attachments) > i {
135+ if i == fits && len(attachments) > i+1 {
136− chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
136+ chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits-1)))
137 break
137 break
138 }
138 }
139 }
139 }
internal/ui/chat/agent.go+53−22
@@ -2,6 +2,7 @@ package chat
2
2
3import (
3import (
4 "encoding/json"
4 "encoding/json"
5+ "fmt"
5 "strings"
6 "strings"
6
7
7 tea "charm.land/bubbletea/v2"
8 tea "charm.land/bubbletea/v2"
@@ -24,6 +25,18 @@ type NestedToolContainer interface {
24 AddNestedTool(tool ToolMessageItem)
25 AddNestedTool(tool ToolMessageItem)
25}
26}
26
27
28+// collapsedNestedHint is the affordance shown under a collapsed sub-agent
29+// card, indicating its nested tool calls can be revealed with space.
30+const collapsedNestedHint = " ▸ space to expand"
31+
32+// toolCountLabel renders a "N tools" summary for a sub-agent header line.
33+func toolCountLabel(n int) string {
34+ if n == 1 {
35+ return "1 tool"
36+ }
37+ return fmt.Sprintf("%d tools", n)
38+}
39+
27// AgentToolMessageItem is a message item that represents an agent tool call.
40// AgentToolMessageItem is a message item that represents an agent tool call.
28type AgentToolMessageItem struct {
41type AgentToolMessageItem struct {
29 *baseToolMessageItem
42 *baseToolMessageItem
@@ -44,7 +57,7 @@ func NewAgentToolMessageItem(
44 canceled bool,
57 canceled bool,
45) *AgentToolMessageItem {
58) *AgentToolMessageItem {
46 t := &AgentToolMessageItem{}
59 t := &AgentToolMessageItem{}
47− t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
60+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled, "")
48 // For the agent tool we keep spinning until the tool call is finished.
61 // For the agent tool we keep spinning until the tool call is finished.
49 t.spinningFunc = func(state SpinningState) bool {
62 t.spinningFunc = func(state SpinningState) bool {
50 return !state.HasResult() && !state.IsCanceled()
63 return !state.HasResult() && !state.IsCanceled()
@@ -139,6 +152,10 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
139 }
152 }
140
153
141 header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts)
154 header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts)
155+ nestedCount := len(r.agent.nestedTools)
156+ if nestedCount > 0 {
157+ header += " " + sty.Tool.ParamMain.Render(toolCountLabel(nestedCount))
158+ }
142 if opts.Compact {
159 if opts.Compact {
143 return header
160 return header
144 }
161 }
@@ -164,17 +181,22 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
164 ),
181 ),
165 )
182 )
166
183
167− // Build tree with nested tool calls.
184+ // Nested tool calls are collapsed by default to keep the conversation
168− childTools := tree.Root(header)
185+ // readable; press space to expand the full call tree.
169−
170− for _, nestedTool := range r.agent.nestedTools {
171− childView := nestedTool.Render(remainingWidth)
172− childTools.Child(childView)
173− }
174−
175− // Build parts.
176 var parts []string
186 var parts []string
187+ if opts.ExpandedContent {
188+ childTools := tree.Root(header)
189+ for _, nestedTool := range r.agent.nestedTools {
190+ childView := nestedTool.Render(remainingWidth)
191+ childTools.Child(childView)
192+ }
177− parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
193+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
194+ } else {
195+ parts = append(parts, header)
196+ if nestedCount > 0 {
197+ parts = append(parts, sty.Tool.AgentPrompt.Render(collapsedNestedHint))
198+ }
199+ }
178
200
179 // Show animation if still running.
201 // Show animation if still running.
180 if !opts.HasResult() && !opts.IsCanceled() {
202 if !opts.HasResult() && !opts.IsCanceled() {
@@ -216,7 +238,7 @@ func NewAgenticFetchToolMessageItem(
216 canceled bool,
238 canceled bool,
217) *AgenticFetchToolMessageItem {
239) *AgenticFetchToolMessageItem {
218 t := &AgenticFetchToolMessageItem{}
240 t := &AgenticFetchToolMessageItem{}
219− t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
241+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled, "")
220 // For the agentic fetch tool we keep spinning until the tool call is finished.
242 // For the agentic fetch tool we keep spinning until the tool call is finished.
221 t.spinningFunc = func(state SpinningState) bool {
243 t.spinningFunc = func(state SpinningState) bool {
222 return !state.HasResult() && !state.IsCanceled()
244 return !state.HasResult() && !state.IsCanceled()
@@ -308,6 +330,10 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
308 }
330 }
309
331
310 header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts, toolParams...)
332 header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts, toolParams...)
333+ nestedCount := len(r.fetch.nestedTools)
334+ if nestedCount > 0 {
335+ header += " " + sty.Tool.ParamMain.Render(toolCountLabel(nestedCount))
336+ }
311 if opts.Compact {
337 if opts.Compact {
312 return header
338 return header
313 }
339 }
@@ -333,17 +359,22 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
333 ),
359 ),
334 )
360 )
335
361
336− // Build tree with nested tool calls.
362+ // Nested tool calls are collapsed by default to keep the conversation
337− childTools := tree.Root(header)
363+ // readable; press space to expand the full call tree.
338−
339− for _, nestedTool := range r.fetch.nestedTools {
340− childView := nestedTool.Render(remainingWidth)
341− childTools.Child(childView)
342− }
343−
344− // Build parts.
345 var parts []string
364 var parts []string
365+ if opts.ExpandedContent {
366+ childTools := tree.Root(header)
367+ for _, nestedTool := range r.fetch.nestedTools {
368+ childView := nestedTool.Render(remainingWidth)
369+ childTools.Child(childView)
370+ }
346− parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
371+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
372+ } else {
373+ parts = append(parts, header)
374+ if nestedCount > 0 {
375+ parts = append(parts, sty.Tool.AgentPrompt.Render(collapsedNestedHint))
376+ }
377+ }
347
378
348 // Show animation if still running.
379 // Show animation if still running.
349 if !opts.HasResult() && !opts.IsCanceled() {
380 if !opts.HasResult() && !opts.IsCanceled() {
internal/ui/chat/assistant.go+313−85
@@ -1,9 +1,7 @@
1package chat
1package chat
2
2
3import (
3import (
4− "encoding/binary"
5 "fmt"
4 "fmt"
6− "hash/fnv"
7 "strings"
5 "strings"
8
6
9 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
@@ -25,7 +23,7 @@ const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to
25// promote the view to a full expansion. The promotion is wired through
23// promote the view to a full expansion. The promotion is wired through
26// the existing ToggleExpanded path (click / space) — F5 deliberately
24// the existing ToggleExpanded path (click / space) — F5 deliberately
27// does not add a new keybinding.
25// does not add a new keybinding.
28−const assistantMessageTailWindowFormat = "… %d earlier lines hidden [click or space for full view]"
26+const assistantMessageTailWindowFormat = "… (%d earlier lines hidden) [click or space to expand]"
29
27
30// maxCollapsedThinkingHeight defines the maximum height of the thinking
28// maxCollapsedThinkingHeight defines the maximum height of the thinking
31const maxCollapsedThinkingHeight = 10
29const maxCollapsedThinkingHeight = 10
@@ -93,11 +91,47 @@ func (s *assistantSection) reset() {
93 *s = assistantSection{}
91 *s = assistantSection{}
94}
92}
95
93
94+// FNV-64a constants. We fold bytes into a plain uint64 accumulator
95+// rather than allocating a fnv.New64a per call: the returned
96+// hash.Hash64 escapes to the heap, which is wasteful on the per-render
97+// hashing hot path. The algorithm (offset basis, prime, xor-then-multiply)
98+// is identical to hash/fnv, so hashes are byte-for-byte the same.
99+const (
100+ fnvOffset64 = 14695981039346656037
101+ fnvPrime64 = 1099511628211
102+)
103+
104+// fnvAddString folds s into an in-progress FNV-64a hash.
105+func fnvAddString(h uint64, s string) uint64 {
106+ for i := 0; i < len(s); i++ {
107+ h ^= uint64(s[i])
108+ h *= fnvPrime64
109+ }
110+ return h
111+}
112+
113+// fnvAddBytes folds b into an in-progress FNV-64a hash.
114+func fnvAddBytes(h uint64, b []byte) uint64 {
115+ for _, c := range b {
116+ h ^= uint64(c)
117+ h *= fnvPrime64
118+ }
119+ return h
120+}
121+
122+// fnvAddU64 folds v's 8 little-endian bytes into an in-progress hash,
123+// matching binary.LittleEndian.PutUint64 followed by a Write.
124+func fnvAddU64(h, v uint64) uint64 {
125+ for i := 0; i < 8; i++ {
126+ h ^= uint64(byte(v >> (8 * i)))
127+ h *= fnvPrime64
128+ }
129+ return h
130+}
131+
96−// fnv64 hashes a single string with FNV-64.
132+// fnv64 hashes a single string with FNV-64a.
97func fnv64(s string) uint64 {
133func fnv64(s string) uint64 {
98− h := fnv.New64a()
134+ return fnvAddString(fnvOffset64, s)
99− _, _ = h.Write([]byte(s))
100− return h.Sum64()
101}
135}
102
136
103// fnvFields hashes a list of byte fields with length-prefix framing
137// fnvFields hashes a list of byte fields with length-prefix framing
@@ -106,14 +140,41 @@ func fnv64(s string) uint64 {
106// boundary between two fields). Each field is preceded by its
140// boundary between two fields). Each field is preceded by its
107// length encoded as 8 bytes little-endian.
141// length encoded as 8 bytes little-endian.
108func fnvFields(fields ...[]byte) uint64 {
142func fnvFields(fields ...[]byte) uint64 {
109− h := fnv.New64a()
143+ h := uint64(fnvOffset64)
110− var lenBuf [8]byte
111 for _, f := range fields {
144 for _, f := range fields {
112− binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
145+ h = fnvAddU64(h, uint64(len(f)))
113− _, _ = h.Write(lenBuf[:])
146+ h = fnvAddBytes(h, f)
114− _, _ = h.Write(f)
147+ }
148+ return h
149+}
150+
151+// assistantRenderKeys bundles the per-render cache keys and tool-call
152+// count computed once at the top of Render/RawRender and threaded
153+// through the render path, so each section's source text is hashed only
154+// once and ToolCalls() is materialized only once per render.
155+type assistantRenderKeys struct {
156+ thinkSrc, thinkExtra uint64
157+ contentSrc, contentExtra uint64
158+ errSrc, errExtra uint64
159+ toolCallCount int
160+}
161+
162+// renderKeys computes the per-render keys once. It calls ToolCalls()
163+// exactly once and reuses its length everywhere the render path needs it.
164+func (a *AssistantMessageItem) renderKeys() assistantRenderKeys {
165+ toolCallCount := len(a.message.ToolCalls())
166+ thinkSrc, thinkExtra := a.thinkingKey(toolCallCount)
167+ contentSrc, contentExtra := a.contentKey()
168+ errSrc, errExtra := a.errorKey()
169+ return assistantRenderKeys{
170+ thinkSrc: thinkSrc,
171+ thinkExtra: thinkExtra,
172+ contentSrc: contentSrc,
173+ contentExtra: contentExtra,
174+ errSrc: errSrc,
175+ errExtra: errExtra,
176+ toolCallCount: toolCallCount,
115 }
177 }
116− return h.Sum64()
117}
178}
118
179
119// AssistantMessageItem represents an assistant message in the chat UI.
180// AssistantMessageItem represents an assistant message in the chat UI.
@@ -208,23 +269,36 @@ func (a *AssistantMessageItem) ID() string {
208
269
209// RawRender implements [MessageItem].
270// RawRender implements [MessageItem].
210func (a *AssistantMessageItem) RawRender(width int) string {
271func (a *AssistantMessageItem) RawRender(width int) string {
211− cappedWidth := cappedMessageWidth(width)
272+ return a.rawRender(width, a.renderKeys())
273+}
212
274
213− var spinner string
275+// rawRenderBody renders the assistant message body (thinking / content /
214− if a.isSpinning() {
276+// error sections) WITHOUT the spinner suffix. The body is stable across
215− spinner = a.renderSpinning()
277+// animation ticks — only the spinner changes — so Render caches its
278+// prefixed form and reuses it while re-rendering just the spinner.
279+func (a *AssistantMessageItem) rawRenderBody(width int, keys assistantRenderKeys) string {
280+ cappedWidth := cappedMessageWidth(width)
281+ content, height := a.renderMessageContent(cappedWidth, keys)
282+ return a.renderHighlighted(content, cappedWidth, height)
283+}
284+
285+// rawRender renders the item body plus, when spinning, the spinner suffix.
286+// It is the reference full render; Render reproduces its output byte-for-byte
287+// via a cached body + freshly-prefixed spinner (validated in
288+// assistant_render_test.go).
289+func (a *AssistantMessageItem) rawRender(width int, keys assistantRenderKeys) string {
290+ body := a.rawRenderBody(width, keys)
291+ if !a.isSpinningWithToolCalls(keys.toolCallCount) {
292+ return body
216 }
293 }
217−
294+ spinner := a.renderSpinning()
218− content, height := a.renderMessageContent(cappedWidth)
219− highlightedContent := a.renderHighlighted(content, cappedWidth, height)
220− if spinner != "" {
295+ if spinner == "" {
221− if highlightedContent != "" {
296+ return body
222− highlightedContent += "\n\n"
223− }
224− return highlightedContent + spinner
225 }
297 }
226−
298+ if body != "" {
227− return highlightedContent
299+ return body + "\n\n" + spinner
300+ }
301+ return spinner
228}
302}
229
303
230// Render implements MessageItem.
304// Render implements MessageItem.
@@ -243,30 +317,82 @@ func (a *AssistantMessageItem) Render(width int) string {
243 // drop. Bypass the cache while spinning (RawRender's spinner
317 // drop. Bypass the cache while spinning (RawRender's spinner
244 // suffix changes every animation frame) or while a highlight
318 // suffix changes every animation frame) or while a highlight
245 // range is active (selection drag).
319 // range is active (selection drag).
246− useCache := !a.isSpinning() && !a.isHighlighted()
320+ keys := a.renderKeys()
321+ spinning := a.isSpinningWithToolCalls(keys.toolCallCount)
247 cappedWidth := cappedMessageWidth(width)
322 cappedWidth := cappedMessageWidth(width)
248− key := a.prefixCacheKey(cappedWidth)
323+ key := a.prefixCacheKey(cappedWidth, keys)
324+
325+ // Only render the style actually used; rendering both wastes work
326+ // (lipgloss.Render is not free) when one is discarded.
327+ var prefix string
328+ if a.focused {
329+ prefix = a.sty.Messages.AssistantFocused.Render()
330+ } else {
331+ prefix = a.sty.Messages.AssistantBlurred.Render()
332+ }
333+
334+ // Resolve the prefixed body. Its cache key excludes the spinner frame,
335+ // so it stays valid across animation ticks — the whole point of the
336+ // decoupling: prefix the (potentially long) thinking body once per
337+ // content change instead of on every 20fps spinner tick. Highlighting
338+ // mutates the body itself, so it bypasses the cache.
339+ useCache := !a.isHighlighted()
340+ prefixedBody, ok := "", false
249 if useCache {
341 if useCache {
250− if cached, ok := a.getCachedPrefixedRender(width, key); ok {
342+ prefixedBody, ok = a.getCachedPrefixedRender(width, key)
251− return cached
343+ }
344+ bodyEmpty := false
345+ if !ok {
346+ body := a.rawRenderBody(width, keys)
347+ bodyEmpty = body == ""
348+ prefixedBody = prefixLines(prefix, body)
349+ // Cache only a non-empty body: an empty body prefixes to the bare
350+ // prefix and is cheap to recompute, and the cache treats "" as a
351+ // miss, so caching it would be indistinguishable from no entry.
352+ if useCache && !bodyEmpty {
353+ a.setCachedPrefixedRender(prefixedBody, width, key)
252 }
354 }
253 }
355 }
254− focused := a.sty.Messages.AssistantFocused.Render()
356+ // A cache hit implies a non-empty body (empty bodies are never cached),
255− blurred := a.sty.Messages.AssistantBlurred.Render()
357+ // so bodyEmpty stays false on the hit path.
256− rendered := a.RawRender(width)
358+
359+ if !spinning {
360+ return prefixedBody
361+ }
362+ spinner := a.renderSpinning()
363+ if spinner == "" {
364+ return prefixedBody
365+ }
366+ return assembleSpinningRender(prefix, prefixedBody, bodyEmpty, prefixLines(prefix, spinner))
367+}
368+
369+// prefixLines prepends prefix to every line of s, matching the per-line
370+// focus/selection prefix that Render applies (done manually rather than via
371+// lipgloss.Render, whose wrapping logic is costly for long messages).
372+func prefixLines(prefix, s string) string {
257− lines := strings.Split(rendered, "\n")
373+ lines := strings.Split(s, "\n")
374+ var sb strings.Builder
258 for i, line := range lines {
375 for i, line := range lines {
259− if a.focused {
376+ if i > 0 {
260− lines[i] = focused + line
377+ sb.WriteString("\n")
261− } else {
262− lines[i] = blurred + line
263 }
378 }
379+ sb.WriteString(prefix)
380+ sb.WriteString(line)
264 }
381 }
265− out := strings.Join(lines, "\n")
382+ return sb.String()
266− if useCache {
383+}
267− a.setCachedPrefixedRender(out, width, key)
384+
385+// assembleSpinningRender reconstructs the prefixed body+spinner output
386+// byte-for-byte identically to prefixLines(prefix, body+"\n\n"+spinner),
387+// but from the separately-prefixed (and body-cached) parts. When the body is
388+// empty the reference render is the spinner alone; otherwise the "\n\n"
389+// separator becomes one prefixed empty line between body and spinner. This
390+// exact identity is validated in assistant_render_test.go.
391+func assembleSpinningRender(prefix, prefixedBody string, bodyEmpty bool, prefixedSpinner string) string {
392+ if bodyEmpty {
393+ return prefixedSpinner
268 }
394 }
269− return out
395+ return prefixedBody + "\n" + prefix + "\n" + prefixedSpinner
270}
396}
271
397
272// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
398// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
@@ -278,27 +404,17 @@ func (a *AssistantMessageItem) Render(width int) string {
278// folded in too because it controls the composition of
404// folded in too because it controls the composition of
279// renderMessageContent (e.g. appending the constant "Canceled"
405// renderMessageContent (e.g. appending the constant "Canceled"
280// string) — that decision lives outside any section's own hash.
406// string) — that decision lives outside any section's own hash.
281−func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
407+func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int, keys assistantRenderKeys) uint64 {
282− thinkSrc, thinkExtra := a.thinkingKey()
408+ h := uint64(fnvOffset64)
283− contentSrc, contentExtra := a.contentKey()
409+ h = fnvAddU64(h, uint64(cappedWidth))
284− errSrc, errExtra := a.errorKey()
410+ h = fnvAddU64(h, keys.thinkSrc)
285− h := fnv.New64a()
411+ h = fnvAddU64(h, keys.thinkExtra)
286− var buf [8]byte
412+ h = fnvAddU64(h, keys.contentSrc)
287− writeU64 := func(v uint64) {
288− for i := range 8 {
289− buf[i] = byte(v >> (8 * i))
290− }
291− _, _ = h.Write(buf[:])
292− }
293− writeU64(uint64(cappedWidth))
294− writeU64(thinkSrc)
295− writeU64(thinkExtra)
296− writeU64(contentSrc)
297− writeU64(contentExtra)
413+ h = fnvAddU64(h, keys.contentExtra)
298− writeU64(errSrc)
414+ h = fnvAddU64(h, keys.errSrc)
299− writeU64(errExtra)
415+ h = fnvAddU64(h, keys.errExtra)
300− writeU64(a.compositionKey())
416+ h = fnvAddU64(h, a.compositionKey())
301− fingerprint := h.Sum64()
417+ fingerprint := h
302 var focusBit uint64
418 var focusBit uint64
303 if a.focused {
419 if a.focused {
304 focusBit = 1
420 focusBit = 1
@@ -327,20 +443,20 @@ func (a *AssistantMessageItem) compositionKey() uint64 {
327// content, and finish reason. Each section is served from its own cache;
443// content, and finish reason. Each section is served from its own cache;
328// only the section whose source text or extras changed since the last
444// only the section whose source text or extras changed since the last
329// render is recomputed.
445// render is recomputed.
330−func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
446+func (a *AssistantMessageItem) renderMessageContent(width int, keys assistantRenderKeys) (string, int) {
331 var messageParts []string
447 var messageParts []string
332 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
448 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
333 content := strings.TrimSpace(a.message.Content().Text)
449 content := strings.TrimSpace(a.message.Content().Text)
334
450
335 if thinking != "" {
451 if thinking != "" {
336− messageParts = append(messageParts, a.cachedThinking(width))
452+ messageParts = append(messageParts, a.cachedThinking(width, keys))
337 }
453 }
338
454
339 if content != "" {
455 if content != "" {
340 if thinking != "" {
456 if thinking != "" {
341 messageParts = append(messageParts, "")
457 messageParts = append(messageParts, "")
342 }
458 }
343− messageParts = append(messageParts, a.cachedContent(width))
459+ messageParts = append(messageParts, a.cachedContent(width, keys))
344 }
460 }
345
461
346 if a.message.IsFinished() {
462 if a.message.IsFinished() {
@@ -348,7 +464,7 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
348 case message.FinishReasonCanceled:
464 case message.FinishReasonCanceled:
349 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
465 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
350 case message.FinishReasonError:
466 case message.FinishReasonError:
351− messageParts = append(messageParts, a.cachedError(width))
467+ messageParts = append(messageParts, a.cachedError(width, keys))
352 }
468 }
353 }
469 }
354
470
@@ -361,11 +477,11 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
361// thinking text that affects the rendered output: the view mode
477// thinking text that affects the rendered output: the view mode
362// (collapsed / tail-window / full) and the footer state (which
478// (collapsed / tail-window / full) and the footer state (which
363// depends on IsThinking, ToolCalls, and ThinkingDuration).
479// depends on IsThinking, ToolCalls, and ThinkingDuration).
364−func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
480+func (a *AssistantMessageItem) thinkingKey(toolCallCount int) (uint64, uint64) {
365 thinking := a.message.ReasoningContent().Thinking
481 thinking := a.message.ReasoningContent().Thinking
366 srcHash := fnv64(thinking)
482 srcHash := fnv64(thinking)
367
483
368− showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
484+ showFooter := !a.message.IsThinking() || toolCallCount > 0
369 var durationStr string
485 var durationStr string
370 if showFooter {
486 if showFooter {
371 duration := a.message.ThinkingDuration()
487 duration := a.message.ThinkingDuration()
@@ -412,20 +528,20 @@ func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
412// caching it on miss. The thinking-box height (used for click target
528// caching it on miss. The thinking-box height (used for click target
413// detection) is preserved across hits via assistantSection.aux so the
529// detection) is preserved across hits via assistantSection.aux so the
414// cached path never desyncs click detection.
530// cached path never desyncs click detection.
415−func (a *AssistantMessageItem) cachedThinking(width int) string {
531+func (a *AssistantMessageItem) cachedThinking(width int, keys assistantRenderKeys) string {
416− srcHash, extra := a.thinkingKey()
532+ srcHash, extra := keys.thinkSrc, keys.thinkExtra
417 if a.thinkingSec.hit(width, srcHash, extra) {
533 if a.thinkingSec.hit(width, srcHash, extra) {
418 a.thinkingBoxHeight = a.thinkingSec.aux
534 a.thinkingBoxHeight = a.thinkingSec.aux
419 return a.thinkingSec.out
535 return a.thinkingSec.out
420 }
536 }
421− out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
537+ out := a.renderThinking(a.message.ReasoningContent().Thinking, width, keys.toolCallCount)
422 a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
538 a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
423 return out
539 return out
424}
540}
425
541
426// cachedContent returns the rendered content section.
542// cachedContent returns the rendered content section.
427−func (a *AssistantMessageItem) cachedContent(width int) string {
543+func (a *AssistantMessageItem) cachedContent(width int, keys assistantRenderKeys) string {
428− srcHash, extra := a.contentKey()
544+ srcHash, extra := keys.contentSrc, keys.contentExtra
429 if a.contentSec.hit(width, srcHash, extra) {
545 if a.contentSec.hit(width, srcHash, extra) {
430 return a.contentSec.out
546 return a.contentSec.out
431 }
547 }
@@ -435,8 +551,8 @@ func (a *AssistantMessageItem) cachedContent(width int) string {
435}
551}
436
552
437// cachedError returns the rendered error section.
553// cachedError returns the rendered error section.
438−func (a *AssistantMessageItem) cachedError(width int) string {
554+func (a *AssistantMessageItem) cachedError(width int, keys assistantRenderKeys) string {
439− srcHash, extra := a.errorKey()
555+ srcHash, extra := keys.errSrc, keys.errExtra
440 if a.errorSec.hit(width, srcHash, extra) {
556 if a.errorSec.hit(width, srcHash, extra) {
441 return a.errorSec.out
557 return a.errorSec.out
442 }
558 }
@@ -452,18 +568,35 @@ func (a *AssistantMessageItem) cachedError(width int) string {
452// boundary problem §4.4 of the design note flags. The bordered
568// boundary problem §4.4 of the design note flags. The bordered
453// ThinkingBox style is applied on top of the (already-windowed)
569// ThinkingBox style is applied on top of the (already-windowed)
454// lines so the visual box matches what the user sees today.
570// lines so the visual box matches what the user sees today.
455−func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
571+func (a *AssistantMessageItem) renderThinking(thinking string, width int, toolCallCount int) string {
456 renderer := common.QuietMarkdownRenderer(a.sty, width)
572 renderer := common.QuietMarkdownRenderer(a.sty, width)
573+
574+ // Cap the raw text handed to glamour while the block is windowed.
575+ // The collapsed and tail-window views only ever DISPLAY the last
576+ // N rendered lines, so rendering the entire (unbounded) reasoning
577+ // trace on every ~33ms streaming flush is wasted work that grows
578+ // with the trace — O(n) per flush, O(n²) over the stream — and is
579+ // the source of the "long thinking makes the UI lag" problem.
580+ // Full expansion is an explicit request to see everything and is
581+ // never bounded. droppedLines keeps the "N lines hidden" count
582+ // roughly truthful and never affects what is displayed.
583+ windowed, droppedLines := boundThinkingInput(thinking, a.thinkingViewMode, width)
457− rendered := a.streamingThinking.Render(thinking, width, renderer)
584+ rendered := a.streamingThinking.Render(windowed, width, renderer)
458 rendered = strings.TrimSpace(rendered)
585 rendered = strings.TrimSpace(rendered)
459
586
460 lines := strings.Split(rendered, "\n")
587 lines := strings.Split(rendered, "\n")
461− totalLines := len(lines)
588+ renderedLines := len(lines)
589+ // totalLines counts everything the trace would render to, so the
590+ // affordance's hidden-line count stays honest even though only
591+ // the windowed tail was actually rendered.
592+ totalLines := renderedLines + droppedLines
462
593
463 switch a.thinkingViewMode {
594 switch a.thinkingViewMode {
464 case thinkingCollapsed:
595 case thinkingCollapsed:
465 if totalLines > maxCollapsedThinkingHeight {
596 if totalLines > maxCollapsedThinkingHeight {
597+ if renderedLines > maxCollapsedThinkingHeight {
466− lines = lines[totalLines-maxCollapsedThinkingHeight:]
598+ lines = lines[renderedLines-maxCollapsedThinkingHeight:]
599+ }
467 hint := a.sty.Messages.ThinkingTruncationHint.Render(
600 hint := a.sty.Messages.ThinkingTruncationHint.Render(
468 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
601 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
469 )
602 )
@@ -471,7 +604,9 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
471 }
604 }
472 case thinkingTailWindow:
605 case thinkingTailWindow:
473 if totalLines > maxExpandedThinkingTailLines {
606 if totalLines > maxExpandedThinkingTailLines {
607+ if renderedLines > maxExpandedThinkingTailLines {
474− lines = lines[totalLines-maxExpandedThinkingTailLines:]
608+ lines = lines[renderedLines-maxExpandedThinkingTailLines:]
609+ }
475 hint := a.sty.Messages.ThinkingTruncationHint.Render(
610 hint := a.sty.Messages.ThinkingTruncationHint.Render(
476 fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
611 fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
477 )
612 )
@@ -485,7 +620,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
485
620
486 var footer string
621 var footer string
487 // if thinking is done add the thought for footer
622 // if thinking is done add the thought for footer
488− if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
623+ if !a.message.IsThinking() || toolCallCount > 0 {
489 duration := a.message.ThinkingDuration()
624 duration := a.message.ThinkingDuration()
490 if duration.String() != "0s" {
625 if duration.String() != "0s" {
491 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
626 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
@@ -500,6 +635,94 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
500 return result
635 return result
501}
636}
502
637
638+// boundThinkingInput caps the raw reasoning text fed to glamour so a
639+// streaming flush costs O(window) instead of O(entire trace). It
640+// returns the (possibly shortened) text to render plus the number of
641+// source lines dropped ahead of the window.
642+//
643+// A full render is preserved for [thinkingFullExpanded] (the user
644+// asked to see everything) and whenever the trace already fits the
645+// budget, so short blocks and the non-streaming steady state behave
646+// exactly as before.
647+//
648+// The window is snapped forward to a line boundary so a source line
649+// is never cut mid-way, and a code fence left open by the cut is
650+// re-opened at the window head so the tail keeps rendering as code
651+// (matching reality) instead of flipping to prose. Any other
652+// construct a raw cut might tear — a list, table, or blockquote at
653+// the very top of the window — only corrupts lines that sit above
654+// the displayed tail slice and are discarded before display.
655+func boundThinkingInput(thinking string, mode thinkingViewMode, width int) (windowed string, droppedLines int) {
656+ budget := thinkingWindowBytes(mode, width)
657+ if budget <= 0 || len(thinking) <= budget {
658+ return thinking, 0
659+ }
660+ // The streamingMarkdown incremental cache already keeps a flush
661+ // cheap when a safe boundary sits within `budget` of the end: it
662+ // re-renders only the small trailing segment. Windowing would
663+ // break that fast path (a sliding window is never a prefix
664+ // extension, forcing a cache reset every flush), so only window
665+ // when the tail since the last boundary is itself larger than the
666+ // budget — the "wall of text with no paragraph break" case the
667+ // cache cannot handle.
668+ if b := findSafeMarkdownBoundary(thinking); b >= 0 && len(thinking)-b <= budget {
669+ return thinking, 0
670+ }
671+ cut := len(thinking) - budget
672+ // Snap forward to the start of the next line so the window never
673+ // begins in the middle of a source line.
674+ if nl := strings.IndexByte(thinking[cut:], '\n'); nl >= 0 {
675+ cut += nl + 1
676+ }
677+ dropped := thinking[:cut]
678+ windowed = thinking[cut:]
679+ // Re-open a code fence left dangling by the cut so the window's
680+ // code renders as code rather than prose.
681+ if fenceLineCount(dropped)%2 != 0 {
682+ windowed = "```\n" + windowed
683+ }
684+ return windowed, strings.Count(dropped, "\n")
685+}
686+
687+// thinkingWindowBytes returns the maximum bytes of raw reasoning text
688+// to render for a view mode, or -1 for "no bound". The budget scales
689+// with the wrap width so the window still comfortably overfills the
690+// visible line cap after wrapping, with a floor for narrow widths.
691+func thinkingWindowBytes(mode thinkingViewMode, width int) int {
692+ if width < 1 {
693+ width = 1
694+ }
695+ switch mode {
696+ case thinkingCollapsed:
697+ return max(4096, maxCollapsedThinkingHeight*width*8)
698+ case thinkingTailWindow:
699+ return max(32768, maxExpandedThinkingTailLines*width*4)
700+ default:
701+ return -1
702+ }
703+}
704+
705+// fenceLineCount counts fenced-code-block delimiter lines in s. An
706+// odd count means a fence is still open at the end of s. Reuses the
707+// same [isFenceLine] rule as the streaming-markdown boundary scanner.
708+func fenceLineCount(s string) int {
709+ n := 0
710+ start := 0
711+ for i := 0; i < len(s); i++ {
712+ if s[i] != '\n' {
713+ continue
714+ }
715+ if isFenceLine(s[start:i]) {
716+ n++
717+ }
718+ start = i + 1
719+ }
720+ if start < len(s) && isFenceLine(s[start:]) {
721+ n++
722+ }
723+ return n
724+}
725+
503// renderMarkdown renders content as markdown. F8 routes the call
726// renderMarkdown renders content as markdown. F8 routes the call
504// through streamingContent, which caches the glamour render of a
727// through streamingContent, which caches the glamour render of a
505// "stable prefix" so each streaming flush only re-renders the
728// "stable prefix" so each streaming flush only re-renders the
@@ -535,11 +758,16 @@ func (a *AssistantMessageItem) renderError(width int) string {
535
758
536// isSpinning returns true if the assistant message is still generating.
759// isSpinning returns true if the assistant message is still generating.
537func (a *AssistantMessageItem) isSpinning() bool {
760func (a *AssistantMessageItem) isSpinning() bool {
761+ return a.isSpinningWithToolCalls(len(a.message.ToolCalls()))
762+}
763+
764+// isSpinningWithToolCalls is isSpinning given an already-computed
765+// tool-call count, so the render path avoids re-materializing ToolCalls().
766+func (a *AssistantMessageItem) isSpinningWithToolCalls(toolCallCount int) bool {
538 isThinking := a.message.IsThinking()
767 isThinking := a.message.IsThinking()
539 isFinished := a.message.IsFinished()
768 isFinished := a.message.IsFinished()
540 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
769 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
541− hasToolCalls := len(a.message.ToolCalls()) > 0
542− return (isThinking || !isFinished) && !hasContent && !hasToolCalls
770+ return (isThinking || !isFinished) && !hasContent && toolCallCount == 0
543}
771}
544
772
545// SetMessage is used to update the underlying message. Only the
773// SetMessage is used to update the underlying message. Only the
internal/ui/chat/assistant_render_test.gonew file+78
@@ -0,0 +1,78 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// combineBodySpinner replicates rawRender's spinning combine: an empty body
9+// yields the spinner alone, otherwise the two are joined by a blank line.
10+func combineBodySpinner(body, spinner string) string {
11+ if body != "" {
12+ return body + "\n\n" + spinner
13+ }
14+ return spinner
15+}
16+
17+// TestAssembleSpinningRender_ByteIdentical proves the decoupled Render path
18+// (cache the prefixed body, prefix the spinner separately, reassemble) is
19+// byte-for-byte identical to the old path of prefixing the full
20+// body+spinner string in one pass. If this ever diverges, the spinner
21+// decoupling would visibly corrupt assistant rendering.
22+func TestAssembleSpinningRender_ByteIdentical(t *testing.T) {
23+ t.Parallel()
24+ prefixes := []string{"", "P", "▏ ", "\x1b[38;5;1m│\x1b[0m "}
25+ bodies := []string{
26+ "",
27+ "single line",
28+ "line1\nline2\nline3",
29+ "has\n\ninternal blank",
30+ "trailing\n",
31+ }
32+ spinners := []string{
33+ "spin",
34+ "spin1\nspin2",
35+ "⠋ Thinking",
36+ }
37+ for _, prefix := range prefixes {
38+ for _, body := range bodies {
39+ for _, spinner := range spinners {
40+ want := prefixLines(prefix, combineBodySpinner(body, spinner))
41+ got := assembleSpinningRender(
42+ prefix,
43+ prefixLines(prefix, body),
44+ body == "",
45+ prefixLines(prefix, spinner),
46+ )
47+ if got != want {
48+ t.Errorf("prefix=%q body=%q spinner=%q\n got=%q\nwant=%q",
49+ prefix, body, spinner, got, want)
50+ }
51+ }
52+ }
53+ }
54+}
55+
56+// TestPrefixLines matches the original inline per-line prefix loop.
57+func TestPrefixLines(t *testing.T) {
58+ t.Parallel()
59+ cases := []struct{ prefix, in, want string }{
60+ {"P", "", "P"},
61+ {"P", "a", "Pa"},
62+ {"P", "a\nb", "Pa\nPb"},
63+ {"P", "a\n\nb", "Pa\nP\nPb"},
64+ {"", "a\nb", "a\nb"},
65+ {"P", "trailing\n", "Ptrailing\nP"},
66+ }
67+ for _, c := range cases {
68+ if got := prefixLines(c.prefix, c.in); got != c.want {
69+ t.Errorf("prefixLines(%q,%q)=%q want %q", c.prefix, c.in, got, c.want)
70+ }
71+ }
72+ // Cross-check against strings.Split semantics for a random-ish sample.
73+ s := "x1\nx2\n\nx3"
74+ want := strings.ReplaceAll("Q"+strings.ReplaceAll(s, "\n", "\nQ"), "\r", "")
75+ if got := prefixLines("Q", s); got != want {
76+ t.Errorf("prefixLines cross-check: got %q want %q", got, want)
77+ }
78+}
internal/ui/chat/assistant_section_cache_test.go+3−3
@@ -149,8 +149,8 @@ func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
149 itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
149 itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
150 itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
150 itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
151
151
152− thinkSrcA, _ := itemA.thinkingKey()
152+ thinkSrcA, _ := itemA.thinkingKey(0)
153− thinkSrcB, _ := itemB.thinkingKey()
153+ thinkSrcB, _ := itemB.thinkingKey(0)
154 require.NotEqual(t, thinkSrcA, thinkSrcB,
154 require.NotEqual(t, thinkSrcA, thinkSrcB,
155 "distinct thinking text must produce distinct FNV-64 source hashes")
155 "distinct thinking text must produce distinct FNV-64 source hashes")
156
156
@@ -162,7 +162,7 @@ func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
162 // Identical source text on a fresh item must produce the same
162 // Identical source text on a fresh item must produce the same
163 // hashes — keying invariant for cache hits.
163 // hashes — keying invariant for cache hits.
164 itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
164 itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
165− thinkSrcAClone, _ := itemAClone.thinkingKey()
165+ thinkSrcAClone, _ := itemAClone.thinkingKey(0)
166 contentSrcAClone, _ := itemAClone.contentKey()
166 contentSrcAClone, _ := itemAClone.contentKey()
167 require.Equal(t, thinkSrcA, thinkSrcAClone)
167 require.Equal(t, thinkSrcA, thinkSrcAClone)
168 require.Equal(t, contentSrcA, contentSrcAClone)
168 require.Equal(t, contentSrcA, contentSrcAClone)
internal/ui/chat/assistant_thinking_bench_test.gonew file+69
@@ -0,0 +1,69 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// benchThinkingStream simulates a reasoning stream: it grows `body`
13+// to full length in `steps` increments and drives the real
14+// AssistantMessageItem render path (SetMessage + RawRender) on every
15+// increment, exactly as the live TUI does on each ~33ms flush while
16+// the block sits collapsed. It guards against a regression of the
17+// O(n²) full-trace re-render that made long thinking lag the UI:
18+// with boundThinkingInput the per-flush cost is bounded, so the wall
19+// (no-blank-line) case stays close to the blank-line case instead of
20+// blowing up quadratically.
21+func benchThinkingStream(b *testing.B, body string, steps int) {
22+ sty := styles.CharmtonePantera()
23+ const width = 80
24+ b.ReportAllocs()
25+ b.ResetTimer()
26+ for n := 0; n < b.N; n++ {
27+ item := NewAssistantMessageItem(&sty, thinkingMsg("")).(*AssistantMessageItem)
28+ for i := 1; i <= steps; i++ {
29+ cut := len(body) * i / steps
30+ item.SetMessage(thinkingMsg(body[:cut]))
31+ _ = item.RawRender(width)
32+ }
33+ }
34+}
35+
36+func thinkingMsg(thinking string) *message.Message {
37+ return &message.Message{
38+ ID: "bench",
39+ Role: message.Assistant,
40+ Parts: []message.ContentPart{
41+ message.ReasoningContent{Thinking: thinking, StartedAt: testStartedAt},
42+ },
43+ }
44+}
45+
46+func benchParagraphs(n int) string {
47+ var sb strings.Builder
48+ for i := 0; i < n; i++ {
49+ fmt.Fprintf(&sb, "Reasoning paragraph %d with a few sentences of prose that a model might emit while working through a problem step by step.\n\n", i)
50+ }
51+ return sb.String()
52+}
53+
54+// benchWall is one giant paragraph with NO blank line — the case
55+// where no safe markdown boundary ever exists and, before
56+// boundThinkingInput, every flush re-rendered the whole growing
57+// trace.
58+func benchWall(n int) string {
59+ var sb strings.Builder
60+ for i := 0; i < n; i++ {
61+ fmt.Fprintf(&sb, "sentence %d that keeps going with no paragraph break at all. ", i)
62+ }
63+ return sb.String()
64+}
65+
66+func BenchmarkThinkingStream_BlankLines(b *testing.B) {
67+ benchThinkingStream(b, benchParagraphs(200), 100)
68+}
69+func BenchmarkThinkingStream_Wall(b *testing.B) { benchThinkingStream(b, benchWall(1500), 100) }
internal/ui/chat/assistant_thinking_bound_test.gonew file+105
@@ -0,0 +1,105 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// TestBoundThinkingInput_ShortNotWindowed: content within the budget
11+// is returned verbatim so short blocks and the non-streaming steady
12+// state are untouched.
13+func TestBoundThinkingInput_ShortNotWindowed(t *testing.T) {
14+ t.Parallel()
15+ in := "a short thought\n\nwith two paragraphs"
16+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
17+ require.Equal(t, in, out)
18+ require.Zero(t, dropped)
19+}
20+
21+// TestBoundThinkingInput_FullExpandedNeverWindowed: full expansion is
22+// an explicit request to see everything, so it is never bounded even
23+// for a huge trace.
24+func TestBoundThinkingInput_FullExpandedNeverWindowed(t *testing.T) {
25+ t.Parallel()
26+ in := strings.Repeat("wall of text with no breaks ", 100000)
27+ out, dropped := boundThinkingInput(in, thinkingFullExpanded, 80)
28+ require.Equal(t, in, out)
29+ require.Zero(t, dropped)
30+}
31+
32+// TestBoundThinkingInput_BoundariedNotWindowed: a large trace whose
33+// tail has a recent safe boundary is NOT windowed — the incremental
34+// cache renders only the small trailing segment, so windowing (which
35+// would reset that cache every flush) must be skipped.
36+func TestBoundThinkingInput_BoundariedNotWindowed(t *testing.T) {
37+ t.Parallel()
38+ var b strings.Builder
39+ for i := 0; i < 5000; i++ {
40+ b.WriteString("paragraph with a few words\n\n")
41+ }
42+ in := b.String()
43+ require.Greater(t, len(in), thinkingWindowBytes(thinkingCollapsed, 80),
44+ "fixture must exceed the budget so only the boundary gate can spare it")
45+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
46+ require.Equal(t, in, out, "boundaried content must not be windowed")
47+ require.Zero(t, dropped)
48+}
49+
50+// TestBoundThinkingInput_WallWindowed: a long unbroken wall (no
51+// paragraph break the incremental cache can use) IS windowed to a
52+// bounded tail.
53+func TestBoundThinkingInput_WallWindowed(t *testing.T) {
54+ t.Parallel()
55+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
56+ in := strings.Repeat("word ", budget) // one giant line, no '\n'
57+ out, _ := boundThinkingInput(in, thinkingCollapsed, 80)
58+ require.Less(t, len(out), len(in), "a wall past budget must be shortened")
59+ require.LessOrEqual(t, len(out), budget+8, "window must respect the budget (+fence slack)")
60+ require.True(t, strings.HasSuffix(in, out), "window must be a tail of the source")
61+}
62+
63+// TestBoundThinkingInput_SnapsToLineBoundary: when a newline exists
64+// inside the trailing budget the window begins at the start of a
65+// line, never mid-line.
66+func TestBoundThinkingInput_SnapsToLineBoundary(t *testing.T) {
67+ t.Parallel()
68+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
69+ // A long single line (forces windowing) followed by clean lines
70+ // within the budget window.
71+ head := strings.Repeat("x", budget)
72+ tail := "\nclean line one\nclean line two\n"
73+ out, dropped := boundThinkingInput(head+tail, thinkingCollapsed, 80)
74+ require.Greater(t, dropped, 0)
75+ require.False(t, strings.HasPrefix(out, "x"),
76+ "window must snap past the truncated head line")
77+ require.True(t, strings.HasPrefix(out, "clean line"))
78+}
79+
80+// TestBoundThinkingInput_ReopensDanglingFence: when the dropped
81+// prefix leaves a code fence open, the window is prefixed with a
82+// fence opener so the tail renders as code (matching reality) instead
83+// of flipping to prose.
84+func TestBoundThinkingInput_ReopensDanglingFence(t *testing.T) {
85+ t.Parallel()
86+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
87+ // Open a fence, then emit a long body (no closing fence) that
88+ // pushes the opener out of the window.
89+ in := "```go\n" + strings.Repeat("code line here\n", budget/15+500)
90+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
91+ require.Greater(t, dropped, 0, "fixture must actually be windowed")
92+ require.True(t, strings.HasPrefix(out, "```\n"),
93+ "a dangling open fence must be re-opened at the window head")
94+}
95+
96+// TestFenceLineCount checks parity accounting used to detect a
97+// dangling open fence.
98+func TestFenceLineCount(t *testing.T) {
99+ t.Parallel()
100+ require.Equal(t, 0, fenceLineCount("no fences here\njust prose\n"))
101+ require.Equal(t, 2, fenceLineCount("```go\ncode\n```\n"))
102+ require.Equal(t, 1, fenceLineCount("```\nopen but never closed\n"))
103+ // A final line with no trailing newline still counts.
104+ require.Equal(t, 1, fenceLineCount("prose\n```"))
105+}
internal/ui/chat/bash.go+5−5
@@ -31,7 +31,7 @@ func NewBashToolMessageItem(
31 result *message.ToolResult,
31 result *message.ToolResult,
32 canceled bool,
32 canceled bool,
33) ToolMessageItem {
33) ToolMessageItem {
34− return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled, "")
35}
35}
36
36
37// BashToolRenderContext renders bash tool messages.
37// BashToolRenderContext renders bash tool messages.
@@ -116,7 +116,7 @@ func NewJobOutputToolMessageItem(
116 result *message.ToolResult,
116 result *message.ToolResult,
117 canceled bool,
117 canceled bool,
118) ToolMessageItem {
118) ToolMessageItem {
119− return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
119+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled, "")
120}
120}
121
121
122// JobOutputToolRenderContext renders job_output tool messages.
122// JobOutputToolRenderContext renders job_output tool messages.
@@ -131,7 +131,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o
131
131
132 var params tools.JobOutputParams
132 var params tools.JobOutputParams
133 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
133 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
134− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
134+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
135 }
135 }
136
136
137 var description string
137 var description string
@@ -167,7 +167,7 @@ func NewJobKillToolMessageItem(
167 result *message.ToolResult,
167 result *message.ToolResult,
168 canceled bool,
168 canceled bool,
169) ToolMessageItem {
169) ToolMessageItem {
170− return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
170+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled, "")
171}
171}
172
172
173// JobKillToolRenderContext renders job_kill tool messages.
173// JobKillToolRenderContext renders job_kill tool messages.
@@ -182,7 +182,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
182
182
183 var params tools.JobKillParams
183 var params tools.JobKillParams
184 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
184 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
185− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
185+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
186 }
186 }
187
187
188 var description string
188 var description string
internal/ui/chat/diagnostics.go+1−1
@@ -27,7 +27,7 @@ func NewDiagnosticsToolMessageItem(
27 result *message.ToolResult,
27 result *message.ToolResult,
28 canceled bool,
28 canceled bool,
29) ToolMessageItem {
29) ToolMessageItem {
30− return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
30+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled, "")
31}
31}
32
32
33// DiagnosticsToolRenderContext renders diagnostics tool messages.
33// DiagnosticsToolRenderContext renders diagnostics tool messages.
internal/ui/chat/docker_mcp.go+4−4
@@ -28,7 +28,7 @@ func NewDockerMCPToolMessageItem(
28 result *message.ToolResult,
28 result *message.ToolResult,
29 canceled bool,
29 canceled bool,
30) ToolMessageItem {
30) ToolMessageItem {
31− return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
31+ return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled, "")
32}
32}
33
33
34// DockerMCPToolRenderContext renders Docker MCP tool messages.
34// DockerMCPToolRenderContext renders Docker MCP tool messages.
@@ -138,7 +138,7 @@ func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, o
138
138
139 // Handle text content.
139 // Handle text content.
140 if opts.Result.Content != "" {
140 if opts.Result.Content != "" {
141− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
141+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
142 parts = append(parts, body)
142 parts = append(parts, body)
143 }
143 }
144
144
@@ -225,7 +225,7 @@ func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string,
225
225
226func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
226func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
227 mainTool := "Docker MCP"
227 mainTool := "Docker MCP"
228− action := tool
228+ var action string
229 actionStyle := sty.Tool.MCPToolName
229 actionStyle := sty.Tool.MCPToolName
230 switch tool {
230 switch tool {
231 case "mcp-exec":
231 case "mcp-exec":
@@ -254,7 +254,7 @@ func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool str
254}
254}
255
255
256func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
256func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
257− action := tool
257+ var action string
258 switch tool {
258 switch tool {
259 case "mcp-exec":
259 case "mcp-exec":
260 action = "exec"
260 action = "exec"
internal/ui/chat/fetch.go+8−8
@@ -26,7 +26,7 @@ func NewFetchToolMessageItem(
26 result *message.ToolResult,
26 result *message.ToolResult,
27 canceled bool,
27 canceled bool,
28) ToolMessageItem {
28) ToolMessageItem {
29− return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
29+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled, "")
30}
30}
31
31
32// FetchToolRenderContext renders fetch tool messages.
32// FetchToolRenderContext renders fetch tool messages.
@@ -41,7 +41,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
41
41
42 var params tools.FetchParams
42 var params tools.FetchParams
43 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
43 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
44− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
44+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
45 }
45 }
46
46
47 toolParams := []string{params.URL}
47 toolParams := []string{params.URL}
@@ -101,7 +101,7 @@ func NewWebFetchToolMessageItem(
101 result *message.ToolResult,
101 result *message.ToolResult,
102 canceled bool,
102 canceled bool,
103) ToolMessageItem {
103) ToolMessageItem {
104− return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
104+ return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled, "")
105}
105}
106
106
107// WebFetchToolRenderContext renders web_fetch tool messages.
107// WebFetchToolRenderContext renders web_fetch tool messages.
@@ -111,16 +111,16 @@ type WebFetchToolRenderContext struct{}
111func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
111func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
112 cappedWidth := cappedMessageWidth(width)
112 cappedWidth := cappedMessageWidth(width)
113 if opts.IsPending() {
113 if opts.IsPending() {
114− return pendingTool(sty, "Fetch", opts.Anim, opts.Compact)
114+ return pendingTool(sty, "Web Fetch", opts.Anim, opts.Compact)
115 }
115 }
116
116
117 var params tools.WebFetchParams
117 var params tools.WebFetchParams
118 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
118 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
119− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
119+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
120 }
120 }
121
121
122 toolParams := []string{params.URL}
122 toolParams := []string{params.URL}
123− header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts, toolParams...)
123+ header := toolHeader(sty, opts.Status, "Web Fetch", cappedWidth, opts, toolParams...)
124 if opts.Compact {
124 if opts.Compact {
125 return header
125 return header
126 }
126 }
@@ -155,7 +155,7 @@ func NewWebSearchToolMessageItem(
155 result *message.ToolResult,
155 result *message.ToolResult,
156 canceled bool,
156 canceled bool,
157) ToolMessageItem {
157) ToolMessageItem {
158− return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
158+ return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled, "")
159}
159}
160
160
161// WebSearchToolRenderContext renders web_search tool messages.
161// WebSearchToolRenderContext renders web_search tool messages.
@@ -170,7 +170,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o
170
170
171 var params tools.WebSearchParams
171 var params tools.WebSearchParams
172 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
172 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
173− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
173+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
174 }
174 }
175
175
176 toolParams := []string{params.Query}
176 toolParams := []string{params.Query}
internal/ui/chat/file.go+17−17
@@ -29,7 +29,7 @@ func NewViewToolMessageItem(
29 result *message.ToolResult,
29 result *message.ToolResult,
30 canceled bool,
30 canceled bool,
31) ToolMessageItem {
31) ToolMessageItem {
32− return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
32+ return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled, "")
33}
33}
34
34
35// ViewToolRenderContext renders view tool messages.
35// ViewToolRenderContext renders view tool messages.
@@ -44,7 +44,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
44
44
45 var params tools.ViewParams
45 var params tools.ViewParams
46 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
46 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
47− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
47+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
48 }
48 }
49
49
50 file := fsext.PrettyPath(params.FilePath)
50 file := fsext.PrettyPath(params.FilePath)
@@ -115,7 +115,7 @@ func NewWriteToolMessageItem(
115 result *message.ToolResult,
115 result *message.ToolResult,
116 canceled bool,
116 canceled bool,
117) ToolMessageItem {
117) ToolMessageItem {
118− return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
118+ return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled, "")
119}
119}
120
120
121// WriteToolRenderContext renders write tool messages.
121// WriteToolRenderContext renders write tool messages.
@@ -130,7 +130,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
130
130
131 var params tools.WriteParams
131 var params tools.WriteParams
132 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
132 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
133− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
133+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
134 }
134 }
135
135
136 file := fsext.PrettyPath(params.FilePath)
136 file := fsext.PrettyPath(params.FilePath)
@@ -150,11 +150,11 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
150 if opts.Result.IsError {
150 if opts.Result.IsError {
151 var meta tools.WriteResponseMetadata
151 var meta tools.WriteResponseMetadata
152 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Diff != "" {
152 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Diff != "" {
153− errLine := toolErrorContent(sty, opts.Result, cappedWidth)
153+ errLine := toolErrorContent(sty, opts.Result, cappedWidth, opts.ExpandedContent)
154− diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent)
154+ diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent, opts.DiffTool)
155 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
155 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
156 }
156 }
157− return joinToolParts(header, toolErrorContent(sty, opts.Result, cappedWidth))
157+ return joinToolParts(header, toolErrorContent(sty, opts.Result, cappedWidth, opts.ExpandedContent))
158 }
158 }
159
159
160 // Render code content with syntax highlighting.
160 // Render code content with syntax highlighting.
@@ -184,7 +184,7 @@ func NewEditToolMessageItem(
184 result *message.ToolResult,
184 result *message.ToolResult,
185 canceled bool,
185 canceled bool,
186) ToolMessageItem {
186) ToolMessageItem {
187− return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
187+ return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled, "")
188}
188}
189
189
190// EditToolRenderContext renders edit tool messages.
190// EditToolRenderContext renders edit tool messages.
@@ -199,7 +199,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
199
199
200 var params tools.EditParams
200 var params tools.EditParams
201 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
201 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
202− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
202+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width, false)
203 }
203 }
204
204
205 file := fsext.PrettyPath(params.FilePath)
205 file := fsext.PrettyPath(params.FilePath)
@@ -223,11 +223,11 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
223 return joinToolParts(header, body)
223 return joinToolParts(header, body)
224 }
224 }
225
225
226− diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
226+ diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent, opts.DiffTool)
227
227
228 // On error (e.g. denied permission), show error above the diff.
228 // On error (e.g. denied permission), show error above the diff.
229 if opts.Result.IsError {
229 if opts.Result.IsError {
230− errLine := toolErrorContent(sty, opts.Result, width)
230+ errLine := toolErrorContent(sty, opts.Result, width, opts.ExpandedContent)
231 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
231 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
232 }
232 }
233
233
@@ -252,7 +252,7 @@ func NewMultiEditToolMessageItem(
252 result *message.ToolResult,
252 result *message.ToolResult,
253 canceled bool,
253 canceled bool,
254) ToolMessageItem {
254) ToolMessageItem {
255− return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
255+ return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled, "")
256}
256}
257
257
258// MultiEditToolRenderContext renders multi-edit tool messages.
258// MultiEditToolRenderContext renders multi-edit tool messages.
@@ -267,7 +267,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
267
267
268 var params tools.MultiEditParams
268 var params tools.MultiEditParams
269 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
269 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
270− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
270+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width, false)
271 }
271 }
272
272
273 file := fsext.PrettyPath(params.FilePath)
273 file := fsext.PrettyPath(params.FilePath)
@@ -297,11 +297,11 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
297 }
297 }
298
298
299 // Render diff with optional failed edits note.
299 // Render diff with optional failed edits note.
300− diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
300+ diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent, opts.DiffTool)
301
301
302 // On error (e.g. denied permission), show error above the diff.
302 // On error (e.g. denied permission), show error above the diff.
303 if opts.Result.IsError {
303 if opts.Result.IsError {
304− errLine := toolErrorContent(sty, opts.Result, width)
304+ errLine := toolErrorContent(sty, opts.Result, width, opts.ExpandedContent)
305 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
305 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
306 }
306 }
307
307
@@ -326,7 +326,7 @@ func NewDownloadToolMessageItem(
326 result *message.ToolResult,
326 result *message.ToolResult,
327 canceled bool,
327 canceled bool,
328) ToolMessageItem {
328) ToolMessageItem {
329− return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
329+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled, "")
330}
330}
331
331
332// DownloadToolRenderContext renders download tool messages.
332// DownloadToolRenderContext renders download tool messages.
@@ -341,7 +341,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
341
341
342 var params tools.DownloadParams
342 var params tools.DownloadParams
343 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
343 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
344− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
344+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
345 }
345 }
346
346
347 toolParams := []string{params.URL}
347 toolParams := []string{params.URL}
internal/ui/chat/generic.go+3−3
@@ -22,7 +22,7 @@ func NewGenericToolMessageItem(
22 result *message.ToolResult,
22 result *message.ToolResult,
23 canceled bool,
23 canceled bool,
24) ToolMessageItem {
24) ToolMessageItem {
25− return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled)
25+ return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled, "")
26}
26}
27
27
28// GenericToolRenderContext renders unknown/generic tool messages.
28// GenericToolRenderContext renders unknown/generic tool messages.
@@ -39,7 +39,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
39
39
40 var params map[string]any
40 var params map[string]any
41 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
41 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
42− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
42+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
43 }
43 }
44
44
45 var toolParams []string
45 var toolParams []string
@@ -68,6 +68,6 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
68 return joinToolParts(header, body)
68 return joinToolParts(header, body)
69 }
69 }
70
70
71− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
71+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
72 return joinToolParts(header, body)
72 return joinToolParts(header, body)
73}
73}
internal/ui/chat/incremental_glamour_test.go+144−16
@@ -1,6 +1,7 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "fmt"
4 "strings"
5 "strings"
5 "testing"
6 "testing"
6
7
@@ -223,12 +224,46 @@ func TestFindSafeMarkdownBoundary_TableDriven(t *testing.T) {
223 {
224 {
224 name: "closed list then paragraph",
225 name: "closed list then paragraph",
225 content: "- one\n- two\n\nPara.",
226 content: "- one\n- two\n\nPara.",
226− // blank line after the list. Last non-blank line
227+ // blank line after the list, then "Para." at
227− // of prefix is "- two" — a list item — so the
228+ // indent 0 (< listBaseIndent+2). The closure-
228− // candidate is REJECTED. (Conservative: we don't
229+ // aware tracker sees the list close at "Para."
229− // know the list is "closed" without looking at
230+ // and accepts the boundary at its start — the
230− // what follows.)
231+ // prefix contains a complete list.
231− want: -1,
232+ want: len("- one\n- two\n\n"),
233+ },
234+ {
235+ name: "closed ordered list then paragraph",
236+ content: "1. first\n2. second\n\nAfter list.",
237+ // Same closure rule for ordered lists. Boundary at
238+ // the start of "After list." — the list closed on
239+ // the dedented non-marker line.
240+ want: len("1. first\n2. second\n\n"),
241+ },
242+ {
243+ name: "list then multiple paragraphs",
244+ content: "- item\n\nPara one.\n\nPara two.",
245+ // The list closes at "Para one." and a boundary is
246+ // accepted there. A later boundary at "Para two."
247+ // should also be found (no list open anymore).
248+ // Latest boundary wins.
249+ want: len("- item\n\nPara one.\n\n"),
250+ },
251+ {
252+ name: "loose list continuation keeps list open",
253+ content: "- item one\n\n continuation\n\nPara after.",
254+ // " continuation" is indented 2 (>=
255+ // listBaseIndent+2) so the list stays open
256+ // through it. The list only closes at "Para
257+ // after." Boundary at the start of "Para after.".
258+ want: len("- item one\n\n continuation\n\n"),
259+ },
260+ {
261+ name: "nested list then closure",
262+ content: "- outer\n - inner\n\nBack to top level.",
263+ // Inner list opens at indent 2. "Back to top
264+ // level." at indent 0 closes everything.
265+ // Boundary at the start of "Back to top level.".
266+ want: len("- outer\n - inner\n\n"),
232 },
267 },
233 {
268 {
234 name: "table at end",
269 name: "table at end",
@@ -638,9 +673,69 @@ func TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash(t *testing.T) {
638 }
673 }
639}
674}
640
675
641−// -----------------------------------------------------------------------
676+// TestStreamingMarkdown_LongThinkingWithLists is the regression
642−// Integration assertions on the wired-in path.
677+// test for the lag bug: a long thinking trace that contains a list
643−// -----------------------------------------------------------------------
678+// early on, followed by many paragraphs. Before the closure-aware
679+// list tracker, the list marker forced findSafeMarkdownBoundary to
680+// return -1 for the ENTIRE trace, so every streaming delta did a
681+// full glamour re-render of the growing text — O(n²) overall and
682+// visible lag on long reasoning.
683+//
684+// With the closure-aware tracker the list closes at the first
685+// dedented paragraph, the stable-prefix cache advances past it,
686+// and subsequent deltas only re-render the trailing partial. This
687+// test asserts the cache actually advances (stablePrefix grows
688+// beyond the list) and the final output is visually equivalent to
689+// a fresh full render.
690+func TestStreamingMarkdown_LongThinkingWithLists(t *testing.T) {
691+ t.Parallel()
692+
693+ // Build a thinking trace: intro, a short list, then many
694+ // paragraphs that simulate a long reasoning section.
695+ var doc strings.Builder
696+ doc.WriteString("Let me analyze this step by step.\n\n")
697+ doc.WriteString("1. First consideration.\n\n")
698+ doc.WriteString("2. Second consideration.\n\n")
699+ doc.WriteString("3. Third consideration.\n\n")
700+ for i := range 200 {
701+ fmt.Fprintf(&doc, "Paragraph %d of the reasoning trace. It has enough text to be a real paragraph.\n\n", i)
702+ }
703+
704+ full := doc.String()
705+ listEnd := strings.Index(full, "3. Third consideration.\n\n") + len("3. Third consideration.\n\n")
706+ require.Greater(t, listEnd, 0, "test setup: list end offset")
707+
708+ const width = 80
709+ r := newTestRenderer(t, width)
710+ var sm streamingMarkdown
711+
712+ // Stream the document in ~50 progressive prefixes. After the
713+ // list closes, the cache should advance past listEnd.
714+ steps := 50
715+ prefixes := progressivePrefixes(full, steps)
716+ var lastOut string
717+ advanced := false
718+ for i, p := range prefixes {
719+ if p == "" {
720+ continue
721+ }
722+ lastOut = sm.Render(p, width, r)
723+ // Once the prefix extends well past the list, the
724+ // stable prefix should have advanced past listEnd.
725+ if len(p) > listEnd+200 && len(sm.stablePrefix) > listEnd {
726+ advanced = true
727+ }
728+ _ = i
729+ }
730+ require.True(t, advanced,
731+ "stable prefix must advance past the closed list — if it didn't, "+
732+ "every delta is doing a full re-render (the lag bug)")
733+
734+ // Final output must be visually equivalent to a fresh render.
735+ fresh := freshRender(t, full, width)
736+ require.Equal(t, normalizeRender(fresh), normalizeRender(lastOut),
737+ "final streaming output must match a fresh full render visually")
738+}
644
739
645// -----------------------------------------------------------------------
740// -----------------------------------------------------------------------
646// T5 / T6 / T7: anywhere-in-prefix hazards (B1 / B2 / B3 from the
741// T5 / T6 / T7: anywhere-in-prefix hazards (B1 / B2 / B3 from the
@@ -726,8 +821,11 @@ func runProgressiveBoundaryRespectTest(t *testing.T, doc string, hazardLineOffse
726// trailing paragraph creates a candidate boundary between the list
821// trailing paragraph creates a candidate boundary between the list
727// item and its continuation; the trailing non-blank line of that
822// item and its continuation; the trailing non-blank line of that
728// candidate prefix is the continuation paragraph (not a list
823// candidate prefix is the continuation paragraph (not a list
729−// marker), so the line-only check would accept it. The
824+// marker), so the line-only check would accept it. The closure-
730−// anywhere-in-prefix list-marker check rejects it.
825+// aware list tracker keeps the list open through the continuation
826+// (indent >= listBaseIndent+2) and only closes it at the dedented
827+// "Trailing paragraph" line, so boundaries INSIDE the list are
828+// rejected while the boundary at the closing line is accepted.
731func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
829func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
732 t.Parallel()
830 t.Parallel()
733
831
@@ -744,12 +842,42 @@ func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
744 }, "\n")
842 }, "\n")
745
843
746 // The first list marker line begins after "Intro paragraph.\n\n".
844 // The first list marker line begins after "Intro paragraph.\n\n".
747− // The cached stable prefix may include that boundary (BEFORE
845+ // The closure boundary is the start of "Trailing paragraph after
748− // the list opens) but must never advance into the list.
846+ // the list." — the first safe boundary after the list closes.
749− hazardOffset := strings.Index(doc, "- item one")
847+ listStart := strings.Index(doc, "- item one")
750− require.Greater(t, hazardOffset, 0, "test setup")
848+ require.Greater(t, listStart, 0, "test setup")
849+ closureStart := strings.Index(doc, "Trailing paragraph")
850+ require.Greater(t, closureStart, listStart, "test setup")
751
851
752− runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
852+ const width = 80
853+ const steps = 25
854+ renderer := newTestRenderer(t, width)
855+ var sm streamingMarkdown
856+
857+ prefixes := progressivePrefixes(doc, steps)
858+ var lastOut string
859+ for i, p := range prefixes {
860+ if p == "" {
861+ continue
862+ }
863+ lastOut = sm.Render(p, width, renderer)
864+ require.NotEmptyf(t, lastOut, "step %d: empty render", i)
865+ // The stable prefix must NEVER be inside the list —
866+ // either it's at/before the intro boundary (before the
867+ // list opens) or at/after the closure boundary (after
868+ // the list closes). A value strictly between listStart
869+ // and closureStart would mean a boundary inside the
870+ // list was accepted, which would split the loose list.
871+ if len(sm.stablePrefix) > listStart && len(sm.stablePrefix) < closureStart {
872+ t.Fatalf("step %d: cached stable prefix landed inside the loose list "+
873+ "(len=%d, list starts at %d, closure at %d)\nsm.stablePrefix=%q",
874+ i, len(sm.stablePrefix), listStart, closureStart, sm.stablePrefix)
875+ }
876+ }
877+
878+ fresh := freshRender(t, doc, width)
879+ require.Equal(t, nonBlankLines(fresh), nonBlankLines(lastOut),
880+ "final streaming output must contain the same non-blank lines as a fresh full render")
753}
881}
754
882
755// TestStreamingMarkdown_HTMLBlock locks in the B2 fix. A raw HTML
883// TestStreamingMarkdown_HTMLBlock locks in the B2 fix. A raw HTML
internal/ui/chat/lsp_restart.go+1−1
@@ -22,7 +22,7 @@ func NewLSPRestartToolMessageItem(
22 result *message.ToolResult,
22 result *message.ToolResult,
23 canceled bool,
23 canceled bool,
24) ToolMessageItem {
24) ToolMessageItem {
25− return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled)
25+ return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled, "")
26}
26}
27
27
28// LSPRestartToolRenderContext renders lsprestart tool messages.
28// LSPRestartToolRenderContext renders lsprestart tool messages.
internal/ui/chat/mcp.go+4−4
@@ -23,7 +23,7 @@ func NewMCPToolMessageItem(
23 result *message.ToolResult,
23 result *message.ToolResult,
24 canceled bool,
24 canceled bool,
25) ToolMessageItem {
25) ToolMessageItem {
26− return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
26+ return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled, "")
27}
27}
28
28
29// MCPToolRenderContext renders bash tool messages.
29// MCPToolRenderContext renders bash tool messages.
@@ -34,7 +34,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
34 cappedWidth := cappedMessageWidth(width)
34 cappedWidth := cappedMessageWidth(width)
35 toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
35 toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
36 if len(toolNameParts) != 3 {
36 if len(toolNameParts) != 3 {
37− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
37+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth, false)
38 }
38 }
39 mcpName := humanizedToolName(toolNameParts[1])
39 mcpName := humanizedToolName(toolNameParts[1])
40 toolName := humanizedToolName(toolNameParts[2])
40 toolName := humanizedToolName(toolNameParts[2])
@@ -50,7 +50,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
50
50
51 var params map[string]any
51 var params map[string]any
52 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
52 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
53− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
53+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
54 }
54 }
55
55
56 var toolParams []string
56 var toolParams []string
@@ -73,6 +73,6 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
73 }
73 }
74
74
75 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
75 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
76− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
76+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
77 return joinToolParts(header, body)
77 return joinToolParts(header, body)
78}
78}
internal/ui/chat/messages.go+2−1
@@ -364,7 +364,7 @@ func cappedMessageWidth(availableWidth int) int {
364//
364//
365// For assistant messages with tool calls, pass a toolResults map to link results.
365// For assistant messages with tool calls, pass a toolResults map to link results.
366// Use BuildToolResultMap to create this map from all messages in a session.
366// Use BuildToolResultMap to create this map from all messages in a session.
367−func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
367+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult, diffTool string) []MessageItem {
368 switch msg.Role {
368 switch msg.Role {
369 case message.User:
369 case message.User:
370 // Reconstruct shell command items from ShellCommand parts.
370 // Reconstruct shell command items from ShellCommand parts.
@@ -401,6 +401,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
401 tc,
401 tc,
402 result,
402 result,
403 msg.FinishReason() == message.FinishReasonCanceled,
403 msg.FinishReason() == message.FinishReasonCanceled,
404+ diffTool,
404 ))
405 ))
405 }
406 }
406 return items
407 return items
internal/ui/chat/references.go+1−1
@@ -23,7 +23,7 @@ func NewReferencesToolMessageItem(
23 result *message.ToolResult,
23 result *message.ToolResult,
24 canceled bool,
24 canceled bool,
25) ToolMessageItem {
25) ToolMessageItem {
26− return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled)
26+ return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled, "")
27}
27}
28
28
29// ReferencesToolRenderContext renders references tool messages.
29// ReferencesToolRenderContext renders references tool messages.
internal/ui/chat/search.go+8−8
@@ -27,7 +27,7 @@ func NewGlobToolMessageItem(
27 result *message.ToolResult,
27 result *message.ToolResult,
28 canceled bool,
28 canceled bool,
29) ToolMessageItem {
29) ToolMessageItem {
30− return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled)
30+ return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled, "")
31}
31}
32
32
33// GlobToolRenderContext renders glob tool messages.
33// GlobToolRenderContext renders glob tool messages.
@@ -42,7 +42,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
42
42
43 var params tools.GlobParams
43 var params tools.GlobParams
44 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
44 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
45− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
45+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
46 }
46 }
47
47
48 toolParams := []string{params.Pattern}
48 toolParams := []string{params.Pattern}
@@ -86,7 +86,7 @@ func NewGrepToolMessageItem(
86 result *message.ToolResult,
86 result *message.ToolResult,
87 canceled bool,
87 canceled bool,
88) ToolMessageItem {
88) ToolMessageItem {
89− return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled)
89+ return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled, "")
90}
90}
91
91
92// GrepToolRenderContext renders grep tool messages.
92// GrepToolRenderContext renders grep tool messages.
@@ -101,7 +101,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
101
101
102 var params tools.GrepParams
102 var params tools.GrepParams
103 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
103 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
104− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
104+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
105 }
105 }
106
106
107 toolParams := []string{params.Pattern}
107 toolParams := []string{params.Pattern}
@@ -151,7 +151,7 @@ func NewLSToolMessageItem(
151 result *message.ToolResult,
151 result *message.ToolResult,
152 canceled bool,
152 canceled bool,
153) ToolMessageItem {
153) ToolMessageItem {
154− return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled)
154+ return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled, "")
155}
155}
156
156
157// LSToolRenderContext renders ls tool messages.
157// LSToolRenderContext renders ls tool messages.
@@ -166,7 +166,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
166
166
167 var params tools.LSParams
167 var params tools.LSParams
168 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
168 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
169− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
169+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
170 }
170 }
171
171
172 path := params.Path
172 path := params.Path
@@ -211,7 +211,7 @@ func NewSourcegraphToolMessageItem(
211 result *message.ToolResult,
211 result *message.ToolResult,
212 canceled bool,
212 canceled bool,
213) ToolMessageItem {
213) ToolMessageItem {
214− return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
214+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled, "")
215}
215}
216
216
217// SourcegraphToolRenderContext renders sourcegraph tool messages.
217// SourcegraphToolRenderContext renders sourcegraph tool messages.
@@ -226,7 +226,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
226
226
227 var params tools.SourcegraphParams
227 var params tools.SourcegraphParams
228 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
228 if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
229− return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
229+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth, false)
230 }
230 }
231
231
232 toolParams := []string{params.Query}
232 toolParams := []string{params.Query}
internal/ui/chat/shell.go+85−32
@@ -42,6 +42,21 @@ type ShellItem struct {
42 sty *styles.Styles
42 sty *styles.Styles
43 pending bool
43 pending bool
44 anim *anim.Anim
44 anim *anim.Anim
45+
46+ // xOffset-independent render cache. The expensive part of RawRender
47+ // (ANSI-reset trim, RemapANSI16, split, per-line width) does not
48+ // depend on the horizontal scroll offset, yet hscroll/highlight
49+ // Bump() forces a full re-render on every keystroke. Cache the
50+ // prepared display lines keyed by the inputs that actually affect
51+ // them, and reapply only the xOffset windowing per render.
52+ shellCacheValid bool
53+ shellCacheOutput string
54+ shellCacheExpanded bool
55+ shellCacheWidth int
56+ shellCachePending bool
57+ shellCacheLines []string
58+ shellCacheTruncated int
59+ shellCacheMaxWidth int
45}
60}
46
61
47var (
62var (
@@ -181,6 +196,14 @@ func (s *ShellItem) ScrollHorizontal(delta int) {
181 s.Bump()
196 s.Bump()
182}
197}
183
198
199+// clearCache drops the base render cache and the xOffset-independent
200+// output cache. The latter embeds the current theme's remapped ANSI
201+// colors (via RemapANSI16), so a style change must re-run the remap.
202+func (s *ShellItem) clearCache() {
203+ s.cachedMessageItem.clearCache()
204+ s.shellCacheValid = false
205+}
206+
184// ToggleExpanded toggles the expanded state and invalidates the cache.
207// ToggleExpanded toggles the expanded state and invalidates the cache.
185func (s *ShellItem) ToggleExpanded() bool {
208func (s *ShellItem) ToggleExpanded() bool {
186 s.expandedContent = !s.expandedContent
209 s.expandedContent = !s.expandedContent
@@ -188,33 +211,19 @@ func (s *ShellItem) ToggleExpanded() bool {
188 return s.expandedContent
211 return s.expandedContent
189}
212}
190
213
191−func (s *ShellItem) RawRender(width int) string {
214+// prepareOutput computes the xOffset-independent render intermediate:
192− cappedWidth := cappedMessageWidth(width)
215+// the trimmed, ANSI-remapped, split-and-truncated display lines plus the
193−
216+// max-line-width clamp value. The result is cached and only recomputed
194− cmd := strings.ReplaceAll(s.command, "\n", " ")
217+// when the output, expansion, capped width, or pending state change, so a
195− cmd = strings.ReplaceAll(cmd, "\t", " ")
218+// horizontal-scroll keystroke (which Bumps the list cache) no longer
196−
219+// re-runs the ANSI trim, RemapANSI16, split, and per-line width scan.
197− var prompt string
220+func (s *ShellItem) prepareOutput(cappedWidth int) (displayLines []string, truncatedCount, maxLineWidth int) {
198− if s.focused {
221+ if s.shellCacheValid &&
199− prompt = s.sty.Messages.ShellPrompt.Render("$")
222+ s.shellCacheOutput == s.output &&
200− } else {
223+ s.shellCacheExpanded == s.expandedContent &&
201− prompt = s.sty.Messages.ShellPromptBlurred.Render("$")
224+ s.shellCacheWidth == cappedWidth &&
202− }
225+ s.shellCachePending == s.pending {
203−
226+ return s.shellCacheLines, s.shellCacheTruncated, s.shellCacheMaxWidth
204− highlighted := s.sty.Messages.ShellCommand.Render(cmd)
205− header := prompt + " " + highlighted
206−
207− if s.pending {
208− if s.output == "" {
209− // Nothing streamed yet: show the spinner under the header.
210− return header + "\n" + s.anim.Render()
211− }
212− } else if s.exitCode != 0 {
213− header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
214− }
215−
216− if s.output == "" {
217− return header
218 }
227 }
219
228
220 // Remap raw ANSI 16-color codes onto legible Charmtone colors so
229 // Remap raw ANSI 16-color codes onto legible Charmtone colors so
@@ -243,8 +252,8 @@ func (s *ShellItem) RawRender(width int) string {
243 maxLines = len(lines)
252 maxLines = len(lines)
244 }
253 }
245
254
246− displayLines := lines
255+ displayLines = lines
247− truncatedCount := 0
256+ truncatedCount = 0
248 if len(lines) > maxLines {
257 if len(lines) > maxLines {
249 if s.pending {
258 if s.pending {
250 // Show the most recent lines while still running.
259 // Show the most recent lines while still running.
@@ -263,7 +272,51 @@ func (s *ShellItem) RawRender(width int) string {
263 maxW = w
272 maxW = w
264 }
273 }
265 }
274 }
266− s.maxLineWidth = max(0, maxW-cappedWidth)
275+ maxLineWidth = max(0, maxW-cappedWidth)
276+
277+ s.shellCacheValid = true
278+ s.shellCacheOutput = s.output
279+ s.shellCacheExpanded = s.expandedContent
280+ s.shellCacheWidth = cappedWidth
281+ s.shellCachePending = s.pending
282+ s.shellCacheLines = displayLines
283+ s.shellCacheTruncated = truncatedCount
284+ s.shellCacheMaxWidth = maxLineWidth
285+
286+ return displayLines, truncatedCount, maxLineWidth
287+}
288+
289+func (s *ShellItem) RawRender(width int) string {
290+ cappedWidth := cappedMessageWidth(width)
291+
292+ cmd := strings.ReplaceAll(s.command, "\n", " ")
293+ cmd = strings.ReplaceAll(cmd, "\t", " ")
294+
295+ var prompt string
296+ if s.focused {
297+ prompt = s.sty.Messages.ShellPrompt.Render("$")
298+ } else {
299+ prompt = s.sty.Messages.ShellPromptBlurred.Render("$")
300+ }
301+
302+ highlighted := s.sty.Messages.ShellCommand.Render(cmd)
303+ header := prompt + " " + highlighted
304+
305+ if s.pending {
306+ if s.output == "" {
307+ // Nothing streamed yet: show the spinner under the header.
308+ return header + "\n" + s.anim.Render()
309+ }
310+ } else if s.exitCode != 0 {
311+ header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
312+ }
313+
314+ if s.output == "" {
315+ return header
316+ }
317+
318+ displayLines, truncatedCount, maxLineWidth := s.prepareOutput(cappedWidth)
319+ s.maxLineWidth = maxLineWidth
267
320
268 var body strings.Builder
321 var body strings.Builder
269
322
@@ -271,7 +324,7 @@ func (s *ShellItem) RawRender(width int) string {
271 // the "more lines" notice before the output.
324 // the "more lines" notice before the output.
272 if truncatedCount > 0 && s.pending {
325 if truncatedCount > 0 && s.pending {
273 body.WriteString(s.sty.Messages.ShellTruncation.Render(
326 body.WriteString(s.sty.Messages.ShellTruncation.Render(
274− fmt.Sprintf("… %d earlier lines", truncatedCount),
327+ fmt.Sprintf(assistantMessageTailWindowFormat, truncatedCount),
275 ))
328 ))
276 body.WriteString("\n")
329 body.WriteString("\n")
277 }
330 }
@@ -289,7 +342,7 @@ func (s *ShellItem) RawRender(width int) string {
289 // When finished, hidden lines are below, so show the notice after.
342 // When finished, hidden lines are below, so show the notice after.
290 if truncatedCount > 0 && !s.pending && !s.expandedContent {
343 if truncatedCount > 0 && !s.pending && !s.expandedContent {
291 body.WriteString(s.sty.Messages.ShellTruncation.Render(
344 body.WriteString(s.sty.Messages.ShellTruncation.Render(
292− fmt.Sprintf("… %d more lines", truncatedCount),
345+ fmt.Sprintf(assistantMessageTruncateFormat, truncatedCount),
293 ))
346 ))
294 return header + "\n" + body.String()
347 return header + "\n" + body.String()
295 }
348 }
internal/ui/chat/streaming_markdown.go+232−251
@@ -201,245 +201,268 @@ func trimGlamourMargins(s string) string {
201// we have the slightest doubt we return -1 and let the caller fall
201// we have the slightest doubt we return -1 and let the caller fall
202// back to a full render.
202// back to a full render.
203//
203//
204−// Decision tree, in order of preference (latest boundary wins):
204+// The implementation is a single O(n) forward scan
205+// ([scanSafeBoundaries]) that tracks block state across the whole
206+// document: fenced-code toggle, list open/close depth, HTML-block
207+// opener, and link-reference-definition presence. State is updated
208+// per line, so every blank-line candidate is evaluated against
209+// already-computed state in O(1) — no re-scanning of the prefix.
205//
210//
206−// 1. Walk backward through every "blank line" position p such that
211+// Closure-aware list tracking (B1) replaces the prior "any list
207−// content[:p] ends with "\n\n" (or "\n[ \t]*\n").
212+// marker anywhere → reject" rule. A list is considered open from
208−// 2. For each candidate, check that content[:p] has an even
213+// the first marker line until a blank line is followed by a
209−// number of triple-backtick fence lines (no open fenced
214+// non-continuation, non-marker line at the same or shallower
210−// block). Any odd count means we'd be cutting inside a fence
215+// indent. Once closed, prefixes ending after the close are
211−// and mis-syntax-highlighting the trailing partial.
216+// accepted, so the incremental cache resumes working for the
212−// 2b. Reject if any line in content[:p] (outside fenced blocks)
217+// paragraphs that follow a list — the common case in LLM reasoning
213−// is a list-marker line, an HTML-block opener, or a link
218+// traces. On any ambiguity the list is kept open (conservative —
219+// falls back to -1).
220+//
214−// reference definition. See [prefixHasOpenHazard] for the
221+// B2 (HTML block) and B3 (link reference definition) remain
215−// reasoning behind these "anywhere in prefix" rejects.
222+// anywhere-in-prefix rejects: the typical assistant output
216−// 3. Reject if the last non-blank line of content[:p] is:
223+// contains neither, so the perf cost is zero in the common case.
217−// - a list item marker line ("^\s*([-*+]|\d+\.)\s")
218−// - a table line (contains "|")
219−// - a block quote ("^\s*>")
220−// - a setext header underline ("^=+\s*$" or "^-+\s*$")
221−// - an indented code line (4+ leading spaces or a tab)
222−// 4. Reject if the line immediately AFTER the boundary (skipping
223−// leading blank lines) looks like a setext underline (a line
224−// of '=' or '-' only). Rendering the prefix as a paragraph
225−// would change once the underline arrived; that's exactly the
226−// "splitting changes the prefix render" hazard §4.4 calls out.
227//
224//
228// Returns the byte offset of the first character AFTER the blank
225// Returns the byte offset of the first character AFTER the blank
229// line, i.e. the start of the trailing segment.
226// line, i.e. the start of the trailing segment.
230func findSafeMarkdownBoundary(content string) int {
227func findSafeMarkdownBoundary(content string) int {
228+ boundaries := scanSafeBoundaries(content)
231− if len(content) == 0 {
229+ if len(boundaries) == 0 {
232 return -1
230 return -1
233 }
231 }
234−
232+ return boundaries[len(boundaries)-1]
235− // Iterate every blank-line position from latest to earliest.
236− for p := blankLineBefore(content, len(content)); p > 0; p = blankLineBefore(content, p-1) {
237− if !isSafeBoundaryAt(content, p) {
238− continue
239− }
240− return p
241− }
242− return -1
243}
233}
244
234
245−// blankLineBefore returns the byte offset of the first character
235+// scanSafeBoundaries walks content once, O(n), tracking block
246−// AFTER the latest blank-line separator that ends strictly before
236+// state, and returns the byte offsets of every safe stable-prefix
247−// `until`. A blank-line separator is a sequence "\n([ \t]*\n)+"
237+// boundary in increasing order. A boundary is the byte offset
248−// — one newline, then one or more lines containing only spaces or
238+// immediately after a blank-line separator at which content[:p]
249−// tabs and terminated by another newline. The returned offset is
239+// can be rendered independently of content[p:].
250−// the start of the first non-blank line that follows the
251−// separator (or the position immediately after the final newline,
252−// if no further content remains).
253//
240//
254−// Returns -1 when no blank-line separator exists before `until`.
241+// State carried across the scan:
255−func blankLineBefore(content string, until int) int {
256− if until <= 0 {
257− return -1
258− }
259− // Walk backward looking for a newline followed (after optional
260− // blank-line content) by another newline. We track the latest
261− // newline we've seen; if the next earlier newline has only
262− // blank chars between them, we have a blank-line separator
263− // and the boundary sits immediately after the latest newline.
264− end := until
265− for end > 0 {
266− nl := strings.LastIndexByte(content[:end], '\n')
267− if nl < 0 {
268− return -1
269− }
270− // Look for an earlier newline whose gap to nl is empty
271− // or whitespace only.
272− prev := strings.LastIndexByte(content[:nl], '\n')
273− for prev >= 0 {
274− gap := content[prev+1 : nl]
275− if isBlankOrSpaces(gap) {
276− return nl + 1
277− }
278− // Gap had non-whitespace; nl is not a blank-line
279− // separator. Move up: try with the earlier newline as
280− // the new "nl" candidate.
281− break
282− }
283− end = nl
284− }
285− return -1
286−}
287−
288−// isBlankOrSpaces reports whether s consists entirely of spaces
289−// and tabs (or is empty).
290−func isBlankOrSpaces(s string) bool {
291− for i := range len(s) {
292− if s[i] != ' ' && s[i] != '\t' {
293− return false
294− }
295− }
296− return true
297−}
298−
299−// isSafeBoundaryAt reports whether content[:p] is a safe stable
300−// prefix. p must be a blank-line boundary (start of a line, with a
301−// blank line immediately preceding).
302//
242//
303−// Beyond the last-line checks, three "anywhere in the prefix"
243+// - inFence: toggled by each fence line. While true, no boundary
304−// hazards force a reject because they cannot be reliably reasoned
244+// is safe (we're inside a fenced code block) and list/html/ref
305−// about by inspecting the trailing line alone. For each of these
245+// detection is skipped so code-block contents don't false-
306−// the simplest, safest rule was chosen — see prefixHasOpenHazard.
246+// trigger the hazards.
307−func isSafeBoundaryAt(content string, p int) bool {
247+// - listDepth: current open-list nesting. Incremented on a
308− prefix := content[:p]
248+// marker line, decremented on closure (see below). A boundary
309−
249+// is only safe when listDepth == 0 (no list open).
310− // (2) Even number of triple-backtick fence lines.
250+// - sawHTMLBlock / sawLinkRef: anywhere-in-prefix flags for B2
311− if countFenceLines(prefix)%2 != 0 {
251+// and B3. Once set they never clear — the prefix is
312− return false
252+// permanently unsafe for splitting.
313− }
314−
315− // (2b) Anywhere-in-prefix hazards: open list (B1), HTML block
316− // opener (B2), reference link definition (B3). Any of these
317− // anywhere in the prefix forces a fallback.
318− if prefixHasOpenHazard(prefix) {
319− return false
320− }
321−
322− // (3) Inspect the last non-blank line of the prefix.
253+// - prevNonBlank: the last non-blank line seen before the
323− lastLine := lastNonBlankLine(prefix)
254+// current candidate, for the last-line check
324− if lastLine != "" && lineOpensConstruct(lastLine) {
255+// ([lineOpensConstruct]).
256+//
257+// Each blank-line candidate is evaluated in O(1) against the
258+// already-computed state, so the full scan is O(lines) regardless
259+// of how many candidates exist. This replaces the prior O(n²)
260+// backward search that re-scanned the prefix for every candidate.
261+//
262+// List closure detection (B1 fix): a list opens on any marker line
263+// when no list is open. A list closes when, after a blank line, a
264+// non-marker line appears at indent < listBaseIndent+2 (not a
265+// continuation paragraph). On any ambiguity the list is kept open
266+// (conservative — falls back to -1). This replaces the prior "any
267+// list marker anywhere → reject" rule, so boundaries after a
268+// closed list followed by paragraphs are accepted — the common
269+// case in LLM reasoning traces.
270+//
271+// Crucially, boundary evaluation happens BEFORE the state update
272+// for the current line: the boundary at the start of line L has
273+// prefix = content[:L], so the state must reflect everything
274+// BEFORE L, not including L's effects. For example, the boundary
275+// at the start of a fence-opening line is safe (the fence hasn't
276+// opened in the prefix yet); the boundary at the start of a list-
277+// marker line is safe (the list hasn't opened in the prefix yet).
278+func scanSafeBoundaries(content string) []int {
279+ if len(content) == 0 {
325− return false
280+ return nil
326 }
281 }
327
282
328− // (4) If anything follows, make sure it doesn't look like a
283+ var boundaries []int
329− // setext underline that would retroactively turn the last
284+ var prevNonBlank string
330− // paragraph of the prefix into a header.
285+ inFence := false
331− if rest := content[p:]; rest != "" {
286+ listDepth := 0
332− first := firstNonBlankLine(rest)
287+ sawHTMLBlock := false
333− if isSetextUnderlineCandidate(first) {
288+ sawLinkRef := false
289+
290+ // listBaseIndent is the indent column of the innermost open
291+ // list's markers. Used for closure detection: a non-marker
292+ // line at indent < listBaseIndent+2 after a blank line closes
293+ // the list.
294+ listBaseIndent := 0
295+
296+ // blankPending tracks whether the line immediately before the
297+ // current line was blank. A boundary candidate exists at the
298+ // start of the first non-blank line after a blank line.
299+ blankPending := false
300+
301+ // processLine handles one line (without its newline
302+ // terminator) starting at byte offset off. It evaluates any
303+ // pending boundary candidate, then updates block state for
304+ // the line.
305+ //
306+ // Boundary evaluation happens BEFORE the state update for the
307+ // current line: the boundary at the start of line L has
308+ // prefix = content[:off], so the state must reflect everything
309+ // BEFORE L. However, when an open list CLOSES at line L (L is
310+ // a non-marker, dedented line after a blank line), the prefix
311+ // contains a COMPLETE list — the boundary is safe even though
312+ // listDepth is still > 0 in the pre-L state. We compute
313+ // listClosesHere before evaluation and pass it as an override.
314+ processLine := func(line string, off int) {
315+ blank := strings.TrimRight(line, " \t") == ""
316+
317+ if blank {
318+ blankPending = true
334− return false
319+ return
335 }
320 }
336− }
337
321
338− return true
322+ trimmed := strings.TrimLeft(line, " \t")
323+ indent := len(line) - len(trimmed)
324+ isMarker := isListItemMarker(trimmed)
325+
326+ // Compute whether this line closes the open list.
327+ // A list closes when a blank line is followed by a
328+ // non-marker line at indent < listBaseIndent+2 (not
329+ // a continuation paragraph).
330+ listClosesHere := listDepth > 0 && blankPending && !isMarker && indent < listBaseIndent+2
331+
332+ // Non-blank line. If a blank line preceded, this is a
333+ // boundary candidate at offset `off`. Evaluate BEFORE
334+ // updating state — the prefix is content[:off].
335+ if blankPending {
336+ if isSafeBoundaryState(inFence, listDepth, listClosesHere, sawHTMLBlock, sawLinkRef, prevNonBlank, trimmed) {
337+ boundaries = append(boundaries, off)
338+ }
339+ blankPending = false
339−}
340+ }
340
341
341−// prefixHasOpenHazard reports whether prefix contains any of three
342+ // Now update state for this line.
342−// constructs that cannot be safely cut at a blank-line boundary
343−// even when the immediately preceding line looks fine. Each check
344−// uses the SIMPLEST viable conservative rule per the F8 round-2
345−// review:
346−//
347−// B1 (loose lists). A loose list has a blank line between an item
348−// and a continuation paragraph that begins with indentation
349−// but no list marker. If a candidate boundary lands on that
350−// blank line, the prefix's trailing non-blank line is the
351−// continuation paragraph, NOT a list marker, so the last-line
352−// check would accept it even though the list is still open.
353−//
354−// Rule chosen: any list-marker line ANYWHERE in the prefix
355−// forces -1. This is overly conservative — it forfeits
356−// boundary advancement past a closed list — but it eliminates
357−// the entire bug class with zero parsing of CommonMark's
358−// loose-list closure semantics. We retain the most useful
359−// boundary in practice: the one BEFORE the list opens (no
360−// marker has appeared in the prefix yet).
361−//
362−// B2 (HTML blocks). CommonMark defines seven HTML-block opener
363−// patterns (script/pre/style/textarea, comments, processing
364−// instructions, CDATA, declarations, recognised tag names).
365−// If the prefix opens an HTML block that the suffix closes,
366−// splitting renders the prefix as raw HTML and the suffix as
367−// prose.
368−//
369−// Rule chosen: any HTML-block opener anywhere in the prefix
370−// forces -1. Same trade-off as B1 — the typical assistant
371−// output contains no raw HTML, so the perf cost is zero in
372−// the common case.
373−//
374−// B3 (reference link definitions). A line of the form
375−// "[label]: <url>" defines a link reference that the suffix
376−// may later use as "[text][label]". Splitting the document
377−// loses the definition because each half is rendered as an
378−// independent glamour document.
379−//
380−// Rule chosen: any reference link definition line anywhere in
381−// the prefix forces -1. Suffix-side reference detection is
382−// fragile (three syntaxes: [text][label], [label][], [label]),
383−// so the prefix-side check is the simpler safe choice.
384−//
385−// All three rules accept the perf hit of "no boundary after a
386−// list / HTML block / link def" in exchange for guaranteed
387−// soundness. If profiling shows this kills the F8 win on real
388−// streaming traces, the next iteration can promote each rule to
389−// its less-conservative variant (closure-aware list tracking,
390−// per-tag HTML close detection, suffix-aware ref tracking).
391−func prefixHasOpenHazard(prefix string) bool {
392− inFence := false
393− for line := range splitLines(prefix) {
394− // Track fenced state so list/html/ref patterns inside a
395− // fenced code block do not falsely trigger the hazards.
396 if isFenceLine(line) {
343 if isFenceLine(line) {
397 inFence = !inFence
344 inFence = !inFence
398− continue
345+ prevNonBlank = line
346+ return
399 }
347 }
348+
400 if inFence {
349 if inFence {
401− continue
350+ prevNonBlank = line
351+ return
402 }
352 }
403− trimmed := strings.TrimLeft(line, " \t")
353+
404− if trimmed == "" {
354+ // Update list depth (closure-aware B1).
405− continue
355+ switch {
356+ case listDepth == 0 && isMarker:
357+ listDepth = 1
358+ listBaseIndent = indent
359+ case listDepth > 0 && isMarker:
360+ switch {
361+ case indent > listBaseIndent:
362+ // Nested list opens.
363+ listDepth++
364+ listBaseIndent = indent
365+ case indent < listBaseIndent:
366+ // Marker at shallower indent: close
367+ // current list. If an outer list
368+ // existed we'd need its base indent;
369+ // we don't track the stack, so be
370+ // conservative and close everything,
371+ // then open a new list at this indent.
372+ listDepth = 1
373+ listBaseIndent = indent
374+ }
375+ // Same indent: another item, depth unchanged.
376+ case listDepth > 0 && !isMarker && blankPending:
377+ // Blank line then non-marker line: check for
378+ // closure. A continuation paragraph is indented
379+ // 2+ relative to the marker.
380+ if indent < listBaseIndent+2 {
381+ // Dedent: list closed. Be conservative
382+ // and close all nesting levels.
383+ listDepth = 0
384+ }
385+ // Otherwise: continuation, list stays open.
406 }
386 }
407− // B1: any list-item marker.
387+ // listDepth > 0 && !isMarker && !blankPending:
408− if isListItemMarker(trimmed) {
388+ // A non-marker line directly after another line
409− return true
389+ // (no blank line between). This is either a lazy
390+ // continuation or part of a tight list's item.
391+ // Keep the list open (conservative).
392+
393+ // B2: HTML block opener (anywhere in prefix).
394+ if !sawHTMLBlock && isHTMLBlockOpener(line) {
395+ sawHTMLBlock = true
410 }
396 }
411− // B2: HTML block opener.
397+ // B3: link reference definition (anywhere in prefix).
412− if isHTMLBlockOpener(line) {
398+ if !sawLinkRef && isLinkRefDefinition(line) {
413− return true
399+ sawLinkRef = true
414 }
400 }
415− // B3: link reference definition.
401+
416− if isLinkRefDefinition(line) {
402+ prevNonBlank = line
417− return true
403+ }
404+
405+ start := 0
406+ for i := 0; i < len(content); i++ {
407+ if content[i] != '\n' {
408+ continue
418 }
409 }
410+ processLine(content[start:i], start)
411+ start = i + 1
419 }
412 }
413+ // Process the trailing segment (content after the last '\n').
414+ // A non-blank trailing line after a blank line is a valid
415+ // boundary candidate. A blank trailing line is irrelevant.
416+ if start < len(content) {
417+ processLine(content[start:], start)
418+ }
419+
420− return false
420+ return boundaries
421}
421}
422
422
423−// countFenceLines counts lines that begin a fenced code block in
423+// isSafeBoundaryState evaluates the safety of a boundary candidate
424−// the CommonMark sense: a line whose first non-whitespace run is
424+// given the block state computed by the forward scan (reflecting
425−// at least three consecutive backticks (or tildes). Each such
425+// everything BEFORE the boundary line). All state is passed in
426−// line toggles the fenced state, so an even count means every
426+// explicitly so this helper stays pure and testable.
427−// opened fence has been closed.
428//
427//
429−// We accept up to three leading spaces of indentation (CommonMark
428+// nextLineTrimmed is the first non-blank line of the suffix
430−// rule) and require the fence characters to be the FIRST
429+// (already left-trimmed), used for the setext-underline check
431−// non-whitespace content of the line. We deliberately do NOT
430+// (rule 4). listClosesHere reports whether the next line closes
432−// attempt to parse info-strings or differentiate opener from
431+// the currently-open list; when true, the prefix contains a
433−// closer beyond toggling — a closing fence is just any line
432+// complete list and the boundary is safe even though listDepth
434−// whose first non-whitespace run is >=3 of the same fence char.
433+// is non-zero in the pre-line state.
435−func countFenceLines(s string) int {
434+func isSafeBoundaryState(inFence bool, listDepth int, listClosesHere, sawHTMLBlock, sawLinkRef bool, prevNonBlank, nextLineTrimmed string) bool {
436− n := 0
435+ // (2) Not inside a fence.
437− for line := range splitLines(s) {
436+ if inFence {
438− if isFenceLine(line) {
437+ return false
439− n++
440− }
438+ }
439+ // (B1) No open list, unless the next line closes it.
440+ if listDepth != 0 && !listClosesHere {
441+ return false
441 }
442 }
443+ // (B2) No HTML block opener anywhere in the prefix.
444+ if sawHTMLBlock {
445+ return false
446+ }
447+ // (B3) No link reference definition anywhere in the prefix.
448+ if sawLinkRef {
442− return n
449+ return false
450+ }
451+ // (3) The last non-blank line of the prefix must not keep a
452+ // construct open. When the list closes at the next line, the
453+ // prefix's last non-blank line is a list marker — that's
454+ // expected and safe because the list is complete, so skip
455+ // the check in that case.
456+ if !listClosesHere && prevNonBlank != "" && lineOpensConstruct(prevNonBlank) {
457+ return false
458+ }
459+ // (4) The first non-blank line of the suffix must not look
460+ // like a setext underline that would retroactively turn the
461+ // last paragraph of the prefix into a header.
462+ if isSetextUnderlineCandidate(nextLineTrimmed) {
463+ return false
464+ }
465+ return true
443}
466}
444
467
445// isFenceLine reports whether line opens or closes a fenced code
468// isFenceLine reports whether line opens or closes a fenced code
@@ -465,48 +488,6 @@ func isFenceLine(line string) bool {
465 return run >= 3
488 return run >= 3
466}
489}
467
490
468−// lastNonBlankLine returns the last non-blank line of s, or ""
469−// when every line is blank.
470−func lastNonBlankLine(s string) string {
471− last := ""
472− for line := range splitLines(s) {
473− if strings.TrimSpace(line) != "" {
474− last = line
475− }
476− }
477− return last
478−}
479−
480−// firstNonBlankLine returns the first non-blank line of s, or ""
481−// when every line is blank.
482−func firstNonBlankLine(s string) string {
483− for line := range splitLines(s) {
484− if strings.TrimSpace(line) != "" {
485− return line
486− }
487− }
488− return ""
489−}
490−
491−// splitLines yields the lines of s without their terminators. The
492−// final segment is yielded even if not newline-terminated.
493−func splitLines(s string) func(yield func(string) bool) {
494− return func(yield func(string) bool) {
495− start := 0
496− for i := 0; i < len(s); i++ {
497− if s[i] == '\n' {
498− if !yield(s[start:i]) {
499− return
500− }
501− start = i + 1
502− }
503− }
504− if start <= len(s)-1 {
505− yield(s[start:])
506− }
507− }
508−}
509−
510// lineOpensConstruct reports whether line keeps a markdown
491// lineOpensConstruct reports whether line keeps a markdown
511// construct open across the boundary. We err conservatively —
492// construct open across the boundary. We err conservatively —
512// any case that smells like list/table/quote/setext/indented-code
493// any case that smells like list/table/quote/setext/indented-code
internal/ui/chat/todos.go+1−1
@@ -31,7 +31,7 @@ func NewTodosToolMessageItem(
31 result *message.ToolResult,
31 result *message.ToolResult,
32 canceled bool,
32 canceled bool,
33) ToolMessageItem {
33) ToolMessageItem {
34− return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
34+ return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled, "")
35}
35}
36
36
37// TodosToolRenderContext renders todos tool messages.
37// TodosToolRenderContext renders todos tool messages.
internal/ui/chat/tool_result_content.go+15−14
@@ -20,19 +20,20 @@ func humanizedToolName(name string) string {
20 return stringext.Capitalize(name)
20 return stringext.Capitalize(name)
21}
21}
22
22
23+var markdownPatterns = []string{
24+ "# ",
25+ "## ",
26+ "**",
27+ "```",
28+ "- ",
29+ "1. ",
30+ "> ",
31+ "---",
32+ "***",
33+}
34+
23func looksLikeMarkdown(content string) bool {
35func looksLikeMarkdown(content string) bool {
24− patterns := []string{
25− "# ",
26− "## ",
27− "**",
28− "```",
29− "- ",
30− "1. ",
31− "> ",
32− "---",
33− "***",
34− }
35− for _, p := range patterns {
36+ for _, p := range markdownPatterns {
36 if strings.Contains(content, p) {
37 if strings.Contains(content, p) {
37 return true
38 return true
38 }
39 }
@@ -40,7 +41,7 @@ func looksLikeMarkdown(content string) bool {
40 return false
41 return false
41}
42}
42
43
43−func renderToolResultTextContent(sty *styles.Styles, content string, widths toolResultContentWidths, expanded bool) string {
44+func renderToolResultTextContent(sty *styles.Styles, content string, widths toolResultContentWidths, expanded bool, diffTool string) string {
44 var result json.RawMessage
45 var result json.RawMessage
45 if err := json.Unmarshal([]byte(content), &result); err == nil {
46 if err := json.Unmarshal([]byte(content), &result); err == nil {
46 prettyResult, err := json.MarshalIndent(result, "", " ")
47 prettyResult, err := json.MarshalIndent(result, "", " ")
@@ -50,7 +51,7 @@ func renderToolResultTextContent(sty *styles.Styles, content string, widths tool
50 return sty.Tool.Body.Render(toolOutputPlainContent(sty, content, widths.Body, expanded))
51 return sty.Tool.Body.Render(toolOutputPlainContent(sty, content, widths.Body, expanded))
51 }
52 }
52 if diffdetect.IsUnifiedDiff(content) {
53 if diffdetect.IsUnifiedDiff(content) {
53− return toolOutputDiffContentFromUnified(sty, content, widths.Diff, expanded)
54+ return toolOutputDiffContentFromUnified(sty, content, widths.Diff, expanded, diffTool)
54 }
55 }
55 if looksLikeMarkdown(content) {
56 if looksLikeMarkdown(content) {
56 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", content, 0, widths.Body, expanded))
57 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", content, 0, widths.Body, expanded))
internal/ui/chat/tool_result_content_test.go+5−5
@@ -72,7 +72,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
72 t.Fatalf("json.MarshalIndent() error = %v", err)
72 t.Fatalf("json.MarshalIndent() error = %v", err)
73 }
73 }
74 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.json", string(prettyResult), 0, widths.Body, false))
74 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.json", string(prettyResult), 0, widths.Body, false))
75− got := renderToolResultTextContent(styPtr, content, widths, false)
75+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
76 if got != expected {
76 if got != expected {
77 t.Fatal("renderToolResultTextContent() did not choose JSON rendering")
77 t.Fatal("renderToolResultTextContent() did not choose JSON rendering")
78 }
78 }
@@ -87,8 +87,8 @@ func TestRenderToolResultTextContent(t *testing.T) {
87-# Old
87-# Old
88+# New
88+# New
89`
89`
90− expected := toolOutputDiffContentFromUnified(styPtr, content, widths.Diff, false)
90+ expected := toolOutputDiffContentFromUnified(styPtr, content, widths.Diff, false, "")
91− got := renderToolResultTextContent(styPtr, content, widths, false)
91+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
92 if got != expected {
92 if got != expected {
93 t.Fatal("renderToolResultTextContent() did not choose diff rendering")
93 t.Fatal("renderToolResultTextContent() did not choose diff rendering")
94 }
94 }
@@ -98,7 +98,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
98 t.Parallel()
98 t.Parallel()
99 content := "# Title\n\nBody"
99 content := "# Title\n\nBody"
100 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.md", content, 0, widths.Body, false))
100 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.md", content, 0, widths.Body, false))
101− got := renderToolResultTextContent(styPtr, content, widths, false)
101+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
102 if got != expected {
102 if got != expected {
103 t.Fatal("renderToolResultTextContent() did not choose markdown rendering")
103 t.Fatal("renderToolResultTextContent() did not choose markdown rendering")
104 }
104 }
@@ -108,7 +108,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
108 t.Parallel()
108 t.Parallel()
109 content := "plain text"
109 content := "plain text"
110 expected := styPtr.Tool.Body.Render(toolOutputPlainContent(styPtr, content, widths.Body, false))
110 expected := styPtr.Tool.Body.Render(toolOutputPlainContent(styPtr, content, widths.Body, false))
111− got := renderToolResultTextContent(styPtr, content, widths, false)
111+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
112 if got != expected {
112 if got != expected {
113 t.Fatal("renderToolResultTextContent() did not choose plain rendering")
113 t.Fatal("renderToolResultTextContent() did not choose plain rendering")
114 }
114 }
internal/ui/chat/tools.go+132−59
@@ -19,6 +19,7 @@ import (
19 "github.com/charmbracelet/crush/internal/stringext"
19 "github.com/charmbracelet/crush/internal/stringext"
20 "github.com/charmbracelet/crush/internal/ui/anim"
20 "github.com/charmbracelet/crush/internal/ui/anim"
21 "github.com/charmbracelet/crush/internal/ui/common"
21 "github.com/charmbracelet/crush/internal/ui/common"
22+ "github.com/charmbracelet/crush/internal/ui/difftastic"
22 "github.com/charmbracelet/crush/internal/ui/list"
23 "github.com/charmbracelet/crush/internal/ui/list"
23 "github.com/charmbracelet/crush/internal/ui/styles"
24 "github.com/charmbracelet/crush/internal/ui/styles"
24 "github.com/charmbracelet/x/ansi"
25 "github.com/charmbracelet/x/ansi"
@@ -27,6 +28,11 @@ import (
27// responseContextHeight limits the number of lines displayed in tool output.
28// responseContextHeight limits the number of lines displayed in tool output.
28const responseContextHeight = 10
29const responseContextHeight = 10
29
30
31+// diffContextHeight limits the number of lines displayed in code-edit
32+// diffs before truncation. Diffs get a larger default cap than plain
33+// tool output so most of a change is visible without expanding.
34+const diffContextHeight = 30
35+
30// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
36// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
31const toolBodyLeftPaddingTotal = 2
37const toolBodyLeftPaddingTotal = 2
32
38
@@ -52,6 +58,7 @@ type ToolMessageItem interface {
52 SetMessageID(id string)
58 SetMessageID(id string)
53 SetStatus(status ToolStatus)
59 SetStatus(status ToolStatus)
54 Status() ToolStatus
60 Status() ToolStatus
61+ SetDiffTool(tool string)
55}
62}
56
63
57// Compactable is an interface for tool items that can render in a compacted mode.
64// Compactable is an interface for tool items that can render in a compacted mode.
@@ -86,7 +93,7 @@ type DefaultToolRenderContext struct{}
86
93
87// RenderTool implements the [ToolRenderer] interface.
94// RenderTool implements the [ToolRenderer] interface.
88func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
95func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
89− return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
96+ return sty.Messages.AssistantBlurred.Render(fmt.Sprintf("Unknown tool: %s", opts.ToolCall.Name))
90}
97}
91
98
92// ToolRenderOpts contains the data needed to render a tool call.
99// ToolRenderOpts contains the data needed to render a tool call.
@@ -98,6 +105,10 @@ type ToolRenderOpts struct {
98 Compact bool
105 Compact bool
99 IsSpinning bool
106 IsSpinning bool
100 Status ToolStatus
107 Status ToolStatus
108+ // DiffTool selects the external diff renderer for edit/multi-edit diffs.
109+ // "builtin" (the default) uses the in-process renderer; "difftastic"
110+ // shells out to the difft binary.
111+ DiffTool string
101}
112}
102
113
103// IsPending returns true if the tool call is still pending (not finished and
114// IsPending returns true if the tool call is still pending (not finished and
@@ -158,6 +169,8 @@ type baseToolMessageItem struct {
158 sty *styles.Styles
169 sty *styles.Styles
159 anim *anim.Anim
170 anim *anim.Anim
160 expandedContent bool
171 expandedContent bool
172+ // diffTool selects the external diff renderer for edit/multi-edit diffs.
173+ diffTool string
161}
174}
162
175
163var _ Expandable = (*baseToolMessageItem)(nil)
176var _ Expandable = (*baseToolMessageItem)(nil)
@@ -169,6 +182,7 @@ func newBaseToolMessageItem(
169 result *message.ToolResult,
182 result *message.ToolResult,
170 toolRenderer ToolRenderer,
183 toolRenderer ToolRenderer,
171 canceled bool,
184 canceled bool,
185+ diffTool string,
172) *baseToolMessageItem {
186) *baseToolMessageItem {
173 // we only do full width for diffs (as far as I know)
187 // we only do full width for diffs (as far as I know)
174 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
188 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
@@ -190,6 +204,7 @@ func newBaseToolMessageItem(
190 result: result,
204 result: result,
191 status: status,
205 status: status,
192 hasCappedWidth: hasCappedWidth,
206 hasCappedWidth: hasCappedWidth,
207+ diffTool: diffTool,
193 }
208 }
194 t.anim = anim.New(anim.Settings{
209 t.anim = anim.New(anim.Settings{
195 ID: toolCall.ID,
210 ID: toolCall.ID,
@@ -214,6 +229,7 @@ func NewToolMessageItem(
214 toolCall message.ToolCall,
229 toolCall message.ToolCall,
215 result *message.ToolResult,
230 result *message.ToolResult,
216 canceled bool,
231 canceled bool,
232+ diffTool string,
217) ToolMessageItem {
233) ToolMessageItem {
218 var item ToolMessageItem
234 var item ToolMessageItem
219 switch toolCall.Name {
235 switch toolCall.Name {
@@ -268,6 +284,7 @@ func NewToolMessageItem(
268 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
284 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
269 }
285 }
270 }
286 }
287+ item.SetDiffTool(diffTool)
271 item.SetMessageID(messageID)
288 item.SetMessageID(messageID)
272 return item
289 return item
273}
290}
@@ -282,6 +299,16 @@ func (t *baseToolMessageItem) SetCompact(compact bool) {
282 t.Bump()
299 t.Bump()
283}
300}
284
301
302+// SetDiffTool sets the external diff tool preference and invalidates the cache.
303+func (t *baseToolMessageItem) SetDiffTool(tool string) {
304+ if t.diffTool == tool {
305+ return
306+ }
307+ t.diffTool = tool
308+ t.clearCache()
309+ t.Bump()
310+}
311+
285// ID returns the unique identifier for this tool message item.
312// ID returns the unique identifier for this tool message item.
286func (t *baseToolMessageItem) ID() string {
313func (t *baseToolMessageItem) ID() string {
287 return t.toolCall.ID
314 return t.toolCall.ID
@@ -333,6 +360,7 @@ func (t *baseToolMessageItem) RawRender(width int) string {
333 Compact: t.isCompact,
360 Compact: t.isCompact,
334 IsSpinning: t.isSpinning(),
361 IsSpinning: t.isSpinning(),
335 Status: t.computeStatus(),
362 Status: t.computeStatus(),
363+ DiffTool: t.diffTool,
336 })
364 })
337
365
338 // Prepend hook indicator if hooks ran for this tool call.
366 // Prepend hook indicator if hooks ran for this tool call.
@@ -379,10 +407,15 @@ func (t *baseToolMessageItem) Render(width int) string {
379 prefix = t.sty.Messages.ToolCallBlurred.Render()
407 prefix = t.sty.Messages.ToolCallBlurred.Render()
380 }
408 }
381 lines := strings.Split(t.RawRender(width), "\n")
409 lines := strings.Split(t.RawRender(width), "\n")
410+ var sb strings.Builder
382 for i, ln := range lines {
411 for i, ln := range lines {
383− lines[i] = prefix + ln
412+ if i > 0 {
413+ sb.WriteString("\n")
414+ }
415+ sb.WriteString(prefix)
416+ sb.WriteString(ln)
384 }
417 }
385− out := strings.Join(lines, "\n")
418+ out := sb.String()
386 if useCache {
419 if useCache {
387 t.setCachedPrefixedRender(out, width, key)
420 t.setCachedPrefixedRender(out, width, key)
388 }
421 }
@@ -523,7 +556,7 @@ func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int)
523 var msg string
556 var msg string
524 switch opts.Status {
557 switch opts.Status {
525 case ToolStatusError:
558 case ToolStatusError:
526− msg = toolErrorContent(sty, opts.Result, width)
559+ msg = toolErrorContent(sty, opts.Result, width, opts.ExpandedContent)
527 case ToolStatusCanceled:
560 case ToolStatusCanceled:
528 msg = sty.Tool.StateCancelled.Render("Canceled.")
561 msg = sty.Tool.StateCancelled.Render("Canceled.")
529 case ToolStatusAwaitingPermission:
562 case ToolStatusAwaitingPermission:
@@ -537,21 +570,42 @@ func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int)
537}
570}
538
571
539// toolErrorContent formats an error message with an ERROR or WARN tag.
572// toolErrorContent formats an error message with an ERROR or WARN tag.
573+//
574+// When collapsed it shows a single truncated line plus a hidden-line
575+// notice; when expanded it renders the full message wrapped to width,
576+// preserving newlines, so multi-line failures (stack traces, compiler
577+// output, edit-failure messages) stay actionable. This mirrors the
578+// assistant-level error renderer's wrapped Details.
540−func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
579+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int, expanded bool) string {
541 if result == nil {
580 if result == nil {
542 return ""
581 return ""
543 }
582 }
544− errContent := strings.ReplaceAll(result.Content, "\n", " ")
583+ content := strings.TrimRight(result.Content, "\n")
584+
585+ tag := sty.Tool.ErrorTag.Render("ERROR")
586+ msgStyle := sty.Tool.ErrorMessage
545− if strings.Contains(errContent, "User denied permission") {
587+ if strings.Contains(content, "User denied permission") {
546− deniedTag := sty.Tool.WarnTag.Render("WARN")
588+ tag = sty.Tool.WarnTag.Render("WARN")
589+ msgStyle = sty.Tool.WarnMessage
590+ }
547− deniedTagWidth := lipgloss.Width(deniedTag)
591+ tagWidth := lipgloss.Width(tag)
548− errContent = ansi.Truncate(errContent, width-deniedTagWidth-3, "…")
592+ msgWidth := max(1, width-tagWidth-1)
549− return fmt.Sprintf("%s %s", deniedTag, sty.Tool.WarnMessage.Render(errContent))
593+
594+ if !expanded {
595+ oneLine := ansi.Truncate(strings.ReplaceAll(content, "\n", " "), msgWidth, "…")
596+ out := fmt.Sprintf("%s %s", tag, msgStyle.Render(oneLine))
597+ if hidden := strings.Count(content, "\n"); hidden > 0 {
598+ out += "\n" + sty.Tool.ContentTruncation.
599+ Width(width).
600+ Render(fmt.Sprintf(assistantMessageTruncateFormat, hidden))
601+ }
602+ return out
550 }
603 }
551− errTag := sty.Tool.ErrorTag.Render("ERROR")
604+
552− tagWidth := lipgloss.Width(errTag)
605+ // Expanded: wrap the full message to width, preserving newlines,
553− errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
606+ // and align continuation lines under the message column.
554− return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
607+ wrapped := msgStyle.Width(msgWidth).Render(content)
608+ return lipgloss.JoinHorizontal(lipgloss.Top, tag+" ", wrapped)
555}
609}
556
610
557// toolIcon returns the status icon for a tool call.
611// toolIcon returns the status icon for a tool call.
@@ -644,6 +698,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
644 maxLines = len(lines) // Show all
698 maxLines = len(lines) // Show all
645 }
699 }
646
700
701+ contentLineStyle := sty.Tool.ContentLine.Width(width)
647 var out []string
702 var out []string
648 for i, ln := range lines {
703 for i, ln := range lines {
649 if i >= maxLines {
704 if i >= maxLines {
@@ -653,7 +708,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
653 if lipgloss.Width(ln) > width {
708 if lipgloss.Width(ln) > width {
654 ln = ansi.Truncate(ln, width, "…")
709 ln = ansi.Truncate(ln, width, "…")
655 }
710 }
656− out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
711+ out = append(out, contentLineStyle.Render(ln))
657 }
712 }
658
713
659 wasTruncated := len(lines) > responseContextHeight
714 wasTruncated := len(lines) > responseContextHeight
@@ -695,16 +750,16 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
695 bodyWidth := width - toolBodyLeftPaddingTotal
750 bodyWidth := width - toolBodyLeftPaddingTotal
696 codeWidth := bodyWidth - maxDigits
751 codeWidth := bodyWidth - maxDigits
697
752
753+ codeLineStyle := sty.Tool.ContentCodeLine.Width(codeWidth)
754+ codeLinePadding := sty.Tool.ContentCodeLine.GetHorizontalPadding()
698 var out []string
755 var out []string
699 for i, ln := range highlightedLines {
756 for i, ln := range highlightedLines {
700 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
757 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
701
758
702 // Truncate accounting for padding that will be added.
759 // Truncate accounting for padding that will be added.
703− ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
760+ ln = ansi.Truncate(ln, codeWidth-codeLinePadding, "…")
704
761
705− codeLine := sty.Tool.ContentCodeLine.
706− Width(codeWidth).
707− Render(ln)
762+ codeLine := codeLineStyle.Render(ln)
708
763
709 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
764 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
710 }
765 }
@@ -936,18 +991,33 @@ func formatSize(bytes int) string {
936 )
991 )
937 switch {
992 switch {
938 case bytes >= mb:
993 case bytes >= mb:
939− return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
994+ result := fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
995+ if strings.HasSuffix(result, ".0 MB") {
996+ result = strings.Replace(result, ".0 MB", " MB", 1)
997+ }
998+ return result
940 case bytes >= kb:
999 case bytes >= kb:
941− return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
1000+ result := fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
1001+ if strings.HasSuffix(result, ".0 KB") {
1002+ result = strings.Replace(result, ".0 KB", " KB", 1)
1003+ }
1004+ return result
942 default:
1005 default:
943 return fmt.Sprintf("%d B", bytes)
1006 return fmt.Sprintf("%d B", bytes)
944 }
1007 }
945}
1008}
946
1009
947// toolOutputDiffContent renders a diff between old and new content.
1010// toolOutputDiffContent renders a diff between old and new content.
948−func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
1011+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool, diffTool string) string {
949 bodyWidth := width - toolBodyLeftPaddingTotal
1012 bodyWidth := width - toolBodyLeftPaddingTotal
950
1013
1014+ // Try difftastic if enabled. Fall back to builtin on any failure.
1015+ if diffTool == "difftastic" {
1016+ if out, err := difftastic.RenderDiff(file, oldContent, newContent, bodyWidth); err == nil && out != "" {
1017+ return sty.Tool.Body.Render(truncateDiff(sty, out, bodyWidth, expanded))
1018+ }
1019+ }
1020+
951 formatter := common.DiffFormatter(sty).
1021 formatter := common.DiffFormatter(sty).
952 Before(file, oldContent).
1022 Before(file, oldContent).
953 After(file, newContent).
1023 After(file, newContent).
@@ -959,22 +1029,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
959 }
1029 }
960
1030
961 formatted := formatter.String()
1031 formatted := formatter.String()
962− lines := strings.Split(formatted, "\n")
963−
964− // Truncate if needed.
965− maxLines := responseContextHeight
966− if expanded {
967− maxLines = len(lines)
968− }
969−
970− if len(lines) > maxLines && !expanded {
971− truncMsg := sty.Tool.DiffTruncation.
972− Width(bodyWidth).
973− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
974− formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
975− }
976−
977− return sty.Tool.Body.Render(formatted)
1032+ return sty.Tool.Body.Render(truncateDiff(sty, formatted, bodyWidth, expanded))
978}
1033}
979
1034
980// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
1035// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
@@ -995,9 +1050,24 @@ func formatNonZero(value int) string {
995}
1050}
996
1051
997// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
1052// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
998−func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
1053+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool, diffTool string) string {
999 bodyWidth := width - toolBodyLeftPaddingTotal
1054 bodyWidth := width - toolBodyLeftPaddingTotal
1000
1055
1056+ // Try difftastic if enabled. Fall back to builtin on any failure.
1057+ if diffTool == "difftastic" {
1058+ if out, err := difftastic.RenderDiff(file, meta.OldContent, meta.NewContent, bodyWidth); err == nil && out != "" {
1059+ formatted := truncateDiff(sty, out, bodyWidth, expanded)
1060+ // Add failed edits note if any exist.
1061+ if len(meta.EditsFailed) > 0 {
1062+ noteTag := sty.Tool.NoteTag.Render("Note")
1063+ noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
1064+ note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
1065+ formatted = formatted + "\n\n" + note
1066+ }
1067+ return sty.Tool.Body.Render(formatted)
1068+ }
1069+ }
1070+
1001 formatter := common.DiffFormatter(sty).
1071 formatter := common.DiffFormatter(sty).
1002 Before(file, meta.OldContent).
1072 Before(file, meta.OldContent).
1003 After(file, meta.NewContent).
1073 After(file, meta.NewContent).
@@ -1009,20 +1079,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
1009 }
1079 }
1010
1080
1011 formatted := formatter.String()
1081 formatted := formatter.String()
1012− lines := strings.Split(formatted, "\n")
1082+ formatted = truncateDiff(sty, formatted, bodyWidth, expanded)
1013−
1014− // Truncate if needed.
1015− maxLines := responseContextHeight
1016− if expanded {
1017− maxLines = len(lines)
1018− }
1019−
1020− if len(lines) > maxLines && !expanded {
1021− truncMsg := sty.Tool.DiffTruncation.
1022− Width(bodyWidth).
1023− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1024− formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
1025− }
1026
1083
1027 // Add failed edits note if any exist.
1084 // Add failed edits note if any exist.
1028 if len(meta.EditsFailed) > 0 {
1085 if len(meta.EditsFailed) > 0 {
@@ -1035,6 +1092,22 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
1035 return sty.Tool.Body.Render(formatted)
1092 return sty.Tool.Body.Render(formatted)
1036}
1093}
1037
1094
1095+// truncateDiff truncates diff output to diffContextHeight lines unless expanded.
1096+func truncateDiff(sty *styles.Styles, content string, bodyWidth int, expanded bool) string {
1097+ lines := strings.Split(content, "\n")
1098+ maxLines := diffContextHeight
1099+ if expanded {
1100+ maxLines = len(lines)
1101+ }
1102+ if len(lines) > maxLines && !expanded {
1103+ truncMsg := sty.Tool.DiffTruncation.
1104+ Width(bodyWidth).
1105+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1106+ return strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
1107+ }
1108+ return content
1109+}
1110+
1038// roundedEnumerator creates a tree enumerator with rounded corners.
1111// roundedEnumerator creates a tree enumerator with rounded corners.
1039func roundedEnumerator(lPadding, width int) tree.Enumerator {
1112func roundedEnumerator(lPadding, width int) tree.Enumerator {
1040 if width == 0 {
1113 if width == 0 {
@@ -1335,7 +1408,7 @@ func (t *baseToolMessageItem) formatBashResultForCopy() string {
1335
1408
1336 var meta tools.BashResponseMetadata
1409 var meta tools.BashResponseMetadata
1337 if t.result.Metadata != "" {
1410 if t.result.Metadata != "" {
1338− json.Unmarshal([]byte(t.result.Metadata), &meta)
1411+ _ = json.Unmarshal([]byte(t.result.Metadata), &meta)
1339 }
1412 }
1340
1413
1341 output := meta.Output
1414 output := meta.Output
@@ -1358,7 +1431,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string {
1358
1431
1359 var meta tools.ViewResponseMetadata
1432 var meta tools.ViewResponseMetadata
1360 if t.result.Metadata != "" {
1433 if t.result.Metadata != "" {
1361− json.Unmarshal([]byte(t.result.Metadata), &meta)
1434+ _ = json.Unmarshal([]byte(t.result.Metadata), &meta)
1362 }
1435 }
1363
1436
1364 if meta.Content == "" {
1437 if meta.Content == "" {
@@ -1429,7 +1502,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string {
1429 }
1502 }
1430
1503
1431 var params tools.EditParams
1504 var params tools.EditParams
1432− json.Unmarshal([]byte(t.toolCall.Input), &params)
1505+ _ = json.Unmarshal([]byte(t.toolCall.Input), &params)
1433
1506
1434 var result strings.Builder
1507 var result strings.Builder
1435
1508
@@ -1464,7 +1537,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1464 }
1537 }
1465
1538
1466 var params tools.MultiEditParams
1539 var params tools.MultiEditParams
1467− json.Unmarshal([]byte(t.toolCall.Input), &params)
1540+ _ = json.Unmarshal([]byte(t.toolCall.Input), &params)
1468
1541
1469 var result strings.Builder
1542 var result strings.Builder
1470 if meta.OldContent != "" || meta.NewContent != "" {
1543 if meta.OldContent != "" || meta.NewContent != "" {
@@ -1655,7 +1728,7 @@ func prettifyToolName(name string) string {
1655 case tools.AgenticFetchToolName:
1728 case tools.AgenticFetchToolName:
1656 return "Agentic Fetch"
1729 return "Agentic Fetch"
1657 case tools.WebFetchToolName:
1730 case tools.WebFetchToolName:
1658− return "Fetch"
1731+ return "Web Fetch"
1659 case tools.WebSearchToolName:
1732 case tools.WebSearchToolName:
1660 return "Search"
1733 return "Search"
1661 case tools.GlobToolName:
1734 case tools.GlobToolName:
internal/ui/chat/unified_diff.go+13−14
@@ -1,11 +1,11 @@
1package chat
1package chat
2
2
3import (
3import (
4− "fmt"
5 "strings"
4 "strings"
6
5
7 "github.com/charmbracelet/crush/internal/diffdetect"
6 "github.com/charmbracelet/crush/internal/diffdetect"
8 "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/crush/internal/ui/common"
8+ "github.com/charmbracelet/crush/internal/ui/difftastic"
9 "github.com/charmbracelet/crush/internal/ui/styles"
9 "github.com/charmbracelet/crush/internal/ui/styles"
10)
10)
11
11
@@ -130,13 +130,23 @@ func parseUnifiedDiff(content string) []parsedDiffFile {
130 return result
130 return result
131}
131}
132
132
133−func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width int, expanded bool) string {
133+func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width int, expanded bool, diffTool string) string {
134 files := parseUnifiedDiff(content)
134 files := parseUnifiedDiff(content)
135 if len(files) == 0 {
135 if len(files) == 0 {
136 bodyWidth := width - toolBodyLeftPaddingTotal
136 bodyWidth := width - toolBodyLeftPaddingTotal
137 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.diff", content, 0, bodyWidth, expanded))
137 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.diff", content, 0, bodyWidth, expanded))
138 }
138 }
139 bodyWidth := width - toolBodyLeftPaddingTotal
139 bodyWidth := width - toolBodyLeftPaddingTotal
140+
141+ // Try difftastic if enabled and there's a single file diff. Fall back to
142+ // builtin on any failure or when multiple files are involved.
143+ if diffTool == "difftastic" && len(files) == 1 {
144+ f := files[0]
145+ if out, err := difftastic.RenderDiff(f.path, f.before, f.after, bodyWidth); err == nil && out != "" {
146+ return sty.Tool.Body.Render(truncateDiff(sty, out, bodyWidth, expanded))
147+ }
148+ }
149+
140 var blocks []string
150 var blocks []string
141 for i, f := range files {
151 for i, f := range files {
142 formatter := common.DiffFormatter(sty).
152 formatter := common.DiffFormatter(sty).
@@ -156,16 +166,5 @@ func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width
156 blocks = append(blocks, formatted)
166 blocks = append(blocks, formatted)
157 }
167 }
158 combined := strings.Join(blocks, "\n")
168 combined := strings.Join(blocks, "\n")
159− lines := strings.Split(combined, "\n")
160− maxLines := responseContextHeight
161− if expanded {
162− maxLines = len(lines)
163− }
164− if len(lines) > maxLines && !expanded {
165− truncMsg := sty.Tool.DiffTruncation.
166− Width(bodyWidth).
167− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
168− combined = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
169− }
170− return sty.Tool.Body.Render(combined)
169+ return sty.Tool.Body.Render(truncateDiff(sty, combined, bodyWidth, expanded))
171}
170}
internal/ui/chat/version_bump_test.go+10−10
@@ -131,7 +131,7 @@ func TestBaseToolMessageItem_MutatorsBumpVersion(t *testing.T) {
131
131
132 sty := styles.CharmtonePantera()
132 sty := styles.CharmtonePantera()
133 tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
133 tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
134− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
134+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
135
135
136 v := item.(versionedItem)
136 v := item.(versionedItem)
137
137
@@ -283,7 +283,7 @@ func TestAgentToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
283
283
284 mkChild := func(id string) ToolMessageItem {
284 mkChild := func(id string) ToolMessageItem {
285 tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
285 tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
286− return NewToolMessageItem(&sty, "msg", tc, nil, false)
286+ return NewToolMessageItem(&sty, "msg", tc, nil, false, "")
287 }
287 }
288
288
289 // AddNestedTool always bumps.
289 // AddNestedTool always bumps.
@@ -320,7 +320,7 @@ func TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T)
320
320
321 mkChild := func(id string) ToolMessageItem {
321 mkChild := func(id string) ToolMessageItem {
322 tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
322 tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
323− return NewToolMessageItem(&sty, "msg", tc, nil, false)
323+ return NewToolMessageItem(&sty, "msg", tc, nil, false, "")
324 }
324 }
325
325
326 requireBump(t, "AddNestedTool", item, func() {
326 requireBump(t, "AddNestedTool", item, func() {
@@ -357,7 +357,7 @@ func TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T
357 item := NewAgentToolMessageItem(&sty, parent, nil, false)
357 item := NewAgentToolMessageItem(&sty, parent, nil, false)
358
358
359 childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
359 childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
360− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
360+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
361 item.AddNestedTool(child)
361 item.AddNestedTool(child)
362
362
363 v0 := item.Version()
363 v0 := item.Version()
@@ -386,7 +386,7 @@ func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *te
386 item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
386 item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
387
387
388 childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
388 childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
389− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
389+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
390 item.AddNestedTool(child)
390 item.AddNestedTool(child)
391
391
392 v0 := item.Version()
392 v0 := item.Version()
@@ -427,7 +427,7 @@ func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
427
427
428 sty := styles.CharmtonePantera()
428 sty := styles.CharmtonePantera()
429 tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
429 tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
430− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
430+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
431 v := item.(versionedItem)
431 v := item.(versionedItem)
432 a, ok := item.(Animatable)
432 a, ok := item.(Animatable)
433 require.True(t, ok, "base tool message item must implement Animatable")
433 require.True(t, ok, "base tool message item must implement Animatable")
@@ -475,7 +475,7 @@ func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
475 parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
475 parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
476
476
477 childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
477 childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
478− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
478+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
479 parent.AddNestedTool(child)
479 parent.AddNestedTool(child)
480
480
481 // Spinning + parent's own ID → parent bumps.
481 // Spinning + parent's own ID → parent bumps.
@@ -520,7 +520,7 @@ func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
520 parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
520 parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
521
521
522 childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
522 childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
523− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
523+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
524 parent.AddNestedTool(child)
524 parent.AddNestedTool(child)
525
525
526 requireBump(t, "Animate[spinning,parent ID]", parent, func() {
526 requireBump(t, "Animate[spinning,parent ID]", parent, func() {
@@ -551,7 +551,7 @@ func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
551
551
552 sty := styles.CharmtonePantera()
552 sty := styles.CharmtonePantera()
553 tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
553 tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
554− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
554+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
555 require.False(t, item.Finished(), "running tool must not be Finished()")
555 require.False(t, item.Finished(), "running tool must not be Finished()")
556
556
557 tcFinished := tc
557 tcFinished := tc
@@ -562,6 +562,6 @@ func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
562
562
563 // Canceled tool with no result is also Finished.
563 // Canceled tool with no result is also Finished.
564 tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
564 tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
565− canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
565+ canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true, "")
566 require.True(t, canceled.Finished(), "canceled tool must be Finished()")
566 require.True(t, canceled.Finished(), "canceled tool must be Finished()")
567}
567}
internal/ui/common/capabilities.go−12
@@ -1,7 +1,6 @@
1package common
1package common
2
2
3import (
3import (
4− "slices"
5 "strings"
4 "strings"
6
5
7 tea "charm.land/bubbletea/v2"
6 tea "charm.land/bubbletea/v2"
@@ -29,8 +28,6 @@ type Capabilities struct {
29 // KittyGraphics indicates whether the terminal supports the Kitty graphics
28 // KittyGraphics indicates whether the terminal supports the Kitty graphics
30 // protocol.
29 // protocol.
31 KittyGraphics bool
30 KittyGraphics bool
32− // SixelGraphics indicates whether the terminal supports Sixel graphics.
33− SixelGraphics bool
34 // Env is the terminal environment variables.
31 // Env is the terminal environment variables.
35 Env uv.Environ
32 Env uv.Environ
36 // TerminalVersion is the terminal version string.
33 // TerminalVersion is the terminal version string.
@@ -56,10 +53,6 @@ func (c *Capabilities) Update(msg any) {
56 c.PixelY = m.Height
53 c.PixelY = m.Height
57 case uv.KittyGraphicsEvent:
54 case uv.KittyGraphicsEvent:
58 c.KittyGraphics = true
55 c.KittyGraphics = true
59− case uv.PrimaryDeviceAttributesEvent:
60− if slices.Contains(m, 4) {
61− c.SixelGraphics = true
62− }
63 case tea.TerminalVersionMsg:
56 case tea.TerminalVersionMsg:
64 c.TerminalVersion = m.Name
57 c.TerminalVersion = m.Name
65 case tea.ModeReportMsg:
58 case tea.ModeReportMsg:
@@ -108,11 +101,6 @@ func (c Capabilities) SupportsKittyGraphics() bool {
108 return c.KittyGraphics
101 return c.KittyGraphics
109}
102}
110
103
111−// SupportsSixelGraphics returns true if the terminal supports Sixel graphics.
112−func (c Capabilities) SupportsSixelGraphics() bool {
113− return c.SixelGraphics
114−}
115−
116// CellSize returns the size of a single terminal cell in pixels.
104// CellSize returns the size of a single terminal cell in pixels.
117func (c Capabilities) CellSize() (width, height int) {
105func (c Capabilities) CellSize() (width, height int) {
118 if c.Columns == 0 || c.Rows == 0 {
106 if c.Columns == 0 || c.Rows == 0 {
internal/ui/common/diff.go+20−1
@@ -1,16 +1,35 @@
1package common
1package common
2
2
3import (
3import (
4+ "sync"
5+
4 "github.com/alecthomas/chroma/v2"
6 "github.com/alecthomas/chroma/v2"
5 "github.com/charmbracelet/crush/internal/ui/diffview"
7 "github.com/charmbracelet/crush/internal/ui/diffview"
6 "github.com/charmbracelet/crush/internal/ui/styles"
8 "github.com/charmbracelet/crush/internal/ui/styles"
7)
9)
8
10
11+// diffStyleCache memoizes the *chroma.Style built for a given theme. Building
12+// the style (MustNewStyle) parses colors for every entry and was previously
13+// done on every DiffFormatter call. The Styles struct is mutated in place on
14+// theme change, so we key on the theme's content fingerprint rather than its
15+// pointer identity.
16+var diffStyleCache sync.Map // map[uint64]*chroma.Style
17+
9// DiffFormatter returns a diff formatter with the given styles that can be
18// DiffFormatter returns a diff formatter with the given styles that can be
10// used to format diff outputs.
19// used to format diff outputs.
11func DiffFormatter(s *styles.Styles) *diffview.DiffView {
20func DiffFormatter(s *styles.Styles) *diffview.DiffView {
12 formatDiff := diffview.New()
21 formatDiff := diffview.New()
22+
13− style := chroma.MustNewStyle("crush", s.ChromaTheme())
23+ theme := s.ChromaTheme()
24+ key := chromaStyleKey(theme, nil)
25+ var style *chroma.Style
26+ if cached, ok := diffStyleCache.Load(key); ok {
27+ style = cached.(*chroma.Style)
28+ } else {
29+ style = chroma.MustNewStyle("crush", theme)
30+ diffStyleCache.Store(key, style)
31+ }
32+
14 diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
33 diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
15 return diff
34 return diff
16}
35}
internal/ui/common/elements.go+3
@@ -115,6 +115,9 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
115 if contextWindow > 0 {
115 if contextWindow > 0 {
116 percentage = (float64(tokens) / float64(contextWindow)) * 100
116 percentage = (float64(tokens) / float64(contextWindow)) * 100
117 }
117 }
118+ if percentage > 100 {
119+ percentage = 100
120+ }
118
121
119 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
122 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
120
123
internal/ui/common/highlight.go+60−11
@@ -2,7 +2,11 @@ package common
2
2
3import (
3import (
4 "bytes"
4 "bytes"
5+ "hash/fnv"
5 "image/color"
6 "image/color"
7+ "sort"
8+ "strconv"
9+ "sync"
6
10
7 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2"
8 "github.com/alecthomas/chroma/v2/formatters"
12 "github.com/alecthomas/chroma/v2/formatters"
@@ -11,6 +15,41 @@ import (
11 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/crush/internal/ui/styles"
12)
16)
13
17
18+// highlightStyleCache memoizes the *chroma.Style built for a given theme +
19+// background combination. Building the style (MustNewStyle + Builder.Transform.
20+// Build) parses colors for every entry and was previously done on every
21+// SyntaxHighlight call. The Styles struct is mutated in place on theme change,
22+// so we key on the theme's content fingerprint (plus bg) rather than its
23+// pointer identity.
24+var highlightStyleCache sync.Map // map[uint64]*chroma.Style
25+
26+// chromaStyleKey derives a stable cache key from the theme entries and bg.
27+func chromaStyleKey(theme chroma.StyleEntries, bg color.Color) uint64 {
28+ h := fnv.New64a()
29+ // Sort token types for a deterministic fingerprint (map order is random).
30+ types := make([]int, 0, len(theme))
31+ for tt := range theme {
32+ types = append(types, int(tt))
33+ }
34+ sort.Ints(types)
35+ for _, tt := range types {
36+ h.Write([]byte(strconv.Itoa(tt)))
37+ h.Write([]byte{0})
38+ h.Write([]byte(theme[chroma.TokenType(tt)]))
39+ h.Write([]byte{0})
40+ }
41+ var buf [8]byte
42+ if bg != nil {
43+ r, g, b, a := bg.RGBA()
44+ buf[0] = byte(r >> 8)
45+ buf[1] = byte(g >> 8)
46+ buf[2] = byte(b >> 8)
47+ buf[3] = byte(a >> 8)
48+ }
49+ h.Write(buf[:4])
50+ return h.Sum64()
51+}
52+
14// SyntaxHighlight applies syntax highlighting to the given source code based
53// SyntaxHighlight applies syntax highlighting to the given source code based
15// on the file name and background color. It returns the highlighted code as a
54// on the file name and background color. It returns the highlighted code as a
16// string.
55// string.
@@ -31,18 +70,28 @@ func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color)
31 f = formatters.Fallback
70 f = formatters.Fallback
32 }
71 }
33
72
73+ // Build (or reuse a cached) chroma style for this theme + background.
74+ theme := st.ChromaTheme()
75+ key := chromaStyleKey(theme, bg)
76+ var s *chroma.Style
77+ if cached, ok := highlightStyleCache.Load(key); ok {
78+ s = cached.(*chroma.Style)
79+ } else {
34− style := chroma.MustNewStyle("crush", st.ChromaTheme())
80+ style := chroma.MustNewStyle("crush", theme)
35
81
36− // Modify the style to use the provided background
82+ // Modify the style to use the provided background
37− s, err := style.Builder().Transform(
83+ built, err := style.Builder().Transform(
38− func(t chroma.StyleEntry) chroma.StyleEntry {
84+ func(t chroma.StyleEntry) chroma.StyleEntry {
39− r, g, b, _ := bg.RGBA()
85+ r, g, b, _ := bg.RGBA()
40− t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
86+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
41− return t
87+ return t
42− },
88+ },
43− ).Build()
89+ ).Build()
44− if err != nil {
90+ if err != nil {
45− s = chromastyles.Fallback
91+ built = chromastyles.Fallback
92+ }
93+ s = built
94+ highlightStyleCache.Store(key, s)
46 }
95 }
47
96
48 // Tokenize and format
97 // Tokenize and format
internal/ui/common/scrollbar.go+4−2
@@ -30,15 +30,17 @@ func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int)
30 }
30 }
31
31
32 // Build the scrollbar.
32 // Build the scrollbar.
33+ thumb := s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)
34+ track := s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)
33 var sb strings.Builder
35 var sb strings.Builder
34 for i := range height {
36 for i := range height {
35 if i > 0 {
37 if i > 0 {
36 sb.WriteString("\n")
38 sb.WriteString("\n")
37 }
39 }
38 if i >= thumbPos && i < thumbPos+thumbSize {
40 if i >= thumbPos && i < thumbPos+thumbSize {
39− sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
41+ sb.WriteString(thumb)
40 } else {
42 } else {
41− sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
43+ sb.WriteString(track)
42 }
44 }
43 }
45 }
44
46
internal/ui/completions/completions.go+6−4
@@ -230,11 +230,13 @@ func (c *Completions) applyNamePriorityFilter(query string) {
230 }
230 }
231
231
232 c.list.SetItems(c.allItems...)
232 c.list.SetItems(c.allItems...)
233+ // SetFilter computes the filtered set (a single fuzzy pass) and stores it
234+ // in the list; read it back instead of recomputing via FilteredItems.
233 c.list.SetFilter(query)
235 c.list.SetFilter(query)
234− raw := c.list.FilteredItems()
236+ n := c.list.Len()
235− filtered := make([]list.FilterableItem, 0, len(raw))
237+ filtered := make([]list.FilterableItem, 0, n)
236− for _, item := range raw {
238+ for i := range n {
237− filterable, ok := item.(list.FilterableItem)
239+ filterable, ok := c.list.ItemAt(i).(list.FilterableItem)
238 if !ok {
240 if !ok {
239 continue
241 continue
240 }
242 }
internal/ui/dialog/actions.go+3
@@ -102,6 +102,9 @@ type (
102type (
102type (
103 ActionChangeAPIKeyState struct {
103 ActionChangeAPIKeyState struct {
104 State APIKeyInputState
104 State APIKeyInputState
105+ // Reason is an optional human-readable explanation, set when
106+ // transitioning to APIKeyInputStateError.
107+ Reason string
105 }
108 }
106)
109)
107
110
internal/ui/dialog/api_key_input.go+54−7
@@ -40,8 +40,9 @@ type APIKeyInput struct {
40 model config.SelectedModel
40 model config.SelectedModel
41 modelType config.SelectedModelType
41 modelType config.SelectedModelType
42
42
43− width int
43+ width int
44− state APIKeyInputState
44+ state APIKeyInputState
45+ errMsg string // reason for the last verification failure
45
46
46 keyMap struct {
47 keyMap struct {
47 Submit key.Binding
48 Submit key.Binding
@@ -91,7 +92,7 @@ func NewAPIKeyInput(
91
92
92 m.keyMap.Submit = key.NewBinding(
93 m.keyMap.Submit = key.NewBinding(
93 key.WithKeys("enter", "ctrl+y"),
94 key.WithKeys("enter", "ctrl+y"),
94− key.WithHelp("enter", "submit"),
95+ key.WithHelp("enter", "confirm"),
95 )
96 )
96 m.keyMap.Close = CloseKey
97 m.keyMap.Close = CloseKey
97
98
@@ -110,8 +111,11 @@ func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
110 m.state = msg.State
111 m.state = msg.State
111 switch m.state {
112 switch m.state {
112 case APIKeyInputStateVerifying:
113 case APIKeyInputStateVerifying:
114+ m.errMsg = ""
113 cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
115 cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
114 return ActionCmd{cmd}
116 return ActionCmd{cmd}
117+ case APIKeyInputStateError:
118+ m.errMsg = msg.Reason
115 }
119 }
116 case spinner.TickMsg:
120 case spinner.TickMsg:
117 switch m.state {
121 switch m.state {
@@ -169,14 +173,23 @@ func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
169
173
170 m.input.Prompt = m.spinner.View()
174 m.input.Prompt = m.spinner.View()
171
175
172− content := strings.Join([]string{
176+ lines := []string{
173 m.headerView(),
177 m.headerView(),
174 inputStyle.Render(m.inputView()),
178 inputStyle.Render(m.inputView()),
179+ }
180+ if m.state == APIKeyInputStateError && m.errMsg != "" {
181+ errStyle := textStyle.
182+ Foreground(charmtone.Cherry).
183+ Width(m.width - dialogStyle.GetHorizontalFrameSize())
184+ lines = append(lines, errStyle.Render(m.errMsg))
185+ }
186+ lines = append(lines,
175 textStyle.Render("This will be written in your global configuration:"),
187 textStyle.Render("This will be written in your global configuration:"),
176 textStyle.Render(config.GlobalConfigData()),
188 textStyle.Render(config.GlobalConfigData()),
177 "",
189 "",
178 helpStyle.Render(m.help.View(m)),
190 helpStyle.Render(m.help.View(m)),
179− }, "\n")
191+ )
192+ content := strings.Join(lines, "\n")
180
193
181 cur := m.Cursor()
194 cur := m.Cursor()
182
195
@@ -301,9 +314,43 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
301 }
314 }
302
315
303 if err == nil {
316 if err == nil {
304− return ActionChangeAPIKeyState{APIKeyInputStateVerified}
317+ return ActionChangeAPIKeyState{State: APIKeyInputStateVerified}
318+ }
319+ return ActionChangeAPIKeyState{
320+ State: APIKeyInputStateError,
321+ Reason: apiKeyErrorReason(err),
322+ }
323+}
324+
325+// apiKeyErrorReason maps a TestConnection error to a concise, user-facing
326+// explanation. Common cases (auth, permission, network) get a friendly hint;
327+// anything else falls back to the raw error message.
328+func apiKeyErrorReason(err error) string {
329+ if err == nil {
330+ return ""
331+ }
332+ msg := strings.TrimSpace(err.Error())
333+ low := strings.ToLower(msg)
334+ switch {
335+ case strings.Contains(low, "401"),
336+ strings.Contains(low, "unauthorized"),
337+ strings.Contains(low, "invalid api key"),
338+ strings.Contains(low, "invalid_api_key"),
339+ strings.Contains(low, "authentication"):
340+ return "Authentication failed — the key was rejected (401)."
341+ case strings.Contains(low, "403"),
342+ strings.Contains(low, "forbidden"):
343+ return "Access forbidden (403) — the key may lack permissions."
344+ case strings.Contains(low, "no such host"),
345+ strings.Contains(low, "timeout"),
346+ strings.Contains(low, "timed out"),
347+ strings.Contains(low, "connection refused"),
348+ strings.Contains(low, "network"),
349+ strings.Contains(low, "dial tcp"):
350+ return "Network error — could not reach the provider."
351+ default:
352+ return msg
305 }
353 }
306− return ActionChangeAPIKeyState{APIKeyInputStateError}
307}
354}
308
355
309func (m *APIKeyInput) saveKeyAndContinue() Action {
356func (m *APIKeyInput) saveKeyAndContinue() Action {
internal/ui/dialog/arguments.go+20−11
@@ -23,6 +23,21 @@ import (
23// ArgumentsID is the identifier for the arguments dialog.
23// ArgumentsID is the identifier for the arguments dialog.
24const ArgumentsID = "arguments"
24const ArgumentsID = "arguments"
25
25
26+// argumentTitleCaser title-cases argument labels. It is stateless and safe to
27+// reuse, so it is kept package-level to avoid rebuilding it on every Draw.
28+var argumentTitleCaser = cases.Title(language.English)
29+
30+// prettyArgumentLabel converts an argument title into a human-friendly label.
31+func prettyArgumentLabel(title string) string {
32+ title = strings.ReplaceAll(title, "_", " ")
33+ title = strings.ReplaceAll(title, "-", " ")
34+ titleParts := strings.Fields(title)
35+ for i, part := range titleParts {
36+ titleParts[i] = argumentTitleCaser.String(strings.ToLower(part))
37+ }
38+ return strings.Join(titleParts, " ")
39+}
40+
26// Dialog sizing for arguments.
41// Dialog sizing for arguments.
27const (
42const (
28 maxInputWidth = 120
43 maxInputWidth = 120
@@ -36,6 +51,7 @@ type Arguments struct {
36 com *common.Common
51 com *common.Common
37 title string
52 title string
38 arguments []commands.Argument
53 arguments []commands.Argument
54+ labels []string // pretty labels, precomputed from argument titles
39 inputs []textinput.Model
55 inputs []textinput.Model
40 focused int
56 focused int
41 spinner spinner.Model
57 spinner spinner.Model
@@ -88,7 +104,9 @@ func NewArguments(com *common.Common, title, description string, arguments []com
88
104
89 // Create input fields for each argument.
105 // Create input fields for each argument.
90 a.inputs = make([]textinput.Model, len(arguments))
106 a.inputs = make([]textinput.Model, len(arguments))
107+ a.labels = make([]string, len(arguments))
91 for i, arg := range arguments {
108 for i, arg := range arguments {
109+ a.labels[i] = prettyArgumentLabel(arg.Title)
92 input := textinput.New()
110 input := textinput.New()
93 input.SetVirtualCursor(false)
111 input.SetVirtualCursor(false)
94 input.SetStyles(com.Styles.TextInput)
112 input.SetStyles(com.Styles.TextInput)
@@ -262,20 +280,12 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
262 dialogContentStyle := s.Dialog.Arguments.Content
280 dialogContentStyle := s.Dialog.Arguments.Content
263 possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
281 possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
264 // Build fields with label and input.
282 // Build fields with label and input.
265− caser := cases.Title(language.English)
266−
267 var fields []string
283 var fields []string
268 for i, arg := range a.arguments {
284 for i, arg := range a.arguments {
269 isFocused := i == a.focused
285 isFocused := i == a.focused
270
286
271− // Try to pretty up the title for the label.
287+ // Label was pretty-printed once at construction.
272− title := strings.ReplaceAll(arg.Title, "_", " ")
288+ labelText := a.labels[i]
273− title = strings.ReplaceAll(title, "-", " ")
274− titleParts := strings.Fields(title)
275− for i, part := range titleParts {
276− titleParts[i] = caser.String(strings.ToLower(part))
277− }
278− labelText := strings.Join(titleParts, " ")
279
289
280 markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
290 markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
281
291
@@ -305,7 +315,6 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
305 renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
315 renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
306
316
307 // Anchor width to the longest field, capped at maxInputWidth.
317 // Anchor width to the longest field, capped at maxInputWidth.
308− const scrollbarWidth = 1
309 width := lipgloss.Width(renderedFields)
318 width := lipgloss.Width(renderedFields)
310 height := lipgloss.Height(renderedFields)
319 height := lipgloss.Height(renderedFields)
311
320
internal/ui/dialog/commands.go+48−163
@@ -2,7 +2,6 @@ package dialog
2
2
3import (
3import (
4 "os"
4 "os"
5− "strings"
6
5
7 "charm.land/bubbles/v2/help"
6 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/key"
7 "charm.land/bubbles/v2/key"
@@ -13,29 +12,16 @@ import (
13 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/list"
16− "github.com/charmbracelet/crush/internal/ui/styles"
17 uv "github.com/charmbracelet/ultraviolet"
15 uv "github.com/charmbracelet/ultraviolet"
18)
16)
19
17
20// CommandsID is the identifier for the commands dialog.
18// CommandsID is the identifier for the commands dialog.
21const CommandsID = "commands"
19const CommandsID = "commands"
22
20
23−// CommandType represents the type of commands being displayed.
24−type CommandType uint
25−
26−// String returns the string representation of the CommandType.
27−func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
28−
29const (
21const (
30 sidebarCompactModeBreakpoint = 120
22 sidebarCompactModeBreakpoint = 120
31)
23)
32
24
33−const (
34− SystemCommands CommandType = iota
35− UserCommands
36− MCPPrompts
37−)
38−
39// Commands represents a dialog that shows available commands.
25// Commands represents a dialog that shows available commands.
40type dockerMCPAvailabilityCheckedMsg struct {
26type dockerMCPAvailabilityCheckedMsg struct {
41 available bool
27 available bool
@@ -48,8 +34,6 @@ type Commands struct {
48 UpDown,
34 UpDown,
49 Next,
35 Next,
50 Previous,
36 Previous,
51− Tab,
52− ShiftTab,
53 Close key.Binding
37 Close key.Binding
54 }
38 }
55
39
@@ -57,7 +41,6 @@ type Commands struct {
57 hasSession bool
41 hasSession bool
58 hasTodos bool
42 hasTodos bool
59 hasQueue bool
43 hasQueue bool
60− selected CommandType
61
44
62 spinner spinner.Model
45 spinner spinner.Model
63 loading bool
46 loading bool
@@ -81,7 +64,6 @@ var _ Dialog = (*Commands)(nil)
81func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, hasQueue bool, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
64func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, hasQueue bool, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
82 c := &Commands{
65 c := &Commands{
83 com: com,
66 com: com,
84− selected: SystemCommands,
85 sessionID: sessionID,
67 sessionID: sessionID,
86 hasSession: hasSession,
68 hasSession: hasSession,
87 hasTodos: hasTodos,
69 hasTodos: hasTodos,
@@ -98,6 +80,8 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
98 c.list = list.NewFilterableList()
80 c.list = list.NewFilterableList()
99 c.list.Focus()
81 c.list.Focus()
100 c.list.SetSelected(0)
82 c.list.SetSelected(0)
83+ c.list.SetEmptyMessage("No commands")
84+ c.list.SetFilterEmptyMessage("No matches")
101
85
102 c.input = textinput.New()
86 c.input = textinput.New()
103 c.input.SetVirtualCursor(false)
87 c.input.SetVirtualCursor(false)
@@ -121,14 +105,6 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
121 key.WithKeys("up", "ctrl+p"),
105 key.WithKeys("up", "ctrl+p"),
122 key.WithHelp("↑", "previous item"),
106 key.WithHelp("↑", "previous item"),
123 )
107 )
124− c.keyMap.Tab = key.NewBinding(
125− key.WithKeys("tab"),
126− key.WithHelp("tab", "switch selection"),
127− )
128− c.keyMap.ShiftTab = key.NewBinding(
129− key.WithKeys("shift+tab"),
130− key.WithHelp("shift+tab", "switch selection prev"),
131− )
132 closeKey := CloseKey
108 closeKey := CloseKey
133 closeKey.SetHelp("esc", "cancel")
109 closeKey.SetHelp("esc", "cancel")
134 c.keyMap.Close = closeKey
110 c.keyMap.Close = closeKey
@@ -138,7 +114,7 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
138 }
114 }
139
115
140 // Set initial commands
116 // Set initial commands
141− c.setCommandItems(c.selected)
117+ c.setCommandItems()
142
118
143 s := spinner.New()
119 s := spinner.New()
144 s.Spinner = spinner.Dot
120 s.Spinner = spinner.Dot
@@ -159,9 +135,7 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
159 case dockerMCPAvailabilityCheckedMsg:
135 case dockerMCPAvailabilityCheckedMsg:
160 c.dockerMCPAvailable = &msg.available
136 c.dockerMCPAvailable = &msg.available
161 c.dockerMCPCheckInFlight = false
137 c.dockerMCPCheckInFlight = false
162− if c.selected == SystemCommands {
163− c.setCommandItems(c.selected)
138+ c.setCommandItems()
164− }
165 return nil
139 return nil
166 case spinner.TickMsg:
140 case spinner.TickMsg:
167 if c.loading {
141 if c.loading {
@@ -195,16 +169,6 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
195 return item.Action()
169 return item.Action()
196 }
170 }
197 }
171 }
198− case key.Matches(msg, c.keyMap.Tab):
199− if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
200− c.selected = c.nextCommandType()
201− c.setCommandItems(c.selected)
202− }
203− case key.Matches(msg, c.keyMap.ShiftTab):
204− if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
205− c.selected = c.previousCommandType()
206− c.setCommandItems(c.selected)
207− }
208 default:
172 default:
209 var cmd tea.Cmd
173 var cmd tea.Cmd
210 for _, item := range c.list.FilteredItems() {
174 for _, item := range c.list.FilteredItems() {
@@ -244,43 +208,16 @@ func (c *Commands) Cursor() *tea.Cursor {
244 return InputCursor(c.com.Styles, c.input.Cursor())
208 return InputCursor(c.com.Styles, c.input.Cursor())
245}
209}
246
210
247−// commandsRadioView generates the command type selector radio buttons.
248−func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
249− if !hasUserCmds && !hasMCPPrompts {
250− return ""
251− }
252−
253− selectedFn := func(t CommandType) string {
254− if t == selected {
255− return sty.Radio.On.Padding(0, 1).Render() + sty.Radio.Label.Render(t.String())
256− }
257− return sty.Radio.Off.Padding(0, 1).Render() + sty.Radio.Label.Render(t.String())
258− }
259−
260− parts := []string{
261− selectedFn(SystemCommands),
262− }
263−
264− if hasUserCmds {
265− parts = append(parts, selectedFn(UserCommands))
266− }
267− if hasMCPPrompts {
268− parts = append(parts, selectedFn(MCPPrompts))
269− }
270−
271− return strings.Join(parts, " ")
272−}
273−
274// Draw implements [Dialog].
211// Draw implements [Dialog].
275func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
212func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
276 t := c.com.Styles
213 t := c.com.Styles
277 width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
214 width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
278 height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
215 height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
279− if area.Dx() != c.windowWidth && c.selected == SystemCommands {
216+ if area.Dx() != c.windowWidth {
280 c.windowWidth = area.Dx()
217 c.windowWidth = area.Dx()
281− // since some items in the list depend on width (e.g. toggle sidebar command),
218+ // Since some items in the list depend on width (e.g. toggle sidebar
282− // we need to reset the command items when width changes
219+ // command), we need to reset the command items when width changes.
283− c.setCommandItems(c.selected)
220+ c.setCommandItems()
284 }
221 }
285
222
286 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
223 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
@@ -296,7 +233,6 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
296
233
297 rc := NewRenderContext(t, width)
234 rc := NewRenderContext(t, width)
298 rc.Title = "Commands"
235 rc.Title = "Commands"
299− rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
300 inputView := t.Dialog.InputPrompt.Render(c.input.View())
236 inputView := t.Dialog.InputPrompt.Render(c.input.View())
301 rc.AddPart(inputView)
237 rc.AddPart(inputView)
302 listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
238 listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
@@ -317,7 +253,6 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
317// ShortHelp implements [help.KeyMap].
253// ShortHelp implements [help.KeyMap].
318func (c *Commands) ShortHelp() []key.Binding {
254func (c *Commands) ShortHelp() []key.Binding {
319 return []key.Binding{
255 return []key.Binding{
320− c.keyMap.Tab,
321 c.keyMap.UpDown,
256 c.keyMap.UpDown,
322 c.keyMap.Select,
257 c.keyMap.Select,
323 c.keyMap.Close,
258 c.keyMap.Close,
@@ -327,96 +262,50 @@ func (c *Commands) ShortHelp() []key.Binding {
327// FullHelp implements [help.KeyMap].
262// FullHelp implements [help.KeyMap].
328func (c *Commands) FullHelp() [][]key.Binding {
263func (c *Commands) FullHelp() [][]key.Binding {
329 return [][]key.Binding{
264 return [][]key.Binding{
330− {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
265+ {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous},
331 {c.keyMap.Close},
266 {c.keyMap.Close},
332 }
267 }
333}
268}
334
269
335−// nextCommandType returns the next command type in the cycle.
270+// setCommandItems sets the command items, combining system, user, and MCP
336−func (c *Commands) nextCommandType() CommandType {
271+// prompt commands into a single unified list.
337− switch c.selected {
272+func (c *Commands) setCommandItems() {
338− case SystemCommands:
273+ commandItems := []list.FilterableItem{}
339− if len(c.customCommands) > 0 {
274+
340− return UserCommands
275+ // System commands.
341− }
276+ for _, cmd := range c.defaultCommands() {
342− if len(c.mcpPrompts) > 0 {
277+ commandItems = append(commandItems, cmd)
343− return MCPPrompts
344− }
345− fallthrough
346− case UserCommands:
347− if len(c.mcpPrompts) > 0 {
348− return MCPPrompts
349− }
350− fallthrough
351− case MCPPrompts:
352− return SystemCommands
353− default:
354− return SystemCommands
355 }
278 }
356−}
357
279
358−// previousCommandType returns the previous command type in the cycle.
280+ // User (custom) commands.
359−func (c *Commands) previousCommandType() CommandType {
360− switch c.selected {
361− case SystemCommands:
362− if len(c.mcpPrompts) > 0 {
363− return MCPPrompts
364− }
365− if len(c.customCommands) > 0 {
281+ for _, cmd := range c.customCommands {
366− return UserCommands
282+ var action Action
283+ if cmd.Skill != nil {
284+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
285+ } else {
286+ action = ActionRunCustomCommand{
287+ Content: cmd.Content,
288+ Arguments: cmd.Arguments,
289+ Skill: cmd.Skill,
290+ }
367 }
291 }
368− return SystemCommands
292+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
369− case UserCommands:
293+ if cmd.Skill != nil {
370− return SystemCommands
294+ item = item.WithDescription(cmd.Skill.Description)
371− case MCPPrompts:
372− if len(c.customCommands) > 0 {
373− return UserCommands
374 }
295 }
375− return SystemCommands
296+ commandItems = append(commandItems, item)
376− default:
377− return SystemCommands
378 }
297 }
379−}
380−
381−// setCommandItems sets the command items based on the specified command type.
382−func (c *Commands) setCommandItems(commandType CommandType) {
383− c.selected = commandType
384
298
385− commandItems := []list.FilterableItem{}
299+ // MCP prompts.
386− switch c.selected {
387− case SystemCommands:
388− for _, cmd := range c.defaultCommands() {
389− commandItems = append(commandItems, cmd)
390− }
391− case UserCommands:
392− for _, cmd := range c.customCommands {
393− var action Action
394− if cmd.Skill != nil {
395− action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
396− } else {
397− action = ActionRunCustomCommand{
398− Content: cmd.Content,
399− Arguments: cmd.Arguments,
400− Skill: cmd.Skill,
401− }
402− }
403− item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
404− if cmd.Skill != nil {
405− item = item.WithDescription(cmd.Skill.Description)
406− }
407− commandItems = append(commandItems, item)
408− }
409− case MCPPrompts:
410− for _, cmd := range c.mcpPrompts {
300+ for _, cmd := range c.mcpPrompts {
411− action := ActionRunMCPPrompt{
301+ action := ActionRunMCPPrompt{
412− Title: cmd.Title,
302+ Title: cmd.Title,
413− Description: cmd.Description,
303+ Description: cmd.Description,
414− PromptID: cmd.PromptID,
304+ PromptID: cmd.PromptID,
415− ClientID: cmd.ClientID,
305+ ClientID: cmd.ClientID,
416− Arguments: cmd.Arguments,
306+ Arguments: cmd.Arguments,
417− }
418− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
419 }
307 }
308+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
420 }
309 }
421
310
422 c.list.SetItems(commandItems...)
311 c.list.SetItems(commandItems...)
@@ -481,9 +370,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
481
370
482 // Add external editor command if $EDITOR is available.
371 // Add external editor command if $EDITOR is available.
483 //
372 //
484− // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv;
373+ // TODO: Use [tea.EnvMsg] to get environment variables instead of os.Getenv;
485− // because os.Getenv does IO is breaks the TEA paradigm and is generally an
374+ // reading the environment directly bypasses the TEA message system and is
486− // antipattern.
375+ // generally an antipattern.
487 if os.Getenv("EDITOR") != "" {
376 if os.Getenv("EDITOR") != "" {
488 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
377 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
489 }
378 }
@@ -537,20 +426,16 @@ func (c *Commands) defaultCommands() []*CommandItem {
537 return commands
426 return commands
538}
427}
539
428
540−// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
429+// SetCustomCommands sets the custom commands and refreshes the view.
541func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
430func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
542 c.customCommands = customCommands
431 c.customCommands = customCommands
543− if c.selected == UserCommands {
544− c.setCommandItems(c.selected)
432+ c.setCommandItems()
545− }
546}
433}
547
434
548−// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
435+// SetMCPPrompts sets the MCP prompts and refreshes the view.
549func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
436func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
550 c.mcpPrompts = mcpPrompts
437 c.mcpPrompts = mcpPrompts
551− if c.selected == MCPPrompts {
552− c.setCommandItems(c.selected)
438+ c.setCommandItems()
553− }
554}
439}
555
440
556// StartLoading implements [LoadingDialog].
441// StartLoading implements [LoadingDialog].
internal/ui/dialog/commands_item.go+6−6
@@ -124,13 +124,13 @@ func (c *CommandItem) Render(width int) string {
124 descStyle = c.t.Dialog.SelectedItem
124 descStyle = c.t.Dialog.SelectedItem
125 }
125 }
126 contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
126 contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
127+ // Indent the description to align with the marker/title text.
128+ indent := strings.Repeat(" ", listItemMarkerWidth)
129+ textWidth := max(0, contentWidth-listItemMarkerWidth)
127− description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
130+ description := ansi.Truncate(strings.TrimSpace(c.description), textWidth, "…")
128 descVisWidth := lipgloss.Width(description)
131 descVisWidth := lipgloss.Width(description)
129− gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
132+ gap := strings.Repeat(" ", max(0, textWidth-descVisWidth))
130− if description == "" {
131− description = " "
132− }
133− rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
133+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(indent+description+gap))
134 }
134 }
135 return rendered
135 return rendered
136}
136}
internal/ui/dialog/dialog.go+1−1
@@ -25,7 +25,7 @@ const (
25// CloseKey is the default key binding to close dialogs.
25// CloseKey is the default key binding to close dialogs.
26var CloseKey = key.NewBinding(
26var CloseKey = key.NewBinding(
27 key.WithKeys("esc", "alt+esc"),
27 key.WithKeys("esc", "alt+esc"),
28− key.WithHelp("esc", "exit"),
28+ key.WithHelp("esc", "cancel"),
29)
29)
30
30
31// Action represents an action taken in a dialog after handling a message.
31// Action represents an action taken in a dialog after handling a message.
internal/ui/dialog/filepicker.go+11−2
@@ -14,10 +14,12 @@ import (
14 "charm.land/bubbles/v2/key"
14 "charm.land/bubbles/v2/key"
15 tea "charm.land/bubbletea/v2"
15 tea "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
16 "charm.land/lipgloss/v2"
17+ "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/common"
19 fimage "github.com/charmbracelet/crush/internal/ui/image"
20 fimage "github.com/charmbracelet/crush/internal/ui/image"
20 uv "github.com/charmbracelet/ultraviolet"
21 uv "github.com/charmbracelet/ultraviolet"
22+ "github.com/charmbracelet/x/ansi"
21)
23)
22
24
23// FilePickerID is the identifier for the FilePicker dialog.
25// FilePickerID is the identifier for the FilePicker dialog.
@@ -69,7 +71,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
69
71
70 f.km.Select = key.NewBinding(
72 f.km.Select = key.NewBinding(
71 key.WithKeys("enter"),
73 key.WithKeys("enter"),
72− key.WithHelp("enter", "accept"),
74+ key.WithHelp("enter", "confirm"),
73 )
75 )
74 f.km.Down = key.NewBinding(
76 f.km.Down = key.NewBinding(
75 key.WithKeys("down", "j"),
77 key.WithKeys("down", "j"),
@@ -93,7 +95,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
93 )
95 )
94 f.km.Close = key.NewBinding(
96 f.km.Close = key.NewBinding(
95 key.WithKeys("esc", "alt+esc"),
97 key.WithKeys("esc", "alt+esc"),
96− key.WithHelp("esc", "close/exit"),
98+ key.WithHelp("esc", "cancel"),
97 )
99 )
98
100
99 fp := filepicker.New()
101 fp := filepicker.New()
@@ -241,8 +243,15 @@ func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
241 rc := NewRenderContext(t, width)
243 rc := NewRenderContext(t, width)
242 rc.Gap = 1
244 rc.Gap = 1
243 rc.Title = "Add Image"
245 rc.Title = "Add Image"
246+ // Make the image-only restriction visible instead of silently no-oping.
247+ rc.TitleInfo = t.Dialog.SecondaryText.Render(" Images only: jpg, png")
244 rc.Help = f.help.View(f)
248 rc.Help = f.help.View(f)
245
249
250+ // Breadcrumb: show the (truncated) current directory so users know where
251+ // they are while navigating.
252+ currentDir := ansi.Truncate(fsext.PrettyPath(f.fp.CurrentDirectory), innerWidth, "…")
253+ rc.AddPart(t.Dialog.SecondaryText.Render(currentDir))
254+
246 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
255 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
247 rc.AddPart(imgPreview)
256 rc.AddPart(imgPreview)
248
257
internal/ui/dialog/models.go+2
@@ -110,6 +110,8 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) {
110 m.list = NewModelsList(t)
110 m.list = NewModelsList(t)
111 m.list.Focus()
111 m.list.Focus()
112 m.list.SetSelected(0)
112 m.list.SetSelected(0)
113+ m.list.SetEmptyMessage("No models")
114+ m.list.SetFilterEmptyMessage("No matches")
113
115
114 m.input = textinput.New()
116 m.input = textinput.New()
115 m.input.SetVirtualCursor(false)
117 m.input.SetVirtualCursor(false)
internal/ui/dialog/models_list.go+52−56
@@ -1,9 +1,6 @@
1package dialog
1package dialog
2
2
3import (
3import (
4− "fmt"
5− "slices"
6− "sort"
7 "strings"
4 "strings"
8
5
9 "github.com/charmbracelet/crush/internal/ui/list"
6 "github.com/charmbracelet/crush/internal/ui/list"
@@ -17,6 +14,11 @@ type ModelsList struct {
17 groups []ModelGroup
14 groups []ModelGroup
18 query string
15 query string
19 t *styles.Styles
16 t *styles.Styles
17+
18+ // emptyMessage is shown when there are no items and no active filter.
19+ emptyMessage string
20+ // filterEmptyMessage is shown when a filter is active but matches nothing.
21+ filterEmptyMessage string
20}
22}
21
23
22// NewModelsList creates a new list suitable for model items and groups.
24// NewModelsList creates a new list suitable for model items and groups.
@@ -190,58 +192,56 @@ func (f *ModelsList) VisibleItems() []list.Item {
190 return items
192 return items
191 }
193 }
192
194
193− filterableItems := make([]list.FilterableItem, 0, f.Len())
195+ // Build the match text once across all items (O(N) rather than O(N*G)).
196+ // Each entry is prefixed with its group's lowercased title, exactly as the
197+ // previous per-group code matched against.
198+ names := make([]string, 0, f.Len())
194 for _, g := range f.groups {
199 for _, g := range f.groups {
200+ prefix := strings.ToLower(g.Title) + " "
195 for _, item := range g.Items {
201 for _, item := range g.Items {
196− filterableItems = append(filterableItems, item)
202+ names = append(names, prefix+item.Filter())
197 }
203 }
198 }
204 }
199
205
206+ // Single fuzzy pass, then bucket matches back to their group by index.
207+ matches := fuzzy.Find(query, names)
208+ matched := make([]*fuzzy.Match, len(names))
209+ for i := range matches {
210+ matched[matches[i].Index] = &matches[i]
211+ }
212+
200 items := []list.Item{}
213 items := []list.Item{}
201− visitedGroups := map[int]bool{}
202
214
203− // Reconstruct groups with matched items
215+ // Reconstruct groups with matched items, preserving original group and
204− // Find which group this item belongs to
216+ // within-group item order.
217+ idx := 0
205− for gi, g := range f.groups {
218+ for _, g := range f.groups {
219+ prefixLen := len(strings.ToLower(g.Title) + " ")
206 addedCount := 0
220 addedCount := 0
207− name := strings.ToLower(g.Title) + " "
221+ for _, item := range g.Items {
208−
222+ if m := matched[idx]; m != nil {
209− names := make([]string, len(filterableItems))
210− for i, item := range filterableItems {
211− ms := item.(*ModelItem)
212− names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
213− }
214−
215− matches := fuzzy.Find(query, names)
216−
217− // Sort by original index to preserve order within the group
218− sort.SliceStable(matches, func(i, j int) bool {
219− return matches[i].Index < matches[j].Index
220− })
221−
222− for _, match := range matches {
223− item := filterableItems[match.Index].(*ModelItem)
224− idxs := []int{}
223+ idxs := []int{}
225− for _, idx := range match.MatchedIndexes {
224+ for _, mIdx := range m.MatchedIndexes {
226− // Adjusts removing provider name highlights
225+ // Adjusts removing provider name highlights
227− if idx < len(name) {
226+ if mIdx < prefixLen {
228− continue
227+ continue
228+ }
229+ idxs = append(idxs, mIdx-prefixLen)
229 }
230 }
230− idxs = append(idxs, idx-len(name))
231− }
232
231
232+ match := *m
233− match.MatchedIndexes = idxs
233+ match.MatchedIndexes = idxs
234− if slices.Contains(g.Items, item) {
234+ if addedCount == 0 {
235− if !visitedGroups[gi] {
236 // Add section header
235 // Add section header
236+ g := g
237 items = append(items, &g)
237 items = append(items, &g)
238− visitedGroups[gi] = true
239 }
238 }
240 // Add the matched item
239 // Add the matched item
241 item.SetMatch(match)
240 item.SetMatch(match)
242 items = append(items, item)
241 items = append(items, item)
243 addedCount++
242 addedCount++
244 }
243 }
244+ idx++
245 }
245 }
246 if addedCount > 0 {
246 if addedCount > 0 {
247 // Add a space separator after each provider section
247 // Add a space separator after each provider section
@@ -252,28 +252,24 @@ func (f *ModelsList) VisibleItems() []list.Item {
252 return items
252 return items
253}
253}
254
254
255−// Render renders the filterable list.
255+// SetEmptyMessage sets the placeholder shown when the list has no items and
256+// no filter is active.
256−func (f *ModelsList) Render() string {
257+func (f *ModelsList) SetEmptyMessage(msg string) {
257− return f.List.Render()
258+ f.emptyMessage = msg
258}
259}
259
260
260−type modelGroups []ModelGroup
261+// SetFilterEmptyMessage sets the placeholder shown when a filter is active but
261−
262+// yields no matches.
262−func (m modelGroups) Len() int {
263+func (f *ModelsList) SetFilterEmptyMessage(msg string) {
263− n := 0
264+ f.filterEmptyMessage = msg
264− for _, g := range m {
265− n += len(g.Items)
266− }
267− return n
268}
265}
269
266
270−func (m modelGroups) String(i int) string {
267+// Render renders the filterable list.
271− count := 0
268+func (f *ModelsList) Render() string {
272− for _, g := range m {
269+ if f.query != "" && f.filterEmptyMessage != "" {
273− if i < count+len(g.Items) {
270+ f.List.SetEmptyMessage(f.filterEmptyMessage)
274− return g.Items[i-count].Filter()
271+ } else {
275− }
272+ f.List.SetEmptyMessage(f.emptyMessage)
276− count += len(g.Items)
277 }
273 }
278− return ""
274+ return f.List.Render()
279}
275}
internal/ui/dialog/oauth.go−2
@@ -1,7 +1,6 @@
1package dialog
1package dialog
2
2
3import (
3import (
4− "context"
5 "fmt"
4 "fmt"
6 "strings"
5 "strings"
7
6
@@ -66,7 +65,6 @@ type OAuth struct {
66 expiresIn int
65 expiresIn int
67 interval int
66 interval int
68 token *oauth.Token
67 token *oauth.Token
69− cancelFunc context.CancelFunc
70}
68}
71
69
72var _ Dialog = (*OAuth)(nil)
70var _ Dialog = (*OAuth)(nil)
internal/ui/dialog/permissions.go+84−21
@@ -15,6 +15,7 @@ import (
15 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/stringext"
16 "github.com/charmbracelet/crush/internal/stringext"
17 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/ui/diffview"
18 "github.com/charmbracelet/crush/internal/ui/styles"
19 "github.com/charmbracelet/crush/internal/ui/styles"
19 uv "github.com/charmbracelet/ultraviolet"
20 uv "github.com/charmbracelet/ultraviolet"
20)
21)
@@ -55,10 +56,8 @@ const (
55
56
56// Permissions represents a dialog for permission requests.
57// Permissions represents a dialog for permission requests.
57type Permissions struct {
58type Permissions struct {
58− com *common.Common
59+ com *common.Common
59− windowWidth int // Terminal window dimensions.
60− windowHeight int
61− fullscreen bool // true when dialog is fullscreen
60+ fullscreen bool // true when dialog is fullscreen
62
61
63 permission permission.PermissionRequest
62 permission permission.PermissionRequest
64 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
63 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
@@ -73,6 +72,13 @@ type Permissions struct {
73 diffXOffset int // horizontal scroll offset for diff view
72 diffXOffset int // horizontal scroll offset for diff view
74 unifiedDiffContent string
73 unifiedDiffContent string
75 splitDiffContent string
74 splitDiffContent string
75+ // diffView is a persistent diff renderer, reused across scrolls/width
76+ // changes so its syntax and diff caches are preserved.
77+ diffView *diffview.DiffView
78+
79+ // Cached non-diff content, invalidated by viewportDirty or a width change.
80+ cachedContent string
81+ cachedContentWidth int
76
82
77 help help.Model
83 help help.Model
78 keyMap permissionsKeyMap
84 keyMap permissionsKeyMap
@@ -127,7 +133,7 @@ func defaultPermissionsKeyMap() permissionsKeyMap {
127 key.WithKeys("d", "D"),
133 key.WithKeys("d", "D"),
128 key.WithHelp("d", "deny"),
134 key.WithHelp("d", "deny"),
129 ),
135 ),
130− Close: CloseKey,
136+ Close: denyCloseKey(),
131 ToggleDiffMode: key.NewBinding(
137 ToggleDiffMode: key.NewBinding(
132 key.WithKeys("t"),
138 key.WithKeys("t"),
133 key.WithHelp("t", "toggle diff view"),
139 key.WithHelp("t", "toggle diff view"),
@@ -163,6 +169,15 @@ func defaultPermissionsKeyMap() permissionsKeyMap {
163 }
169 }
164}
170}
165
171
172+// denyCloseKey returns the close binding for the permissions dialog. It
173+// mirrors [CloseKey] but its help label reads "deny" because pressing esc
174+// rejects the permission request rather than merely dismissing the dialog.
175+func denyCloseKey() key.Binding {
176+ k := CloseKey
177+ k.SetHelp("esc", "deny")
178+ return k
179+}
180+
166var _ Dialog = (*Permissions)(nil)
181var _ Dialog = (*Permissions)(nil)
167
182
168// PermissionsOption configures the permissions dialog.
183// PermissionsOption configures the permissions dialog.
@@ -197,9 +212,11 @@ func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts
197 }
212 }
198
213
199 p := &Permissions{
214 p := &Permissions{
200− com: com,
215+ com: com,
201− permission: perm,
216+ permission: perm,
217+ // Default to Deny (index 2) so that confirming with enter does not
218+ // grant the most permissive choice. See renderButtons for ordering.
202− selectedOption: 0,
219+ selectedOption: 2,
203 viewport: vp,
220 viewport: vp,
204 help: h,
221 help: h,
205 keyMap: km,
222 keyMap: km,
@@ -438,7 +455,11 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
438 if content != "" {
455 if content != "" {
439 parts = append(parts, "", content)
456 parts = append(parts, "", content)
440 }
457 }
441− parts = append(parts, "", buttons, "", helpView)
458+ parts = append(parts, "", buttons)
459+ if hint := p.renderButtonHint(contentWidth); hint != "" {
460+ parts = append(parts, hint)
461+ }
462+ parts = append(parts, "", helpView)
442
463
443 innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
464 innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
444 DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
465 DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
@@ -527,28 +548,44 @@ func prettyName(name string) string {
527}
548}
528
549
529func (p *Permissions) renderContent(width int) string {
550func (p *Permissions) renderContent(width int) string {
551+ // Diff views manage their own caching (see renderDiff).
530 switch p.permission.ToolName {
552 switch p.permission.ToolName {
531− case tools.BashToolName:
532− return p.renderBashContent(width)
533 case tools.EditToolName:
553 case tools.EditToolName:
534 return p.renderEditContent(width)
554 return p.renderEditContent(width)
535 case tools.WriteToolName:
555 case tools.WriteToolName:
536 return p.renderWriteContent(width)
556 return p.renderWriteContent(width)
537 case tools.MultiEditToolName:
557 case tools.MultiEditToolName:
538 return p.renderMultiEditContent(width)
558 return p.renderMultiEditContent(width)
559+ }
560+
561+ // Non-diff content is cached the same way the diff is: recompute only when
562+ // the viewport is dirty or the width changed. This avoids re-running JSON
563+ // (un)marshalling, syntax highlighting and wrapping on every frame.
564+ if !p.viewportDirty && width == p.cachedContentWidth {
565+ return p.cachedContent
566+ }
567+
568+ var content string
569+ switch p.permission.ToolName {
570+ case tools.BashToolName:
571+ content = p.renderBashContent(width)
539 case tools.DownloadToolName:
572 case tools.DownloadToolName:
540− return p.renderDownloadContent(width)
573+ content = p.renderDownloadContent(width)
541 case tools.FetchToolName:
574 case tools.FetchToolName:
542− return p.renderFetchContent(width)
575+ content = p.renderFetchContent(width)
543 case tools.AgenticFetchToolName:
576 case tools.AgenticFetchToolName:
544− return p.renderAgenticFetchContent(width)
577+ content = p.renderAgenticFetchContent(width)
545 case tools.ViewToolName:
578 case tools.ViewToolName:
546− return p.renderViewContent(width)
579+ content = p.renderViewContent(width)
547 case tools.LSToolName:
580 case tools.LSToolName:
548− return p.renderLSContent(width)
581+ content = p.renderLSContent(width)
549 default:
582 default:
550− return p.renderDefaultContent(width)
583+ content = p.renderDefaultContent(width)
551 }
584 }
585+
586+ p.cachedContent = content
587+ p.cachedContentWidth = width
588+ return content
552}
589}
553
590
554func (p *Permissions) renderBashContent(width int) string {
591func (p *Permissions) renderBashContent(width int) string {
@@ -593,9 +630,17 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten
593 }
630 }
594
631
595 isSplitMode := p.isSplitMode()
632 isSplitMode := p.isSplitMode()
633+
634+ // Build the diff view once. Its content never changes for the lifetime of
635+ // the dialog, so reusing it across scrolls and width changes preserves the
636+ // syntax/diff caches; only XOffset/Width/layout are updated per render.
637+ if p.diffView == nil {
596− formatter := common.DiffFormatter(p.com.Styles).
638+ p.diffView = common.DiffFormatter(p.com.Styles).
597− Before(fsext.PrettyPath(filePath), oldContent).
639+ Before(fsext.PrettyPath(filePath), oldContent).
598− After(fsext.PrettyPath(filePath), newContent).
640+ After(fsext.PrettyPath(filePath), newContent)
641+ }
642+
643+ formatter := p.diffView.
599 XOffset(p.diffXOffset).
644 XOffset(p.diffXOffset).
600 Width(contentWidth)
645 Width(contentWidth)
601
646
@@ -662,7 +707,7 @@ func (p *Permissions) renderViewContent(width int) string {
662 if params.Offset > 0 {
707 if params.Offset > 0 {
663 content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
708 content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
664 }
709 }
665− if params.Limit > 0 && params.Limit != 2000 {
710+ if params.Limit > 0 && params.Limit != tools.DefaultReadLimit {
666 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
711 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
667 }
712 }
668
713
@@ -758,6 +803,24 @@ func (p *Permissions) renderButtons(contentWidth int) string {
758 Render(content)
803 Render(content)
759}
804}
760
805
806+// renderButtonHint renders a dim one-line description of the currently
807+// highlighted button so the consequence of the selection is explicit.
808+func (p *Permissions) renderButtonHint(contentWidth int) string {
809+ var msg string
810+ switch p.selectedOption {
811+ case 0:
812+ msg = "Allow this action once"
813+ case 1:
814+ msg = "Auto-allow this tool for the rest of the session"
815+ default:
816+ msg = "Reject and tell the agent"
817+ }
818+ return p.com.Styles.Dialog.SecondaryText.
819+ Width(contentWidth).
820+ Align(lipgloss.Right).
821+ Render(msg)
822+}
823+
761func (p *Permissions) canScroll() bool {
824func (p *Permissions) canScroll() bool {
762 if p.hasDiffView() {
825 if p.hasDiffView() {
763 // Diff views can always scroll.
826 // Diff views can always scroll.
internal/ui/dialog/permissions_test.go+7−6
@@ -54,22 +54,23 @@ func TestPermissions_NavigationCyclesOptions(t *testing.T) {
54 t.Parallel()
54 t.Parallel()
55
55
56 p := newTestPermissions(t)
56 p := newTestPermissions(t)
57+ // The dialog defaults to Deny (index 2) so enter does not grant the most
58+ // permissive choice.
59+ require.Equal(t, 2, p.selectedOption)
60+
61+ // Tab cycles forward (wraps to 0).
62+ p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
57 require.Equal(t, 0, p.selectedOption)
63 require.Equal(t, 0, p.selectedOption)
58
64
59− // Tab cycles forward.
60 p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
65 p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
61 require.Equal(t, 1, p.selectedOption)
66 require.Equal(t, 1, p.selectedOption)
62
67
63 p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
68 p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
64 require.Equal(t, 2, p.selectedOption)
69 require.Equal(t, 2, p.selectedOption)
65
70
66− // Wrap around.
67− p.HandleMsg(tea.KeyPressMsg{Code: tea.KeyTab})
68− require.Equal(t, 0, p.selectedOption)
69−
70 // Left cycles backward.
71 // Left cycles backward.
71 p.HandleMsg(keyMsg('h'))
72 p.HandleMsg(keyMsg('h'))
72− require.Equal(t, 2, p.selectedOption)
73+ require.Equal(t, 1, p.selectedOption)
73}
74}
74
75
75// TestPermissions_EnterConfirmsSelection verifies that enter confirms the
76// TestPermissions_EnterConfirmsSelection verifies that enter confirms the
internal/ui/dialog/sessions.go+16−11
@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
61 s := new(Session)
61 s := new(Session)
62 s.sessionsMode = sessionsModeNormal
62 s.sessionsMode = sessionsModeNormal
63 s.com = com
63 s.com = com
64− sessions, err := com.Workspace.ListSessions(context.TODO())
64+ sessions, err := com.Workspace.ListSessions(context.Background())
65 if err != nil {
65 if err != nil {
66 return nil, err
66 return nil, err
67 }
67 }
@@ -81,6 +81,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
81 s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
81 s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
82 s.list.Focus()
82 s.list.Focus()
83 s.list.SetSelected(s.selectedSessionInx)
83 s.list.SetSelected(s.selectedSessionInx)
84+ s.list.SetEmptyMessage("No sessions yet")
85+ s.list.SetFilterEmptyMessage("No matches")
84
86
85 s.input = textinput.New()
87 s.input = textinput.New()
86 s.input.SetVirtualCursor(false)
88 s.input.SetVirtualCursor(false)
@@ -89,8 +91,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
89 s.input.Focus()
91 s.input.Focus()
90
92
91 s.keyMap.Select = key.NewBinding(
93 s.keyMap.Select = key.NewBinding(
92− key.WithKeys("enter", "tab", "ctrl+y"),
94+ key.WithKeys("enter", "ctrl+y"),
93− key.WithHelp("enter", "choose"),
95+ key.WithHelp("enter", "confirm"),
94 )
96 )
95 s.keyMap.Next = key.NewBinding(
97 s.keyMap.Next = key.NewBinding(
96 key.WithKeys("down", "ctrl+n"),
98 key.WithKeys("down", "ctrl+n"),
@@ -121,7 +123,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
121 key.WithHelp("esc", "cancel"),
123 key.WithHelp("esc", "cancel"),
122 )
124 )
123 s.keyMap.ConfirmDelete = key.NewBinding(
125 s.keyMap.ConfirmDelete = key.NewBinding(
124− key.WithKeys("y"),
126+ key.WithKeys("y", "enter"),
125 key.WithHelp("y", "delete"),
127 key.WithHelp("y", "delete"),
126 )
128 )
127 s.keyMap.CancelDelete = key.NewBinding(
129 s.keyMap.CancelDelete = key.NewBinding(
@@ -204,8 +206,9 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
204 s.list.ScrollToSelected()
206 s.list.ScrollToSelected()
205 case key.Matches(msg, s.keyMap.Select):
207 case key.Matches(msg, s.keyMap.Select):
206 if item := s.list.SelectedItem(); item != nil {
208 if item := s.list.SelectedItem(); item != nil {
207− sessionItem := item.(*SessionItem)
209+ if sessionItem, ok := item.(*SessionItem); ok {
208− return ActionSelectSession{sessionItem.Session}
210+ return ActionSelectSession{sessionItem.Session}
211+ }
209 }
212 }
210 default:
213 default:
211 var cmd tea.Cmd
214 var cmd tea.Cmd
@@ -327,7 +330,9 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
327
330
328func (s *Session) selectedSessionItem() *SessionItem {
331func (s *Session) selectedSessionItem() *SessionItem {
329 if item := s.list.SelectedItem(); item != nil {
332 if item := s.list.SelectedItem(); item != nil {
330− return item.(*SessionItem)
333+ if sessionItem, ok := item.(*SessionItem); ok {
334+ return sessionItem
335+ }
331 }
336 }
332 return nil
337 return nil
333}
338}
@@ -356,7 +361,7 @@ func (s *Session) removeSession(id string) {
356
361
357func (s *Session) deleteSessionCmd(id string) tea.Cmd {
362func (s *Session) deleteSessionCmd(id string) tea.Cmd {
358 return func() tea.Msg {
363 return func() tea.Msg {
359− err := s.com.Workspace.DeleteSession(context.TODO(), id)
364+ err := s.com.Workspace.DeleteSession(context.Background(), id)
360 if err != nil {
365 if err != nil {
361 return util.NewErrorMsg(err)
366 return util.NewErrorMsg(err)
362 }
367 }
@@ -382,9 +387,9 @@ func (s *Session) confirmRenameSession() Action {
382}
387}
383
388
384func (s *Session) updateSession(session session.Session) {
389func (s *Session) updateSession(session session.Session) {
385− for existingID, sess := range s.sessions {
390+ for i, sess := range s.sessions {
386 if sess.ID == session.ID {
391 if sess.ID == session.ID {
387− s.sessions[existingID] = session
392+ s.sessions[i] = session
388 break
393 break
389 }
394 }
390 }
395 }
@@ -392,7 +397,7 @@ func (s *Session) updateSession(session session.Session) {
392
397
393func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
398func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
394 return func() tea.Msg {
399 return func() tea.Msg {
395− _, err := s.com.Workspace.SaveSession(context.TODO(), session)
400+ _, err := s.com.Workspace.SaveSession(context.Background(), session)
396 if err != nil {
401 if err != nil {
397 return util.NewErrorMsg(err)
402 return util.NewErrorMsg(err)
398 }
403 }
internal/ui/dialog/sessions_item.go+12−2
@@ -140,6 +140,14 @@ type ListItemStyles struct {
140 InfoTextFocused lipgloss.Style
140 InfoTextFocused lipgloss.Style
141}
141}
142
142
143+// listItemMarker is a leading glyph prepended to the focused list row so
144+// selection is conveyed by shape (not background color alone). Non-focused
145+// rows are padded with equivalent leading spaces to keep text aligned.
146+const (
147+ listItemMarker = "▍ "
148+ listItemMarkerWidth = 2
149+)
150+
143func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
151func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
144 if cache == nil {
152 if cache == nil {
145 cache = make(map[int]string)
153 cache = make(map[int]string)
@@ -151,13 +159,15 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width
151 }
159 }
152
160
153 style := t.ItemBlurred
161 style := t.ItemBlurred
162+ marker := strings.Repeat(" ", listItemMarkerWidth)
154 if focused {
163 if focused {
155 style = t.ItemFocused
164 style = t.ItemFocused
165+ marker = listItemMarker
156 }
166 }
157
167
158 var infoText string
168 var infoText string
159 var infoWidth int
169 var infoWidth int
160− lineWidth := width
170+ lineWidth := max(0, width-listItemMarkerWidth)
161 if len(info) > 0 {
171 if len(info) > 0 {
162 infoText = fmt.Sprintf(" %s ", info)
172 infoText = fmt.Sprintf(" %s ", info)
163 if focused {
173 if focused {
@@ -202,7 +212,7 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width
202 content = strings.Join(parts, "")
212 content = strings.Join(parts, "")
203 }
213 }
204
214
205− content = style.Render(content + gap + infoText)
215+ content = style.Render(marker + content + gap + infoText)
206 cache[width] = content
216 cache[width] = content
207 return content
217 return content
208}
218}
internal/ui/difftastic/difftastic.gonew file+60
@@ -0,0 +1,60 @@
1+// Package difftastic provides a syntax-aware structural diff renderer by
2+// shelling out to the difftastic binary (https://github.com/Wilfred/difftastic).
3+package difftastic
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+)
14+
15+// RenderDiff runs difftastic on before/after content and returns ANSI-colored
16+// output. Returns empty string and a nil error if difft is not available so
17+// callers can fall back to the builtin renderer.
18+func RenderDiff(filePath, before, after string, width int) (string, error) {
19+ if _, err := exec.LookPath("difft"); err != nil {
20+ return "", nil
21+ }
22+
23+ dir, err := os.MkdirTemp("", "crush-difftastic-*")
24+ if err != nil {
25+ return "", fmt.Errorf("failed to create temp directory: %w", err)
26+ }
27+ defer os.RemoveAll(dir)
28+
29+ ext := filepath.Ext(filePath)
30+ oldPath := filepath.Join(dir, "old"+ext)
31+ newPath := filepath.Join(dir, "new"+ext)
32+
33+ if err := os.WriteFile(oldPath, []byte(before), 0o644); err != nil {
34+ return "", fmt.Errorf("failed to write before content: %w", err)
35+ }
36+ if err := os.WriteFile(newPath, []byte(after), 0o644); err != nil {
37+ return "", fmt.Errorf("failed to write after content: %w", err)
38+ }
39+
40+ args := []string{
41+ "--color=always",
42+ "--display=inline",
43+ "--width=" + strconv.Itoa(width),
44+ oldPath,
45+ newPath,
46+ }
47+
48+ cmd := exec.CommandContext(context.Background(), "difft", args...)
49+ output, err := cmd.CombinedOutput()
50+ if err != nil {
51+ // difftastic returns non-zero exit code when differences are found.
52+ // The output is still valid, so return it rather than the error.
53+ if len(output) > 0 {
54+ return string(output), nil
55+ }
56+ return "", fmt.Errorf("failed to run difftastic: %w", err)
57+ }
58+
59+ return strings.TrimRight(string(output), "\n"), nil
60+}
internal/ui/diffview/diffview.go+2−2
@@ -791,8 +791,8 @@ func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) str
791
791
792 // Create a hash of the content + color to use as cache key
792 // Create a hash of the content + color to use as cache key
793 h := xxh3.New()
793 h := xxh3.New()
794− h.Write([]byte(source))
794+ _, _ = h.Write([]byte(source))
795− h.Write([]byte(colorStr))
795+ _, _ = h.Write([]byte(colorStr))
796 return fmt.Sprintf("%x", h.Sum(nil))
796 return fmt.Sprintf("%x", h.Sum(nil))
797}
797}
798
798
internal/ui/diffview/diffview_test.go−8
@@ -329,11 +329,3 @@ func assertLineWidth(t *testing.T, expected int, output string) {
329 t.Errorf("expected output width to be == %d, got %d", expected, lineWidth)
329 t.Errorf("expected output width to be == %d, got %d", expected, lineWidth)
330 }
330 }
331}
331}
332−
333−func assertHeight(t *testing.T, expected int, output string) {
334− output = strings.TrimSuffix(output, "\n")
335− lines := strings.Count(output, "\n") + 1
336− if lines != expected {
337− t.Errorf("expected output height to be == %d, got %d", expected, lines)
338− }
339−}
internal/ui/diffview/style.go+46−44
@@ -1,10 +1,52 @@
1package diffview
1package diffview
2
2
3import (
3import (
4+ "image/color"
5+
4 "charm.land/lipgloss/v2"
6 "charm.land/lipgloss/v2"
5 "github.com/charmbracelet/x/exp/charmtone"
7 "github.com/charmbracelet/x/exp/charmtone"
6)
8)
7
9
10+// Diff insert/delete backgrounds are derived by washing a semantic accent
11+// (green for inserts, red for deletes) over the base background. Keeping the
12+// derivation here gives both DefaultDarkStyle/DefaultLightStyle and the styles
13+// package's palette-driven theme a single source of truth, rather than several
14+// copies of the same hardcoded hex.
15+const (
16+ diffCodeTint = 0.10 // symbol/code background wash strength.
17+ diffLineNumTint = 0.07 // line-number background wash (slightly darker).
18+)
19+
20+// BlendBg washes accent over base by ratio in [0,1], returning a subtle tint.
21+// It is the shared basis for diff insert/delete backgrounds.
22+func BlendBg(base, accent color.Color, ratio float64) color.Color {
23+ br, bg, bb, _ := base.RGBA()
24+ ar, ag, ab, _ := accent.RGBA()
25+ lerp := func(a, b uint32) uint8 {
26+ return uint8(float64(a>>8)*(1-ratio) + float64(b>>8)*ratio + 0.5)
27+ }
28+ return color.RGBA{R: lerp(br, ar), G: lerp(bg, ag), B: lerp(bb, ab), A: 0xff}
29+}
30+
31+// ChangeLineStyle builds an insert- or delete-line style from semantic colors:
32+// accent drives the symbol/line-number foreground and the background wash over
33+// base, while codeFg colors the code text (pass nil to leave it unset).
34+func ChangeLineStyle(accent, base, codeFg color.Color) LineStyle {
35+ code := lipgloss.NewStyle().Background(BlendBg(base, accent, diffCodeTint))
36+ if codeFg != nil {
37+ code = code.Foreground(codeFg)
38+ }
39+ return LineStyle{
40+ LineNumber: lipgloss.NewStyle().
41+ Foreground(accent).
42+ Background(BlendBg(base, accent, diffLineNumTint)),
43+ Symbol: lipgloss.NewStyle().
44+ Foreground(accent).
45+ Background(BlendBg(base, accent, diffCodeTint)),
46+ Code: code,
47+ }
48+}
49+
8// LineStyle defines the styles for a given line type in the diff view.
50// LineStyle defines the styles for a given line type in the diff view.
9type LineStyle struct {
51type LineStyle struct {
10 LineNumber lipgloss.Style
52 LineNumber lipgloss.Style
@@ -49,28 +91,8 @@ func DefaultLightStyle() Style {
49 Foreground(charmtone.Pepper).
91 Foreground(charmtone.Pepper).
50 Background(charmtone.Salt),
92 Background(charmtone.Salt),
51 },
93 },
52− InsertLine: LineStyle{
94+ InsertLine: ChangeLineStyle(charmtone.Turtle, charmtone.Salt, charmtone.Pepper),
53− LineNumber: lipgloss.NewStyle().
95+ DeleteLine: ChangeLineStyle(charmtone.Cherry, charmtone.Salt, charmtone.Pepper),
54− Foreground(charmtone.Turtle).
55− Background(lipgloss.Color("#c8e6c9")),
56− Symbol: lipgloss.NewStyle().
57− Foreground(charmtone.Turtle).
58− Background(lipgloss.Color("#e8f5e9")),
59− Code: lipgloss.NewStyle().
60− Foreground(charmtone.Pepper).
61− Background(lipgloss.Color("#e8f5e9")),
62− },
63− DeleteLine: LineStyle{
64− LineNumber: lipgloss.NewStyle().
65− Foreground(charmtone.Cherry).
66− Background(lipgloss.Color("#ffcdd2")),
67− Symbol: lipgloss.NewStyle().
68− Foreground(charmtone.Cherry).
69− Background(lipgloss.Color("#ffebee")),
70− Code: lipgloss.NewStyle().
71− Foreground(charmtone.Pepper).
72− Background(lipgloss.Color("#ffebee")),
73− },
74 Filename: LineStyle{
96 Filename: LineStyle{
75 LineNumber: lipgloss.NewStyle().
97 LineNumber: lipgloss.NewStyle().
76 Foreground(charmtone.Iron).
98 Foreground(charmtone.Iron).
@@ -107,28 +129,8 @@ func DefaultDarkStyle() Style {
107 Foreground(charmtone.Salt).
129 Foreground(charmtone.Salt).
108 Background(charmtone.Pepper),
130 Background(charmtone.Pepper),
109 },
131 },
110− InsertLine: LineStyle{
132+ InsertLine: ChangeLineStyle(charmtone.Turtle, charmtone.Pepper, charmtone.Salt),
111− LineNumber: lipgloss.NewStyle().
133+ DeleteLine: ChangeLineStyle(charmtone.Cherry, charmtone.Pepper, charmtone.Salt),
112− Foreground(charmtone.Turtle).
113− Background(lipgloss.Color("#293229")),
114− Symbol: lipgloss.NewStyle().
115− Foreground(charmtone.Turtle).
116− Background(lipgloss.Color("#303a30")),
117− Code: lipgloss.NewStyle().
118− Foreground(charmtone.Salt).
119− Background(lipgloss.Color("#303a30")),
120− },
121− DeleteLine: LineStyle{
122− LineNumber: lipgloss.NewStyle().
123− Foreground(charmtone.Cherry).
124− Background(lipgloss.Color("#332929")),
125− Symbol: lipgloss.NewStyle().
126− Foreground(charmtone.Cherry).
127− Background(lipgloss.Color("#3a3030")),
128− Code: lipgloss.NewStyle().
129− Foreground(charmtone.Salt).
130− Background(lipgloss.Color("#3a3030")),
131− },
132 Filename: LineStyle{
134 Filename: LineStyle{
133 LineNumber: lipgloss.NewStyle().
135 LineNumber: lipgloss.NewStyle().
134 Foreground(charmtone.Smoke).
136 Foreground(charmtone.Smoke).
internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden+3−3
@@ -3,7 +3,7 @@
3  2     2   
3  2     2   
4  3  import (   3  import ( 
4  3  import (   3  import ( 
5  4   "fmt"   4   "fmt" 
5  4   "fmt"   4   "fmt" 
6−       5 +  "strings" 
6+       5 +  "strings" 
7  5  )   6  ) 
7  5  )   6  ) 
8  6     7   
8  6     7   
9  7  func main() {   8  func main() { 
9  7  func main() {   8  func main() { 
@@ -11,6 +11,6 @@
11  9  }  10  } 
11  9  }  10  } 
12 10    11   
12 10    11   
13 11  func getContent() string {  12  func getContent() string { 
13 11  func getContent() string {  12  func getContent() string { 
14− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
15−      14 +  return content 
15+      14 +  return content 
16 13  }  15  } 
16 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden+3−3
@@ -3,7 +3,7 @@
3  2     2   
3  2     2   
4  3  import (   3  import ( 
4  3  import (   3  import ( 
5  4   "fmt"   4   "fmt" 
5  4   "fmt"   4   "fmt" 
6−       5 +  "strings" 
6+       5 +  "strings" 
7  5  )   6  ) 
7  5  )   6  ) 
8  6     7   
8  6     7   
9  7  func main() {   8  func main() { 
9  7  func main() {   8  func main() { 
@@ -11,6 +11,6 @@
11  9  }  10  } 
11  9  }  10  } 
12 10    11   
12 10    11   
13 11  func getContent() string {  12  func getContent() string { 
13 11  func getContent() string {  12  func getContent() string { 
14− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
15−      14 +  return content 
15+      14 +  return content 
16 13  }  15  } 
16 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden+2−2
@@ -2,6 +2,6 @@
2  5  )   5  ) 
2  5  )   5  ) 
3  6     6   
3  6     6   
4  7  func main() {   7  func main() { 
4  7  func main() {   7  func main() { 
5−  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
5+  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
6−       9 +  fmt.Println(content) 
6+       9 +  fmt.Println(content) 
7  9  }  10  } 
7  9  }  10  } 
internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden+2−2
@@ -2,6 +2,6 @@
2  5  )   5  ) 
2  5  )   5  ) 
3  6     6   
3  6     6   
4  7  func main() {   7  func main() { 
4  7  func main() {   7  func main() { 
5−  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
5+  8 -  fmt.Println("Hello, world!")   8 +  content := "Hello, world!" 
6−       9 +  fmt.Println(content) 
6+       9 +  fmt.Println(content) 
7  9  }  10  } 
7  9  }  10  } 
internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden+3−3
@@ -1,4 +1,4 @@
1 …  @@ -1,3 +1,3 @@   …   
1 …  @@ -1,3 +1,3 @@   …   
2− 1 - a  1 + d 
2+ 1 - a  1 + d 
3− 2 - b  2 + e 
3+ 2 - b  2 + e 
4− 3 - c  3 + f 
4+ 3 - c  3 + f 
internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden+3−3
@@ -1,4 +1,4 @@
1 …  @@ -1,3 +1,3 @@   …   
1 …  @@ -1,3 +1,3 @@   …   
2− 1 - a  1 + d 
2+ 1 - a  1 + d 
3− 2 - b  2 + e 
3+ 2 - b  2 + e 
4− 3 - c  3 + f 
4+ 3 - c  3 + f 
internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden+2−2
@@ -2,6 +2,6 @@
2 )  ) 
2 )  ) 
3    
3    
4 func main() {  func main() { 
4 func main() {  func main() { 
5−-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
5+-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
6−  +  fmt.Println(content) 
6+  +  fmt.Println(content) 
7 }  } 
7 }  } 
internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden+2−2
@@ -2,6 +2,6 @@
2 )  ) 
2 )  ) 
3    
3    
4 func main() {  func main() { 
4 func main() {  func main() { 
5−-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
5+-  fmt.Println("Hello, world!") +  content := "Hello, world!" 
6−  +  fmt.Println(content) 
6+  +  fmt.Println(content) 
7 }  } 
7 }  } 
internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4  "fmt"   4  "fmt" 
4  4  "fmt"   4  "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4  "fmt"   4  "fmt" 
4  4  "fmt"   4  "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConte… 12  func getConte…
12 11  func getConte… 12  func getConte…
13− 12 -  return "H… 13 +  content :…
13+ 12 -  return "H… 13 +  content :…
14−      14 +  return co…
14+      14 +  return co…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConte… 12  func getConte…
12 11  func getConte… 12  func getConte…
13− 12 -  return "H… 13 +  content :…
13+ 12 -  return "H… 13 +  content :…
14−      14 +  return co…
14+      14 +  return co…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden+4−4
@@ -3,7 +3,7 @@
3  2   2   
3  2   2   
4  3   3  import ( 
4  3   3  import ( 
5  4   4   "fmt" 
5  4   4   "fmt" 
6−     5 +  "strings" 
6+     5 +  "strings" 
7  5   6  ) 
7  5   6  ) 
8  6   7   
8  6   7   
9  7   8  func main() { 
9  7   8  func main() { 
@@ -11,7 +11,7 @@
11  9  10  } 
11  9  10  } 
12 10  11   
12 10  11   
13 11  12  func getContent() string { 
13 11  12  func getContent() string { 
14− 12    -  return "Hello, world!" 
14+ 12    -  return "Hello, world!" 
15−    13 +  content := strings.ToUpper("Hello, World!") 
15+    13 +  content := strings.ToUpper("Hello, World!") 
16−    14 +  return content 
16+    14 +  return content 
17 13  15  } 
17 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden+4−4
@@ -3,7 +3,7 @@
3  2   2   
3  2   2   
4  3   3  import ( 
4  3   3  import ( 
5  4   4   "fmt" 
5  4   4   "fmt" 
6−     5 +  "strings" 
6+     5 +  "strings" 
7  5   6  ) 
7  5   6  ) 
8  6   7   
8  6   7   
9  7   8  func main() { 
9  7   8  func main() { 
@@ -11,7 +11,7 @@
11  9  10  } 
11  9  10  } 
12 10  11   
12 10  11   
13 11  12  func getContent() string { 
13 11  12  func getContent() string { 
14− 12    -  return "Hello, world!" 
14+ 12    -  return "Hello, world!" 
15−    13 +  content := strings.ToUpper("Hello, World!") 
15+    13 +  content := strings.ToUpper("Hello, World!") 
16−    14 +  return content 
16+    14 +  return content 
17 13  15  } 
17 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  ) 
2  5   5  ) 
3  6   6   
3  6   6   
4  7   7  func main() { 
4  7   7  func main() { 
5−  8    -  fmt.Println("Hello, world!") 
5+  8    -  fmt.Println("Hello, world!") 
6−     8 +  content := "Hello, world!" 
6+     8 +  content := "Hello, world!" 
7−     9 +  fmt.Println(content) 
7+     9 +  fmt.Println(content) 
8  9  10  } 
8  9  10  } 
internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  ) 
2  5   5  ) 
3  6   6   
3  6   6   
4  7   7  func main() { 
4  7   7  func main() { 
5−  8    -  fmt.Println("Hello, world!") 
5+  8    -  fmt.Println("Hello, world!") 
6−     8 +  content := "Hello, world!" 
6+     8 +  content := "Hello, world!" 
7−     9 +  fmt.Println(content) 
7+     9 +  fmt.Println(content) 
8  9  10  } 
8  9  10  } 
internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden+6−6
@@ -1,7 +1,7 @@
1 …  …  @@ -1,3 +1,3 @@  
1 …  …  @@ -1,3 +1,3 @@  
2− 1    - a 
2+ 1    - a 
3− 2    - b 
3+ 2    - b 
4− 3    - c 
4+ 3    - c 
5−    1 + d 
5+    1 + d 
6−    2 + e 
6+    2 + e 
7−    3 + f 
7+    3 + f 
internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden+6−6
@@ -1,7 +1,7 @@
1 …  …  @@ -1,3 +1,3 @@  
1 …  …  @@ -1,3 +1,3 @@  
2− 1    - a 
2+ 1    - a 
3− 2    - b 
3+ 2    - b 
4− 3    - c 
4+ 3    - c 
5−    1 + d 
5+    1 + d 
6−    2 + e 
6+    2 + e 
7−    3 + f 
7+    3 + f 
internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden+3−3
@@ -2,7 +2,7 @@
2 ) 
2 ) 
3  
3  
4 func main() { 
4 func main() { 
5−-  fmt.Println("Hello, world!") 
5+-  fmt.Println("Hello, world!") 
6−+  content := "Hello, world!" 
6++  content := "Hello, world!" 
7−+  fmt.Println(content) 
7++  fmt.Println(content) 
8 } 
8 } 
internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden+3−3
@@ -2,7 +2,7 @@
2 ) 
2 ) 
3  
3  
4 func main() { 
4 func main() { 
5−-  fmt.Println("Hello, world!") 
5+-  fmt.Println("Hello, world!") 
6−+  content := "Hello, world!" 
6++  content := "Hello, world!" 
7−+  fmt.Println(content) 
7++  fmt.Println(content) 
8 } 
8 } 
internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4  "fmt" 
4  4   4  "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4  "fmt" 
4  4   4  "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUppe…
14+    13 +  content := strings.ToUppe…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUppe…
14+    13 +  content := strings.ToUppe…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden+1−1
@@ -2,4 +2,4 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden+1−1
@@ -2,5 +2,5 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  …  …   …  … 
6  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden+1−1
@@ -2,6 +2,6 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  …  …   …  … 
7  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden+1−1
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  …  …   …  … 
8  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden+1−1
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden+1−1
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden+1−1
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden+1−1
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden+2−2
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,4 +10,4 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13−  …  …   …  … 
13+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,5 +10,5 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−  …  …   …  … 
14+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden+1−1
@@ -2,4 +2,4 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden+1−1
@@ -2,5 +2,5 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  …   …  … 
6  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden+1−1
@@ -2,6 +2,6 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  …   …  … 
7  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden+1−1
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  …   …  … 
8  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden+1−1
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden+1−1
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden+1−1
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden+1−1
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden+2−2
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,4 +10,4 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13−  …   …  … 
13+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden+3−3
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,5 +10,5 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−  …   …  … 
14+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,6 +10,6 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−  …   …  … 
15+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden+5−5
@@ -1,9 +1,9 @@
1  …  @@ -1,6 +1,8 @@    …   
1  …  @@ -1,6 +1,8 @@    …   
2−  1 - // this is   1 + /** 
2+  1 - // this is   1 + /** 
3−  2 - // a regular   2 +  * this is 
3+  2 - // a regular   2 +  * this is 
4−  3 - // comment   3 +  * a block 
4+  3 - // comment   3 +  * a block 
5−       4 +  * comment 
5+       4 +  * comment 
6−       5 +  */ 
6+       5 +  */ 
7  4  $(function() {   6  $(function() { 
7  4  $(function() {   6  $(function() { 
8  5   console.log("Hello, world!");   7   console.log("Hello, world!"); 
8  5   console.log("Hello, world!");   7   console.log("Hello, world!"); 
9  6  });   8  }); 
9  6  });   8  }); 
internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden+8−8
@@ -1,12 +1,12 @@
1  …   …  @@ -1,6 +1,8 @@  
1  …   …  @@ -1,6 +1,8 @@  
2−  1    - // this is 
2+  1    - // this is 
3−  2    - // a regular 
3+  2    - // a regular 
4−  3    - // comment 
4+  3    - // comment 
5−     1 + /** 
5+     1 + /** 
6−     2 +  * this is 
6+     2 +  * this is 
7−     3 +  * a block 
7+     3 +  * a block 
8−     4 +  * comment 
8+     4 +  * comment 
9−     5 +  */ 
9+     5 +  */ 
10  4   6  $(function() { 
10  4   6  $(function() { 
11  5   7   console.log("Hello, world!"); 
11  5   7   console.log("Hello, world!"); 
12  6   8  }); 
12  6   8  }); 
internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden+3−3
@@ -2,7 +2,7 @@
2 
2 
3 
3 
4 
4 
5− 
5+ 
6 
6 
7 
7 
8 
8 
@@ -10,6 +10,6 @@
10 
10 
11 
11 
12 
12 
13− 
13+ 
14− 
14+ 
15 
15 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden+3−3
@@ -2,7 +2,7 @@
2  
2  
3  
3  
4  
4  
5−  
5+  
6  
6  
7  
7  
8  
8  
@@ -10,6 +10,6 @@
10  
10  
11 1
11 1
12 1
12 1
13− 1
13+ 1
14−  
14+  
15 1
15 1
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden+3−3
@@ -2,7 +2,7 @@
2  2
2  2
3  3
3  3
4  4
4  4
5−  
5+  
6  5
6  5
7  6
7  6
8  7
8  7
@@ -10,6 +10,6 @@
10  9
10  9
11 10
11 10
12 11
12 11
13− 12
13+ 12
14−  
14+  
15 13
15 13
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden+3−3
@@ -2,7 +2,7 @@
2  2 
2  2 
3  3 
3  3 
4  4 
4  4 
5−   
5+   
6  5 
6  5 
7  6 
7  6 
8  7 
8  7 
@@ -10,6 +10,6 @@
10  9 
10  9 
11 10 
11 10 
12 11 
12 11 
13− 12 
13+ 12 
14−   
14+   
15 13 
15 13 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden+3−3
@@ -2,7 +2,7 @@
2  2  
2  2  
3  3  
3  3  
4  4  
4  4  
5−    
5+    
6  5  
6  5  
7  6  
7  6  
8  7  
8  7  
@@ -10,6 +10,6 @@
10  9  
10  9  
11 10  
11 10  
12 11  
12 11  
13− 12 -
13+ 12 -
14−    
14+    
15 13  
15 13  
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden+3−3
@@ -2,7 +2,7 @@
2  2  
2  2  
3  3  
3  3  
4  4  
4  4  
5−    
5+    
6  5  
6  5  
7  6  
7  6  
8  7  
8  7  
@@ -10,6 +10,6 @@
10  9  
10  9  
11 10  
11 10  
12 11  
12 11  
13− 12 - 
13+ 12 - 
14−    
14+    
15 13  
15 13  
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden+3−3
@@ -2,7 +2,7 @@
2  2   
2  2   
3  3   
3  3   
4  4   
4  4   
5−     
5+     
6  5   
6  5   
7  6   
7  6   
8  7   
8  7   
@@ -10,6 +10,6 @@
10  9   
10  9   
11 10   
11 10   
12 11   
12 11   
13− 12 -  
13+ 12 -  
14−     
14+     
15 13   
15 13   
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden+3−3
@@ -2,7 +2,7 @@
2  2    
2  2    
3  3    
3  3    
4  4    
4  4    
5−      
5+      
6  5    
6  5    
7  6    
7  6    
8  7    
8  7    
@@ -10,6 +10,6 @@
10  9   1
10  9   1
11 10   1
11 10   1
12 11   1
12 11   1
13− 12 -  1
13+ 12 -  1
14−     1
14+     1
15 13   1
15 13   1
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden+3−3
@@ -2,7 +2,7 @@
2  2    2 
2  2    2 
3  3    3 
3  3    3 
4  4    4 
4  4    4 
5−      5 
5+      5 
6  5    6 
6  5    6 
7  6    7 
7  6    7 
8  7    8 
8  7    8 
@@ -10,6 +10,6 @@
10  9   10 
10  9   10 
11 10   11 
11 10   11 
12 11   12 
12 11   12 
13− 12 - 13 
13+ 12 - 13 
14−     14 
14+     14 
15 13   15 
15 13   15 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden+3−3
@@ -2,7 +2,7 @@
2  2    2  
2  2    2  
3  3    3  
3  3    3  
4  4    4  
4  4    4  
5−      5 +
5+      5 +
6  5    6  
6  5    6  
7  6    7  
7  6    7  
8  7    8  
8  7    8  
@@ -10,6 +10,6 @@
10  9   10  
10  9   10  
11 10   11  
11 10   11  
12 11   12  
12 11   12  
13− 12 - 13 +
13+ 12 - 13 +
14−     14 +
14+     14 +
15 13   15  
15 13   15  
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden+3−3
@@ -2,7 +2,7 @@
2  2    2  
2  2    2  
3  3    3  
3  3    3  
4  4    4  
4  4    4  
5−      5 +
5+      5 +
6  5    6  
6  5    6  
7  6    7  
7  6    7  
8  7    8  
8  7    8  
@@ -10,6 +10,6 @@
10  9   10  
10  9   10  
11 10   11  
11 10   11  
12 11   12  
12 11   12  
13− 12 -  13 +
13+ 12 -  13 +
14−     14 +
14+     14 +
15 13   15  
15 13   15  
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden+3−3
@@ -2,7 +2,7 @@
2  2    2  
2  2    2  
3  3    3  
3  3    3  
4  4    4  
4  4    4  
5−      5 + 
5+      5 + 
6  5    6  
6  5    6  
7  6    7  
7  6    7  
8  7    8  
8  7    8  
@@ -10,6 +10,6 @@
10  9   10  
10  9   10  
11 10   11  
11 10   11  
12 11   12  
12 11   12  
13− 12 -  13 + 
13+ 12 -  13 + 
14−     14 + 
14+     14 + 
15 13   15  
15 13   15  
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden+3−3
@@ -2,7 +2,7 @@
2  2    2   
2  2    2   
3  3    3   
3  3    3   
4  4    4   
4  4    4   
5−      5 +  
5+      5 +  
6  5    6   
6  5    6   
7  6    7   
7  6    7   
8  7    8   
8  7    8   
@@ -10,6 +10,6 @@
10  9   10   
10  9   10   
11 10   11   
11 10   11   
12 11   12   
12 11   12   
13− 12 -  13 +  
13+ 12 -  13 +  
14−     14 +  
14+     14 +  
15 13   15   
15 13   15   
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  …  3  …
3  3  …  3  …
4  4  …  4  …
4  4  …  4  …
5−       5 + …
5+       5 + …
6  5  )  6  )
6  5  )  6  )
7  6     7   
7  6     7   
8  7  …  8  …
8  7  …  8  …
@@ -10,6 +10,6 @@
10  9  } 10  }
10  9  } 10  }
11 10    11   
11 10    11   
12 11  … 12  …
12 11  … 12  …
13− 12 - … 13 + …
13+ 12 - … 13 + …
14−      14 + …
14+      14 + …
15 13  } 15  }
15 13  } 15  }
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  …  3  … 
3  3  …  3  … 
4  4  …  4  … 
4  4  …  4  … 
5−       5 + … 
5+       5 + … 
6  5  )  6  ) 
6  5  )  6  ) 
7  6     7   
7  6     7   
8  7  …  8  … 
8  7  …  8  … 
@@ -10,6 +10,6 @@
10  9  } 10  } 
10  9  } 10  } 
11 10    11   
11 10    11   
12 11  … 12  … 
12 11  … 12  … 
13− 12 - … 13 + … 
13+ 12 - … 13 + … 
14−      14 + … 
14+      14 + … 
15 13  } 15  } 
15 13  } 15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  i…  3  i…
3  3  i…  3  i…
4  4   …  4   …
4  4   …  4   …
5−       5 +  …
5+       5 +  …
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  f…  8  f…
8  7  f…  8  f…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  f… 12  f…
12 11  f… 12  f…
13− 12 -  … 13 +  …
13+ 12 -  … 13 +  …
14−      14 +  …
14+      14 +  …
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  i…  3  i… 
3  3  i…  3  i… 
4  4   …  4   … 
4  4   …  4   … 
5−       5 +  … 
5+       5 +  … 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  f…  8  f… 
8  7  f…  8  f… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  f… 12  f… 
12 11  f… 12  f… 
13− 12 -  … 13 +  … 
13+ 12 -  … 13 +  … 
14−      14 +  … 
14+      14 +  … 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  im…  3  im…
3  3  im…  3  im…
4  4   …  4   …
4  4   …  4   …
5−       5 +  …
5+       5 +  …
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  fu…  8  fu…
8  7  fu…  8  fu…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  fu… 12  fu…
12 11  fu… 12  fu…
13− 12 -  … 13 +  …
13+ 12 -  … 13 +  …
14−      14 +  …
14+      14 +  …
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  im…  3  im… 
3  3  im…  3  im… 
4  4   …  4   … 
4  4   …  4   … 
5−       5 +  … 
5+       5 +  … 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  fu…  8  fu… 
8  7  fu…  8  fu… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  fu… 12  fu… 
12 11  fu… 12  fu… 
13− 12 -  … 13 +  … 
13+ 12 -  … 13 +  … 
14−      14 +  … 
14+      14 +  … 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  imp…  3  imp…
3  3  imp…  3  imp…
4  4   …  4   …
4  4   …  4   …
5−       5 +  …
5+       5 +  …
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  fun…  8  fun…
8  7  fun…  8  fun…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  fun… 12  fun…
12 11  fun… 12  fun…
13− 12 -  … 13 +  …
13+ 12 -  … 13 +  …
14−      14 +  …
14+      14 +  …
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  imp…  3  imp… 
3  3  imp…  3  imp… 
4  4   …  4   … 
4  4   …  4   … 
5−       5 +  … 
5+       5 +  … 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  fun…  8  fun… 
8  7  fun…  8  fun… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  fun… 12  fun… 
12 11  fun… 12  fun… 
13− 12 -  … 13 +  … 
13+ 12 -  … 13 +  … 
14−      14 +  … 
14+      14 +  … 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  impo…  3  impo…
3  3  impo…  3  impo…
4  4   …  4   …
4  4   …  4   …
5−       5 +  …
5+       5 +  …
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func…  8  func…
8  7  func…  8  func…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func… 12  func…
12 11  func… 12  func…
13− 12 -  … 13 +  …
13+ 12 -  … 13 +  …
14−      14 +  …
14+      14 +  …
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  impo…  3  impo… 
3  3  impo…  3  impo… 
4  4   …  4   … 
4  4   …  4   … 
5−       5 +  … 
5+       5 +  … 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func…  8  func… 
8  7  func…  8  func… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func… 12  func… 
12 11  func… 12  func… 
13− 12 -  … 13 +  … 
13+ 12 -  … 13 +  … 
14−      14 +  … 
14+      14 +  … 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  impor…  3  impor…
3  3  impor…  3  impor…
4  4   "…  4   "…
4  4   "…  4   "…
5−       5 +  "…
5+       5 +  "…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func …  8  func …
8  7  func …  8  func …
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func … 12  func …
12 11  func … 12  func …
13− 12 -  r… 13 +  c…
13+ 12 -  r… 13 +  c…
14−      14 +  r…
14+      14 +  r…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  impor…  3  impor… 
3  3  impor…  3  impor… 
4  4   "…  4   "… 
4  4   "…  4   "… 
5−       5 +  "… 
5+       5 +  "… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func …  8  func … 
8  7  func …  8  func … 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func … 12  func … 
12 11  func … 12  func … 
13− 12 -  r… 13 +  c… 
13+ 12 -  r… 13 +  c… 
14−      14 +  r… 
14+      14 +  r… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import…  3  import…
3  3  import…  3  import…
4  4   "f…  4   "f…
4  4   "f…  4   "f…
5−       5 +  "s…
5+       5 +  "s…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func m…  8  func m…
8  7  func m…  8  func m…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func g… 12  func g…
12 11  func g… 12  func g…
13− 12 -  re… 13 +  co…
13+ 12 -  re… 13 +  co…
14−      14 +  re…
14+      14 +  re…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import…  3  import… 
3  3  import…  3  import… 
4  4   "f…  4   "f… 
4  4   "f…  4   "f… 
5−       5 +  "s… 
5+       5 +  "s… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func m…  8  func m… 
8  7  func m…  8  func m… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func g… 12  func g… 
12 11  func g… 12  func g… 
13− 12 -  re… 13 +  co… 
13+ 12 -  re… 13 +  co… 
14−      14 +  re… 
14+      14 +  re… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (  3  import (
3  3  import (  3  import (
4  4   "fm…  4   "fm…
4  4   "fm…  4   "fm…
5−       5 +  "st…
5+       5 +  "st…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func ma…  8  func ma…
8  7  func ma…  8  func ma…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func ge… 12  func ge…
12 11  func ge… 12  func ge…
13− 12 -  ret… 13 +  con…
13+ 12 -  ret… 13 +  con…
14−      14 +  ret…
14+      14 +  ret…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (  3  import ( 
3  3  import (  3  import ( 
4  4   "fm…  4   "fm… 
4  4   "fm…  4   "fm… 
5−       5 +  "st… 
5+       5 +  "st… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func ma…  8  func ma… 
8  7  func ma…  8  func ma… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func ge… 12  func ge… 
12 11  func ge… 12  func ge… 
13− 12 -  ret… 13 +  con… 
13+ 12 -  ret… 13 +  con… 
14−      14 +  ret… 
14+      14 +  ret… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"  4   "fmt"
4  4   "fmt"  4   "fmt"
5−       5 +  "str…
5+       5 +  "str…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func mai…  8  func mai…
8  7  func mai…  8  func mai…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func get… 12  func get…
12 11  func get… 12  func get…
13− 12 -  retu… 13 +  cont…
13+ 12 -  retu… 13 +  cont…
14−      14 +  retu…
14+      14 +  retu…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"  4   "fmt" 
4  4   "fmt"  4   "fmt" 
5−       5 +  "str… 
5+       5 +  "str… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func mai…  8  func mai… 
8  7  func mai…  8  func mai… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func get… 12  func get… 
12 11  func get… 12  func get… 
13− 12 -  retu… 13 +  cont… 
13+ 12 -  retu… 13 +  cont… 
14−      14 +  retu… 
14+      14 +  retu… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "stri…
5+       5 +  "stri…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main…  8  func main…
8  7  func main…  8  func main…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getC… 12  func getC…
12 11  func getC… 12  func getC…
13− 12 -  retur… 13 +  conte…
13+ 12 -  retur… 13 +  conte…
14−      14 +  retur…
14+      14 +  retur…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "stri… 
5+       5 +  "stri… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main…  8  func main… 
8  7  func main…  8  func main… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getC… 12  func getC… 
12 11  func getC… 12  func getC… 
13− 12 -  retur… 13 +  conte… 
13+ 12 -  retur… 13 +  conte… 
14−      14 +  retur… 
14+      14 +  retur… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strin…
5+       5 +  "strin…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main(…  8  func main(…
8  7  func main(…  8  func main(…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCo… 12  func getCo…
12 11  func getCo… 12  func getCo…
13− 12 -  return… 13 +  conten…
13+ 12 -  return… 13 +  conten…
14−      14 +  return…
14+      14 +  return…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strin… 
5+       5 +  "strin… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main(…  8  func main(… 
8  7  func main(…  8  func main(… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCo… 12  func getCo… 
12 11  func getCo… 12  func getCo… 
13− 12 -  return… 13 +  conten… 
13+ 12 -  return… 13 +  conten… 
14−      14 +  return… 
14+      14 +  return… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "string…
5+       5 +  "string…
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main()…  8  func main()…
8  7  func main()…  8  func main()…
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCon… 12  func getCon…
12 11  func getCon… 12  func getCon…
13− 12 -  return … 13 +  content…
13+ 12 -  return … 13 +  content…
14−      14 +  return …
14+      14 +  return …
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "string… 
5+       5 +  "string… 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main()…  8  func main()… 
8  7  func main()…  8  func main()… 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCon… 12  func getCon… 
12 11  func getCon… 12  func getCon… 
13− 12 -  return … 13 +  content… 
13+ 12 -  return … 13 +  content… 
14−      14 +  return … 
14+      14 +  return … 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings"
5+       5 +  "strings"
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {  8  func main() {
8  7  func main() {  8  func main() {
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCont… 12  func getCont…
12 11  func getCont… 12  func getCont…
13− 12 -  return "… 13 +  content …
13+ 12 -  return "… 13 +  content …
14−      14 +  return c…
14+      14 +  return c…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {  8  func main() { 
8  7  func main() {  8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getCont… 12  func getCont… 
12 11  func getCont… 12  func getCont… 
13− 12 -  return "… 13 +  content … 
13+ 12 -  return "… 13 +  content … 
14−      14 +  return c… 
14+      14 +  return c… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConte… 12  func getConte…
12 11  func getConte… 12  func getConte…
13− 12 -  return "H… 13 +  content :…
13+ 12 -  return "H… 13 +  content :…
14−      14 +  return co…
14+      14 +  return co…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConte… 12  func getConte… 
12 11  func getConte… 12  func getConte… 
13− 12 -  return "H… 13 +  content :… 
13+ 12 -  return "H… 13 +  content :… 
14−      14 +  return co… 
14+      14 +  return co… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConten… 12  func getConten…
12 11  func getConten… 12  func getConten…
13− 12 -  return "He… 13 +  content :=…
13+ 12 -  return "He… 13 +  content :=…
14−      14 +  return con…
14+      14 +  return con…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getConten… 12  func getConten… 
12 11  func getConten… 12  func getConten… 
13− 12 -  return "He… 13 +  content :=… 
13+ 12 -  return "He… 13 +  content :=… 
14−      14 +  return con… 
14+      14 +  return con… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent… 12  func getContent…
12 11  func getContent… 12  func getContent…
13− 12 -  return "Hel… 13 +  content := …
13+ 12 -  return "Hel… 13 +  content := …
14−      14 +  return cont…
14+      14 +  return cont…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent… 12  func getContent… 
12 11  func getContent… 12  func getContent… 
13− 12 -  return "Hel… 13 +  content := … 
13+ 12 -  return "Hel… 13 +  content := … 
14−      14 +  return cont… 
14+      14 +  return cont… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent(… 12  func getContent(…
12 11  func getContent(… 12  func getContent(…
13− 12 -  return "Hell… 13 +  content := s…
13+ 12 -  return "Hell… 13 +  content := s…
14−      14 +  return conte…
14+      14 +  return conte…
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent(… 12  func getContent(… 
12 11  func getContent(… 12  func getContent(… 
13− 12 -  return "Hell… 13 +  content := s… 
13+ 12 -  return "Hell… 13 +  content := s… 
14−      14 +  return conte… 
14+      14 +  return conte… 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent()… 12  func getContent()…
12 11  func getContent()… 12  func getContent()…
13− 12 -  return "Hello… 13 +  content := st…
13+ 12 -  return "Hello… 13 +  content := st…
14−      14 +  return content
14+      14 +  return content
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent()… 12  func getContent()… 
12 11  func getContent()… 12  func getContent()… 
13− 12 -  return "Hello… 13 +  content := st… 
13+ 12 -  return "Hello… 13 +  content := st… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() … 12  func getContent() …
12 11  func getContent() … 12  func getContent() …
13− 12 -  return "Hello,… 13 +  content := str…
13+ 12 -  return "Hello,… 13 +  content := str…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() … 12  func getContent() … 
12 11  func getContent() … 12  func getContent() … 
13− 12 -  return "Hello,… 13 +  content := str… 
13+ 12 -  return "Hello,… 13 +  content := str… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() s… 12  func getContent() s…
12 11  func getContent() s… 12  func getContent() s…
13− 12 -  return "Hello, … 13 +  content := stri…
13+ 12 -  return "Hello, … 13 +  content := stri…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() s… 12  func getContent() s… 
12 11  func getContent() s… 12  func getContent() s… 
13− 12 -  return "Hello, … 13 +  content := stri… 
13+ 12 -  return "Hello, … 13 +  content := stri… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() st… 12  func getContent() st…
12 11  func getContent() st… 12  func getContent() st…
13− 12 -  return "Hello, w… 13 +  content := strin…
13+ 12 -  return "Hello, w… 13 +  content := strin…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() st… 12  func getContent() st… 
12 11  func getContent() st… 12  func getContent() st… 
13− 12 -  return "Hello, w… 13 +  content := strin… 
13+ 12 -  return "Hello, w… 13 +  content := strin… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() str… 12  func getContent() str…
12 11  func getContent() str… 12  func getContent() str…
13− 12 -  return "Hello, wo… 13 +  content := string…
13+ 12 -  return "Hello, wo… 13 +  content := string…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() str… 12  func getContent() str… 
12 11  func getContent() str… 12  func getContent() str… 
13− 12 -  return "Hello, wo… 13 +  content := string… 
13+ 12 -  return "Hello, wo… 13 +  content := string… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() stri… 12  func getContent() stri…
12 11  func getContent() stri… 12  func getContent() stri…
13− 12 -  return "Hello, wor… 13 +  content := strings…
13+ 12 -  return "Hello, wor… 13 +  content := strings…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() stri… 12  func getContent() stri… 
12 11  func getContent() stri… 12  func getContent() stri… 
13− 12 -  return "Hello, wor… 13 +  content := strings… 
13+ 12 -  return "Hello, wor… 13 +  content := strings… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() strin… 12  func getContent() strin…
12 11  func getContent() strin… 12  func getContent() strin…
13− 12 -  return "Hello, worl… 13 +  content := strings.…
13+ 12 -  return "Hello, worl… 13 +  content := strings.…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() strin… 12  func getContent() strin… 
12 11  func getContent() strin… 12  func getContent() strin… 
13− 12 -  return "Hello, worl… 13 +  content := strings.… 
13+ 12 -  return "Hello, worl… 13 +  content := strings.… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string… 12  func getContent() string…
12 11  func getContent() string… 12  func getContent() string…
13− 12 -  return "Hello, world… 13 +  content := strings.T…
13+ 12 -  return "Hello, world… 13 +  content := strings.T…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string… 12  func getContent() string… 
12 11  func getContent() string… 12  func getContent() string… 
13− 12 -  return "Hello, world… 13 +  content := strings.T… 
13+ 12 -  return "Hello, world… 13 +  content := strings.T… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string { 12  func getContent() string {
12 11  func getContent() string { 12  func getContent() string {
13− 12 -  return "Hello, world!" 13 +  content := strings.To…
13+ 12 -  return "Hello, world!" 13 +  content := strings.To…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string { 12  func getContent() string { 
12 11  func getContent() string { 12  func getContent() string { 
13− 12 -  return "Hello, world!" 13 +  content := strings.To… 
13+ 12 -  return "Hello, world!" 13 +  content := strings.To… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToU…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToU…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToU… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToU… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUp…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUp…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUp… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUp… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpp…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpp…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpp… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpp… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUppe…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUppe…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUppe… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUppe… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper(…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper(…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper(… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper(… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("H…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("H…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("H… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("H… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("He…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("He…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("He… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("He… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hel…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hel…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hel… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hel… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hell…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hell…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hell… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hell… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello,…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello,…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello,… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello,… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, …
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, …
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, … 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, … 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, W…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, W…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, W… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, W… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wo…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wo…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wo… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wo… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wor…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wor…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wor… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Wor… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Worl…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Worl…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Worl… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, Worl… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!…
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!…
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!… 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!… 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!")
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!")
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden+3−3
@@ -2,7 +2,7 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−       5 +  "strings" 
5+       5 +  "strings" 
6  5  )   6  ) 
6  5  )   6  ) 
7  6     7   
7  6     7   
8  7  func main() {   8  func main() { 
8  7  func main() {   8  func main() { 
@@ -10,6 +10,6 @@
10  9  }  10  } 
10  9  }  10  } 
11 10    11   
11 10    11   
12 11  func getContent() string {  12  func getContent() string { 
12 11  func getContent() string {  12  func getContent() string { 
13− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
13+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
14−      14 +  return content 
14+      14 +  return content 
15 13  }  15  } 
15 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden+4−4
@@ -2,7 +2,7 @@
2 
2 
3 
3 
4 
4 
5− 
5+ 
6 
6 
7 
7 
8 
8 
@@ -10,7 +10,7 @@
10 
10 
11 
11 
12 
12 
13− 
13+ 
14− 
14+ 
15− 
15+ 
16 
16 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden+4−4
@@ -2,7 +2,7 @@
2  
2  
3  
3  
4  
4  
5−  
5+  
6  
6  
7  
7  
8  
8  
@@ -10,7 +10,7 @@
10  
10  
11 1
11 1
12 1
12 1
13− 1
13+ 1
14−  
14+  
15−  
15+  
16 1
16 1
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden+4−4
@@ -2,7 +2,7 @@
2  2
2  2
3  3
3  3
4  4
4  4
5−  
5+  
6  5
6  5
7  6
7  6
8  7
8  7
@@ -10,7 +10,7 @@
10  9
10  9
11 10
11 10
12 11
12 11
13− 12
13+ 12
14−  
14+  
15−  
15+  
16 13
16 13
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden+4−4
@@ -2,7 +2,7 @@
2  2 
2  2 
3  3 
3  3 
4  4 
4  4 
5−   
5+   
6  5 
6  5 
7  6 
7  6 
8  7 
8  7 
@@ -10,7 +10,7 @@
10  9 
10  9 
11 10 
11 10 
12 11 
12 11 
13− 12 
13+ 12 
14−   
14+   
15−   
15+   
16 13 
16 13 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden+4−4
@@ -2,7 +2,7 @@
2  2  
2  2  
3  3  
3  3  
4  4  
4  4  
5−    
5+    
6  5  
6  5  
7  6  
7  6  
8  7  
8  7  
@@ -10,7 +10,7 @@
10  9  
10  9  
11 10  
11 10  
12 11  
12 11  
13− 12  
13+ 12  
14−    
14+    
15−    
15+    
16 13  
16 13  
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden+4−4
@@ -2,7 +2,7 @@
2  2   
2  2   
3  3   
3  3   
4  4   
4  4   
5−     
5+     
6  5   
6  5   
7  6   
7  6   
8  7   
8  7   
@@ -10,7 +10,7 @@
10  9  1
10  9  1
11 10  1
11 10  1
12 11  1
12 11  1
13− 12   
13+ 12   
14−    1
14+    1
15−    1
15+    1
16 13  1
16 13  1
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden+4−4
@@ -2,7 +2,7 @@
2  2   2
2  2   2
3  3   3
3  3   3
4  4   4
4  4   4
5−     5
5+     5
6  5   6
6  5   6
7  6   7
7  6   7
8  7   8
8  7   8
@@ -10,7 +10,7 @@
10  9  10
10  9  10
11 10  11
11 10  11
12 11  12
12 11  12
13− 12   
13+ 12   
14−    13
14+    13
15−    14
15+    14
16 13  15
16 13  15
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden+4−4
@@ -2,7 +2,7 @@
2  2   2 
2  2   2 
3  3   3 
3  3   3 
4  4   4 
4  4   4 
5−     5 
5+     5 
6  5   6 
6  5   6 
7  6   7 
7  6   7 
8  7   8 
8  7   8 
@@ -10,7 +10,7 @@
10  9  10 
10  9  10 
11 10  11 
11 10  11 
12 11  12 
12 11  12 
13− 12    
13+ 12    
14−    13 
14+    13 
15−    14 
15+    14 
16 13  15 
16 13  15 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden+4−4
@@ -2,7 +2,7 @@
2  2   2  
2  2   2  
3  3   3  
3  3   3  
4  4   4  
4  4   4  
5−     5 +
5+     5 +
6  5   6  
6  5   6  
7  6   7  
7  6   7  
8  7   8  
8  7   8  
@@ -10,7 +10,7 @@
10  9  10  
10  9  10  
11 10  11  
11 10  11  
12 11  12  
12 11  12  
13− 12    -
13+ 12    -
14−    13 +
14+    13 +
15−    14 +
15+    14 +
16 13  15  
16 13  15  
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden+4−4
@@ -2,7 +2,7 @@
2  2   2  
2  2   2  
3  3   3  
3  3   3  
4  4   4  
4  4   4  
5−     5 + 
5+     5 + 
6  5   6  
6  5   6  
7  6   7  
7  6   7  
8  7   8  
8  7   8  
@@ -10,7 +10,7 @@
10  9  10  
10  9  10  
11 10  11  
11 10  11  
12 11  12  
12 11  12  
13− 12    - 
13+ 12    - 
14−    13 + 
14+    13 + 
15−    14 + 
15+    14 + 
16 13  15  
16 13  15  
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  …
3  3   3  …
4  4   4  …
4  4   4  …
5−     5 + …
5+     5 + …
6  5   6  )
6  5   6  )
7  6   7   
7  6   7   
8  7   8  …
8  7   8  …
@@ -10,7 +10,7 @@
10  9  10  }
10  9  10  }
11 10  11   
11 10  11   
12 11  12  …
12 11  12  …
13− 12    - …
13+ 12    - …
14−    13 + …
14+    13 + …
15−    14 + …
15+    14 + …
16 13  15  }
16 13  15  }
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  i…
3  3   3  i…
4  4   4   …
4  4   4   …
5−     5 +  …
5+     5 +  …
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  f…
8  7   8  f…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  f…
12 11  12  f…
13− 12    -  …
13+ 12    -  …
14−    13 +  …
14+    13 +  …
15−    14 +  …
15+    14 +  …
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  im…
3  3   3  im…
4  4   4   …
4  4   4   …
5−     5 +  …
5+     5 +  …
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  fu…
8  7   8  fu…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  fu…
12 11  12  fu…
13− 12    -  …
13+ 12    -  …
14−    13 +  …
14+    13 +  …
15−    14 +  …
15+    14 +  …
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  imp…
3  3   3  imp…
4  4   4   …
4  4   4   …
5−     5 +  …
5+     5 +  …
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  fun…
8  7   8  fun…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  fun…
12 11  12  fun…
13− 12    -  …
13+ 12    -  …
14−    13 +  …
14+    13 +  …
15−    14 +  …
15+    14 +  …
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  impo…
3  3   3  impo…
4  4   4   …
4  4   4   …
5−     5 +  …
5+     5 +  …
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func…
8  7   8  func…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func…
12 11  12  func…
13− 12    -  …
13+ 12    -  …
14−    13 +  …
14+    13 +  …
15−    14 +  …
15+    14 +  …
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  impor…
3  3   3  impor…
4  4   4   "…
4  4   4   "…
5−     5 +  "…
5+     5 +  "…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func …
8  7   8  func …
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func …
12 11  12  func …
13− 12    -  r…
13+ 12    -  r…
14−    13 +  c…
14+    13 +  c…
15−    14 +  r…
15+    14 +  r…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import…
3  3   3  import…
4  4   4   "f…
4  4   4   "f…
5−     5 +  "s…
5+     5 +  "s…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func m…
8  7   8  func m…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func g…
12 11  12  func g…
13− 12    -  re…
13+ 12    -  re…
14−    13 +  co…
14+    13 +  co…
15−    14 +  re…
15+    14 +  re…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import (
3  3   3  import (
4  4   4   "fm…
4  4   4   "fm…
5−     5 +  "st…
5+     5 +  "st…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func ma…
8  7   8  func ma…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func ge…
12 11  12  func ge…
13− 12    -  ret…
13+ 12    -  ret…
14−    13 +  con…
14+    13 +  con…
15−    14 +  ret…
15+    14 +  ret…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt"
4  4   4   "fmt"
5−     5 +  "str…
5+     5 +  "str…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func mai…
8  7   8  func mai…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func get…
12 11  12  func get…
13− 12    -  retu…
13+ 12    -  retu…
14−    13 +  cont…
14+    13 +  cont…
15−    14 +  retu…
15+    14 +  retu…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "stri…
5+     5 +  "stri…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main…
8  7   8  func main…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getC…
12 11  12  func getC…
13− 12    -  retur…
13+ 12    -  retur…
14−    13 +  conte…
14+    13 +  conte…
15−    14 +  retur…
15+    14 +  retur…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strin…
5+     5 +  "strin…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main(…
8  7   8  func main(…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getCo…
12 11  12  func getCo…
13− 12    -  return…
13+ 12    -  return…
14−    13 +  conten…
14+    13 +  conten…
15−    14 +  return…
15+    14 +  return…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "string…
5+     5 +  "string…
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main()…
8  7   8  func main()…
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getCon…
12 11  12  func getCon…
13− 12    -  return …
13+ 12    -  return …
14−    13 +  content…
14+    13 +  content…
15−    14 +  return …
15+    14 +  return …
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings"
5+     5 +  "strings"
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() {
8  7   8  func main() {
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getCont…
12 11  12  func getCont…
13− 12    -  return "…
13+ 12    -  return "…
14−    13 +  content …
14+    13 +  content …
15−    14 +  return c…
15+    14 +  return c…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getConte…
12 11  12  func getConte…
13− 12    -  return "H…
13+ 12    -  return "H…
14−    13 +  content :…
14+    13 +  content :…
15−    14 +  return co…
15+    14 +  return co…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getConten…
12 11  12  func getConten…
13− 12    -  return "He…
13+ 12    -  return "He…
14−    13 +  content :=…
14+    13 +  content :=…
15−    14 +  return con…
15+    14 +  return con…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent…
12 11  12  func getContent…
13− 12    -  return "Hel…
13+ 12    -  return "Hel…
14−    13 +  content := …
14+    13 +  content := …
15−    14 +  return cont…
15+    14 +  return cont…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent(…
12 11  12  func getContent(…
13− 12    -  return "Hell…
13+ 12    -  return "Hell…
14−    13 +  content := s…
14+    13 +  content := s…
15−    14 +  return conte…
15+    14 +  return conte…
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent()…
12 11  12  func getContent()…
13− 12    -  return "Hello…
13+ 12    -  return "Hello…
14−    13 +  content := st…
14+    13 +  content := st…
15−    14 +  return content
15+    14 +  return content
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() …
12 11  12  func getContent() …
13− 12    -  return "Hello,…
13+ 12    -  return "Hello,…
14−    13 +  content := str…
14+    13 +  content := str…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() s…
12 11  12  func getContent() s…
13− 12    -  return "Hello, …
13+ 12    -  return "Hello, …
14−    13 +  content := stri…
14+    13 +  content := stri…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() st…
12 11  12  func getContent() st…
13− 12    -  return "Hello, w…
13+ 12    -  return "Hello, w…
14−    13 +  content := strin…
14+    13 +  content := strin…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() str…
12 11  12  func getContent() str…
13− 12    -  return "Hello, wo…
13+ 12    -  return "Hello, wo…
14−    13 +  content := string…
14+    13 +  content := string…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() stri…
12 11  12  func getContent() stri…
13− 12    -  return "Hello, wor…
13+ 12    -  return "Hello, wor…
14−    13 +  content := strings…
14+    13 +  content := strings…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() strin…
12 11  12  func getContent() strin…
13− 12    -  return "Hello, worl…
13+ 12    -  return "Hello, worl…
14−    13 +  content := strings.…
14+    13 +  content := strings.…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string…
12 11  12  func getContent() string…
13− 12    -  return "Hello, world…
13+ 12    -  return "Hello, world…
14−    13 +  content := strings.T…
14+    13 +  content := strings.T…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string {
12 11  12  func getContent() string {
13− 12    -  return "Hello, world!"
13+ 12    -  return "Hello, world!"
14−    13 +  content := strings.To…
14+    13 +  content := strings.To…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToU…
14+    13 +  content := strings.ToU…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUp…
14+    13 +  content := strings.ToUp…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpp…
14+    13 +  content := strings.ToUpp…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUppe…
14+    13 +  content := strings.ToUppe…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper…
14+    13 +  content := strings.ToUpper…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper(…
14+    13 +  content := strings.ToUpper(…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("…
14+    13 +  content := strings.ToUpper("…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("H…
14+    13 +  content := strings.ToUpper("H…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("He…
14+    13 +  content := strings.ToUpper("He…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hel…
14+    13 +  content := strings.ToUpper("Hel…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hell…
14+    13 +  content := strings.ToUpper("Hell…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello…
14+    13 +  content := strings.ToUpper("Hello…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello,…
14+    13 +  content := strings.ToUpper("Hello,…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, …
14+    13 +  content := strings.ToUpper("Hello, …
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, W…
14+    13 +  content := strings.ToUpper("Hello, W…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, Wo…
14+    13 +  content := strings.ToUpper("Hello, Wo…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, Wor…
14+    13 +  content := strings.ToUpper("Hello, Wor…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, Worl…
14+    13 +  content := strings.ToUpper("Hello, Worl…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World…
14+    13 +  content := strings.ToUpper("Hello, World…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!…
14+    13 +  content := strings.ToUpper("Hello, World!…
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!")
14+    13 +  content := strings.ToUpper("Hello, World!")
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden+4−4
@@ -2,7 +2,7 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−     5 +  "strings" 
5+     5 +  "strings" 
6  5   6  ) 
6  5   6  ) 
7  6   7   
7  6   7   
8  7   8  func main() { 
8  7   8  func main() { 
@@ -10,7 +10,7 @@
10  9  10  } 
10  9  10  } 
11 10  11   
11 10  11   
12 11  12  func getContent() string { 
12 11  12  func getContent() string { 
13− 12    -  return "Hello, world!" 
13+ 12    -  return "Hello, world!" 
14−    13 +  content := strings.ToUpper("Hello, World!") 
14+    13 +  content := strings.ToUpper("Hello, World!") 
15−    14 +  return content 
15+    14 +  return content 
16 13  15  } 
16 13  15  } 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden+2−2
@@ -2,6 +2,6 @@
2  5  )   5  ) 
2  5  )   5  ) 
3  6     6   
3  6     6   
4  7  func main() {   7  func main() { 
4  7  func main() {   7  func main() { 
5−  8 -  fmt.Println("Hello,…  8 +  content := "Hello, …
5+  8 -  fmt.Println("Hello,…  8 +  content := "Hello, …
6−       9 +  fmt.Println(content)
6+       9 +  fmt.Println(content)
7  9  }  10  } 
7  9  }  10  } 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …unc main() {   7  …unc main() { 
4  7  …unc main() {   7  …unc main() { 
5−  8 -… fmt.Println("Hello, …  8 +… content := "Hello, w…
5+  8 -… fmt.Println("Hello, …  8 +… content := "Hello, w…
6−       9 +… fmt.Println(content) 
6+       9 +… fmt.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …nc main() {   7  …nc main() { 
4  7  …nc main() {   7  …nc main() { 
5−  8 -… fmt.Println("Hello, w…  8 +… content := "Hello, wo…
5+  8 -… fmt.Println("Hello, w…  8 +… content := "Hello, wo…
6−       9 +… fmt.Println(content) 
6+       9 +… fmt.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …c main() {   7  …c main() { 
4  7  …c main() {   7  …c main() { 
5−  8 -… fmt.Println("Hello, wo…  8 +… content := "Hello, wor…
5+  8 -… fmt.Println("Hello, wo…  8 +… content := "Hello, wor…
6−       9 +… fmt.Println(content) 
6+       9 +… fmt.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  … main() {   7  … main() { 
4  7  … main() {   7  … main() { 
5−  8 -…fmt.Println("Hello, wor…  8 +…content := "Hello, worl…
5+  8 -…fmt.Println("Hello, wor…  8 +…content := "Hello, worl…
6−       9 +…fmt.Println(content) 
6+       9 +…fmt.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …main() {   7  …main() { 
4  7  …main() {   7  …main() { 
5−  8 -…mt.Println("Hello, worl…  8 +…ontent := "Hello, world…
5+  8 -…mt.Println("Hello, worl…  8 +…ontent := "Hello, world…
6−       9 +…mt.Println(content) 
6+       9 +…mt.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …ain() {   7  …ain() { 
4  7  …ain() {   7  …ain() { 
5−  8 -…t.Println("Hello, world…  8 +…ntent := "Hello, world!"
5+  8 -…t.Println("Hello, world…  8 +…ntent := "Hello, world!"
6−       9 +…t.Println(content) 
6+       9 +…t.Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …in() {   7  …in() { 
4  7  …in() {   7  …in() { 
5−  8 -….Println("Hello, world!…  8 +…tent := "Hello, world!" 
5+  8 -….Println("Hello, world!…  8 +…tent := "Hello, world!" 
6−       9 +….Println(content) 
6+       9 +….Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …n() {   7  …n() { 
4  7  …n() {   7  …n() { 
5−  8 -…Println("Hello, world!")  8 +…ent := "Hello, world!" 
5+  8 -…Println("Hello, world!")  8 +…ent := "Hello, world!" 
6−       9 +…Println(content) 
6+       9 +…Println(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …() {   7  …() { 
4  7  …() {   7  …() { 
5−  8 -…rintln("Hello, world!")   8 +…nt := "Hello, world!" 
5+  8 -…rintln("Hello, world!")   8 +…nt := "Hello, world!" 
6−       9 +…rintln(content) 
6+       9 +…rintln(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …) {   7  …) { 
4  7  …) {   7  …) { 
5−  8 -…intln("Hello, world!")   8 +…t := "Hello, world!" 
5+  8 -…intln("Hello, world!")   8 +…t := "Hello, world!" 
6−       9 +…intln(content) 
6+       9 +…intln(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  … {   7  … { 
4  7  … {   7  … { 
5−  8 -…ntln("Hello, world!")   8 +… := "Hello, world!" 
5+  8 -…ntln("Hello, world!")   8 +… := "Hello, world!" 
6−       9 +…ntln(content) 
6+       9 +…ntln(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …{   7  …{ 
4  7  …{   7  …{ 
5−  8 -…tln("Hello, world!")   8 +…:= "Hello, world!" 
5+  8 -…tln("Hello, world!")   8 +…:= "Hello, world!" 
6−       9 +…tln(content) 
6+       9 +…tln(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…ln("Hello, world!")   8 +…= "Hello, world!" 
5+  8 -…ln("Hello, world!")   8 +…= "Hello, world!" 
6−       9 +…ln(content) 
6+       9 +…ln(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…n("Hello, world!")   8 +… "Hello, world!" 
5+  8 -…n("Hello, world!")   8 +… "Hello, world!" 
6−       9 +…n(content) 
6+       9 +…n(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…("Hello, world!")   8 +…"Hello, world!" 
5+  8 -…("Hello, world!")   8 +…"Hello, world!" 
6−       9 +…(content) 
6+       9 +…(content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…"Hello, world!")   8 +…Hello, world!" 
5+  8 -…"Hello, world!")   8 +…Hello, world!" 
6−       9 +…content) 
6+       9 +…content) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…Hello, world!")   8 +…ello, world!" 
5+  8 -…Hello, world!")   8 +…ello, world!" 
6−       9 +…ontent) 
6+       9 +…ontent) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…ello, world!")   8 +…llo, world!" 
5+  8 -…ello, world!")   8 +…llo, world!" 
6−       9 +…ntent) 
6+       9 +…ntent) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…llo, world!")   8 +…lo, world!" 
5+  8 -…llo, world!")   8 +…lo, world!" 
6−       9 +…tent) 
6+       9 +…tent) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden+2−2
@@ -2,6 +2,6 @@
2  5  …   5  … 
2  5  …   5  … 
3  6     6   
3  6     6   
4  7  …   7  … 
4  7  …   7  … 
5−  8 -…lo, world!")   8 +…o, world!" 
5+  8 -…lo, world!")   8 +…o, world!" 
6−       9 +…ent) 
6+       9 +…ent) 
7  9  …  10  … 
7  9  …  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  ) 
2  5   5  ) 
3  6   6   
3  6   6   
4  7   7  func main() { 
4  7   7  func main() { 
5−  8    -  fmt.Println("Hello, world!") 
5+  8    -  fmt.Println("Hello, world!") 
6−     8 +  content := "Hello, world!" 
6+     8 +  content := "Hello, world!" 
7−     9 +  fmt.Println(content) 
7+     9 +  fmt.Println(content) 
8  9  10  } 
8  9  10  } 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …unc main() { 
4  7   7  …unc main() { 
5−  8    -… fmt.Println("Hello, world!") 
5+  8    -… fmt.Println("Hello, world!") 
6−     8 +… content := "Hello, world!" 
6+     8 +… content := "Hello, world!" 
7−     9 +… fmt.Println(content) 
7+     9 +… fmt.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …nc main() { 
4  7   7  …nc main() { 
5−  8    -… fmt.Println("Hello, world!") 
5+  8    -… fmt.Println("Hello, world!") 
6−     8 +… content := "Hello, world!" 
6+     8 +… content := "Hello, world!" 
7−     9 +… fmt.Println(content) 
7+     9 +… fmt.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …c main() { 
4  7   7  …c main() { 
5−  8    -… fmt.Println("Hello, world!") 
5+  8    -… fmt.Println("Hello, world!") 
6−     8 +… content := "Hello, world!" 
6+     8 +… content := "Hello, world!" 
7−     9 +… fmt.Println(content) 
7+     9 +… fmt.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … main() { 
4  7   7  … main() { 
5−  8    -…fmt.Println("Hello, world!") 
5+  8    -…fmt.Println("Hello, world!") 
6−     8 +…content := "Hello, world!" 
6+     8 +…content := "Hello, world!" 
7−     9 +…fmt.Println(content) 
7+     9 +…fmt.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …main() { 
4  7   7  …main() { 
5−  8    -…mt.Println("Hello, world!") 
5+  8    -…mt.Println("Hello, world!") 
6−     8 +…ontent := "Hello, world!" 
6+     8 +…ontent := "Hello, world!" 
7−     9 +…mt.Println(content) 
7+     9 +…mt.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …ain() { 
4  7   7  …ain() { 
5−  8    -…t.Println("Hello, world!") 
5+  8    -…t.Println("Hello, world!") 
6−     8 +…ntent := "Hello, world!" 
6+     8 +…ntent := "Hello, world!" 
7−     9 +…t.Println(content) 
7+     9 +…t.Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …in() { 
4  7   7  …in() { 
5−  8    -….Println("Hello, world!") 
5+  8    -….Println("Hello, world!") 
6−     8 +…tent := "Hello, world!" 
6+     8 +…tent := "Hello, world!" 
7−     9 +….Println(content) 
7+     9 +….Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …n() { 
4  7   7  …n() { 
5−  8    -…Println("Hello, world!") 
5+  8    -…Println("Hello, world!") 
6−     8 +…ent := "Hello, world!" 
6+     8 +…ent := "Hello, world!" 
7−     9 +…Println(content) 
7+     9 +…Println(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …() { 
4  7   7  …() { 
5−  8    -…rintln("Hello, world!") 
5+  8    -…rintln("Hello, world!") 
6−     8 +…nt := "Hello, world!" 
6+     8 +…nt := "Hello, world!" 
7−     9 +…rintln(content) 
7+     9 +…rintln(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …) { 
4  7   7  …) { 
5−  8    -…intln("Hello, world!") 
5+  8    -…intln("Hello, world!") 
6−     8 +…t := "Hello, world!" 
6+     8 +…t := "Hello, world!" 
7−     9 +…intln(content) 
7+     9 +…intln(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … { 
4  7   7  … { 
5−  8    -…ntln("Hello, world!") 
5+  8    -…ntln("Hello, world!") 
6−     8 +… := "Hello, world!" 
6+     8 +… := "Hello, world!" 
7−     9 +…ntln(content) 
7+     9 +…ntln(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  …{ 
4  7   7  …{ 
5−  8    -…tln("Hello, world!") 
5+  8    -…tln("Hello, world!") 
6−     8 +…:= "Hello, world!" 
6+     8 +…:= "Hello, world!" 
7−     9 +…tln(content) 
7+     9 +…tln(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…ln("Hello, world!") 
5+  8    -…ln("Hello, world!") 
6−     8 +…= "Hello, world!" 
6+     8 +…= "Hello, world!" 
7−     9 +…ln(content) 
7+     9 +…ln(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…n("Hello, world!") 
5+  8    -…n("Hello, world!") 
6−     8 +… "Hello, world!" 
6+     8 +… "Hello, world!" 
7−     9 +…n(content) 
7+     9 +…n(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…("Hello, world!") 
5+  8    -…("Hello, world!") 
6−     8 +…"Hello, world!" 
6+     8 +…"Hello, world!" 
7−     9 +…(content) 
7+     9 +…(content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…"Hello, world!") 
5+  8    -…"Hello, world!") 
6−     8 +…Hello, world!" 
6+     8 +…Hello, world!" 
7−     9 +…content) 
7+     9 +…content) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…Hello, world!") 
5+  8    -…Hello, world!") 
6−     8 +…ello, world!" 
6+     8 +…ello, world!" 
7−     9 +…ontent) 
7+     9 +…ontent) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…ello, world!") 
5+  8    -…ello, world!") 
6−     8 +…llo, world!" 
6+     8 +…llo, world!" 
7−     9 +…ntent) 
7+     9 +…ntent) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…llo, world!") 
5+  8    -…llo, world!") 
6−     8 +…lo, world!" 
6+     8 +…lo, world!" 
7−     9 +…tent) 
7+     9 +…tent) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden+3−3
@@ -2,7 +2,7 @@
2  5   5  … 
2  5   5  … 
3  6   6   
3  6   6   
4  7   7  … 
4  7   7  … 
5−  8    -…lo, world!") 
5+  8    -…lo, world!") 
6−     8 +…o, world!" 
6+     8 +…o, world!" 
7−     9 +…ent) 
7+     9 +…ent) 
8  9  10  … 
8  9  10  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden+1−1
@@ -2,4 +2,4 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden+1−1
@@ -1,5 +1,5 @@
1  2     2   
1  2     2   
2  3  import (   3  import ( 
2  3  import (   3  import ( 
3  4   "fmt"   4   "fmt" 
3  4   "fmt"   4   "fmt" 
4−       5 +  "strings" 
4+       5 +  "strings" 
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden+1−1
@@ -1,5 +1,5 @@
1  3  import (   3  import ( 
1  3  import (   3  import ( 
2  4   "fmt"   4   "fmt" 
2  4   "fmt"   4   "fmt" 
3−       5 +  "strings" 
3+       5 +  "strings" 
4  5  )   6  ) 
4  5  )   6  ) 
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden+1−1
@@ -1,5 +1,5 @@
1  4   "fmt"   4   "fmt" 
1  4   "fmt"   4   "fmt" 
2−       5 +  "strings" 
2+       5 +  "strings" 
3  5  )   6  ) 
3  5  )   6  ) 
4  6     7   
4  6     7   
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden+1−1
@@ -1,4 +1,4 @@
1−       5 +  "strings" 
1+       5 +  "strings" 
2  5  )   6  ) 
2  5  )   6  ) 
3  6     7   
3  6     7   
4  7  func main() {   8  func main() { 
4  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden+1−1
@@ -2,4 +2,4 @@
2  9  }  10  } 
2  9  }  10  } 
3 10    11   
3 10    11   
4 11  func getContent() string {  12  func getContent() string { 
4 11  func getContent() string {  12  func getContent() string { 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden+2−2
@@ -1,5 +1,5 @@
1  9  }  10  } 
1  9  }  10  } 
2 10    11   
2 10    11   
3 11  func getContent() string {  12  func getContent() string { 
3 11  func getContent() string {  12  func getContent() string { 
4− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden+1−1
@@ -2,4 +2,4 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden+1−1
@@ -1,5 +1,5 @@
1  2   2   
1  2   2   
2  3   3  import ( 
2  3   3  import ( 
3  4   4   "fmt" 
3  4   4   "fmt" 
4−     5 +  "strings" 
4+     5 +  "strings" 
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden+1−1
@@ -1,5 +1,5 @@
1  3   3  import ( 
1  3   3  import ( 
2  4   4   "fmt" 
2  4   4   "fmt" 
3−     5 +  "strings" 
3+     5 +  "strings" 
4  5   6  ) 
4  5   6  ) 
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden+1−1
@@ -1,5 +1,5 @@
1  4   4   "fmt" 
1  4   4   "fmt" 
2−     5 +  "strings" 
2+     5 +  "strings" 
3  5   6  ) 
3  5   6  ) 
4  6   7   
4  6   7   
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden+1−1
@@ -1,4 +1,4 @@
1−     5 +  "strings" 
1+     5 +  "strings" 
2  5   6  ) 
2  5   6  ) 
3  6   7   
3  6   7   
4  7   8  func main() { 
4  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden+1−1
@@ -2,4 +2,4 @@
2  9  10  } 
2  9  10  } 
3 10  11   
3 10  11   
4 11  12  func getContent() string { 
4 11  12  func getContent() string { 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden+2−2
@@ -1,5 +1,5 @@
1  9  10  } 
1  9  10  } 
2 10  11   
2 10  11   
3 11  12  func getContent() string { 
3 11  12  func getContent() string { 
4− 12    -  return "Hello, world!" 
4+ 12    -  return "Hello, world!" 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden+3−3
@@ -1,5 +1,5 @@
1 10  11   
1 10  11   
2 11  12  func getContent() string { 
2 11  12  func getContent() string { 
3− 12    -  return "Hello, world!" 
3+ 12    -  return "Hello, world!" 
4−    13 +  content := strings.ToUpper("Hello, World!") 
4+    13 +  content := strings.ToUpper("Hello, World!") 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden+1−1
@@ -2,4 +2,4 @@
2  2     2   
2  2     2   
3  3  import (   3  import ( 
3  3  import (   3  import ( 
4  4   "fmt"   4   "fmt" 
4  4   "fmt"   4   "fmt" 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden+1−1
@@ -1,5 +1,5 @@
1  2     2   
1  2     2   
2  3  import (   3  import ( 
2  3  import (   3  import ( 
3  4   "fmt"   4   "fmt" 
3  4   "fmt"   4   "fmt" 
4−       5 +  "strings" 
4+       5 +  "strings" 
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden+1−1
@@ -1,5 +1,5 @@
1  3  import (   3  import ( 
1  3  import (   3  import ( 
2  4   "fmt"   4   "fmt" 
2  4   "fmt"   4   "fmt" 
3−       5 +  "strings" 
3+       5 +  "strings" 
4  5  )   6  ) 
4  5  )   6  ) 
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden+1−1
@@ -1,5 +1,5 @@
1  4   "fmt"   4   "fmt" 
1  4   "fmt"   4   "fmt" 
2−       5 +  "strings" 
2+       5 +  "strings" 
3  5  )   6  ) 
3  5  )   6  ) 
4  6     7   
4  6     7   
5  …  …   …  … 
5  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden+1−1
@@ -1,4 +1,4 @@
1−       5 +  "strings" 
1+       5 +  "strings" 
2  5  )   6  ) 
2  5  )   6  ) 
3  6     7   
3  6     7   
4  7  func main() {   8  func main() { 
4  7  func main() {   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden+1−1
@@ -2,4 +2,4 @@
2  9  }  10  } 
2  9  }  10  } 
3 10    11   
3 10    11   
4 11  func getContent() string {  12  func getContent() string { 
4 11  func getContent() string {  12  func getContent() string { 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden+2−2
@@ -1,5 +1,5 @@
1  9  }  10  } 
1  9  }  10  } 
2 10    11   
2 10    11   
3 11  func getContent() string {  12  func getContent() string { 
3 11  func getContent() string {  12  func getContent() string { 
4− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
5−  …  …   …  … 
5+  …  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden+2−2
@@ -1,5 +1,5 @@
1 10    11   
1 10    11   
2 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
3− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4−      14 +  return content 
4+      14 +  return content 
5 13  }  15  } 
5 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden+2−2
@@ -1,4 +1,4 @@
1 11  func getContent() string {  12  func getContent() string { 
1 11  func getContent() string {  12  func getContent() string { 
2− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
2+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3−      14 +  return content 
3+      14 +  return content 
4 13  }  15  } 
4 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden+2−2
@@ -1,3 +1,3 @@
1− 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
1+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
2−      14 +  return content 
2+      14 +  return content 
3 13  }  15  } 
3 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden+1−1
@@ -1,2 +1,2 @@
1−      14 +  return content 
1+      14 +  return content 
2 13  }  15  } 
2 13  }  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden+1−1
@@ -2,4 +2,4 @@
2  2   2   
2  2   2   
3  3   3  import ( 
3  3   3  import ( 
4  4   4   "fmt" 
4  4   4   "fmt" 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden+1−1
@@ -1,5 +1,5 @@
1  2   2   
1  2   2   
2  3   3  import ( 
2  3   3  import ( 
3  4   4   "fmt" 
3  4   4   "fmt" 
4−     5 +  "strings" 
4+     5 +  "strings" 
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden+1−1
@@ -1,5 +1,5 @@
1  3   3  import ( 
1  3   3  import ( 
2  4   4   "fmt" 
2  4   4   "fmt" 
3−     5 +  "strings" 
3+     5 +  "strings" 
4  5   6  ) 
4  5   6  ) 
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden+1−1
@@ -1,5 +1,5 @@
1  4   4   "fmt" 
1  4   4   "fmt" 
2−     5 +  "strings" 
2+     5 +  "strings" 
3  5   6  ) 
3  5   6  ) 
4  6   7   
4  6   7   
5  …   …  … 
5  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden+1−1
@@ -1,4 +1,4 @@
1−     5 +  "strings" 
1+     5 +  "strings" 
2  5   6  ) 
2  5   6  ) 
3  6   7   
3  6   7   
4  7   8  func main() { 
4  7   8  func main() { 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden+1−1
@@ -2,4 +2,4 @@
2  9  10  } 
2  9  10  } 
3 10  11   
3 10  11   
4 11  12  func getContent() string { 
4 11  12  func getContent() string { 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden+2−2
@@ -1,5 +1,5 @@
1  9  10  } 
1  9  10  } 
2 10  11   
2 10  11   
3 11  12  func getContent() string { 
3 11  12  func getContent() string { 
4− 12    -  return "Hello, world!" 
4+ 12    -  return "Hello, world!" 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden+3−3
@@ -1,5 +1,5 @@
1 10  11   
1 10  11   
2 11  12  func getContent() string { 
2 11  12  func getContent() string { 
3− 12    -  return "Hello, world!" 
3+ 12    -  return "Hello, world!" 
4−    13 +  content := strings.ToUpper("Hello, World!") 
4+    13 +  content := strings.ToUpper("Hello, World!") 
5−  …   …  … 
5+  …   …  … 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden+3−3
@@ -1,5 +1,5 @@
1 11  12  func getContent() string { 
1 11  12  func getContent() string { 
2− 12    -  return "Hello, world!" 
2+ 12    -  return "Hello, world!" 
3−    13 +  content := strings.ToUpper("Hello, World!") 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−    14 +  return content 
4+    14 +  return content 
5 13  15  } 
5 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden+3−3
@@ -1,4 +1,4 @@
1− 12    -  return "Hello, world!" 
1+ 12    -  return "Hello, world!" 
2−    13 +  content := strings.ToUpper("Hello, World!") 
2+    13 +  content := strings.ToUpper("Hello, World!") 
3−    14 +  return content 
3+    14 +  return content 
4 13  15  } 
4 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden+2−2
@@ -1,3 +1,3 @@
1−    13 +  content := strings.ToUpper("Hello, World!") 
1+    13 +  content := strings.ToUpper("Hello, World!") 
2−    14 +  return content 
2+    14 +  return content 
3 13  15  } 
3 13  15  } 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden+1−1
@@ -1,2 +1,2 @@
1−    14 +  return content 
1+    14 +  return content 
2 13  15  } 
2 13  15  } 
internal/ui/diffview/util.go+8−1
@@ -2,13 +2,20 @@ package diffview
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "strconv"
5 "strings"
6 "strings"
6
7
7 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
8)
9)
9
10
10func pad(v any, width int) string {
11func pad(v any, width int) string {
11− s := fmt.Sprintf("%v", v)
12+ var s string
13+ switch n := v.(type) {
14+ case int:
15+ s = strconv.Itoa(n)
16+ default:
17+ s = fmt.Sprintf("%v", v)
18+ }
12 w := ansi.StringWidth(s)
19 w := ansi.StringWidth(s)
13 if w >= width {
20 if w >= width {
14 return s
21 return s
internal/ui/list/filterable.go+33−1
@@ -23,6 +23,14 @@ type FilterableList struct {
23 *List
23 *List
24 items []FilterableItem
24 items []FilterableItem
25 query string
25 query string
26+ // dirty tracks whether the filtered set needs to be recomputed. It is set
27+ // by any mutation of items or query and cleared after Render recomputes.
28+ dirty bool
29+
30+ // emptyMessage is shown when there are no items and no active filter.
31+ emptyMessage string
32+ // filterEmptyMessage is shown when a filter is active but matches nothing.
33+ filterEmptyMessage string
26}
34}
27
35
28// NewFilterableList creates a new filterable list.
36// NewFilterableList creates a new filterable list.
@@ -39,6 +47,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList {
39// SetItems sets the list items and updates the filtered items.
47// SetItems sets the list items and updates the filtered items.
40func (f *FilterableList) SetItems(items ...FilterableItem) {
48func (f *FilterableList) SetItems(items ...FilterableItem) {
41 f.items = items
49 f.items = items
50+ f.dirty = true
42 fitems := make([]Item, len(items))
51 fitems := make([]Item, len(items))
43 for i, item := range items {
52 for i, item := range items {
44 fitems[i] = item
53 fitems[i] = item
@@ -49,6 +58,7 @@ func (f *FilterableList) SetItems(items ...FilterableItem) {
49// AppendItems appends items to the list and updates the filtered items.
58// AppendItems appends items to the list and updates the filtered items.
50func (f *FilterableList) AppendItems(items ...FilterableItem) {
59func (f *FilterableList) AppendItems(items ...FilterableItem) {
51 f.items = append(f.items, items...)
60 f.items = append(f.items, items...)
61+ f.dirty = true
52 itms := make([]Item, len(f.items))
62 itms := make([]Item, len(f.items))
53 for i, item := range f.items {
63 for i, item := range f.items {
54 itms[i] = item
64 itms[i] = item
@@ -59,6 +69,7 @@ func (f *FilterableList) AppendItems(items ...FilterableItem) {
59// PrependItems prepends items to the list and updates the filtered items.
69// PrependItems prepends items to the list and updates the filtered items.
60func (f *FilterableList) PrependItems(items ...FilterableItem) {
70func (f *FilterableList) PrependItems(items ...FilterableItem) {
61 f.items = append(items, f.items...)
71 f.items = append(items, f.items...)
72+ f.dirty = true
62 itms := make([]Item, len(f.items))
73 itms := make([]Item, len(f.items))
63 for i, item := range f.items {
74 for i, item := range f.items {
64 itms[i] = item
75 itms[i] = item
@@ -69,6 +80,7 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) {
69// SetFilter sets the filter query and updates the list items.
80// SetFilter sets the filter query and updates the list items.
70func (f *FilterableList) SetFilter(q string) {
81func (f *FilterableList) SetFilter(q string) {
71 f.query = q
82 f.query = q
83+ f.dirty = true
72 f.List.SetItems(f.FilteredItems()...)
84 f.List.SetItems(f.FilteredItems()...)
73 f.ScrollToTop()
85 f.ScrollToTop()
74}
86}
@@ -118,8 +130,28 @@ func (f *FilterableList) FilteredItems() []Item {
118 return matchedItems
130 return matchedItems
119}
131}
120
132
133+// SetEmptyMessage sets the placeholder shown when the list has no items and
134+// no filter is active.
135+func (f *FilterableList) SetEmptyMessage(msg string) {
136+ f.emptyMessage = msg
137+}
138+
139+// SetFilterEmptyMessage sets the placeholder shown when a filter is active but
140+// yields no matches.
141+func (f *FilterableList) SetFilterEmptyMessage(msg string) {
142+ f.filterEmptyMessage = msg
143+}
144+
121// Render renders the filterable list.
145// Render renders the filterable list.
122func (f *FilterableList) Render() string {
146func (f *FilterableList) Render() string {
123− f.List.SetItems(f.FilteredItems()...)
147+ if f.dirty {
148+ f.List.SetItems(f.FilteredItems()...)
149+ f.dirty = false
150+ }
151+ if f.query != "" && f.filterEmptyMessage != "" {
152+ f.List.SetEmptyMessage(f.filterEmptyMessage)
153+ } else {
154+ f.List.SetEmptyMessage(f.emptyMessage)
155+ }
124 return f.List.Render()
156 return f.List.Render()
125}
157}
internal/ui/list/highlight.go+2−1
@@ -136,9 +136,10 @@ func HighlightBuffer(content string, area image.Rectangle, startLine, startCol,
136
136
137// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
137// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
138func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
138func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
139+ uvStyle := ToStyle(lgStyle)
139 return func(_ int, _ int, c *uv.Cell) *uv.Cell {
140 return func(_ int, _ int, c *uv.Cell) *uv.Cell {
140 if c != nil {
141 if c != nil {
141− c.Style = ToStyle(lgStyle)
142+ c.Style = uvStyle
142 }
143 }
143 return c
144 return c
144 }
145 }
internal/ui/list/list.go+23−4
@@ -2,6 +2,8 @@ package list
2
2
3import (
3import (
4 "strings"
4 "strings"
5+
6+ "charm.land/lipgloss/v2"
5)
7)
6
8
7// List represents a list of items that can be lazily rendered. A list is
9// List represents a list of items that can be lazily rendered. A list is
@@ -50,6 +52,11 @@ type List struct {
50 // without calling back into Render.
52 // without calling back into Render.
51 cache map[Item]*listCacheEntry
53 cache map[Item]*listCacheEntry
52
54
55+ // emptyMessage is a placeholder rendered centered (and dimmed) when
56+ // the list has zero items, so an empty or fully-filtered list is not
57+ // shown as a blank void. Empty by default (renders nothing).
58+ emptyMessage string
59+
53 // freezeSuppressed marks items the list must not freeze on the
60 // freezeSuppressed marks items the list must not freeze on the
54 // next render even when their Finished() reports true. This is
61 // next render even when their Finished() reports true. This is
55 // the §4.5.1 selection-drag escape hatch (option (a)): items
62 // the §4.5.1 selection-drag escape hatch (option (a)): items
@@ -109,6 +116,12 @@ func (l *List) SetSize(width, height int) {
109 l.height = height
116 l.height = height
110}
117}
111
118
119+// SetEmptyMessage sets a placeholder message rendered centered and dimmed
120+// when the list has no items. An empty string disables the placeholder.
121+func (l *List) SetEmptyMessage(msg string) {
122+ l.emptyMessage = msg
123+}
124+
112// SetGap sets the gap between items.
125// SetGap sets the gap between items.
113func (l *List) SetGap(gap int) {
126func (l *List) SetGap(gap int) {
114 l.gap = gap
127 l.gap = gap
@@ -321,9 +334,7 @@ func (l *List) renderItemEntry(idx int) *listCacheEntry {
321
334
322// invalidateAll drops every cache entry. Called on width changes.
335// invalidateAll drops every cache entry. Called on width changes.
323func (l *List) invalidateAll() {
336func (l *List) invalidateAll() {
324− for k := range l.cache {
337+ l.cache = make(map[Item]*listCacheEntry)
325− delete(l.cache, k)
326− }
327 l.totalHeightValid = false
338 l.totalHeightValid = false
328}
339}
329
340
@@ -516,7 +527,15 @@ func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
516// lines we now drop implicitly per item).
527// lines we now drop implicitly per item).
517func (l *List) Render() string {
528func (l *List) Render() string {
518 if len(l.items) == 0 {
529 if len(l.items) == 0 {
519− return ""
530+ if l.emptyMessage == "" {
531+ return ""
532+ }
533+ msg := lipgloss.NewStyle().Faint(true).Render(l.emptyMessage)
534+ return lipgloss.Place(
535+ max(l.width, 0), max(l.height, 0),
536+ lipgloss.Center, lipgloss.Center,
537+ msg,
538+ )
520 }
539 }
521
540
522 budget := max(l.height, 0)
541 budget := max(l.height, 0)
internal/ui/model/chat.go+31−9
@@ -465,7 +465,13 @@ func (m *Chat) ScrollToTop() tea.Cmd {
465// ScrollBy scrolls the chat view by the given number of line deltas.
465// ScrollBy scrolls the chat view by the given number of line deltas.
466func (m *Chat) ScrollBy(lines int) tea.Cmd {
466func (m *Chat) ScrollBy(lines int) tea.Cmd {
467 m.list.ScrollBy(lines)
467 m.list.ScrollBy(lines)
468− m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
468+ if lines < 0 {
469+ // Scrolling up always disables follow mode.
470+ m.follow = false
471+ } else if m.AtBottom() {
472+ // Scrolling down re-enables follow when we reach the bottom.
473+ m.follow = true
474+ }
469 return m.showScrollbar()
475 return m.showScrollbar()
470}
476}
471
477
@@ -667,6 +673,18 @@ func (m *Chat) RemoveMessage(id string) {
667 return
673 return
668 }
674 }
669
675
676+ // Clean up nested tool IDs that point to this item's index before
677+ // removing it, so they don't end up pointing to a wrong item after
678+ // the rebuild.
679+ if item, ok := m.list.ItemAt(idx).(chat.MessageItem); ok {
680+ if container, ok := item.(chat.NestedToolContainer); ok {
681+ for _, nested := range container.NestedTools() {
682+ delete(m.idInxMap, nested.ID())
683+ delete(m.pausedAnimations, nested.ID())
684+ }
685+ }
686+ }
687+
670 // Remove from list
688 // Remove from list
671 m.list.RemoveItem(idx)
689 m.list.RemoveItem(idx)
672
690
@@ -677,6 +695,12 @@ func (m *Chat) RemoveMessage(id string) {
677 for i := idx; i < m.list.Len(); i++ {
695 for i := idx; i < m.list.Len(); i++ {
678 if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
696 if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
679 m.idInxMap[item.ID()] = i
697 m.idInxMap[item.ID()] = i
698+ // Also update nested tool IDs for containers.
699+ if container, ok := item.(chat.NestedToolContainer); ok {
700+ for _, nested := range container.NestedTools() {
701+ m.idInxMap[nested.ID()] = i
702+ }
703+ }
680 }
704 }
681 }
705 }
682
706
@@ -700,10 +724,11 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
700// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
724// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
701func (m *Chat) ToggleExpandedSelectedItem() {
725func (m *Chat) ToggleExpandedSelectedItem() {
702 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
726 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
727+ wasFollowing := m.follow
703 if !expandable.ToggleExpanded() {
728 if !expandable.ToggleExpanded() {
704 m.ScrollToIndex(m.list.Selected())
729 m.ScrollToIndex(m.list.Selected())
705 }
730 }
706− if m.AtBottom() {
731+ if wasFollowing {
707 m.ScrollToBottom()
732 m.ScrollToBottom()
708 }
733 }
709 }
734 }
@@ -830,14 +855,15 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
830 // toggling expansion for clicks outside the clickable area.
855 // toggling expansion for clicks outside the clickable area.
831 if handled {
856 if handled {
832 if expandable, ok := selectedItem.(chat.Expandable); ok {
857 if expandable, ok := selectedItem.(chat.Expandable); ok {
858+ wasFollowing := m.follow
833 if !expandable.ToggleExpanded() {
859 if !expandable.ToggleExpanded() {
834 m.ScrollToIndex(m.list.Selected())
860 m.ScrollToIndex(m.list.Selected())
835 }
861 }
862+ if wasFollowing {
863+ m.ScrollToBottom()
864+ }
836 }
865 }
837 }
866 }
838− if m.AtBottom() {
839− m.ScrollToBottom()
840− }
841 return handled
867 return handled
842 }
868 }
843
869
@@ -1098,10 +1124,6 @@ func findWordBoundaries(line string, col int) (startCol, endCol int) {
1098 return 0, 0
1124 return 0, 0
1099 }
1125 }
1100
1126
1101− i := displaywidth.StringGraphemes(line)
1102− for i.Next() {
1103− }
1104−
1105 // Segment the line into words using UAX#29.
1127 // Segment the line into words using UAX#29.
1106 lineCol := 0 // tracks the visited column widths
1128 lineCol := 0 // tracks the visited column widths
1107 lastCol := 0 // tracks the start of the current token
1129 lastCol := 0 // tracks the start of the current token
internal/ui/model/header.go+93−10
@@ -2,6 +2,8 @@ package model
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "hash/fnv"
6+ "strconv"
5 "strings"
7 "strings"
6
8
7 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2"
@@ -30,6 +32,18 @@ type header struct {
30 com *common.Common
32 com *common.Common
31 width int
33 width int
32 compact bool
34 compact bool
35+
36+ // compact-header render cache. drawHeader otherwise rebuilds the whole
37+ // compact header (LSP loop, config/model lookups, several
38+ // lipgloss.Render calls, PrettyPath+DirTrim, ansi.Truncate) every
39+ // frame; memoize the rendered string keyed by a cheap fingerprint of
40+ // everything it depends on.
41+ compactCacheKey uint64
42+ compactCacheView string
43+ hasCompactCache bool
44+ // compactFPBuf is a scratch buffer reused across frames to build the
45+ // compact-header fingerprint without allocating (see compactFingerprint).
46+ compactFPBuf []byte
33}
47}
34
48
35// newHeader creates a new header model.
49// newHeader creates a new header model.
@@ -59,9 +73,13 @@ func (h *header) refresh() {
59 // Force drawHeader to re-render the wide logo on the next frame.
73 // Force drawHeader to re-render the wide logo on the next frame.
60 h.width = 0
74 h.width = 0
61 h.logo = ""
75 h.logo = ""
76+ // The compact-header cache embeds the old theme's styles; drop it.
77+ h.hasCompactCache = false
62}
78}
63
79
64−// drawHeader draws the header for the given session.
80+// drawHeader draws the header for the given session. lspErrorCount is the
81+// total diagnostic count summed by the caller from local LSP state, so
82+// this render path never issues an LSPGetStates RPC.
65func (h *header) drawHeader(
83func (h *header) drawHeader(
66 scr uv.Screen,
84 scr uv.Screen,
67 area uv.Rectangle,
85 area uv.Rectangle,
@@ -70,8 +88,8 @@ func (h *header) drawHeader(
70 detailsOpen bool,
88 detailsOpen bool,
71 width int,
89 width int,
72 hyperCredits *int,
90 hyperCredits *int,
91+ lspErrorCount int,
73) {
92) {
74− t := h.com.Styles
75 if width != h.width || compact != h.compact {
93 if width != h.width || compact != h.compact {
76 h.logo = renderLogo(h.com.Styles, compact, h.com.IsHyper(), width)
94 h.logo = renderLogo(h.com.Styles, compact, h.com.IsHyper(), width)
77 }
95 }
@@ -88,14 +106,79 @@ func (h *header) drawHeader(
88 return
106 return
89 }
107 }
90
108
109+ key := h.compactFingerprint(session, detailsOpen, width, hyperCredits, lspErrorCount)
110+ if !h.hasCompactCache || key != h.compactCacheKey {
111+ h.compactCacheView = h.buildCompactHeader(session, detailsOpen, width, hyperCredits, lspErrorCount)
112+ h.compactCacheKey = key
113+ h.hasCompactCache = true
114+ }
115+
116+ uv.NewStyledString(h.compactCacheView).Draw(scr, area)
117+}
118+
119+// compactFingerprint hashes every input that affects the compact header
120+// render, so drawHeader can reuse the cached string when nothing changed.
121+func (h *header) compactFingerprint(
122+ session *session.Session,
123+ detailsOpen bool,
124+ width int,
125+ hyperCredits *int,
126+ lspErrorCount int,
127+) uint64 {
128+ var contextWindow int64
129+ agentCfg := h.com.Config().Agents[config.AgentCoder]
130+ if model := h.com.Config().GetModelByType(agentCfg.Model); model != nil {
131+ contextWindow = model.ContextWindow
132+ }
133+ credits := 0
134+ hasCredits := hyperCredits != nil
135+ if hasCredits {
136+ credits = *hyperCredits
137+ }
138+ // Build into a reused buffer with strconv appends and hash once, rather
139+ // than fmt.Sprintf: this runs every frame in compact mode, so the arg
140+ // boxing and result-string allocation Sprintf performed were pure churn.
141+ b := h.compactFPBuf[:0]
142+ putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
143+ putInt("w=", int64(width))
144+ b = append(b, ";sid="...)
145+ b = append(b, session.ID...)
146+ putInt(";ct=", session.CompletionTokens)
147+ putInt(";pt=", session.PromptTokens)
148+ b = append(b, ";eu="...)
149+ b = strconv.AppendBool(b, session.EstimatedUsage)
150+ putInt(";lsp=", int64(lspErrorCount))
151+ b = append(b, ";det="...)
152+ b = strconv.AppendBool(b, detailsOpen)
153+ b = append(b, ";hyper="...)
154+ b = strconv.AppendBool(b, h.com.IsHyper())
155+ b = append(b, ";hc="...)
156+ b = strconv.AppendBool(b, hasCredits)
157+ putInt("/", int64(credits))
158+ putInt(";cw=", contextWindow)
159+ b = append(b, ";cwd="...)
160+ b = append(b, h.com.Workspace.WorkingDir()...)
161+ h.compactFPBuf = b // retain the grown buffer for reuse next frame
162+
163+ hash := fnv.New64a()
164+ hash.Write(b)
165+ return hash.Sum64()
166+}
167+
168+// buildCompactHeader renders the compact header string.
169+func (h *header) buildCompactHeader(
170+ session *session.Session,
171+ detailsOpen bool,
172+ width int,
173+ hyperCredits *int,
174+ lspErrorCount int,
175+) string {
176+ t := h.com.Styles
177+
91 var b strings.Builder
178 var b strings.Builder
92 b.WriteString(h.compactLogo)
179 b.WriteString(h.compactLogo)
93
180
94 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
181 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
95− lspErrorCount := 0
96− for _, info := range h.com.Workspace.LSPGetStates() {
97− lspErrorCount += info.DiagnosticCount
98− }
99 details := renderHeaderDetails(
182 details := renderHeaderDetails(
100 h.com,
183 h.com,
101 session,
184 session,
@@ -121,10 +204,7 @@ func (h *header) drawHeader(
121
204
122 b.WriteString(details)
205 b.WriteString(details)
123
206
124− view := uv.NewStyledString(
207+ return t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
125− t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String()),
126− )
127− view.Draw(scr, area)
128}
208}
129
209
130// renderHeaderDetails renders the details section of the header.
210// renderHeaderDetails renders the details section of the header.
@@ -148,6 +228,9 @@ func renderHeaderDetails(
148 model := com.Config().GetModelByType(agentCfg.Model)
228 model := com.Config().GetModelByType(agentCfg.Model)
149 if model != nil && model.ContextWindow > 0 {
229 if model != nil && model.ContextWindow > 0 {
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
230 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
231+ if percentage > 100 {
232+ percentage = 100
233+ }
151 percentageText := fmt.Sprintf("%d%%", int(percentage))
234 percentageText := fmt.Sprintf("%d%%", int(percentage))
152 if session.EstimatedUsage {
235 if session.EstimatedUsage {
153 percentageText = "~" + percentageText
236 percentageText = "~" + percentageText
internal/ui/model/history.go+1−2
@@ -1,7 +1,6 @@
1package model
1package model
2
2
3import (
3import (
4− "context"
5 "log/slog"
4 "log/slog"
6 "strings"
5 "strings"
7
6
@@ -18,7 +17,7 @@ type promptHistoryLoadedMsg struct {
18// loadPromptHistory loads user messages for history navigation.
17// loadPromptHistory loads user messages for history navigation.
19func (m *UI) loadPromptHistory() tea.Cmd {
18func (m *UI) loadPromptHistory() tea.Cmd {
20 return func() tea.Msg {
19 return func() tea.Msg {
21− ctx := context.Background()
20+ ctx := m.ctx
22 var messages []message.Message
21 var messages []message.Message
23 var err error
22 var err error
24
23
internal/ui/model/keys.go+25−21
@@ -4,14 +4,14 @@ import "charm.land/bubbles/v2/key"
4
4
5type KeyMap struct {
5type KeyMap struct {
6 Editor struct {
6 Editor struct {
7− AddFile key.Binding
7+ SendMessage key.Binding
8− SendMessage key.Binding
8+ OpenEditor key.Binding
9− OpenEditor key.Binding
9+ Newline key.Binding
10− Newline key.Binding
10+ AddImage key.Binding
11− AddImage key.Binding
11+ PasteImage key.Binding
12− PasteImage key.Binding
12+ MentionFile key.Binding
13− MentionFile key.Binding
13+ Commands key.Binding
14− Commands key.Binding
14+ ShellCommand key.Binding
15
15
16 // Attachments key maps
16 // Attachments key maps
17 AttachmentDeleteMode key.Binding
17 AttachmentDeleteMode key.Binding
@@ -25,7 +25,6 @@ type KeyMap struct {
25
25
26 Chat struct {
26 Chat struct {
27 NewSession key.Binding
27 NewSession key.Binding
28− AddAttachment key.Binding
29 Cancel key.Binding
28 Cancel key.Binding
30 Tab key.Binding
29 Tab key.Binding
31 Details key.Binding
30 Details key.Binding
@@ -67,6 +66,7 @@ type KeyMap struct {
67 Sessions key.Binding
66 Sessions key.Binding
68 Tab key.Binding
67 Tab key.Binding
69 ToggleYolo key.Binding
68 ToggleYolo key.Binding
69+ CopyError key.Binding
70}
70}
71
71
72func DefaultKeyMap() KeyMap {
72func DefaultKeyMap() KeyMap {
@@ -103,12 +103,12 @@ func DefaultKeyMap() KeyMap {
103 key.WithKeys("ctrl+y"),
103 key.WithKeys("ctrl+y"),
104 key.WithHelp("ctrl+y", "toggle yolo"),
104 key.WithHelp("ctrl+y", "toggle yolo"),
105 ),
105 ),
106+ CopyError: key.NewBinding(
107+ key.WithKeys("ctrl+e"),
108+ key.WithHelp("ctrl+e", "copy error"),
109+ ),
106 }
110 }
107
111
108− km.Editor.AddFile = key.NewBinding(
109− key.WithKeys("/"),
110− key.WithHelp("/", "add file"),
111− )
112 km.Editor.SendMessage = key.NewBinding(
112 km.Editor.SendMessage = key.NewBinding(
113 key.WithKeys("enter"),
113 key.WithKeys("enter"),
114 key.WithHelp("enter", "send"),
114 key.WithHelp("enter", "send"),
@@ -140,6 +140,14 @@ func DefaultKeyMap() KeyMap {
140 key.WithKeys("/"),
140 key.WithKeys("/"),
141 key.WithHelp("/", "commands"),
141 key.WithHelp("/", "commands"),
142 )
142 )
143+ // ShellCommand is a help-only hint: bang (!) shell mode is entered by
144+ // typing "!" at the start of an empty editor (see the bang-mode handling
145+ // in ui.go), not via a key.Matches lookup, so this binding exists purely
146+ // to surface the feature in the help view.
147+ km.Editor.ShellCommand = key.NewBinding(
148+ key.WithKeys("!"),
149+ key.WithHelp("!", "shell command"),
150+ )
143 km.Editor.AttachmentDeleteMode = key.NewBinding(
151 km.Editor.AttachmentDeleteMode = key.NewBinding(
144 key.WithKeys("ctrl+r"),
152 key.WithKeys("ctrl+r"),
145 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
153 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
@@ -163,10 +171,6 @@ func DefaultKeyMap() KeyMap {
163 key.WithKeys("ctrl+n"),
171 key.WithKeys("ctrl+n"),
164 key.WithHelp("ctrl+n", "new session"),
172 key.WithHelp("ctrl+n", "new session"),
165 )
173 )
166− km.Chat.AddAttachment = key.NewBinding(
167− key.WithKeys("ctrl+f"),
168− key.WithHelp("ctrl+f", "add attachment"),
169− )
170 km.Chat.Cancel = key.NewBinding(
174 km.Chat.Cancel = key.NewBinding(
171 key.WithKeys("esc", "alt+esc"),
175 key.WithKeys("esc", "alt+esc"),
172 key.WithHelp("esc", "cancel"),
176 key.WithHelp("esc", "cancel"),
@@ -194,11 +198,11 @@ func DefaultKeyMap() KeyMap {
194
198
195 km.Chat.Down = key.NewBinding(
199 km.Chat.Down = key.NewBinding(
196 key.WithKeys("down", "ctrl+j", "j"),
200 key.WithKeys("down", "ctrl+j", "j"),
197− key.WithHelp("↓", "down"),
201+ key.WithHelp("↓/j", "down"),
198 )
202 )
199 km.Chat.Up = key.NewBinding(
203 km.Chat.Up = key.NewBinding(
200 key.WithKeys("up", "ctrl+k", "k"),
204 key.WithKeys("up", "ctrl+k", "k"),
201− key.WithHelp("↑", "up"),
205+ key.WithHelp("↑/k", "up"),
202 )
206 )
203 km.Chat.UpDown = key.NewBinding(
207 km.Chat.UpDown = key.NewBinding(
204 key.WithKeys("up", "down"),
208 key.WithKeys("up", "down"),
@@ -206,11 +210,11 @@ func DefaultKeyMap() KeyMap {
206 )
210 )
207 km.Chat.UpOneItem = key.NewBinding(
211 km.Chat.UpOneItem = key.NewBinding(
208 key.WithKeys("shift+up", "K"),
212 key.WithKeys("shift+up", "K"),
209− key.WithHelp("shift+↑", "up one item"),
213+ key.WithHelp("shift+↑/K", "up one item"),
210 )
214 )
211 km.Chat.DownOneItem = key.NewBinding(
215 km.Chat.DownOneItem = key.NewBinding(
212 key.WithKeys("shift+down", "J"),
216 key.WithKeys("shift+down", "J"),
213− key.WithHelp("shift+↓", "down one item"),
217+ key.WithHelp("shift+↓/J", "down one item"),
214 )
218 )
215 km.Chat.UpDownOneItem = key.NewBinding(
219 km.Chat.UpDownOneItem = key.NewBinding(
216 key.WithKeys("shift+up", "shift+down"),
220 key.WithKeys("shift+up", "shift+down"),
internal/ui/model/landing.go+78−9
@@ -1,9 +1,11 @@
1package model
1package model
2
2
3import (
3import (
4+ "fmt"
4 "image"
5 "image"
5
6
6 "charm.land/lipgloss/v2"
7 "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/config"
7 "github.com/charmbracelet/crush/internal/ui/common"
9 "github.com/charmbracelet/crush/internal/ui/common"
8 "github.com/charmbracelet/crush/internal/workspace"
10 "github.com/charmbracelet/crush/internal/workspace"
9 "github.com/charmbracelet/ultraviolet/layout"
11 "github.com/charmbracelet/ultraviolet/layout"
@@ -11,14 +13,68 @@ import (
11
13
12// selectedLargeModel returns the currently selected large language model from
14// selectedLargeModel returns the currently selected large language model from
13// the agent coordinator, if one exists.
15// the agent coordinator, if one exists.
16+//
17+// Resolving the model hits the agent coordinator, which is an RPC
18+// (AgentIsReady + AgentModel) in client mode. Because this runs on the
19+// per-frame sidebar fingerprint path, the result is cached against a
20+// cheap, locally-cached config fingerprint (largeModelConfigKey) and
21+// only re-fetched when the selection changes. A nil result is never
22+// cached, so the not-ready → ready transition is picked up on the next
23+// frame.
14func (m *UI) selectedLargeModel() *workspace.AgentModel {
24func (m *UI) selectedLargeModel() *workspace.AgentModel {
25+ key := m.largeModelConfigKey()
26+ if m.renderCache.largeModel != nil && key == m.renderCache.largeModelCacheKey {
27+ return m.renderCache.largeModel
28+ }
29+ m.renderCache.largeModelCacheKey = key
15 if m.com.Workspace.AgentIsReady() {
30 if m.com.Workspace.AgentIsReady() {
16 model := m.com.Workspace.AgentModel()
31 model := m.com.Workspace.AgentModel()
17− return &model
32+ m.renderCache.largeModel = &model
33+ return m.renderCache.largeModel
18 }
34 }
35+ m.renderCache.largeModel = nil
19 return nil
36 return nil
20}
37}
21
38
39+// largeModelConfigKey builds a cheap fingerprint of the locally-cached
40+// config that determines the selected large (coder) model. The resolved
41+// model is cached against this key so selectedLargeModel only re-fetches
42+// when the selection actually changes. The cached workspace config is
43+// refreshed on every server-side config change (ConfigChanged), so
44+// remote model switches flip this key too.
45+func (m *UI) largeModelConfigKey() string {
46+ cfg := m.com.Config()
47+ if cfg == nil {
48+ return ""
49+ }
50+ agentCfg, ok := cfg.Agents[config.AgentCoder]
51+ if !ok {
52+ return "no-agent"
53+ }
54+ sm, ok := cfg.Models[agentCfg.Model]
55+ if !ok {
56+ return "no-model:" + string(agentCfg.Model)
57+ }
58+ // Fold in the catwalk-derived fields that the sidebar renders (looked
59+ // up from the same cached config, no RPC), so a provider-config
60+ // change that alters e.g. the context window or reasoning support for
61+ // the same provider+model still flips the key and forces a refresh.
62+ var cwName, provName string
63+ var contextWindow int64
64+ var canReason bool
65+ if cm := cfg.GetModelByType(agentCfg.Model); cm != nil {
66+ cwName = cm.Name
67+ contextWindow = cm.ContextWindow
68+ canReason = cm.CanReason
69+ }
70+ if pc, ok := cfg.Providers.Get(sm.Provider); ok {
71+ provName = pc.Name
72+ }
73+ return fmt.Sprintf("%s|%s|%s|%t|%s|%s|%s|%d|%t",
74+ agentCfg.Model, sm.Provider, sm.Model, sm.Think, sm.ReasoningEffort,
75+ provName, cwName, contextWindow, canReason)
76+}
77+
22// landingView renders the landing page view showing the current working
78// landingView renders the landing page view showing the current working
23// directory, model information, and LSP/MCP status in a two-column layout.
79// directory, model information, and LSP/MCP status in a two-column layout.
24func (m *UI) landingView() string {
80func (m *UI) landingView() string {
@@ -30,7 +86,8 @@ func (m *UI) landingView() string {
30 cwd,
86 cwd,
31 }
87 }
32
88
33− parts = append(parts, "", m.modelInfo(width))
89+ cta := t.Resource.AdditionalText.Render("Type a message to begin · / for commands · ctrl+s for sessions")
90+ parts = append(parts, "", m.modelInfo(width), "", cta)
34 infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
91 infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
35
92
36 var remainingHeightArea image.Rectangle
93 var remainingHeightArea image.Rectangle
@@ -39,13 +96,25 @@ func (m *UI) landingView() string {
39 layout.Fill(1),
96 layout.Fill(1),
40 ).Split(m.layout.main).Assign(new(image.Rectangle), &remainingHeightArea)
97 ).Split(m.layout.main).Assign(new(image.Rectangle), &remainingHeightArea)
41
98
42− mcpLspSectionWidth := min(30, (width-2)/3)
99+ // On narrow terminals the three side-by-side sections get squeezed to a
43−
100+ // few columns each, so stack them vertically instead. narrowLandingWidth
44− lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
101+ // is the point below which a 3-column split would leave each column under
45− mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
102+ // ~20 cells wide ((width-2)/3 < 20).
46− skillsSection := m.skillsInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
103+ const narrowLandingWidth = 60
47−
104+ var content string
48− content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection, " ", skillsSection)
105+ if width < narrowLandingWidth {
106+ perSection := max(1, remainingHeightArea.Dy()/3)
107+ lspSection := m.lspInfo(width, perSection, false)
108+ mcpSection := m.mcpInfo(width, perSection, false)
109+ skillsSection := m.skillsInfo(width, perSection, false)
110+ content = lipgloss.JoinVertical(lipgloss.Left, lspSection, "", mcpSection, "", skillsSection)
111+ } else {
112+ mcpLspSectionWidth := min(30, (width-2)/3)
113+ lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
114+ mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
115+ skillsSection := m.skillsInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
116+ content = lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection, " ", skillsSection)
117+ }
49
118
50 return lipgloss.NewStyle().
119 return lipgloss.NewStyle().
51 Width(width).
120 Width(width).
internal/ui/model/lsp.go+23−2
@@ -20,6 +20,23 @@ type LSPInfo struct {
20 Diagnostics map[protocol.DiagnosticSeverity]int
20 Diagnostics map[protocol.DiagnosticSeverity]int
21}
21}
22
22
23+// refreshLSPState re-fetches the LSP client states and their per-severity
24+// diagnostic counts into the local mirrors (m.lspStates / m.lspDiagnostics).
25+// It is called from the LSP event handlers (state change / diagnostics
26+// change) so the sidebar fingerprint — computed every frame — can read
27+// fresh values from local state instead of issuing per-LSP diagnostics
28+// RPCs on the render hot path. Every diagnostics change publishes an LSP
29+// event (see internal/app/lsp_events.go), so the mirror always changes
30+// whenever the rendered sidebar would.
31+func (m *UI) refreshLSPState() {
32+ m.lspStates = m.com.Workspace.LSPGetStates()
33+ counts := make(map[string]lsp.DiagnosticCounts, len(m.lspStates))
34+ for name := range m.lspStates {
35+ counts[name] = m.com.Workspace.LSPGetDiagnosticCounts(name)
36+ }
37+ m.lspDiagnostics = counts
38+}
39+
23// lspInfo renders the LSP status section showing active LSP clients and their
40// lspInfo renders the LSP status section showing active LSP clients and their
24// diagnostic counts.
41// diagnostic counts.
25func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
42func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
@@ -32,7 +49,11 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
32 var lsps []LSPInfo
49 var lsps []LSPInfo
33 for _, state := range states {
50 for _, state := range states {
34 lspErrs := map[protocol.DiagnosticSeverity]int{}
51 lspErrs := map[protocol.DiagnosticSeverity]int{}
35− counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
52+ // Read from the local mirror (refreshed on every LSP event) rather
53+ // than issuing a per-LSP diagnostics RPC on the render path. This
54+ // also keeps the rendered counts consistent with the sidebar
55+ // fingerprint, which reads the same mirror.
56+ counts := m.lspDiagnostics[state.Name]
36 lspErrs[protocol.SeverityError] = counts.Error
57 lspErrs[protocol.SeverityError] = counts.Error
37 lspErrs[protocol.SeverityWarning] = counts.Warning
58 lspErrs[protocol.SeverityWarning] = counts.Warning
38 lspErrs[protocol.SeverityHint] = counts.Hint
59 lspErrs[protocol.SeverityHint] = counts.Hint
@@ -118,7 +139,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
118
139
119 if len(renderedLsps) > maxItems {
140 if len(renderedLsps) > maxItems {
120 visibleItems := renderedLsps[:maxItems-1]
141 visibleItems := renderedLsps[:maxItems-1]
121− remaining := len(renderedLsps) - maxItems
142+ remaining := len(renderedLsps) - (maxItems - 1)
122 visibleItems = append(visibleItems, t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
143 visibleItems = append(visibleItems, t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
123 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
144 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
124 }
145 }
internal/ui/model/mcp.go+9−2
@@ -14,10 +14,17 @@ import (
14// mcpInfo renders the MCP status section showing active MCP clients and their
14// mcpInfo renders the MCP status section showing active MCP clients and their
15// tool/prompt counts.
15// tool/prompt counts.
16func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
16func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
17+ return m.mcpInfoSorted(m.com.Config().MCP.Sorted(), width, maxItems, isSection)
18+}
19+
20+// mcpInfoSorted renders the MCP status section from an already-sorted MCP
21+// config list, letting callers on the sidebar render path reuse a single
22+// MCP.Sorted() result instead of recomputing it.
23+func (m *UI) mcpInfoSorted(mcpSorted []config.MCP, width, maxItems int, isSection bool) string {
17 var mcps []mcp.ClientInfo
24 var mcps []mcp.ClientInfo
18 t := m.com.Styles
25 t := m.com.Styles
19
26
20− for _, mcp := range m.com.Config().MCP.Sorted() {
27+ for _, mcp := range mcpSorted {
21 if state, ok := m.mcpStates[mcp.Name]; ok {
28 if state, ok := m.mcpStates[mcp.Name]; ok {
22 mcps = append(mcps, state)
29 mcps = append(mcps, state)
23 }
30 }
@@ -99,7 +106,7 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
99
106
100 if len(renderedMcps) > maxItems {
107 if len(renderedMcps) > maxItems {
101 visibleItems := renderedMcps[:maxItems-1]
108 visibleItems := renderedMcps[:maxItems-1]
102− remaining := len(renderedMcps) - maxItems
109+ remaining := len(renderedMcps) - (maxItems - 1)
103 visibleItems = append(visibleItems, t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
110 visibleItems = append(visibleItems, t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
104 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
111 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
105 }
112 }
internal/ui/model/onboarding.go+1−2
@@ -72,7 +72,6 @@ func (m *UI) initializeProject() tea.Cmd {
72
72
73// skipInitializeProject skips project initialization and transitions to the landing view.
73// skipInitializeProject skips project initialization and transitions to the landing view.
74func (m *UI) skipInitializeProject() tea.Cmd {
74func (m *UI) skipInitializeProject() tea.Cmd {
75− // TODO: initialize the project
76 m.setState(uiLanding, uiFocusEditor)
75 m.setState(uiLanding, uiFocusEditor)
77 // mark the project as initialized
76 // mark the project as initialized
78 return m.markProjectInitializedCmd()
77 return m.markProjectInitializedCmd()
@@ -86,7 +85,7 @@ func (m *UI) initializeView() string {
86
85
87 header := s.Header.Render("Would you like to initialize this project?")
86 header := s.Header.Render("Would you like to initialize this project?")
88 path := s.Accent.PaddingLeft(2).Render(cwd)
87 path := s.Accent.PaddingLeft(2).Render(cwd)
89− 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))
88+ desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into a %s file which serves as general context.", initFile))
90 hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
89 hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
91 prompt := s.Content.Render("Would you like to initialize now?")
90 prompt := s.Content.Render("Would you like to initialize now?")
92
91
internal/ui/model/pills.go+47−4
@@ -88,7 +88,7 @@ func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bo
88
88
89 total := len(todos)
89 total := len(todos)
90
90
91− label := t.Pills.TodoLabel.Render("To-Do")
91+ label := t.Pills.TodoLabel.Render("Tasks")
92 progress := t.Pills.TodoProgress.Render(fmt.Sprintf("%d/%d", completed, total))
92 progress := t.Pills.TodoProgress.Render(fmt.Sprintf("%d/%d", completed, total))
93
93
94 var content string
94 var content string
@@ -245,9 +245,52 @@ func (m *UI) pillsAreaHeight() int {
245 return pillsAreaHeight
245 return pillsAreaHeight
246}
246}
247
247
248−// renderPills renders the pills panel and stores it in m.pillsView.
248+// pillsCacheState builds a fingerprint of every input that affects the
249+// rendered pills. Returned alongside the width for reuse by renderPills.
250+func (m *UI) pillsCacheState() string {
251+ if !m.hasSession() {
252+ return "no-session"
253+ }
254+ width := m.layout.pills.Dx()
255+ hasQueue := m.promptQueue > 0
256+
257+ var kb strings.Builder
258+ fmt.Fprintf(&kb, "w=%d;theme=%s;q=%d;exp=%t;sec=%d;",
259+ width, m.themeKey, m.promptQueue, m.pillsExpanded, m.focusedPillSection)
260+ // The in-progress spinner icon changes every animation frame, so fold
261+ // its current view in only while spinning.
262+ if m.todoIsSpinning {
263+ kb.WriteString("spin=")
264+ kb.WriteString(m.todoSpinner.View())
265+ kb.WriteByte(';')
266+ }
267+ for i := range m.session.Todos {
268+ td := &m.session.Todos[i]
269+ fmt.Fprintf(&kb, "t%d=%v|%s|%s;", i, td.Status, td.Content, td.ActiveForm)
270+ }
271+ // The expanded queue list content only affects output when the queue
272+ // section is expanded and focused.
273+ if m.pillsExpanded && m.focusedPillSection == pillSectionQueue && hasQueue {
274+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
275+ for _, it := range m.com.Workspace.AgentQueuedPromptsList(m.session.ID) {
276+ kb.WriteString(it)
277+ kb.WriteByte(';')
278+ }
279+ }
280+ }
281+ return kb.String()
282+}
283+
284+// renderPills renders the pills panel and stores it in m.renderCache.pillsView.
249func (m *UI) renderPills() {
285func (m *UI) renderPills() {
250− m.pillsView = ""
286+ // Skip re-rendering when nothing that affects the output changed.
287+ key := m.pillsCacheState()
288+ if key == m.renderCache.pillsCacheKey {
289+ return
290+ }
291+ m.renderCache.pillsCacheKey = key
292+
293+ m.renderCache.pillsView = ""
251 if !m.hasSession() {
294 if !m.hasSession() {
252 return
295 return
253 }
296 }
@@ -316,5 +359,5 @@ func (m *UI) renderPills() {
316 pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
359 pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
317 }
360 }
318
361
319− m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
362+ m.renderCache.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
320}
363}
internal/ui/model/session.go+5−6
@@ -1,7 +1,6 @@
1package model
1package model
2
2
3import (
3import (
4− "context"
5 "fmt"
4 "fmt"
6 "log/slog"
5 "log/slog"
7 "path/filepath"
6 "path/filepath"
@@ -71,7 +70,7 @@ type SessionFile struct {
71// UI never blocks on the call.
70// UI never blocks on the call.
72func (m *UI) loadSession(sessionID string) tea.Cmd {
71func (m *UI) loadSession(sessionID string) tea.Cmd {
73 load := func() tea.Msg {
72 load := func() tea.Msg {
74− session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
73+ session, err := m.com.Workspace.GetSession(m.ctx, sessionID)
75 if err != nil {
74 if err != nil {
76 return util.ReportError(err)
75 return util.ReportError(err)
77 }
76 }
@@ -81,7 +80,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
81 return util.ReportError(err)
80 return util.ReportError(err)
82 }
81 }
83
82
84− readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
83+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(m.ctx, sessionID)
85 if err != nil {
84 if err != nil {
86 slog.Error("Failed to load read files for session", "error", err)
85 slog.Error("Failed to load read files for session", "error", err)
87 }
86 }
@@ -102,7 +101,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
102// state.
101// state.
103func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
102func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
104 return func() tea.Msg {
103 return func() tea.Msg {
105− if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil {
104+ if err := m.com.Workspace.SetCurrentSession(m.ctx, sessionID); err != nil {
106 slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
105 slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
107 }
106 }
108 return nil
107 return nil
@@ -110,7 +109,7 @@ func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
110}
109}
111
110
112func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
111func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
113− files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
112+ files, err := m.com.Workspace.ListSessionHistory(m.ctx, sessionID)
114 if err != nil {
113 if err != nil {
115 return nil, err
114 return nil, err
116 }
115 }
@@ -264,7 +263,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
264 }
263 }
265
264
266 return func() tea.Msg {
265 return func() tea.Msg {
267− ctx := context.Background()
266+ ctx := m.ctx
268 for _, path := range paths {
267 for _, path := range paths {
269 m.com.Workspace.LSPStart(ctx, path)
268 m.com.Workspace.LSPStart(ctx, path)
270 }
269 }
internal/ui/model/sidebar.go+175−21
@@ -3,9 +3,13 @@ package model
3import (
3import (
4 "cmp"
4 "cmp"
5 "fmt"
5 "fmt"
6+ "hash/fnv"
6 "image"
7 "image"
8+ "sort"
9+ "strconv"
7
10
8 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/logo"
14 "github.com/charmbracelet/crush/internal/ui/logo"
11 uv "github.com/charmbracelet/ultraviolet"
15 uv "github.com/charmbracelet/ultraviolet"
@@ -126,6 +130,139 @@ func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skil
126 return maxFiles, maxLSPs, maxMCPs, maxSkills
130 return maxFiles, maxLSPs, maxMCPs, maxSkills
127}
131}
128
132
133+// sidebarFingerprint computes a hash of every input that affects the
134+// rendered sidebar, so drawSidebar can reuse a cached render when nothing
135+// changed. skillItems and mcpSorted are computed once per render by
136+// drawSidebar and reused by buildSidebar to avoid recomputing them.
137+func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) uint64 {
138+ // Build the fingerprint into a reused scratch buffer using strconv
139+ // appends instead of fmt.Fprintf. This runs on every frame (before the
140+ // sidebar render cache is consulted), so the reflection-based arg boxing
141+ // fmt.Fprintf performs was pure per-frame allocation churn. Appending to
142+ // a retained buffer and hashing it once is allocation-free after warmup.
143+ b := m.renderCache.sidebarFPBuf[:0]
144+ putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
145+ putStr := func(k, v string) { b = append(b, k...); b = append(b, v...) }
146+ putBool := func(k string, v bool) { b = append(b, k...); b = strconv.AppendBool(b, v) }
147+
148+ putInt("w=", int64(area.Dx()))
149+ putInt(";h=", int64(area.Dy()))
150+ putStr(";theme=", m.themeKey)
151+ b = append(b, ';')
152+
153+ if m.session != nil {
154+ putStr("sid=", m.session.ID)
155+ putStr(";title=", m.session.Title)
156+ putInt(";ct=", m.session.CompletionTokens)
157+ putInt(";pt=", m.session.PromptTokens)
158+ b = append(b, ";cost="...)
159+ b = strconv.AppendFloat(b, m.session.Cost, 'f', -1, 64)
160+ putBool(";eu=", m.session.EstimatedUsage)
161+ b = append(b, ';')
162+ }
163+
164+ putStr("cwd=", m.com.Workspace.WorkingDir())
165+ putBool(";hyper=", m.com.IsHyper())
166+ b = append(b, ';')
167+ if m.hyperCredits != nil {
168+ putInt("hc=", int64(*m.hyperCredits))
169+ b = append(b, ';')
170+ }
171+ // The logo string is already cached; fold it in so any change to it (or
172+ // the small-render breakpoint) invalidates the sidebar.
173+ putStr("logo=", m.sidebarLogo)
174+ b = append(b, ';')
175+
176+ if model := m.selectedLargeModel(); model != nil {
177+ putStr("model=", model.ModelCfg.Provider)
178+ putStr("|", model.CatwalkCfg.Name)
179+ putBool("|reason=", model.CatwalkCfg.CanReason)
180+ putInt("|lvls=", int64(len(model.CatwalkCfg.ReasoningLevels)))
181+ putStr("|def=", model.CatwalkCfg.DefaultReasoningEffort)
182+ putBool("|think=", model.ModelCfg.Think)
183+ putStr("|eff=", model.ModelCfg.ReasoningEffort)
184+ putInt("|ctx=", int64(model.CatwalkCfg.ContextWindow))
185+ b = append(b, ';')
186+ } else {
187+ b = append(b, "model=nil;"...)
188+ }
189+
190+ // Files.
191+ for i := range m.sessionFiles {
192+ f := &m.sessionFiles[i]
193+ putInt("f", int64(i))
194+ putStr("=", f.LatestVersion.Path)
195+ putInt("|", int64(f.Additions))
196+ putInt("|", int64(f.Deletions))
197+ putInt("|", f.LatestVersion.UpdatedAt)
198+ b = append(b, ';')
199+ }
200+
201+ // LSPs (sorted by name for a stable fingerprint), with diagnostic counts.
202+ lspNames := make([]string, 0, len(m.lspStates))
203+ for name := range m.lspStates {
204+ lspNames = append(lspNames, name)
205+ }
206+ sort.Strings(lspNames)
207+ for _, name := range lspNames {
208+ st := m.lspStates[name]
209+ // Read the split diagnostic counts from the local mirror instead
210+ // of issuing a per-LSP diagnostics RPC every frame. The mirror is
211+ // refreshed on every LSP event (state or diagnostics change), the
212+ // same events that keep m.lspStates fresh, so this changes exactly
213+ // when the rendered LSP section would.
214+ counts := m.lspDiagnostics[name]
215+ // Hash the error text, not just its presence: the render prints
216+ // the message, so a changed message within the same state must
217+ // invalidate the cache.
218+ putStr("l=", st.Name)
219+ putInt("|", int64(st.State))
220+ putStr("|err=", errText(st.Error))
221+ putInt("|", int64(counts.Error))
222+ putInt("/", int64(counts.Warning))
223+ putInt("/", int64(counts.Hint))
224+ putInt("/", int64(counts.Information))
225+ b = append(b, ';')
226+ }
227+
228+ // MCPs (config order, matching the render).
229+ for _, mcpCfg := range mcpSorted {
230+ state, ok := m.mcpStates[mcpCfg.Name]
231+ if !ok {
232+ continue
233+ }
234+ putStr("m=", state.Name)
235+ putInt("|", int64(state.State))
236+ putStr("|err=", errText(state.Error))
237+ putInt("|", int64(state.Counts.Tools))
238+ putInt("/", int64(state.Counts.Prompts))
239+ putInt("/", int64(state.Counts.Resources))
240+ b = append(b, ';')
241+ }
242+
243+ // Skills.
244+ for _, it := range skillItems {
245+ putStr("s=", it.icon)
246+ putStr("|", it.name)
247+ putStr("|", it.title)
248+ b = append(b, ';')
249+ }
250+
251+ m.renderCache.sidebarFPBuf = b // retain the grown buffer for reuse next frame
252+ h := fnv.New64a()
253+ h.Write(b)
254+ return h.Sum64()
255+}
256+
257+// errText returns err's message, or "" when nil. Used to fold error text
258+// into cache fingerprints.
259+func errText(err error) string {
260+ if err == nil {
261+ return ""
262+ }
263+ return err.Error()
264+}
265+
129// sidebar renders the chat sidebar containing session title, working
266// sidebar renders the chat sidebar containing session title, working
130// directory, model info, file list, LSP status, and MCP status.
267// directory, model info, file list, LSP status, and MCP status.
131func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
268func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
@@ -133,6 +270,25 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
133 return
270 return
134 }
271 }
135
272
273+ // Compute the skill items and sorted MCP list once per render and
274+ // thread them through both the fingerprint and buildSidebar, instead
275+ // of recomputing (clone+sort / map build) them several times.
276+ skillItems := m.skillStatusItems()
277+ mcpSorted := m.com.Config().MCP.Sorted()
278+
279+ key := m.sidebarFingerprint(area, skillItems, mcpSorted)
280+ if !m.renderCache.hasSidebarCache || key != m.renderCache.sidebarCacheKey {
281+ m.renderCache.sidebarView = m.buildSidebar(area, skillItems, mcpSorted)
282+ m.renderCache.sidebarCacheKey = key
283+ m.renderCache.hasSidebarCache = true
284+ }
285+
286+ uv.NewStyledString(m.renderCache.sidebarView).Draw(scr, area)
287+}
288+
289+// buildSidebar renders the sidebar content block as a string. skillItems
290+// and mcpSorted are precomputed by drawSidebar and reused here.
291+func (m *UI) buildSidebar(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) string {
136 const logoHeightBreakpoint = 30
292 const logoHeightBreakpoint = 30
137
293
138 t := m.com.Styles
294 t := m.com.Styles
@@ -179,37 +335,35 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
179 lspsCount := len(m.lspStates)
335 lspsCount := len(m.lspStates)
180
336
181 mcpsCount := 0
337 mcpsCount := 0
182− for _, mcpCfg := range m.com.Config().MCP.Sorted() {
338+ for _, mcpCfg := range mcpSorted {
183 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
339 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
184 mcpsCount++
340 mcpsCount++
185 }
341 }
186 }
342 }
187
343
188− skillsCount := len(m.skillStatusItems())
344+ skillsCount := len(skillItems)
189
345
190 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
346 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
191
347
192 lspSection := m.lspInfo(width, maxLSPs, true)
348 lspSection := m.lspInfo(width, maxLSPs, true)
193− mcpSection := m.mcpInfo(width, maxMCPs, true)
349+ mcpSection := m.mcpInfoSorted(mcpSorted, width, maxMCPs, true)
194− skillsSection := m.skillsInfo(width, maxSkills, true)
350+ skillsSection := m.skillsInfoItems(skillItems, width, maxSkills, true)
195 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
351 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
196
352
197− uv.NewStyledString(
353+ return lipgloss.NewStyle().
198− lipgloss.NewStyle().
354+ MaxWidth(width).
199− MaxWidth(width).
355+ MaxHeight(height).
200− MaxHeight(height).
356+ Render(
201− Render(
357+ lipgloss.JoinVertical(
202− lipgloss.JoinVertical(
358+ lipgloss.Left,
203− lipgloss.Left,
359+ sidebarHeader,
204− sidebarHeader,
360+ filesSection,
205− filesSection,
361+ "",
206− "",
362+ lspSection,
207− lspSection,
363+ "",
208− "",
364+ mcpSection,
209− mcpSection,
365+ "",
210− "",
366+ skillsSection,
211− skillsSection,
212− ),
213 ),
367 ),
214− ).Draw(scr, area)
368+ )
215}
369}
internal/ui/model/skills.go+15−5
@@ -28,7 +28,14 @@ var builtinSkillsCache struct {
28
28
29func cachedBuiltinSkills() []*skills.Skill {
29func cachedBuiltinSkills() []*skills.Skill {
30 builtinSkillsCache.once.Do(func() {
30 builtinSkillsCache.once.Do(func() {
31− builtinSkillsCache.skills = skills.DiscoverBuiltin()
31+ builtin := skills.DiscoverBuiltin()
32+ // Sort once here rather than on every skillStatusItems call: the
33+ // returned slice is shared, so sorting it in place repeatedly is
34+ // wasted work (and mutates shared state on the render hot path).
35+ slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
36+ return strings.Compare(a.Name, b.Name)
37+ })
38+ builtinSkillsCache.skills = builtin
32 })
39 })
33 return builtinSkillsCache.skills
40 return builtinSkillsCache.skills
34}
41}
@@ -36,6 +43,13 @@ func cachedBuiltinSkills() []*skills.Skill {
36// skillsInfo renders the skill discovery status section showing loaded and
43// skillsInfo renders the skill discovery status section showing loaded and
37// invalid skills.
44// invalid skills.
38func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
45func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
46+ return m.skillsInfoItems(m.skillStatusItems(), width, maxItems, isSection)
47+}
48+
49+// skillsInfoItems renders the skill status section from an already-computed
50+// item list, letting callers on the sidebar render path reuse a single
51+// skillStatusItems() result instead of recomputing it.
52+func (m *UI) skillsInfoItems(items []skillStatusItem, width, maxItems int, isSection bool) string {
39 t := m.com.Styles
53 t := m.com.Styles
40
54
41 title := t.Resource.Heading.Render("Skills")
55 title := t.Resource.Heading.Render("Skills")
@@ -43,7 +57,6 @@ func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
43 title = common.Section(t, title, width)
57 title = common.Section(t, title, width)
44 }
58 }
45
59
46− items := m.skillStatusItems()
47 if len(items) == 0 {
60 if len(items) == 0 {
48 list := t.Resource.AdditionalText.Render("None")
61 list := t.Resource.AdditionalText.Render("None")
49 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
62 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
@@ -95,9 +108,6 @@ func (m *UI) skillStatusItems() []skillStatusItem {
95 }
108 }
96
109
97 builtin := cachedBuiltinSkills()
110 builtin := cachedBuiltinSkills()
98− slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
99− return strings.Compare(a.Name, b.Name)
100− })
101 for _, skill := range builtin {
111 for _, skill := range builtin {
102 if _, ok := stateNames[skill.Name]; ok {
112 if _, ok := stateNames[skill.Name]; ok {
103 continue
113 continue
internal/ui/model/ui.go+303−86
@@ -38,6 +38,7 @@ import (
38 "github.com/charmbracelet/crush/internal/fsext"
38 "github.com/charmbracelet/crush/internal/fsext"
39 "github.com/charmbracelet/crush/internal/history"
39 "github.com/charmbracelet/crush/internal/history"
40 "github.com/charmbracelet/crush/internal/home"
40 "github.com/charmbracelet/crush/internal/home"
41+ "github.com/charmbracelet/crush/internal/lsp"
41 "github.com/charmbracelet/crush/internal/message"
42 "github.com/charmbracelet/crush/internal/message"
42 "github.com/charmbracelet/crush/internal/permission"
43 "github.com/charmbracelet/crush/internal/permission"
43 "github.com/charmbracelet/crush/internal/pubsub"
44 "github.com/charmbracelet/crush/internal/pubsub"
@@ -174,7 +175,38 @@ type (
174)
175)
175
176
176// UI represents the main user interface model.
177// UI represents the main user interface model.
178+// renderCache holds the per-frame render memoization the UI consults on
179+// every draw: the sidebar block (drawSidebar re-renders fully each frame),
180+// the resolved large model (an RPC in client mode), and the pills block.
181+// Grouped out of the UI struct so the memoization layer is separable from
182+// model state.
183+type renderCache struct {
184+ // sidebarView is the memoized sidebar block, keyed by sidebarCacheKey
185+ // (a fingerprint of every input affecting its output).
186+ sidebarView string
187+ sidebarCacheKey uint64
188+ hasSidebarCache bool
189+ // sidebarFPBuf is a scratch buffer reused across frames to build the
190+ // sidebar fingerprint without allocating (see sidebarFingerprint).
191+ sidebarFPBuf []byte
192+
193+ // largeModel caches the resolved selected large model — resolving it
194+ // hits the agent coordinator (an RPC in client mode) — keyed by
195+ // largeModelCacheKey. A nil result is never cached so the
196+ // not-ready → ready transition is picked up on the next frame.
197+ largeModel *workspace.AgentModel
198+ largeModelCacheKey string
199+
200+ // pillsView is the memoized pills block, keyed by pillsCacheKey.
201+ pillsView string
202+ pillsCacheKey string
203+}
204+
177type UI struct {
205type UI struct {
206+ // ctx is the program's lifecycle context (cancelled on SIGINT / program
207+ // exit). Async workspace operations launched from tea.Cmd closures use
208+ // it so they can be cancelled at teardown instead of orphaning.
209+ ctx context.Context
178 com *common.Common
210 com *common.Common
179 session *session.Session
211 session *session.Session
180 sessionFiles []SessionFile
212 sessionFiles []SessionFile
@@ -198,9 +230,15 @@ type UI struct {
198
230
199 // themeKey identifies the currently applied theme so applyTheme can
231 // themeKey identifies the currently applied theme so applyTheme can
200 // skip the expensive style rebuild when switching to a provider that
232 // skip the expensive style rebuild when switching to a provider that
201− // resolves to the same theme.
233+ // resolves to the same theme. It encodes both the provider theme family
234+ // and the light/dark background mode.
202 themeKey string
235 themeKey string
203
236
237+ // dark tracks the most recently detected terminal background mode (from
238+ // tea.BackgroundColorMsg). It defaults to true and is only consulted
239+ // when the configured theme is "auto"; see effectiveDark.
240+ dark bool
241+
204 focus uiFocusState
242 focus uiFocusState
205 state uiState
243 state uiState
206
244
@@ -213,6 +251,12 @@ type UI struct {
213 // isCanceling tracks whether the user has pressed escape once to cancel.
251 // isCanceling tracks whether the user has pressed escape once to cancel.
214 isCanceling bool
252 isCanceling bool
215
253
254+ // lastError holds the full text of the most recent error/warning status
255+ // message. Error/warning notifications are truncated to a single line in
256+ // the status bar and are not auto-expired; this retains the untruncated
257+ // text so it can be copied to the clipboard (keyMap.CopyError).
258+ lastError string
259+
216 // bangMode tracks whether the editor is in bang (!) shell mode.
260 // bangMode tracks whether the editor is in bang (!) shell mode.
217 bangMode bool
261 bangMode bool
218 bangWasEmpty bool // true when bang prompt became empty on last keystroke
262 bangWasEmpty bool // true when bang prompt became empty on last keystroke
@@ -265,6 +309,13 @@ type UI struct {
265
309
266 // lsp
310 // lsp
267 lspStates map[string]workspace.LSPClientInfo
311 lspStates map[string]workspace.LSPClientInfo
312+ // lspDiagnostics mirrors the per-LSP diagnostic severity counts
313+ // locally. It is refreshed only when an LSP event arrives (the same
314+ // events that already refresh lspStates), so the sidebar fingerprint
315+ // — computed every frame — can read the split counts without issuing
316+ // a per-LSP diagnostics RPC (which, in client mode, fetches and
317+ // decodes every diagnostic).
318+ lspDiagnostics map[string]lsp.DiagnosticCounts
268
319
269 // mcp
320 // mcp
270 mcpStates map[string]mcp.ClientInfo
321 mcpStates map[string]mcp.ClientInfo
@@ -275,6 +326,10 @@ type UI struct {
275 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
326 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
276 sidebarLogo string
327 sidebarLogo string
277
328
329+ // renderCache holds the per-frame render memoization (sidebar, pills,
330+ // and resolved large-model caches); see the renderCache type.
331+ renderCache renderCache
332+
278 // Notification state
333 // Notification state
279 notifyBackend notification.Backend
334 notifyBackend notification.Backend
280 notifyWindowFocused bool
335 notifyWindowFocused bool
@@ -297,7 +352,6 @@ type UI struct {
297 pillsAutoExpanded bool
352 pillsAutoExpanded bool
298 focusedPillSection pillSection
353 focusedPillSection pillSection
299 promptQueue int
354 promptQueue int
300− pillsView string
301
355
302 // Todo spinner
356 // Todo spinner
303 todoSpinner spinner.Model
357 todoSpinner spinner.Model
@@ -318,7 +372,7 @@ type UI struct {
318}
372}
319
373
320// New creates a new instance of the [UI] model.
374// New creates a new instance of the [UI] model.
321−func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
375+func New(ctx context.Context, com *common.Common, initialSessionID string, continueLast bool) *UI {
322 // Editor components
376 // Editor components
323 ta := textarea.New()
377 ta := textarea.New()
324 ta.SetStyles(com.Styles.Editor.Textarea)
378 ta.SetStyles(com.Styles.Editor.Textarea)
@@ -369,6 +423,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
369 header := newHeader(com)
423 header := newHeader(com)
370
424
371 ui := &UI{
425 ui := &UI{
426+ ctx: ctx,
372 com: com,
427 com: com,
373 dialog: dialog.NewOverlay(),
428 dialog: dialog.NewOverlay(),
374 keyMap: keyMap,
429 keyMap: keyMap,
@@ -379,6 +434,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
379 attachments: attachments,
434 attachments: attachments,
380 todoSpinner: todoSpinner,
435 todoSpinner: todoSpinner,
381 lspStates: make(map[string]workspace.LSPClientInfo),
436 lspStates: make(map[string]workspace.LSPClientInfo),
437+ lspDiagnostics: make(map[string]lsp.DiagnosticCounts),
382 mcpStates: make(map[string]mcp.ClientInfo),
438 mcpStates: make(map[string]mcp.ClientInfo),
383 notifyBackend: notification.NoopBackend{},
439 notifyBackend: notification.NoopBackend{},
384 notifyWindowFocused: true,
440 notifyWindowFocused: true,
@@ -391,8 +447,17 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
391
447
392 // Seed the active theme key from the large model provider so the
448 // Seed the active theme key from the large model provider so the
393 // first model selection can correctly skip a redundant theme swap.
449 // first model selection can correctly skip a redundant theme swap.
450+ // DefaultCommon builds the dark theme at startup, so the mode is seeded
451+ // dark; a tea.BackgroundColorMsg (or a forced "light" config) reconciles
452+ // it to the light palette on the first frame.
453+ ui.dark = true
394 if cfg := com.Config(); cfg != nil {
454 if cfg := com.Config(); cfg != nil {
395− ui.themeKey = styles.ThemeKeyForProvider(cfg.Models[config.SelectedModelTypeLarge].Provider)
455+ // Seed with the dark key because DefaultCommon builds the dark theme;
456+ // reconcileTheme applies the light palette on the first frame when
457+ // the terminal reports a light background or "light" is forced.
458+ ui.themeKey = styles.ThemeKeyForProviderMode(
459+ cfg.Models[config.SelectedModelTypeLarge].Provider, true,
460+ )
396 }
461 }
397
462
398 ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
463 ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
@@ -430,6 +495,9 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
430// Init initializes the UI model.
495// Init initializes the UI model.
431func (m *UI) Init() tea.Cmd {
496func (m *UI) Init() tea.Cmd {
432 var cmds []tea.Cmd
497 var cmds []tea.Cmd
498+ // Ask the terminal for its background color so we can pick the light or
499+ // dark palette (see the tea.BackgroundColorMsg handler in Update).
500+ cmds = append(cmds, tea.RequestBackgroundColor)
433 if m.state == uiOnboarding {
501 if m.state == uiOnboarding {
434 if cmd := m.openModelsDialog(); cmd != nil {
502 if cmd := m.openModelsDialog(); cmd != nil {
435 cmds = append(cmds, cmd)
503 cmds = append(cmds, cmd)
@@ -459,7 +527,7 @@ func (m *UI) loadInitialSession() tea.Cmd {
459 return m.loadSession(m.initialSessionID)
527 return m.loadSession(m.initialSessionID)
460 case m.continueLastSession:
528 case m.continueLastSession:
461 return func() tea.Msg {
529 return func() tea.Msg {
462− sessions, err := m.com.Workspace.ListSessions(context.Background())
530+ sessions, err := m.com.Workspace.ListSessions(m.ctx)
463 if err != nil || len(sessions) == 0 {
531 if err != nil || len(sessions) == 0 {
464 return nil
532 return nil
465 }
533 }
@@ -541,14 +609,32 @@ func (m *UI) updateNotificationBackend() {
541}
609}
542
610
543// shouldSendNotification returns true if notifications should be sent based on
611// shouldSendNotification returns true if notifications should be sent based on
544−// current state. Focus reporting must be supported, window must not be
612+// current state. The window must not be focused and notifications must not be
545−// focused, and notifications must not be disabled in config.
613+// disabled in config. Focus reporting is only required in auto-detection mode:
614+// when the user explicitly chooses a notification style, we honor it even on
615+// terminals that don't support focus events (e.g. SSH sessions).
546func (m *UI) shouldSendNotification() bool {
616func (m *UI) shouldSendNotification() bool {
547 cfg := m.com.Config()
617 cfg := m.com.Config()
548− if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
618+ var style string
619+ if cfg != nil && cfg.Options != nil {
620+ style = cfg.Options.NotificationStyle
621+ }
622+ if style == "disabled" {
549 return false
623 return false
550 }
624 }
551− return m.caps.ReportFocusEvents && !m.notifyWindowFocused
625+ // Never notify while the window is focused. On terminals without focus
626+ // reporting, notifyWindowFocused stays false, so this doesn't suppress.
627+ if m.notifyWindowFocused {
628+ return false
629+ }
630+ // An explicit style (anything other than auto/empty) is honored regardless
631+ // of focus event support.
632+ if style != "" && style != "auto" {
633+ return true
634+ }
635+ // Auto-detection mode: without focus events we can't suppress notifications
636+ // while focused, so we disable them entirely to avoid spamming the user.
637+ return m.caps.ReportFocusEvents
552}
638}
553
639
554// setState changes the UI state and focus.
640// setState changes the UI state and focus.
@@ -571,7 +657,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
571 slog.Error("Failed to load custom commands", "error", err)
657 slog.Error("Failed to load custom commands", "error", err)
572 }
658 }
573 // Append user-invocable skills as commands.
659 // Append user-invocable skills as commands.
574− skillEntries, err := m.com.Workspace.ListSkills(context.Background())
660+ skillEntries, err := m.com.Workspace.ListSkills(m.ctx)
575 if err != nil {
661 if err != nil {
576 slog.Error("Failed to load skill commands", "error", err)
662 slog.Error("Failed to load skill commands", "error", err)
577 }
663 }
@@ -632,7 +718,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
632 m.session = msg.session
718 m.session = msg.session
633 m.sessionFiles = msg.files
719 m.sessionFiles = msg.files
634 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
720 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
635− msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
721+ msgs, err := m.com.Workspace.ListMessages(m.ctx, m.session.ID)
636 if err != nil {
722 if err != nil {
637 cmds = append(cmds, util.ReportError(err))
723 cmds = append(cmds, util.ReportError(err))
638 break
724 break
@@ -772,9 +858,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
772 case pubsub.Event[history.File]:
858 case pubsub.Event[history.File]:
773 cmds = append(cmds, m.handleFileEvent(msg.Payload))
859 cmds = append(cmds, m.handleFileEvent(msg.Payload))
774 case pubsub.Event[app.LSPEvent]:
860 case pubsub.Event[app.LSPEvent]:
775− m.lspStates = m.com.Workspace.LSPGetStates()
861+ m.refreshLSPState()
776 case pubsub.Event[workspace.LSPEvent]:
862 case pubsub.Event[workspace.LSPEvent]:
777− m.lspStates = m.com.Workspace.LSPGetStates()
863+ m.refreshLSPState()
778 case pubsub.Event[skills.Event]:
864 case pubsub.Event[skills.Event]:
779 m.skillStates = msg.Payload.States
865 m.skillStates = msg.Payload.States
780 case pubsub.Event[mcp.Event]:
866 case pubsub.Event[mcp.Event]:
@@ -796,7 +882,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
796 cmds = append(cmds, cmd)
882 cmds = append(cmds, cmd)
797 }
883 }
798 if cmd := m.sendNotification(notification.Notification{
884 if cmd := m.sendNotification(notification.Notification{
799− Title: "Crush is waiting...",
885+ Title: "Crush needs permission",
800 Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
886 Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
801 }); cmd != nil {
887 }); cmd != nil {
802 cmds = append(cmds, cmd)
888 cmds = append(cmds, cmd)
@@ -812,9 +898,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
812 m.sendProgressBar = xstrings.ContainsAnyOf(termVersion, "ghostty", "iterm2", "rio")
898 m.sendProgressBar = xstrings.ContainsAnyOf(termVersion, "ghostty", "iterm2", "rio")
813 }
899 }
814 return m, nil
900 return m, nil
901+ case tea.BackgroundColorMsg:
902+ // The terminal reported its background color; switch to the light
903+ // or dark palette to match (unless the theme is forced via config).
904+ m.dark = msg.IsDark()
905+ m.reconcileTheme()
815 case tea.WindowSizeMsg:
906 case tea.WindowSizeMsg:
816 m.width, m.height = msg.Width, msg.Height
907 m.width, m.height = msg.Width, msg.Height
817 m.updateLayoutAndSize()
908 m.updateLayoutAndSize()
909+ // Fallback for terminals that never answer the background-color
910+ // query: a forced "light"/"dark" config still takes effect here.
911+ m.reconcileTheme()
818 if m.state == uiChat && m.chat.Follow() {
912 if m.state == uiChat && m.chat.Follow() {
819 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
913 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
820 cmds = append(cmds, cmd)
914 cmds = append(cmds, cmd)
@@ -1058,11 +1152,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1058 slog.Error("Error reported", "error", msg.Msg)
1152 slog.Error("Error reported", "error", msg.Msg)
1059 }
1153 }
1060 m.status.SetInfoMsg(msg)
1154 m.status.SetInfoMsg(msg)
1061− ttl := msg.TTL
1155+ // Errors and warnings persist until replaced or dismissed (rather than
1062− if ttl <= 0 {
1156+ // auto-expiring like info/success/update) so the user can read them and
1063− ttl = DefaultStatusTTL
1157+ // copy the full text (ctrl+e). Retain the untruncated message for that.
1158+ if msg.Type == util.InfoTypeError || msg.Type == util.InfoTypeWarn {
1159+ m.lastError = msg.Msg
1160+ } else {
1161+ ttl := msg.TTL
1162+ if ttl <= 0 {
1163+ ttl = DefaultStatusTTL
1164+ }
1165+ cmds = append(cmds, clearInfoMsgCmd(ttl))
1064 }
1166 }
1065− cmds = append(cmds, clearInfoMsgCmd(ttl))
1066 case app.UpdateAvailableMsg:
1167 case app.UpdateAvailableMsg:
1067 text := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
1168 text := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
1068 if msg.IsDevelopment {
1169 if msg.IsDevelopment {
@@ -1127,25 +1228,23 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
1127 msgPtrs[i] = &msgs[i]
1228 msgPtrs[i] = &msgs[i]
1128 }
1229 }
1129 toolResultMap := chat.BuildToolResultMap(msgPtrs)
1230 toolResultMap := chat.BuildToolResultMap(msgPtrs)
1130− if len(msgPtrs) > 0 {
1131− m.lastUserMessageTime = msgPtrs[0].CreatedAt
1132− }
1133
1231
1134 // Add messages to chat with linked tool results
1232 // Add messages to chat with linked tool results
1233+ diffTool := m.com.Config().Options.TUI.DiffTool
1135 items := make([]chat.MessageItem, 0, len(msgs)*2)
1234 items := make([]chat.MessageItem, 0, len(msgs)*2)
1136 for _, msg := range msgPtrs {
1235 for _, msg := range msgPtrs {
1137 switch msg.Role {
1236 switch msg.Role {
1138 case message.User:
1237 case message.User:
1139 m.lastUserMessageTime = msg.CreatedAt
1238 m.lastUserMessageTime = msg.CreatedAt
1140− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1239+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
1141 case message.Assistant:
1240 case message.Assistant:
1142− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1241+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
1143 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1242 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1144 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1243 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1145 items = append(items, infoItem)
1244 items = append(items, infoItem)
1146 }
1245 }
1147 default:
1246 default:
1148− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1247+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
1149 }
1248 }
1150 }
1249 }
1151
1250
@@ -1191,7 +1290,7 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
1191 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
1290 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
1192
1291
1193 // Fetch nested messages.
1292 // Fetch nested messages.
1194− nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
1293+ nestedMsgs, err := m.com.Workspace.ListMessages(m.ctx, agentSessionID)
1195 if err != nil || len(nestedMsgs) == 0 {
1294 if err != nil || len(nestedMsgs) == 0 {
1196 continue
1295 continue
1197 }
1296 }
@@ -1206,7 +1305,7 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
1206 // Extract nested tool items.
1305 // Extract nested tool items.
1207 var nestedTools []chat.ToolMessageItem
1306 var nestedTools []chat.ToolMessageItem
1208 for _, nestedMsg := range nestedMsgPtrs {
1307 for _, nestedMsg := range nestedMsgPtrs {
1209− nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
1308+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap, m.com.Config().Options.TUI.DiffTool)
1210 for _, nestedItem := range nestedItems {
1309 for _, nestedItem := range nestedItems {
1211 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
1310 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
1212 // Mark nested tools as simple (compact) rendering.
1311 // Mark nested tools as simple (compact) rendering.
@@ -1256,7 +1355,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
1256 return nil
1355 return nil
1257 }
1356 }
1258 m.lastUserMessageTime = msg.CreatedAt
1357 m.lastUserMessageTime = msg.CreatedAt
1259− items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1358+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil, m.com.Config().Options.TUI.DiffTool)
1260 for _, item := range items {
1359 for _, item := range items {
1261 if animatable, ok := item.(chat.Animatable); ok {
1360 if animatable, ok := item.(chat.Animatable); ok {
1262 if cmd := animatable.StartAnimation(); cmd != nil {
1361 if cmd := animatable.StartAnimation(); cmd != nil {
@@ -1269,7 +1368,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
1269 cmds = append(cmds, cmd)
1368 cmds = append(cmds, cmd)
1270 }
1369 }
1271 case message.Assistant:
1370 case message.Assistant:
1272− items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1371+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil, m.com.Config().Options.TUI.DiffTool)
1273 for _, item := range items {
1372 for _, item := range items {
1274 if animatable, ok := item.(chat.Animatable); ok {
1373 if animatable, ok := item.(chat.Animatable); ok {
1275 if cmd := animatable.StartAnimation(); cmd != nil {
1374 if cmd := animatable.StartAnimation(); cmd != nil {
@@ -1378,7 +1477,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1378 }
1477 }
1379 }
1478 }
1380 if existingToolItem == nil {
1479 if existingToolItem == nil {
1381− items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1480+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false, m.com.Config().Options.TUI.DiffTool))
1382 }
1481 }
1383 }
1482 }
1384
1483
@@ -1419,18 +1518,13 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
1419
1518
1420 // Find the parent agent tool item.
1519 // Find the parent agent tool item.
1421 var agentItem chat.NestedToolContainer
1520 var agentItem chat.NestedToolContainer
1422− for i := 0; i < m.chat.Len(); i++ {
1521+ if item := m.chat.MessageItem(toolCallID); item != nil {
1423− item := m.chat.MessageItem(toolCallID)
1424− if item == nil {
1425− continue
1426− }
1427 if agent, ok := item.(chat.NestedToolContainer); ok {
1522 if agent, ok := item.(chat.NestedToolContainer); ok {
1428 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1523 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1429 if toolMessageItem.ToolCall().ID == toolCallID {
1524 if toolMessageItem.ToolCall().ID == toolCallID {
1430 // Verify this agent belongs to the correct parent message.
1525 // Verify this agent belongs to the correct parent message.
1431 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1526 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1432 agentItem = agent
1527 agentItem = agent
1433− break
1434 }
1528 }
1435 }
1529 }
1436 }
1530 }
@@ -1455,7 +1549,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
1455 }
1549 }
1456 if !found {
1550 if !found {
1457 // Create a new nested tool item.
1551 // Create a new nested tool item.
1458− nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1552+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false, m.com.Config().Options.TUI.DiffTool)
1459 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1553 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1460 simplifiable.SetCompact(true)
1554 simplifiable.SetCompact(true)
1461 }
1555 }
@@ -1576,7 +1670,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1576 break
1670 break
1577 }
1671 }
1578 cmds = append(cmds, func() tea.Msg {
1672 cmds = append(cmds, func() tea.Msg {
1579− err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1673+ err := m.com.Workspace.AgentSummarize(m.ctx, msg.SessionID)
1580 if err != nil {
1674 if err != nil {
1581 return util.ReportError(err)()
1675 return util.ReportError(err)()
1582 }
1676 }
@@ -1622,7 +1716,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1622 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1716 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1623 return util.ReportError(err)()
1717 return util.ReportError(err)()
1624 }
1718 }
1625− m.com.Workspace.UpdateAgentModel(context.TODO())
1719+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
1720+ slog.Warn("failed to update agent model", "error", err)
1721+ }
1626 status := "disabled"
1722 status := "disabled"
1627 if currentModel.Think {
1723 if currentModel.Think {
1628 status = "enabled"
1724 status = "enabled"
@@ -1661,7 +1757,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1661 cmds = append(cmds, m.disableDockerMCP)
1757 cmds = append(cmds, m.disableDockerMCP)
1662 case dialog.ActionInitializeProject:
1758 case dialog.ActionInitializeProject:
1663 if m.isAgentBusy() {
1759 if m.isAgentBusy() {
1664− cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1760+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before initializing project..."))
1665 break
1761 break
1666 }
1762 }
1667 cmds = append(cmds, m.initializeProject())
1763 cmds = append(cmds, m.initializeProject())
@@ -1697,7 +1793,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1697 }
1793 }
1698
1794
1699 cmds = append(cmds, func() tea.Msg {
1795 cmds = append(cmds, func() tea.Msg {
1700− m.com.Workspace.UpdateAgentModel(context.TODO())
1796+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
1797+ slog.Warn("failed to update agent model", "error", err)
1798+ }
1701 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1799 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1702 })
1800 })
1703 m.dialog.CloseDialog(dialog.ReasoningID)
1801 m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1788,7 +1886,7 @@ func substituteArgs(content string, args map[string]string) string {
1788// OAuth dialog opens.
1886// OAuth dialog opens.
1789func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
1887func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
1790 return func() tea.Msg {
1888 return func() tea.Msg {
1791− ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1889+ ctx, cancel := context.WithTimeout(m.ctx, 15*time.Second)
1792 defer cancel()
1890 defer cancel()
1793 if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
1891 if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
1794 slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
1892 slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
@@ -1823,7 +1921,7 @@ func (m *UI) fetchHyperCredits() tea.Cmd {
1823 }
1921 }
1824
1922
1825 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
1923 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
1826− ctxRefresh, cancelRefresh := context.WithTimeout(context.Background(), 15*time.Second)
1924+ ctxRefresh, cancelRefresh := context.WithTimeout(m.ctx, 15*time.Second)
1827 defer cancelRefresh()
1925 defer cancelRefresh()
1828 if err := m.com.Workspace.RefreshOAuthToken(ctxRefresh, config.ScopeGlobal, hyper.Name); err != nil {
1926 if err := m.com.Workspace.RefreshOAuthToken(ctxRefresh, config.ScopeGlobal, hyper.Name); err != nil {
1829 slog.Warn("Hyper OAuth refresh failed before fetching credits, trying with existing token", "error", err)
1927 slog.Warn("Hyper OAuth refresh failed before fetching credits, trying with existing token", "error", err)
@@ -1832,7 +1930,7 @@ func (m *UI) fetchHyperCredits() tea.Cmd {
1832 }
1930 }
1833 }
1931 }
1834
1932
1835− ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1933+ ctx, cancel := context.WithTimeout(m.ctx, 10*time.Second)
1836 defer cancel()
1934 defer cancel()
1837 credits, err := hyper.FetchCredits(ctx, apiKey)
1935 credits, err := hyper.FetchCredits(ctx, apiKey)
1838 if err != nil {
1936 if err != nil {
@@ -1908,7 +2006,7 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1908 }
2006 }
1909
2007
1910 cmds = append(cmds, func() tea.Msg {
2008 cmds = append(cmds, func() tea.Msg {
1911− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
2009+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
1912 return util.ReportError(err)
2010 return util.ReportError(err)
1913 }
2011 }
1914
2012
@@ -1931,7 +2029,7 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1931 if isOnboarding {
2029 if isOnboarding {
1932 m.setState(uiLanding, uiFocusEditor)
2030 m.setState(uiLanding, uiFocusEditor)
1933 m.com.Config().SetupAgents()
2031 m.com.Config().SetupAgents()
1934− if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
2032+ if err := m.com.Workspace.InitCoderAgent(m.ctx); err != nil {
1935 cmds = append(cmds, util.ReportError(err))
2033 cmds = append(cmds, util.ReportError(err))
1936 }
2034 }
1937 } else if m.com.IsHyper() {
2035 } else if m.com.IsHyper() {
@@ -2033,6 +2131,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
2033 }
2131 }
2034 cmds = append(cmds, util.ReportInfo("Yolo mode "+status))
2132 cmds = append(cmds, util.ReportInfo("Yolo mode "+status))
2035 return true
2133 return true
2134+ case key.Matches(msg, m.keyMap.CopyError):
2135+ if m.lastError == "" {
2136+ return false
2137+ }
2138+ cmds = append(cmds, common.CopyToClipboard(m.lastError, "Error copied to clipboard"))
2139+ return true
2036 }
2140 }
2037 return false
2141 return false
2038 }
2142 }
@@ -2399,6 +2503,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
2399
2503
2400// drawHeader draws the header section of the UI.
2504// drawHeader draws the header section of the UI.
2401func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2505func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2506+ // Sum diagnostics from the local LSP mirror instead of an
2507+ // LSPGetStates RPC per frame; m.lspStates is refreshed on every LSP
2508+ // event, so the total changes exactly when the header would.
2509+ lspErrorCount := 0
2510+ for _, info := range m.lspStates {
2511+ lspErrorCount += info.DiagnosticCount
2512+ }
2402 m.header.drawHeader(
2513 m.header.drawHeader(
2403 scr,
2514 scr,
2404 area,
2515 area,
@@ -2407,6 +2518,7 @@ func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2407 m.detailsOpen,
2518 m.detailsOpen,
2408 area.Dx(),
2519 area.Dx(),
2409 m.hyperCredits,
2520 m.hyperCredits,
2521+ lspErrorCount,
2410 )
2522 )
2411}
2523}
2412
2524
@@ -2458,8 +2570,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
2458 }
2570 }
2459
2571
2460 m.chat.Draw(scr, layout.main)
2572 m.chat.Draw(scr, layout.main)
2461− if layout.pills.Dy() > 0 && m.pillsView != "" {
2573+ if layout.pills.Dy() > 0 && m.renderCache.pillsView != "" {
2462− uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2574+ uv.NewStyledString(m.renderCache.pillsView).Draw(scr, layout.pills)
2463 }
2575 }
2464
2576
2465 editorWidth := scr.Bounds().Dx()
2577 editorWidth := scr.Bounds().Dx()
@@ -2553,16 +2665,36 @@ func (m *UI) View() tea.View {
2553 canvas := uv.NewScreenBuffer(m.width, m.height)
2665 canvas := uv.NewScreenBuffer(m.width, m.height)
2554 v.Cursor = m.Draw(canvas, canvas.Bounds())
2666 v.Cursor = m.Draw(canvas, canvas.Bounds())
2555
2667
2556− content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2668+ // Single pass over the rendered screen: normalize \r\n → \n and trim
2557− contentLines := strings.Split(content, "\n")
2669+ // trailing spaces per line, writing straight into one builder instead
2558− for i, line := range contentLines {
2670+ // of allocating a replaced copy, a line slice, and a joined copy.
2559− // Trim trailing spaces for concise rendering
2671+ rendered := canvas.Render()
2560− contentLines[i] = strings.TrimRight(line, " ")
2672+ var sb strings.Builder
2673+ sb.Grow(len(rendered))
2674+ start := 0
2675+ for i := 0; i <= len(rendered); i++ {
2676+ if i < len(rendered) && rendered[i] != '\n' {
2677+ continue
2678+ }
2679+ line := rendered[start:i]
2680+ // Drop a single trailing '\r' immediately before the newline
2681+ // (the \r\n → \n normalization).
2682+ if len(line) > 0 && line[len(line)-1] == '\r' {
2683+ line = line[:len(line)-1]
2684+ }
2685+ // Trim trailing spaces only (matches strings.TrimRight(line, " ")).
2686+ end := len(line)
2687+ for end > 0 && line[end-1] == ' ' {
2688+ end--
2689+ }
2690+ sb.WriteString(line[:end])
2691+ if i < len(rendered) {
2692+ sb.WriteByte('\n')
2693+ }
2694+ start = i + 1
2561 }
2695 }
2562
2696
2563− content = strings.Join(contentLines, "\n")
2697+ v.Content = sb.String()
2564−
2565− v.Content = content
2566 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2698 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2567 // HACK: use a random percentage to prevent ghostty from hiding it
2699 // HACK: use a random percentage to prevent ghostty from hiding it
2568 // after a timeout.
2700 // after a timeout.
@@ -2616,6 +2748,9 @@ func (m *UI) ShortHelp() []key.Binding {
2616 binds,
2748 binds,
2617 k.Editor.Newline,
2749 k.Editor.Newline,
2618 )
2750 )
2751+ if m.textarea.Value() == "" {
2752+ binds = append(binds, k.Editor.ShellCommand)
2753+ }
2619 case uiFocusMain:
2754 case uiFocusMain:
2620 binds = append(
2755 binds = append(
2621 binds,
2756 binds,
@@ -2629,8 +2764,16 @@ func (m *UI) ShortHelp() []key.Binding {
2629 binds = append(binds, k.Chat.PillLeft)
2764 binds = append(binds, k.Chat.PillLeft)
2630 }
2765 }
2631 }
2766 }
2767+ case uiLanding:
2768+ binds = append(
2769+ binds,
2770+ tab,
2771+ commands,
2772+ k.Models,
2773+ k.Editor.Newline,
2774+ k.Chat.NewSession,
2775+ )
2632 default:
2776 default:
2633− // TODO: other states
2634 // if m.session == nil {
2777 // if m.session == nil {
2635 // no session selected
2778 // no session selected
2636 binds = append(
2779 binds = append(
@@ -2641,6 +2784,10 @@ func (m *UI) ShortHelp() []key.Binding {
2641 )
2784 )
2642 }
2785 }
2643
2786
2787+ if m.lastError != "" {
2788+ binds = append(binds, k.CopyError)
2789+ }
2790+
2644 binds = append(
2791 binds = append(
2645 binds,
2792 binds,
2646 k.Quit,
2793 k.Quit,
@@ -2713,6 +2860,9 @@ func (m *UI) FullHelp() [][]key.Binding {
2713 if m.currentModelSupportsImages() {
2860 if m.currentModelSupportsImages() {
2714 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2861 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2715 }
2862 }
2863+ if m.textarea.Value() == "" {
2864+ editorBinds = append(editorBinds, k.Editor.ShellCommand)
2865+ }
2716 binds = append(binds, editorBinds)
2866 binds = append(binds, editorBinds)
2717 if hasAttachments {
2867 if hasAttachments {
2718 binds = append(
2868 binds = append(
@@ -2748,6 +2898,38 @@ func (m *UI) FullHelp() [][]key.Binding {
2748 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2898 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2749 }
2899 }
2750 }
2900 }
2901+ case uiLanding:
2902+ binds = append(
2903+ binds,
2904+ []key.Binding{
2905+ commands,
2906+ k.Models,
2907+ k.Sessions,
2908+ k.ToggleYolo,
2909+ },
2910+ )
2911+ editorBinds := []key.Binding{
2912+ k.Editor.Newline,
2913+ k.Editor.MentionFile,
2914+ k.Editor.OpenEditor,
2915+ }
2916+ if m.currentModelSupportsImages() {
2917+ editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2918+ }
2919+ if m.textarea.Value() == "" {
2920+ editorBinds = append(editorBinds, k.Editor.ShellCommand)
2921+ }
2922+ binds = append(binds, editorBinds)
2923+ if hasAttachments {
2924+ binds = append(
2925+ binds,
2926+ []key.Binding{
2927+ k.Editor.AttachmentDeleteMode,
2928+ k.Editor.DeleteAllAttachments,
2929+ k.Editor.Escape,
2930+ },
2931+ )
2932+ }
2751 default:
2933 default:
2752 if m.session == nil {
2934 if m.session == nil {
2753 // no session selected
2935 // no session selected
@@ -2768,6 +2950,9 @@ func (m *UI) FullHelp() [][]key.Binding {
2768 if m.currentModelSupportsImages() {
2950 if m.currentModelSupportsImages() {
2769 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2951 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2770 }
2952 }
2953+ if m.textarea.Value() == "" {
2954+ editorBinds = append(editorBinds, k.Editor.ShellCommand)
2955+ }
2771 binds = append(binds, editorBinds)
2956 binds = append(binds, editorBinds)
2772 if hasAttachments {
2957 if hasAttachments {
2773 binds = append(
2958 binds = append(
@@ -2782,13 +2967,11 @@ func (m *UI) FullHelp() [][]key.Binding {
2782 }
2967 }
2783 }
2968 }
2784
2969
2785− binds = append(
2970+ lastRow := []key.Binding{help, k.Quit}
2786− binds,
2971+ if m.lastError != "" {
2787− []key.Binding{
2972+ lastRow = append([]key.Binding{k.CopyError}, lastRow...)
2788− help,
2973+ }
2789− k.Quit,
2974+ binds = append(binds, lastRow)
2790− },
2791− )
2792
2975
2793 return binds
2976 return binds
2794}
2977}
@@ -2934,7 +3117,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
2934 appRect.Min.X += 1
3117 appRect.Min.X += 1
2935 appRect.Max.X -= 1
3118 appRect.Max.X -= 1
2936
3119
2937− if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
3120+ switch m.state {
3121+ case uiOnboarding, uiInitialize, uiLanding:
2938 // extra padding on left and right for these states
3122 // extra padding on left and right for these states
2939 appRect.Min.X += 1
3123 appRect.Min.X += 1
2940 appRect.Max.X -= 1
3124 appRect.Max.X -= 1
@@ -3123,8 +3307,9 @@ func (m *UI) openEditor(value string) tea.Cmd {
3123 return util.ReportError(err)
3307 return util.ReportError(err)
3124 }
3308 }
3125 tmpPath := tmpfile.Name()
3309 tmpPath := tmpfile.Name()
3126− defer tmpfile.Close() //nolint:errcheck
3310+ defer tmpfile.Close()
3127 if _, err := tmpfile.WriteString(value); err != nil {
3311 if _, err := tmpfile.WriteString(value); err != nil {
3312+ _ = os.Remove(tmpPath)
3128 return util.ReportError(err)
3313 return util.ReportError(err)
3129 }
3314 }
3130 cmd, err := editor.Command(
3315 cmd, err := editor.Command(
@@ -3136,6 +3321,7 @@ func (m *UI) openEditor(value string) tea.Cmd {
3136 ),
3321 ),
3137 )
3322 )
3138 if err != nil {
3323 if err != nil {
3324+ _ = os.Remove(tmpPath)
3139 return util.ReportError(err)
3325 return util.ReportError(err)
3140 }
3326 }
3141 return tea.ExecProcess(cmd, func(err error) tea.Msg {
3327 return tea.ExecProcess(cmd, func(err error) tea.Msg {
@@ -3261,9 +3447,9 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
3261
3447
3262 if m.hasSession() {
3448 if m.hasSession() {
3263 // Skip attachment if file was already read and hasn't been modified.
3449 // Skip attachment if file was already read and hasn't been modified.
3264− lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
3450+ lastRead := m.com.Workspace.FileTrackerLastReadTime(m.ctx, m.session.ID, absPath)
3265 if !lastRead.IsZero() {
3451 if !lastRead.IsZero() {
3266− if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
3452+ if info, err := os.Stat(absPath); err == nil && !info.ModTime().After(lastRead) {
3267 return nil
3453 return nil
3268 }
3454 }
3269 }
3455 }
@@ -3274,14 +3460,14 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
3274 m.sessionFileReads = append(m.sessionFileReads, absPath)
3460 m.sessionFileReads = append(m.sessionFileReads, absPath)
3275
3461
3276 // Add file as attachment.
3462 // Add file as attachment.
3277− content, err := os.ReadFile(path)
3463+ content, err := os.ReadFile(absPath)
3278 if err != nil {
3464 if err != nil {
3279 // If it fails, let the LLM handle it later.
3465 // If it fails, let the LLM handle it later.
3280 return nil
3466 return nil
3281 }
3467 }
3282
3468
3283 return message.Attachment{
3469 return message.Attachment{
3284− FilePath: path,
3470+ FilePath: absPath,
3285 FileName: filepath.Base(path),
3471 FileName: filepath.Base(path),
3286 MimeType: mimeOf(content),
3472 MimeType: mimeOf(content),
3287 Content: content,
3473 Content: content,
@@ -3303,7 +3489,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
3303
3489
3304 resourceCmd := func() tea.Msg {
3490 resourceCmd := func() tea.Msg {
3305 contents, err := m.com.Workspace.ReadMCPResource(
3491 contents, err := m.com.Workspace.ReadMCPResource(
3306− context.Background(),
3492+ m.ctx,
3307 item.MCPName,
3493 item.MCPName,
3308 item.URI,
3494 item.URI,
3309 )
3495 )
@@ -3438,12 +3624,40 @@ func (m *UI) cacheSidebarLogo(width int) {
3438// invalidating the markdown renderer cache and re-rendering the entire
3624// invalidating the markdown renderer cache and re-rendering the entire
3439// transcript for no visible change.
3625// transcript for no visible change.
3440func (m *UI) applyThemeForProvider(providerID string) {
3626func (m *UI) applyThemeForProvider(providerID string) {
3441− key := styles.ThemeKeyForProvider(providerID)
3627+ dark := m.effectiveDark()
3628+ key := styles.ThemeKeyForProviderMode(providerID, dark)
3442 if key == m.themeKey {
3629 if key == m.themeKey {
3443 return
3630 return
3444 }
3631 }
3445 m.themeKey = key
3632 m.themeKey = key
3446− m.applyTheme(styles.ThemeForProvider(providerID))
3633+ m.applyTheme(styles.ThemeForProviderMode(providerID, dark))
3634+}
3635+
3636+// effectiveDark reports whether the dark palette should be used. A "dark"
3637+// or "light" theme setting forces the mode; "auto" (the default) follows
3638+// the detected terminal background (m.dark).
3639+func (m *UI) effectiveDark() bool {
3640+ if cfg := m.com.Config(); cfg != nil && cfg.Options != nil && cfg.Options.TUI != nil {
3641+ switch cfg.Options.TUI.Theme {
3642+ case "light":
3643+ return false
3644+ case "dark":
3645+ return true
3646+ }
3647+ }
3648+ return m.dark
3649+}
3650+
3651+// reconcileTheme re-selects the active theme for the current large-model
3652+// provider and background mode, applying it only when it differs from the
3653+// theme already in effect. It is safe to call on every background-color or
3654+// window-size event.
3655+func (m *UI) reconcileTheme() {
3656+ providerID := ""
3657+ if cfg := m.com.Config(); cfg != nil {
3658+ providerID = cfg.Models[config.SelectedModelTypeLarge].Provider
3659+ }
3660+ m.applyThemeForProvider(providerID)
3447}
3661}
3448
3662
3449// applyTheme replaces the active styles with the given theme, drops the
3663// applyTheme replaces the active styles with the given theme, drops the
@@ -3484,7 +3698,7 @@ func (m *UI) refreshStyles() {
3484// return one.
3698// return one.
3485func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3699func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3486 return func() tea.Msg {
3700 return func() tea.Msg {
3487− content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3701+ content, result, err := m.com.Workspace.ReadSkill(m.ctx, skillID)
3488 if err != nil {
3702 if err != nil {
3489 return util.NewErrorMsg(err)
3703 return util.NewErrorMsg(err)
3490 }
3704 }
@@ -3509,7 +3723,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
3509
3723
3510 var cmds []tea.Cmd
3724 var cmds []tea.Cmd
3511 if !m.hasSession() {
3725 if !m.hasSession() {
3512− newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3726+ newSession, err := m.com.Workspace.CreateSession(m.ctx, "New Session")
3513 if err != nil {
3727 if err != nil {
3514 return util.ReportError(err)
3728 return util.ReportError(err)
3515 }
3729 }
@@ -3523,7 +3737,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
3523 m.setState(uiChat, m.focus)
3737 m.setState(uiChat, m.focus)
3524 }
3738 }
3525
3739
3526− ctx := context.Background()
3740+ ctx := m.ctx
3527 cmds = append(cmds, func() tea.Msg {
3741 cmds = append(cmds, func() tea.Msg {
3528 for _, path := range m.sessionFileReads {
3742 for _, path := range m.sessionFileReads {
3529 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
3743 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
@@ -3539,7 +3753,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
3539 // been accepted (HTTP 202) or synchronously with a validation
3753 // been accepted (HTTP 202) or synchronously with a validation
3540 // or transport error. Run failures and cancellation surface
3754 // or transport error. Run failures and cancellation surface
3541 // through SSE-derived events, not this return value.
3755 // through SSE-derived events, not this return value.
3542− err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3756+ err := m.com.Workspace.AgentRun(m.ctx, sessionID, content, attachments...)
3543 if err != nil {
3757 if err != nil {
3544 return util.InfoMsg{
3758 return util.InfoMsg{
3545 Type: util.InfoTypeError,
3759 Type: util.InfoTypeError,
@@ -3563,7 +3777,7 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3563func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cmd {
3777func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cmd {
3564 var cmds []tea.Cmd
3778 var cmds []tea.Cmd
3565 if !m.hasSession() {
3779 if !m.hasSession() {
3566− newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3780+ newSession, err := m.com.Workspace.CreateSession(m.ctx, "New Session")
3567 if err != nil {
3781 if err != nil {
3568 return util.ReportError(err)
3782 return util.ReportError(err)
3569 }
3783 }
@@ -3616,7 +3830,7 @@ func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cm
3616 return shellStreamMsg{PendingID: pendingID, Chunk: chunk, streamCh: streamCh}
3830 return shellStreamMsg{PendingID: pendingID, Chunk: chunk, streamCh: streamCh}
3617 })
3831 })
3618
3832
3619− ctx, cancel := context.WithCancel(context.Background())
3833+ ctx, cancel := context.WithCancel(m.ctx)
3620 m.bangCancel = cancel
3834 m.bangCancel = cancel
3621
3835
3622 cmds = append(cmds, func() tea.Msg {
3836 cmds = append(cmds, func() tea.Msg {
@@ -3903,8 +4117,8 @@ func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3903 case notify.TypeAgentFinished:
4117 case notify.TypeAgentFinished:
3904 var cmds []tea.Cmd
4118 var cmds []tea.Cmd
3905 cmds = append(cmds, m.sendNotification(notification.Notification{
4119 cmds = append(cmds, m.sendNotification(notification.Notification{
3906− Title: "Crush is waiting...",
4120+ Title: "Crush finished",
3907− Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
4121+ Message: fmt.Sprintf("Turn complete — %s", n.SessionTitle),
3908 }))
4122 }))
3909 if m.com.IsHyper() {
4123 if m.com.IsHyper() {
3910 cmds = append(cmds, m.fetchHyperCredits())
4124 cmds = append(cmds, m.fetchHyperCredits())
@@ -3951,12 +4165,14 @@ func (m *UI) newSession() tea.Cmd {
3951 m.pillsExpanded = false
4165 m.pillsExpanded = false
3952 m.pillsAutoExpanded = false
4166 m.pillsAutoExpanded = false
3953 m.promptQueue = 0
4167 m.promptQueue = 0
3954− m.pillsView = ""
4168+ m.renderCache.pillsView = ""
4169+ m.renderCache.pillsCacheKey = ""
4170+ m.renderCache.hasSidebarCache = false
3955 m.historyReset()
4171 m.historyReset()
3956 agenttools.ResetCache()
4172 agenttools.ResetCache()
3957 return tea.Batch(
4173 return tea.Batch(
3958 func() tea.Msg {
4174 func() tea.Msg {
3959− m.com.Workspace.LSPStopAll(context.Background())
4175+ m.com.Workspace.LSPStopAll(m.ctx)
3960 return nil
4176 return nil
3961 },
4177 },
3962 m.loadPromptHistory(),
4178 m.loadPromptHistory(),
@@ -4251,8 +4467,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
4251 load := func() tea.Msg {
4467 load := func() tea.Msg {
4252 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
4468 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
4253 if err != nil {
4469 if err != nil {
4254− // TODO: make this better
4470+ return util.ReportError(fmt.Errorf("failed to load MCP prompt %q from %q: %w", promptID, clientID, err))()
4255− return util.ReportError(err)()
4256 }
4471 }
4257
4472
4258 if prompt == "" {
4473 if prompt == "" {
@@ -4276,7 +4491,9 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
4276
4491
4277func (m *UI) handleStateChanged() tea.Cmd {
4492func (m *UI) handleStateChanged() tea.Cmd {
4278 return func() tea.Msg {
4493 return func() tea.Msg {
4279− m.com.Workspace.UpdateAgentModel(context.Background())
4494+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
4495+ slog.Warn("failed to update agent model", "error", err)
4496+ }
4280 return mcpStateChangedMsg{
4497 return mcpStateChangedMsg{
4281 states: m.com.Workspace.MCPGetStates(),
4498 states: m.com.Workspace.MCPGetStates(),
4282 }
4499 }
@@ -4317,7 +4534,7 @@ func (m *UI) copyChatHighlight() tea.Cmd {
4317}
4534}
4318
4535
4319func (m *UI) enableDockerMCP() tea.Msg {
4536func (m *UI) enableDockerMCP() tea.Msg {
4320− ctx := context.Background()
4537+ ctx := m.ctx
4321 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
4538 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
4322 return util.ReportError(err)()
4539 return util.ReportError(err)()
4323 }
4540 }
internal/ui/styles/grad.go+51−2
@@ -2,13 +2,52 @@ package styles
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "hash/fnv"
5 "image/color"
6 "image/color"
6 "strings"
7 "strings"
8+ "sync"
7
9
8 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
9 "github.com/rivo/uniseg"
11 "github.com/rivo/uniseg"
10)
12)
11
13
14+// gradCache memoizes the per-cluster rendered output of ForegroundGrad. The
15+// function walks grapheme clusters (uniseg), blends a color ramp and renders
16+// each cluster on every call, and it is invoked every frame by dialog titles
17+// and similar constant text. Results are keyed by a fingerprint of every input
18+// that affects the output.
19+var gradCache sync.Map // map[uint64][]string
20+
21+// gradKey derives a stable cache key from all inputs that affect the rendered
22+// gradient output.
23+func gradKey(base lipgloss.Style, input string, bold bool, color1, color2 color.Color) uint64 {
24+ h := fnv.New64a()
25+ h.Write([]byte(input))
26+ h.Write([]byte{0})
27+ writeColor := func(c color.Color) {
28+ var buf [8]byte
29+ if c != nil {
30+ r, g, b, a := c.RGBA()
31+ buf[0] = byte(r >> 8)
32+ buf[1] = byte(g >> 8)
33+ buf[2] = byte(b >> 8)
34+ buf[3] = byte(a >> 8)
35+ }
36+ h.Write(buf[:4])
37+ }
38+ writeColor(color1)
39+ writeColor(color2)
40+ if bold {
41+ h.Write([]byte{1})
42+ } else {
43+ h.Write([]byte{0})
44+ }
45+ // base carries the remaining style attributes (e.g. background); include a
46+ // fingerprint so distinct bases never collide.
47+ fmt.Fprintf(h, "%v", base)
48+ return h.Sum64()
49+}
50+
12// ForegroundGrad returns a slice of strings representing the input string
51// ForegroundGrad returns a slice of strings representing the input string
13// rendered with a horizontal gradient foreground from color1 to color2. Each
52// rendered with a horizontal gradient foreground from color1 to color2. Each
14// string in the returned slice corresponds to a grapheme cluster in the input
53// string in the returned slice corresponds to a grapheme cluster in the input
@@ -17,10 +56,20 @@ func ForegroundGrad(base lipgloss.Style, input string, bold bool, color1, color2
17 if input == "" {
56 if input == "" {
18 return []string{""}
57 return []string{""}
19 }
58 }
59+ key := gradKey(base, input, bold, color1, color2)
60+ if cached, ok := gradCache.Load(key); ok {
61+ return cached.([]string)
62+ }
63+ result := foregroundGrad(base, input, bold, color1, color2)
64+ gradCache.Store(key, result)
65+ return result
66+}
67+
68+func foregroundGrad(base lipgloss.Style, input string, bold bool, color1, color2 color.Color) []string {
20 if len(input) == 1 {
69 if len(input) == 1 {
21 style := base.Foreground(color1)
70 style := base.Foreground(color1)
22 if bold {
71 if bold {
23− style.Bold(true)
72+ style = style.Bold(true)
24 }
73 }
25 return []string{style.Render(input)}
74 return []string{style.Render(input)}
26 }
75 }
@@ -34,7 +83,7 @@ func ForegroundGrad(base lipgloss.Style, input string, bold bool, color1, color2
34 for i, c := range ramp {
83 for i, c := range ramp {
35 style := base.Foreground(c)
84 style := base.Foreground(c)
36 if bold {
85 if bold {
37− style.Bold(true)
86+ style = style.Bold(true)
38 }
87 }
39 clusters[i] = style.Render(clusters[i])
88 clusters[i] = style.Render(clusters[i])
40 }
89 }
internal/ui/styles/quickstyle.go+7−21
@@ -550,26 +550,12 @@ func quickStyle(o quickStyleOpts) Styles {
550 Foreground(o.fgMoreSubtle).
550 Foreground(o.fgMoreSubtle).
551 Background(o.bgBase),
551 Background(o.bgBase),
552 },
552 },
553− InsertLine: diffview.LineStyle{
553+ // Insert/delete colors are derived from the semantic palette (green
554− LineNumber: lipgloss.NewStyle().
554+ // for inserts, red for deletes) washed over the base background, so a
555− Foreground(lipgloss.Color("#629657")).
555+ // future light theme adapts without hardcoded hex. Code foreground is
556− Background(lipgloss.Color("#2b322a")),
556+ // left unset to inherit the syntax highlighting.
557− Symbol: lipgloss.NewStyle().
557+ InsertLine: diffview.ChangeLineStyle(o.success, o.bgBase, nil),
558− Foreground(lipgloss.Color("#629657")).
558+ DeleteLine: diffview.ChangeLineStyle(o.error, o.bgBase, nil),
559− Background(lipgloss.Color("#323931")),
560− Code: lipgloss.NewStyle().
561− Background(lipgloss.Color("#323931")),
562− },
563− DeleteLine: diffview.LineStyle{
564− LineNumber: lipgloss.NewStyle().
565− Foreground(lipgloss.Color("#a45c59")).
566− Background(lipgloss.Color("#312929")),
567− Symbol: lipgloss.NewStyle().
568− Foreground(lipgloss.Color("#a45c59")).
569− Background(lipgloss.Color("#383030")),
570− Code: lipgloss.NewStyle().
571− Background(lipgloss.Color("#383030")),
572− },
573 Filename: diffview.LineStyle{
559 Filename: diffview.LineStyle{
574 LineNumber: lipgloss.NewStyle().
560 LineNumber: lipgloss.NewStyle().
575 Foreground(o.fgSubtle).
561 Foreground(o.fgSubtle).
@@ -618,7 +604,7 @@ func quickStyle(o quickStyleOpts) Styles {
618 s.Tool.IconPending = base.Foreground(o.successMostSubtle).SetString(ToolPending)
604 s.Tool.IconPending = base.Foreground(o.successMostSubtle).SetString(ToolPending)
619 s.Tool.IconSuccess = base.Foreground(o.success).SetString(ToolSuccess)
605 s.Tool.IconSuccess = base.Foreground(o.success).SetString(ToolSuccess)
620 s.Tool.IconError = base.Foreground(o.error).SetString(ToolError)
606 s.Tool.IconError = base.Foreground(o.error).SetString(ToolError)
621− s.Tool.IconCancelled = muted.SetString(ToolPending)
607+ s.Tool.IconCancelled = muted.SetString(ToolCancelled)
622
608
623 s.Tool.NameNormal = base.Foreground(o.info)
609 s.Tool.NameNormal = base.Foreground(o.info)
624 s.Tool.NameNested = base.Foreground(o.info)
610 s.Tool.NameNested = base.Foreground(o.info)
internal/ui/styles/styles.go+4−3
@@ -25,9 +25,10 @@ const (
25
25
26 ArrowRightIcon string = "→"
26 ArrowRightIcon string = "→"
27
27
28− ToolPending string = "●"
28+ ToolPending string = "●"
29− ToolSuccess string = "✓"
29+ ToolSuccess string = "✓"
30− ToolError string = "×"
30+ ToolError string = "×"
31+ ToolCancelled string = "⊘"
31
32
32 RadioOn string = "◉"
33 RadioOn string = "◉"
33 RadioOff string = "○"
34 RadioOff string = "○"
internal/ui/styles/themes.go+131−12
@@ -19,22 +19,76 @@ func ThemeKeyForProvider(providerID string) string {
19 }
19 }
20}
20}
21
21
22−// ThemeForProvider returns the Styles associated with the given provider
22+// ThemeForProvider returns the dark Styles associated with the given
23−// ID. Unknown or empty provider IDs yield the default Charmtone Pantera
23+// provider ID. Unknown or empty provider IDs yield the default Charmtone
24−// theme.
24+// Pantera theme. Retained for callers that always want the dark theme
25+// (e.g. the non-interactive CLI); the interactive TUI uses the
26+// background-aware [ThemeForProviderMode].
25func ThemeForProvider(providerID string) Styles {
27func ThemeForProvider(providerID string) Styles {
28+ return ThemeForProviderMode(providerID, true)
29+}
30+
31+// ThemeKeyForProviderMode returns a stable identifier for the theme
32+// associated with the given provider ID and background mode (dark/light).
33+// The key incorporates the mode so callers can detect when a background
34+// change would actually alter the active theme and skip the rebuild
35+// otherwise.
36+func ThemeKeyForProviderMode(providerID string, dark bool) string {
37+ base := ThemeKeyForProvider(providerID)
38+ if dark {
39+ return base + "-dark"
40+ }
41+ return base + "-light"
42+}
43+
44+// ThemeForProviderMode returns the Styles for the given provider ID in the
45+// requested background mode. When dark is false a light-background palette
46+// is returned so the UI stays legible on light terminals.
47+func ThemeForProviderMode(providerID string, dark bool) Styles {
26 switch ThemeKeyForProvider(providerID) {
48 switch ThemeKeyForProvider(providerID) {
27 case "hyper":
49 case "hyper":
28− return HypercrushObsidiana()
50+ if dark {
51+ return HypercrushObsidiana()
52+ }
53+ return HypercrushLatte()
29 default:
54 default:
30− return CharmtonePantera()
55+ if dark {
56+ return CharmtonePantera()
57+ }
58+ return CharmtoneLatte()
31 }
59 }
32}
60}
33
61
34// CharmtonePantera returns the Charmtone dark theme. It's the default style
62// CharmtonePantera returns the Charmtone dark theme. It's the default style
35// for the UI.
63// for the UI.
36func CharmtonePantera() Styles {
64func CharmtonePantera() Styles {
37− s := quickStyle(quickStyleOpts{
65+ return buildCharmtone(charmtoneDarkOpts())
66+}
67+
68+// CharmtoneLatte returns the Charmtone light theme, used on light-background
69+// terminals. It mirrors the dark theme's structure but inverts the neutral
70+// ramp and swaps the neon accent/semantic colors for darker shades that stay
71+// legible on a light background.
72+//
73+// NOTE: this palette is a first pass tuned by hand; the exact accent/semantic
74+// color choices are worth a visual review on real light terminals.
75+func CharmtoneLatte() Styles {
76+ return buildCharmtone(charmtoneLightOpts())
77+}
78+
79+// HypercrushObsidiana returns the Hypercrush dark theme.
80+func HypercrushObsidiana() Styles {
81+ return CharmtonePantera()
82+}
83+
84+// HypercrushLatte returns the Hypercrush light theme.
85+func HypercrushLatte() Styles {
86+ return CharmtoneLatte()
87+}
88+
89+// charmtoneDarkOpts returns the color options for the Charmtone dark theme.
90+func charmtoneDarkOpts() quickStyleOpts {
91+ return quickStyleOpts{
38 primary: charmtone.Charple,
92 primary: charmtone.Charple,
39 secondary: charmtone.Dolly,
93 secondary: charmtone.Dolly,
40 accent: charmtone.Bok,
94 accent: charmtone.Bok,
@@ -86,7 +140,77 @@ func CharmtonePantera() Styles {
86 ansiBrightMagenta: charmtone.Blush,
140 ansiBrightMagenta: charmtone.Blush,
87 ansiBrightCyan: charmtone.Sardine,
141 ansiBrightCyan: charmtone.Sardine,
88 ansiBrightWhite: charmtone.Salt,
142 ansiBrightWhite: charmtone.Salt,
89− })
143+ }
144+}
145+
146+// charmtoneLightOpts returns the color options for the Charmtone light theme.
147+// The neutral ramp is inverted (light backgrounds, dark foregrounds) and the
148+// neon accents/semantics from the dark theme are replaced with darker,
149+// higher-contrast shades so text stays readable on a light background.
150+func charmtoneLightOpts() quickStyleOpts {
151+ return quickStyleOpts{
152+ primary: charmtone.Charple, // saturated purple reads on white
153+ secondary: charmtone.Grape,
154+ accent: charmtone.Zinc,
155+ keyword: charmtone.Prince,
156+
157+ // Foregrounds: dark on light, decreasing visibility.
158+ fgBase: charmtone.Pepper,
159+ fgSubtle: charmtone.Iron,
160+ fgMoreSubtle: charmtone.Oyster,
161+ fgMostSubtle: charmtone.Steam,
162+
163+ onPrimary: charmtone.Butter, // light text on the purple primary
164+
165+ // Backgrounds: light base, increasingly visible (darker) greys.
166+ bgBase: charmtone.Salt,
167+ bgLeastVisible: charmtone.Sash,
168+ bgLessVisible: charmtone.Steep,
169+ bgMostVisible: charmtone.Smoke,
170+
171+ separator: charmtone.Steep,
172+
173+ destructive: charmtone.Coral,
174+ error: charmtone.Sriracha,
175+ warningSubtle: charmtone.Yam,
176+ warning: charmtone.Tang,
177+ denied: charmtone.Paprika,
178+ busy: charmtone.Cumin,
179+ info: charmtone.Damson,
180+ infoMoreSubtle: charmtone.Malibu,
181+ infoMostSubtle: charmtone.Anchovy,
182+ success: charmtone.Pickle,
183+ successMoreSubtle: charmtone.Guac,
184+ successMostSubtle: charmtone.Zinc,
185+
186+ // ANSI 16-color palette tuned for legibility on a light background:
187+ // normal colors are darker/saturated, brights slightly lighter.
188+ ansiBlack: charmtone.Pepper,
189+ ansiRed: charmtone.Pom,
190+ ansiGreen: charmtone.Pickle,
191+ ansiYellow: charmtone.Cumin,
192+ ansiBlue: charmtone.Oceania,
193+ ansiMagenta: charmtone.Grape,
194+ ansiCyan: charmtone.Damson,
195+ ansiWhite: charmtone.Oyster,
196+
197+ ansiBrightBlack: charmtone.Steam,
198+ ansiBrightRed: charmtone.Sriracha,
199+ ansiBrightGreen: charmtone.Guac,
200+ ansiBrightYellow: charmtone.Tang,
201+ ansiBrightBlue: charmtone.Sapphire,
202+ ansiBrightMagenta: charmtone.Urchin,
203+ ansiBrightCyan: charmtone.Zinc,
204+ ansiBrightWhite: charmtone.Iron,
205+ }
206+}
207+
208+// buildCharmtone builds a Styles value from the given color options and
209+// applies the shared bang-prompt and shell-bar accent overrides. These
210+// overrides use saturated purple accents that read on both light and dark
211+// backgrounds.
212+func buildCharmtone(opts quickStyleOpts) Styles {
213+ s := quickStyle(opts)
90
214
91 // Bang ! prompt overrides - use Salt/Hazy/Larple colors.
215 // Bang ! prompt overrides - use Salt/Hazy/Larple colors.
92 s.Editor.PromptBangIconFocused = s.Editor.PromptBangIconFocused.
216 s.Editor.PromptBangIconFocused = s.Editor.PromptBangIconFocused.
@@ -109,8 +233,3 @@ func CharmtonePantera() Styles {
109
233
110 return s
234 return s
111}
235}
112−
113−// HypercrushObsidiana returns the Hypercrush dark theme.
114−func HypercrushObsidiana() Styles {
115− return CharmtonePantera()
116−}
internal/ui/xchroma/chroma.go+37−19
@@ -13,37 +13,55 @@ import (
13// Lip Gloss for foreground styling, while keeping a forced background color.
13// Lip Gloss for foreground styling, while keeping a forced background color.
14func Formatter(bgColor color.Color, processValue func(string) string) chroma.Formatter {
14func Formatter(bgColor color.Color, processValue func(string) string) chroma.Formatter {
15 return chroma.FormatterFunc(func(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
15 return chroma.FormatterFunc(func(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
16+ // Precompute the base background style once, then memoize the
17+ // resolved lipgloss.Style per token type so the per-token work is
18+ // just a map lookup + one Render.
19+ baseStyle := lipgloss.NewStyle().Background(bgColor)
20+
21+ type tokenEntry struct {
22+ style lipgloss.Style
23+ zero bool
24+ }
25+ palette := make(map[chroma.TokenType]tokenEntry)
26+
16 for token := it(); token != chroma.EOF; token = it() {
27 for token := it(); token != chroma.EOF; token = it() {
17 value := token.Value
28 value := token.Value
18 if processValue != nil {
29 if processValue != nil {
19 value = processValue(value)
30 value = processValue(value)
20 }
31 }
21
32
22− entry := style.Get(token.Type)
33+ te, ok := palette[token.Type]
23− if entry.IsZero() {
34+ if !ok {
35+ entry := style.Get(token.Type)
36+ if entry.IsZero() {
37+ te = tokenEntry{zero: true}
38+ } else {
39+ s := baseStyle
40+ if entry.Bold == chroma.Yes {
41+ s = s.Bold(true)
42+ }
43+ if entry.Underline == chroma.Yes {
44+ s = s.Underline(true)
45+ }
46+ if entry.Italic == chroma.Yes {
47+ s = s.Italic(true)
48+ }
49+ if entry.Colour.IsSet() {
50+ s = s.Foreground(lipgloss.Color(entry.Colour.String()))
51+ }
52+ te = tokenEntry{style: s}
53+ }
54+ palette[token.Type] = te
55+ }
56+
57+ if te.zero {
24 if _, err := fmt.Fprint(w, value); err != nil {
58 if _, err := fmt.Fprint(w, value); err != nil {
25 return err
59 return err
26 }
60 }
27 continue
61 continue
28 }
62 }
29
63
30− s := lipgloss.NewStyle().
64+ if _, err := fmt.Fprint(w, te.style.Render(value)); err != nil {
31− Background(bgColor)
32−
33− if entry.Bold == chroma.Yes {
34− s = s.Bold(true)
35− }
36− if entry.Underline == chroma.Yes {
37− s = s.Underline(true)
38− }
39− if entry.Italic == chroma.Yes {
40− s = s.Italic(true)
41− }
42− if entry.Colour.IsSet() {
43− s = s.Foreground(lipgloss.Color(entry.Colour.String()))
44− }
45−
46− if _, err := fmt.Fprint(w, s.Render(value)); err != nil {
47 return err
65 return err
48 }
66 }
49 }
67 }
schema.json+10
@@ -748,6 +748,16 @@
748 ],
748 ],
749 "description": "Diff mode for the TUI interface"
749 "description": "Diff mode for the TUI interface"
750 },
750 },
751+ "theme": {
752+ "type": "string",
753+ "enum": [
754+ "auto",
755+ "dark",
756+ "light"
757+ ],
758+ "description": "TUI color scheme",
759+ "default": "auto"
760+ },
751 "completions": {
761 "completions": {
752 "$ref": "#/$defs/Completions",
762 "$ref": "#/$defs/Completions",
753 "description": "Completions UI options"
763 "description": "Completions UI options"
vendor/charm.land/bubbles/v2/LICENSEnew file+21
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2020-2026 Charmbracelet, Inc.
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
vendor/charm.land/bubbles/v2/cursor/cursor.gonew file+241
@@ -0,0 +1,241 @@
1+// Package cursor provides a virtual cursor to support the textinput and
2+// textarea elements.
3+package cursor
4+
5+import (
6+ "context"
7+ "sync/atomic"
8+ "time"
9+
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+)
13+
14+const defaultBlinkSpeed = time.Millisecond * 530
15+
16+// Internal ID management. Used during animating to ensure that frame messages
17+// are received only by spinner components that sent them.
18+var lastID int64
19+
20+func nextID() int {
21+ return int(atomic.AddInt64(&lastID, 1))
22+}
23+
24+// initialBlinkMsg initializes cursor blinking.
25+type initialBlinkMsg struct{}
26+
27+// BlinkMsg signals that the cursor should blink. It contains metadata that
28+// allows us to tell if the blink message is the one we're expecting.
29+type BlinkMsg struct {
30+ id int
31+ tag int
32+}
33+
34+// blinkCanceled is sent when a blink operation is canceled.
35+type blinkCanceled struct{}
36+
37+// blinkCtx manages cursor blinking.
38+type blinkCtx struct {
39+ ctx context.Context
40+ cancel context.CancelFunc
41+}
42+
43+// Mode describes the behavior of the cursor.
44+type Mode int
45+
46+// Available cursor modes.
47+const (
48+ CursorBlink Mode = iota
49+ CursorStatic
50+ CursorHide
51+)
52+
53+// String returns the cursor mode in a human-readable format. This method is
54+// provisional and for informational purposes only.
55+func (c Mode) String() string {
56+ return [...]string{
57+ "blink",
58+ "static",
59+ "hidden",
60+ }[c]
61+}
62+
63+// Model is the Bubble Tea model for this cursor element.
64+type Model struct {
65+ // Style styles the cursor block.
66+ Style lipgloss.Style
67+
68+ // TextStyle is the style used for the cursor when it is blinking
69+ // (hidden), i.e. displaying normal text.
70+ TextStyle lipgloss.Style
71+
72+ // BlinkSpeed is the speed at which the cursor blinks. This has no effect
73+ // unless [CursorMode] is not set to [CursorBlink].
74+ BlinkSpeed time.Duration
75+
76+ // IsBlinked is the state of the cursor blink. When true, the cursor is
77+ // hidden.
78+ IsBlinked bool
79+
80+ // char is the character under the cursor
81+ char string
82+
83+ // The ID of this Model as it relates to other cursors
84+ id int
85+
86+ // focus indicates whether the containing input is focused
87+ focus bool
88+
89+ // Used to manage cursor blink
90+ blinkCtx *blinkCtx
91+
92+ // The ID of the blink message we're expecting to receive.
93+ blinkTag int
94+
95+ // mode determines the behavior of the cursor
96+ mode Mode
97+}
98+
99+// New creates a new model with default settings.
100+func New() Model {
101+ return Model{
102+ id: nextID(),
103+ BlinkSpeed: defaultBlinkSpeed,
104+ IsBlinked: true,
105+ mode: CursorBlink,
106+
107+ blinkCtx: &blinkCtx{
108+ ctx: context.Background(),
109+ },
110+ }
111+}
112+
113+// Update updates the cursor.
114+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
115+ switch msg := msg.(type) {
116+ case initialBlinkMsg:
117+ // We accept all initialBlinkMsgs generated by the Blink command.
118+
119+ if m.mode != CursorBlink || !m.focus {
120+ return m, nil
121+ }
122+
123+ cmd := m.Blink()
124+ return m, cmd
125+
126+ case tea.FocusMsg:
127+ return m, m.Focus()
128+
129+ case tea.BlurMsg:
130+ m.Blur()
131+ return m, nil
132+
133+ case BlinkMsg:
134+ // We're choosy about whether to accept blinkMsgs so that our cursor
135+ // only exactly when it should.
136+
137+ // Is this model blink-able?
138+ if m.mode != CursorBlink || !m.focus {
139+ return m, nil
140+ }
141+
142+ // Were we expecting this blink message?
143+ if msg.id != m.id || msg.tag != m.blinkTag {
144+ return m, nil
145+ }
146+
147+ var cmd tea.Cmd
148+ if m.mode == CursorBlink {
149+ m.IsBlinked = !m.IsBlinked
150+ cmd = m.Blink()
151+ }
152+ return m, cmd
153+
154+ case blinkCanceled: // no-op
155+ return m, nil
156+ }
157+ return m, nil
158+}
159+
160+// Mode returns the model's cursor mode. For available cursor modes, see
161+// type Mode.
162+func (m Model) Mode() Mode {
163+ return m.mode
164+}
165+
166+// SetMode sets the model's cursor mode. This method returns a command.
167+//
168+// For available cursor modes, see type CursorMode.
169+func (m *Model) SetMode(mode Mode) tea.Cmd {
170+ // Adjust the mode value if it's value is out of range
171+ if mode < CursorBlink || mode > CursorHide {
172+ return nil
173+ }
174+ m.mode = mode
175+ m.IsBlinked = m.mode == CursorHide || !m.focus
176+ if mode == CursorBlink {
177+ return Blink
178+ }
179+ return nil
180+}
181+
182+// Blink is a command used to manage cursor blinking.
183+func (m *Model) Blink() tea.Cmd {
184+ if m.mode != CursorBlink {
185+ return nil
186+ }
187+
188+ if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
189+ m.blinkCtx.cancel()
190+ }
191+
192+ ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
193+ m.blinkCtx.cancel = cancel
194+
195+ m.blinkTag++
196+ blinkMsg := BlinkMsg{id: m.id, tag: m.blinkTag}
197+
198+ return func() tea.Msg {
199+ defer cancel()
200+ <-ctx.Done()
201+ if ctx.Err() == context.DeadlineExceeded {
202+ return blinkMsg
203+ }
204+ return blinkCanceled{}
205+ }
206+}
207+
208+// Blink is a command used to initialize cursor blinking.
209+func Blink() tea.Msg {
210+ return initialBlinkMsg{}
211+}
212+
213+// Focus focuses the cursor to allow it to blink if desired.
214+func (m *Model) Focus() tea.Cmd {
215+ m.focus = true
216+ m.IsBlinked = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
217+
218+ if m.mode == CursorBlink && m.focus {
219+ return m.Blink()
220+ }
221+ return nil
222+}
223+
224+// Blur blurs the cursor.
225+func (m *Model) Blur() {
226+ m.focus = false
227+ m.IsBlinked = true
228+}
229+
230+// SetChar sets the character under the cursor.
231+func (m *Model) SetChar(char string) {
232+ m.char = char
233+}
234+
235+// View displays the cursor.
236+func (m Model) View() string {
237+ if m.IsBlinked {
238+ return m.TextStyle.Inline(true).Render(m.char)
239+ }
240+ return m.Style.Inline(true).Reverse(true).Render(m.char)
241+}
vendor/charm.land/bubbles/v2/filepicker/filepicker.gonew file+529
@@ -0,0 +1,529 @@
1+// Package filepicker provides a file picker component for Bubble Tea
2+// applications.
3+package filepicker
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strconv"
11+ "strings"
12+ "sync/atomic"
13+
14+ "charm.land/bubbles/v2/key"
15+ tea "charm.land/bubbletea/v2"
16+ "charm.land/lipgloss/v2"
17+ "github.com/dustin/go-humanize"
18+)
19+
20+var lastID int64
21+
22+func nextID() int {
23+ return int(atomic.AddInt64(&lastID, 1))
24+}
25+
26+// New returns a new filepicker model with default styling and key bindings.
27+func New() Model {
28+ return Model{
29+ id: nextID(),
30+ CurrentDirectory: ".",
31+ Cursor: ">",
32+ AllowedTypes: []string{},
33+ selected: 0,
34+ ShowPermissions: true,
35+ ShowSize: true,
36+ ShowHidden: false,
37+ DirAllowed: false,
38+ FileAllowed: true,
39+ AutoHeight: true,
40+ height: 0,
41+ maxIdx: 0,
42+ minIdx: 0,
43+ selectedStack: newStack(),
44+ minStack: newStack(),
45+ maxStack: newStack(),
46+ KeyMap: DefaultKeyMap(),
47+ Styles: DefaultStyles(),
48+ }
49+}
50+
51+type errorMsg struct {
52+ err error
53+}
54+
55+type readDirMsg struct {
56+ id int
57+ entries []os.DirEntry
58+}
59+
60+const (
61+ marginBottom = 5
62+ fileSizeWidth = 7
63+ paddingLeft = 2
64+)
65+
66+// KeyMap defines key bindings for each user action.
67+type KeyMap struct {
68+ GoToTop key.Binding
69+ GoToLast key.Binding
70+ Down key.Binding
71+ Up key.Binding
72+ PageUp key.Binding
73+ PageDown key.Binding
74+ Back key.Binding
75+ Open key.Binding
76+ Select key.Binding
77+}
78+
79+// DefaultKeyMap defines the default keybindings.
80+func DefaultKeyMap() KeyMap {
81+ return KeyMap{
82+ GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")),
83+ GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")),
84+ Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")),
85+ Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")),
86+ PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")),
87+ PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")),
88+ Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
89+ Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")),
90+ Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
91+ }
92+}
93+
94+// Styles defines the possible customizations for styles in the file picker.
95+type Styles struct {
96+ DisabledCursor lipgloss.Style
97+ Cursor lipgloss.Style
98+ Symlink lipgloss.Style
99+ Directory lipgloss.Style
100+ File lipgloss.Style
101+ DisabledFile lipgloss.Style
102+ Permission lipgloss.Style
103+ Selected lipgloss.Style
104+ DisabledSelected lipgloss.Style
105+ FileSize lipgloss.Style
106+ EmptyDirectory lipgloss.Style
107+}
108+
109+// DefaultStyles defines the default styling for the file picker.
110+func DefaultStyles() Styles {
111+ return Styles{
112+ DisabledCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
113+ Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
114+ Symlink: lipgloss.NewStyle().Foreground(lipgloss.Color("36")),
115+ Directory: lipgloss.NewStyle().Foreground(lipgloss.Color("99")),
116+ File: lipgloss.NewStyle(),
117+ DisabledFile: lipgloss.NewStyle().Foreground(lipgloss.Color("243")),
118+ DisabledSelected: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
119+ Permission: lipgloss.NewStyle().Foreground(lipgloss.Color("244")),
120+ Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
121+ FileSize: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right),
122+ EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
123+ }
124+}
125+
126+// Model represents a file picker.
127+type Model struct {
128+ id int
129+
130+ // Path is the path which the user has selected with the file picker.
131+ Path string
132+
133+ // CurrentDirectory is the directory that the user is currently in.
134+ CurrentDirectory string
135+
136+ // AllowedTypes specifies which file types the user may select.
137+ // If empty the user may select any file.
138+ AllowedTypes []string
139+
140+ KeyMap KeyMap
141+ files []os.DirEntry
142+ ShowPermissions bool
143+ ShowSize bool
144+ ShowHidden bool
145+ DirAllowed bool
146+ FileAllowed bool
147+
148+ FileSelected string
149+ selected int
150+ selectedStack stack
151+
152+ minIdx int
153+ maxIdx int
154+ maxStack stack
155+ minStack stack
156+
157+ height int
158+ AutoHeight bool
159+
160+ Cursor string
161+ Styles Styles
162+}
163+
164+type stack struct {
165+ Push func(int)
166+ Pop func() int
167+ Length func() int
168+}
169+
170+func newStack() stack {
171+ slice := make([]int, 0)
172+ return stack{
173+ Push: func(i int) {
174+ slice = append(slice, i)
175+ },
176+ Pop: func() int {
177+ res := slice[len(slice)-1]
178+ slice = slice[:len(slice)-1]
179+ return res
180+ },
181+ Length: func() int {
182+ return len(slice)
183+ },
184+ }
185+}
186+
187+func (m *Model) pushView(selected, minimum, maximum int) {
188+ m.selectedStack.Push(selected)
189+ m.minStack.Push(minimum)
190+ m.maxStack.Push(maximum)
191+}
192+
193+func (m *Model) popView() (int, int, int) {
194+ return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
195+}
196+
197+func (m Model) readDir(path string, showHidden bool) tea.Cmd {
198+ return func() tea.Msg {
199+ dirEntries, err := os.ReadDir(path)
200+ if err != nil {
201+ return errorMsg{err}
202+ }
203+
204+ sort.Slice(dirEntries, func(i, j int) bool {
205+ if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
206+ return dirEntries[i].Name() < dirEntries[j].Name()
207+ }
208+ return dirEntries[i].IsDir()
209+ })
210+
211+ if showHidden {
212+ return readDirMsg{id: m.id, entries: dirEntries}
213+ }
214+
215+ var sanitizedDirEntries []os.DirEntry
216+ for _, dirEntry := range dirEntries {
217+ isHidden, _ := IsHidden(dirEntry.Name())
218+ if isHidden {
219+ continue
220+ }
221+ sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
222+ }
223+ return readDirMsg{id: m.id, entries: sanitizedDirEntries}
224+ }
225+}
226+
227+// SetHeight sets the height of the file picker.
228+func (m *Model) SetHeight(h int) {
229+ m.height = h
230+ if m.maxIdx > m.height-1 {
231+ m.maxIdx = m.minIdx + m.height - 1
232+ }
233+}
234+
235+// Height returns the height of the file picker.
236+func (m Model) Height() int {
237+ return m.height
238+}
239+
240+// Init initializes the file picker model.
241+func (m Model) Init() tea.Cmd {
242+ return m.readDir(m.CurrentDirectory, m.ShowHidden)
243+}
244+
245+// Update handles user interactions within the file picker model.
246+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
247+ switch msg := msg.(type) {
248+ case readDirMsg:
249+ if msg.id != m.id {
250+ break
251+ }
252+ m.files = msg.entries
253+ m.maxIdx = max(m.maxIdx, m.Height()-1)
254+ case tea.WindowSizeMsg:
255+ if m.AutoHeight {
256+ m.SetHeight(msg.Height - marginBottom)
257+ }
258+ m.maxIdx = m.Height() - 1
259+ case tea.KeyPressMsg:
260+ switch {
261+ case key.Matches(msg, m.KeyMap.GoToTop):
262+ m.selected = 0
263+ m.minIdx = 0
264+ m.maxIdx = m.Height() - 1
265+ case key.Matches(msg, m.KeyMap.GoToLast):
266+ m.selected = len(m.files) - 1
267+ m.minIdx = len(m.files) - m.Height()
268+ m.maxIdx = len(m.files) - 1
269+ case key.Matches(msg, m.KeyMap.Down):
270+ m.selected++
271+ if m.selected >= len(m.files) {
272+ m.selected = len(m.files) - 1
273+ }
274+ if m.selected > m.maxIdx {
275+ m.minIdx++
276+ m.maxIdx++
277+ }
278+ case key.Matches(msg, m.KeyMap.Up):
279+ m.selected--
280+ if m.selected < 0 {
281+ m.selected = 0
282+ }
283+ if m.selected < m.minIdx {
284+ m.minIdx--
285+ m.maxIdx--
286+ }
287+ case key.Matches(msg, m.KeyMap.PageDown):
288+ m.selected += m.Height()
289+ if m.selected >= len(m.files) {
290+ m.selected = len(m.files) - 1
291+ }
292+ m.minIdx += m.Height()
293+ m.maxIdx += m.Height()
294+
295+ if m.maxIdx >= len(m.files) {
296+ m.maxIdx = len(m.files) - 1
297+ m.minIdx = m.maxIdx - m.Height()
298+ }
299+ case key.Matches(msg, m.KeyMap.PageUp):
300+ m.selected -= m.Height()
301+ if m.selected < 0 {
302+ m.selected = 0
303+ }
304+ m.minIdx -= m.Height()
305+ m.maxIdx -= m.Height()
306+
307+ if m.minIdx < 0 {
308+ m.minIdx = 0
309+ m.maxIdx = m.minIdx + m.Height()
310+ }
311+ case key.Matches(msg, m.KeyMap.Back):
312+ m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
313+ if m.selectedStack.Length() > 0 {
314+ m.selected, m.minIdx, m.maxIdx = m.popView()
315+ } else {
316+ m.selected = 0
317+ m.minIdx = 0
318+ m.maxIdx = m.Height() - 1
319+ }
320+ return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
321+ case key.Matches(msg, m.KeyMap.Open):
322+ if len(m.files) == 0 {
323+ break
324+ }
325+
326+ f := m.files[m.selected]
327+ info, err := f.Info()
328+ if err != nil {
329+ break
330+ }
331+ isSymlink := info.Mode()&os.ModeSymlink != 0
332+ isDir := f.IsDir()
333+
334+ if isSymlink {
335+ symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
336+ info, err := os.Stat(symlinkPath)
337+ if err != nil {
338+ break
339+ }
340+ if info.IsDir() {
341+ isDir = true
342+ }
343+ }
344+
345+ if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) {
346+ if key.Matches(msg, m.KeyMap.Select) {
347+ // Select the current path as the selection
348+ m.Path = filepath.Join(m.CurrentDirectory, f.Name())
349+ }
350+ }
351+
352+ if !isDir {
353+ break
354+ }
355+
356+ m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
357+ m.pushView(m.selected, m.minIdx, m.maxIdx)
358+ m.selected = 0
359+ m.minIdx = 0
360+ m.maxIdx = m.Height() - 1
361+ return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
362+ }
363+ }
364+ return m, nil
365+}
366+
367+// View returns the view of the file picker.
368+func (m Model) View() string {
369+ if len(m.files) == 0 {
370+ return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
371+ }
372+ var s strings.Builder
373+
374+ for i, f := range m.files {
375+ if i < m.minIdx || i > m.maxIdx {
376+ continue
377+ }
378+
379+ var symlinkPath string
380+ info, err := f.Info()
381+ if err != nil {
382+ continue
383+ }
384+ isSymlink := info.Mode()&os.ModeSymlink != 0
385+ size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
386+ name := f.Name()
387+
388+ if isSymlink {
389+ symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
390+ }
391+
392+ disabled := !m.canSelect(name) && !f.IsDir()
393+
394+ if m.selected == i { //nolint:nestif
395+ selected := ""
396+ if m.ShowPermissions {
397+ selected += " " + info.Mode().String()
398+ }
399+ if m.ShowSize {
400+ selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
401+ }
402+ selected += " " + name
403+ if isSymlink {
404+ selected += " → " + symlinkPath
405+ }
406+ if disabled {
407+ s.WriteString(m.Styles.DisabledCursor.Render(m.Cursor) + m.Styles.DisabledSelected.Render(selected))
408+ } else {
409+ s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
410+ }
411+ s.WriteRune('\n')
412+ continue
413+ }
414+
415+ style := m.Styles.File
416+ if f.IsDir() {
417+ style = m.Styles.Directory
418+ } else if isSymlink {
419+ style = m.Styles.Symlink
420+ } else if disabled {
421+ style = m.Styles.DisabledFile
422+ }
423+
424+ fileName := style.Render(name)
425+ s.WriteString(m.Styles.Cursor.Render(" "))
426+ if isSymlink {
427+ fileName += " → " + symlinkPath
428+ }
429+ if m.ShowPermissions {
430+ s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
431+ }
432+ if m.ShowSize {
433+ s.WriteString(m.Styles.FileSize.Render(size))
434+ }
435+ s.WriteString(" " + fileName)
436+ s.WriteRune('\n')
437+ }
438+
439+ for i := lipgloss.Height(s.String()); i <= m.Height(); i++ {
440+ s.WriteRune('\n')
441+ }
442+
443+ return s.String()
444+}
445+
446+// DidSelectFile returns whether a user has selected a file (on this msg).
447+func (m Model) DidSelectFile(msg tea.Msg) (bool, string) {
448+ didSelect, path := m.didSelectFile(msg)
449+ if didSelect && m.canSelect(path) {
450+ return true, path
451+ }
452+ return false, ""
453+}
454+
455+// DidSelectDisabledFile returns whether a user tried to select a disabled file
456+// (on this msg). This is necessary only if you would like to warn the user that
457+// they tried to select a disabled file.
458+func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) {
459+ didSelect, path := m.didSelectFile(msg)
460+ if didSelect && !m.canSelect(path) {
461+ return true, path
462+ }
463+ return false, ""
464+}
465+
466+func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
467+ if len(m.files) == 0 {
468+ return false, ""
469+ }
470+ switch msg := msg.(type) {
471+ case tea.KeyPressMsg:
472+ // If the msg does not match the Select keymap then this could not have been a selection.
473+ if !key.Matches(msg, m.KeyMap.Select) {
474+ return false, ""
475+ }
476+
477+ // The key press was a selection, let's confirm whether the current file could
478+ // be selected or used for navigating deeper into the stack.
479+ f := m.files[m.selected]
480+ info, err := f.Info()
481+ if err != nil {
482+ return false, ""
483+ }
484+ isSymlink := info.Mode()&os.ModeSymlink != 0
485+ isDir := f.IsDir()
486+
487+ if isSymlink {
488+ symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
489+ info, err := os.Stat(symlinkPath)
490+ if err != nil {
491+ break
492+ }
493+ if info.IsDir() {
494+ isDir = true
495+ }
496+ }
497+
498+ if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" {
499+ return true, m.Path
500+ }
501+
502+ // If the msg was not a KeyPressMsg, then the file could not have been selected this iteration.
503+ // Only a KeyPressMsg can select a file.
504+ default:
505+ return false, ""
506+ }
507+ return false, ""
508+}
509+
510+func (m Model) canSelect(file string) bool {
511+ if len(m.AllowedTypes) <= 0 {
512+ return true
513+ }
514+
515+ for _, ext := range m.AllowedTypes {
516+ if strings.HasSuffix(file, ext) {
517+ return true
518+ }
519+ }
520+ return false
521+}
522+
523+// HighlightedPath returns the path of the currently highlighted file or directory.
524+func (m Model) HighlightedPath() string {
525+ if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
526+ return ""
527+ }
528+ return filepath.Join(m.CurrentDirectory, m.files[m.selected].Name())
529+}
vendor/charm.land/bubbles/v2/filepicker/hidden_unix.gonew file+11
@@ -0,0 +1,11 @@
1+//go:build !windows
2+// +build !windows
3+
4+package filepicker
5+
6+import "strings"
7+
8+// IsHidden reports whether a file is hidden or not.
9+func IsHidden(file string) (bool, error) {
10+ return strings.HasPrefix(file, "."), nil
11+}
vendor/charm.land/bubbles/v2/filepicker/hidden_windows.gonew file+21
@@ -0,0 +1,21 @@
1+//go:build windows
2+// +build windows
3+
4+package filepicker
5+
6+import (
7+ "syscall"
8+)
9+
10+// IsHidden reports whether a file is hidden or not.
11+func IsHidden(file string) (bool, error) {
12+ pointer, err := syscall.UTF16PtrFromString(file)
13+ if err != nil {
14+ return false, err //nolint:wrapcheck
15+ }
16+ attributes, err := syscall.GetFileAttributes(pointer)
17+ if err != nil {
18+ return false, err //nolint:wrapcheck
19+ }
20+ return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
21+}
vendor/charm.land/bubbles/v2/help/help.gonew file+253
@@ -0,0 +1,253 @@
1+// Package help provides a simple help view for Bubble Tea applications.
2+package help
3+
4+import (
5+ "strings"
6+
7+ "charm.land/bubbles/v2/key"
8+ tea "charm.land/bubbletea/v2"
9+ "charm.land/lipgloss/v2"
10+)
11+
12+// KeyMap is a map of keybindings used to generate help. Since it's an
13+// interface it can be any type, though struct or a map[string][]key.Binding
14+// are likely candidates.
15+//
16+// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
17+// rendered in the help view, so in theory generated help should self-manage.
18+type KeyMap interface {
19+ // ShortHelp returns a slice of bindings to be displayed in the short
20+ // version of the help. The help bubble will render help in the order in
21+ // which the help items are returned here.
22+ ShortHelp() []key.Binding
23+
24+ // FullHelp returns an extended group of help items, grouped by columns.
25+ // The help bubble will render the help in the order in which the help
26+ // items are returned here.
27+ FullHelp() [][]key.Binding
28+}
29+
30+// Styles is a set of available style definitions for the Help bubble.
31+type Styles struct {
32+ Ellipsis lipgloss.Style
33+
34+ // Styling for the short help
35+ ShortKey lipgloss.Style
36+ ShortDesc lipgloss.Style
37+ ShortSeparator lipgloss.Style
38+
39+ // Styling for the full help
40+ FullKey lipgloss.Style
41+ FullDesc lipgloss.Style
42+ FullSeparator lipgloss.Style
43+}
44+
45+// DefaultStyles returns a set of default styles for the help bubble. Light or
46+// dark styles can be selected by passing true or false to the isDark
47+// parameter.
48+func DefaultStyles(isDark bool) Styles {
49+ lightDark := lipgloss.LightDark(isDark)
50+
51+ keyStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#909090"), lipgloss.Color("#626262")))
52+ descStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#B2B2B2"), lipgloss.Color("#4A4A4A")))
53+ sepStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#DADADA"), lipgloss.Color("#3C3C3C")))
54+
55+ return Styles{
56+ ShortKey: keyStyle,
57+ ShortDesc: descStyle,
58+ ShortSeparator: sepStyle,
59+ Ellipsis: sepStyle,
60+ FullKey: keyStyle,
61+ FullDesc: descStyle,
62+ FullSeparator: sepStyle,
63+ }
64+}
65+
66+// DefaultDarkStyles returns a set of default styles for dark backgrounds.
67+func DefaultDarkStyles() Styles {
68+ return DefaultStyles(true)
69+}
70+
71+// DefaultLightStyles returns a set of default styles for light backgrounds.
72+func DefaultLightStyles() Styles {
73+ return DefaultStyles(false)
74+}
75+
76+// Model contains the state of the help view.
77+type Model struct {
78+ ShowAll bool // if true, render the "full" help menu
79+
80+ ShortSeparator string
81+ FullSeparator string
82+
83+ // The symbol we use in the short help when help items have been truncated
84+ // due to width. Periods of ellipsis by default.
85+ Ellipsis string
86+
87+ Styles Styles
88+
89+ width int
90+}
91+
92+// New creates a new help view with some useful defaults.
93+func New() Model {
94+ return Model{
95+ ShortSeparator: " • ",
96+ FullSeparator: " ",
97+ Ellipsis: "…",
98+ Styles: DefaultDarkStyles(),
99+ }
100+}
101+
102+// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
103+func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
104+ return m, nil
105+}
106+
107+// View renders the help view's current state.
108+func (m Model) View(k KeyMap) string {
109+ if m.ShowAll {
110+ return m.FullHelpView(k.FullHelp())
111+ }
112+ return m.ShortHelpView(k.ShortHelp())
113+}
114+
115+// SetWidth sets the maximum width for the help view.
116+func (m *Model) SetWidth(w int) {
117+ m.width = w
118+}
119+
120+// Width returns the maximum width for the help view.
121+func (m Model) Width() int {
122+ return m.width
123+}
124+
125+// ShortHelpView renders a single line help view from a slice of keybindings.
126+// If the line is longer than the maximum width it will be gracefully
127+// truncated, showing only as many help items as possible.
128+func (m Model) ShortHelpView(bindings []key.Binding) string {
129+ if len(bindings) == 0 {
130+ return ""
131+ }
132+
133+ var b strings.Builder
134+ var totalWidth int
135+ separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
136+
137+ for i, kb := range bindings {
138+ if !kb.Enabled() {
139+ continue
140+ }
141+
142+ // Sep
143+ var sep string
144+ if totalWidth > 0 && i < len(bindings) {
145+ sep = separator
146+ }
147+
148+ // Item
149+ str := sep +
150+ m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
151+ m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
152+ w := lipgloss.Width(str)
153+
154+ // Tail
155+ if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
156+ if tail != "" {
157+ b.WriteString(tail)
158+ }
159+ break
160+ }
161+
162+ totalWidth += w
163+ b.WriteString(str)
164+ }
165+
166+ return b.String()
167+}
168+
169+// FullHelpView renders help columns from a slice of key binding slices. Each
170+// top level slice entry renders into a column.
171+func (m Model) FullHelpView(groups [][]key.Binding) string {
172+ if len(groups) == 0 {
173+ return ""
174+ }
175+
176+ // Linter note: at this time we don't think it's worth the additional
177+ // code complexity involved in preallocating this slice.
178+ var (
179+ out []string
180+
181+ totalWidth int
182+ separator = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator)
183+ )
184+
185+ // Iterate over groups to build columns
186+ for i, group := range groups {
187+ if group == nil || !shouldRenderColumn(group) {
188+ continue
189+ }
190+ var (
191+ sep string
192+ keys []string
193+ descriptions []string
194+ )
195+
196+ // Sep
197+ if totalWidth > 0 && i < len(groups) {
198+ sep = separator
199+ }
200+
201+ // Separate keys and descriptions into different slices
202+ for _, kb := range group {
203+ if !kb.Enabled() {
204+ continue
205+ }
206+ keys = append(keys, kb.Help().Key)
207+ descriptions = append(descriptions, kb.Help().Desc)
208+ }
209+
210+ // Column
211+ col := lipgloss.JoinHorizontal(lipgloss.Top,
212+ sep,
213+ m.Styles.FullKey.Render(strings.Join(keys, "\n")),
214+ " ",
215+ m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
216+ )
217+ w := lipgloss.Width(col)
218+
219+ // Tail
220+ if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
221+ if tail != "" {
222+ out = append(out, tail)
223+ }
224+ break
225+ }
226+
227+ totalWidth += w
228+ out = append(out, col)
229+ }
230+
231+ return lipgloss.JoinHorizontal(lipgloss.Top, out...)
232+}
233+
234+func (m Model) shouldAddItem(totalWidth, width int) (tail string, ok bool) {
235+ // If there's room for an ellipsis, print that.
236+ if m.width > 0 && totalWidth+width > m.width {
237+ tail = " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis)
238+
239+ if totalWidth+lipgloss.Width(tail) < m.width {
240+ return tail, false
241+ }
242+ }
243+ return "", true
244+}
245+
246+func shouldRenderColumn(b []key.Binding) (ok bool) {
247+ for _, v := range b {
248+ if v.Enabled() {
249+ return true
250+ }
251+ }
252+ return false
253+}
vendor/charm.land/bubbles/v2/internal/memoization/memoization.gonew file+125
@@ -0,0 +1,125 @@
1+// Package memoization implement a simple memoization cache. It's designed to
2+// improve performance in textarea.
3+package memoization
4+
5+import (
6+ "container/list"
7+ "crypto/sha256"
8+ "fmt"
9+ "sync"
10+)
11+
12+// Hasher is an interface that requires a Hash method. The Hash method is
13+// expected to return a string representation of the hash of the object.
14+type Hasher interface {
15+ Hash() string
16+}
17+
18+// entry is a struct that holds a key-value pair. It is used as an element
19+// in the evictionList of the MemoCache.
20+type entry[T any] struct {
21+ key string
22+ value T
23+}
24+
25+// MemoCache is a struct that represents a cache with a set capacity. It
26+// uses an LRU (Least Recently Used) eviction policy. It is safe for
27+// concurrent use.
28+type MemoCache[H Hasher, T any] struct {
29+ capacity int
30+ mutex sync.Mutex
31+ cache map[string]*list.Element // The cache holding the results
32+ evictionList *list.List // A list to keep track of the order for LRU
33+ hashableItems map[string]T // This map keeps track of the original hashable items (optional)
34+}
35+
36+// NewMemoCache is a function that creates a new MemoCache with a given
37+// capacity. It returns a pointer to the created MemoCache.
38+func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
39+ return &MemoCache[H, T]{
40+ capacity: capacity,
41+ cache: make(map[string]*list.Element),
42+ evictionList: list.New(),
43+ hashableItems: make(map[string]T),
44+ }
45+}
46+
47+// Capacity is a method that returns the capacity of the MemoCache.
48+func (m *MemoCache[H, T]) Capacity() int {
49+ return m.capacity
50+}
51+
52+// Size is a method that returns the current size of the MemoCache. It is
53+// the number of items currently stored in the cache.
54+func (m *MemoCache[H, T]) Size() int {
55+ m.mutex.Lock()
56+ defer m.mutex.Unlock()
57+ return m.evictionList.Len()
58+}
59+
60+// Get is a method that returns the value associated with the given
61+// hashable item in the MemoCache. If there is no corresponding value, the
62+// method returns nil.
63+func (m *MemoCache[H, T]) Get(h H) (T, bool) {
64+ m.mutex.Lock()
65+ defer m.mutex.Unlock()
66+
67+ hashedKey := h.Hash()
68+ if element, found := m.cache[hashedKey]; found {
69+ m.evictionList.MoveToFront(element)
70+ return element.Value.(*entry[T]).value, true
71+ }
72+ var result T
73+ return result, false
74+}
75+
76+// Set is a method that sets the value for the given hashable item in the
77+// MemoCache. If the cache is at capacity, it evicts the least recently
78+// used item before adding the new item.
79+func (m *MemoCache[H, T]) Set(h H, value T) {
80+ m.mutex.Lock()
81+ defer m.mutex.Unlock()
82+
83+ hashedKey := h.Hash()
84+ if element, found := m.cache[hashedKey]; found {
85+ m.evictionList.MoveToFront(element)
86+ element.Value.(*entry[T]).value = value
87+ return
88+ }
89+
90+ // Check if the cache is at capacity
91+ if m.evictionList.Len() >= m.capacity {
92+ // Evict the least recently used item from the cache
93+ toEvict := m.evictionList.Back()
94+ if toEvict != nil {
95+ evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
96+ delete(m.cache, evictedEntry.key)
97+ delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
98+ }
99+ }
100+
101+ // Add the value to the cache and the evictionList
102+ newEntry := &entry[T]{
103+ key: hashedKey,
104+ value: value,
105+ }
106+ element := m.evictionList.PushFront(newEntry)
107+ m.cache[hashedKey] = element
108+ m.hashableItems[hashedKey] = value // if you're keeping track of original items
109+}
110+
111+// HString is a type that implements the Hasher interface for strings.
112+type HString string
113+
114+// Hash is a method that returns the hash of the string.
115+func (h HString) Hash() string {
116+ return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
117+}
118+
119+// HInt is a type that implements the Hasher interface for integers.
120+type HInt int
121+
122+// Hash is a method that returns the hash of the integer.
123+func (h HInt) Hash() string {
124+ return fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%d", h)))
125+}
vendor/charm.land/bubbles/v2/internal/runeutil/runeutil.gonew file+102
@@ -0,0 +1,102 @@
1+// Package runeutil provides utility functions for tidying up incoming runes
2+// from Key messages.
3+package runeutil
4+
5+import (
6+ "unicode"
7+ "unicode/utf8"
8+)
9+
10+// Sanitizer is a helper for bubble widgets that want to process
11+// Runes from input key messages.
12+type Sanitizer interface {
13+ // Sanitize removes control characters from runes in a KeyRunes
14+ // message, and optionally replaces newline/carriage return/tabs by a
15+ // specified character.
16+ //
17+ // The rune array is modified in-place if possible. In that case, the
18+ // returned slice is the original slice shortened after the control
19+ // characters have been removed/translated.
20+ Sanitize(runes []rune) []rune
21+}
22+
23+// NewSanitizer constructs a rune sanitizer.
24+func NewSanitizer(opts ...Option) Sanitizer {
25+ s := sanitizer{
26+ replaceNewLine: []rune("\n"),
27+ replaceTab: []rune(" "),
28+ }
29+ for _, o := range opts {
30+ s = o(s)
31+ }
32+ return &s
33+}
34+
35+// Option is the type of option that can be passed to Sanitize().
36+type Option func(sanitizer) sanitizer
37+
38+// ReplaceTabs replaces tabs by the specified string.
39+func ReplaceTabs(tabRepl string) Option {
40+ return func(s sanitizer) sanitizer {
41+ s.replaceTab = []rune(tabRepl)
42+ return s
43+ }
44+}
45+
46+// ReplaceNewlines replaces newline characters by the specified string.
47+func ReplaceNewlines(nlRepl string) Option {
48+ return func(s sanitizer) sanitizer {
49+ s.replaceNewLine = []rune(nlRepl)
50+ return s
51+ }
52+}
53+
54+func (s *sanitizer) Sanitize(runes []rune) []rune {
55+ // dstrunes are where we are storing the result.
56+ dstrunes := runes[:0:len(runes)]
57+ // copied indicates whether dstrunes is an alias of runes
58+ // or a copy. We need a copy when dst moves past src.
59+ // We use this as an optimization to avoid allocating
60+ // a new rune slice in the common case where the output
61+ // is smaller or equal to the input.
62+ copied := false
63+
64+ for src := range runes {
65+ r := runes[src]
66+ switch {
67+ case r == utf8.RuneError:
68+ // skip
69+
70+ case r == '\r' || r == '\n':
71+ if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
72+ dst := len(dstrunes)
73+ dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
74+ copy(dstrunes, runes[:dst])
75+ copied = true
76+ }
77+ dstrunes = append(dstrunes, s.replaceNewLine...)
78+
79+ case r == '\t':
80+ if len(dstrunes)+len(s.replaceTab) > src && !copied {
81+ dst := len(dstrunes)
82+ dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
83+ copy(dstrunes, runes[:dst])
84+ copied = true
85+ }
86+ dstrunes = append(dstrunes, s.replaceTab...)
87+
88+ case unicode.IsControl(r):
89+ // Other control characters: skip.
90+
91+ default:
92+ // Keep the character.
93+ dstrunes = append(dstrunes, runes[src])
94+ }
95+ }
96+ return dstrunes
97+}
98+
99+type sanitizer struct {
100+ replaceNewLine []rune
101+ replaceTab []rune
102+}
vendor/charm.land/bubbles/v2/key/key.gonew file+140
@@ -0,0 +1,140 @@
1+// Package key provides some types and functions for generating user-definable
2+// keymappings useful in Bubble Tea components. There are a few different ways
3+// you can define a keymapping with this package. Here's one example:
4+//
5+// type KeyMap struct {
6+// Up key.Binding
7+// Down key.Binding
8+// }
9+//
10+// var DefaultKeyMap = KeyMap{
11+// Up: key.NewBinding(
12+// key.WithKeys("k", "up"), // actual keybindings
13+// key.WithHelp("↑/k", "move up"), // corresponding help text
14+// ),
15+// Down: key.NewBinding(
16+// key.WithKeys("j", "down"),
17+// key.WithHelp("↓/j", "move down"),
18+// ),
19+// }
20+//
21+// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
22+// switch msg := msg.(type) {
23+// case tea.KeyPressMsg:
24+// switch {
25+// case key.Matches(msg, DefaultKeyMap.Up):
26+// // The user pressed up
27+// case key.Matches(msg, DefaultKeyMap.Down):
28+// // The user pressed down
29+// }
30+// }
31+//
32+// // ...
33+// }
34+//
35+// The help information, which is not used in the example above, can be used
36+// to render help text for keystrokes in your views.
37+package key
38+
39+import "fmt"
40+
41+// Binding describes a set of keybindings and, optionally, their associated
42+// help text.
43+type Binding struct {
44+ keys []string
45+ help Help
46+ disabled bool
47+}
48+
49+// BindingOpt is an initialization option for a keybinding. It's used as an
50+// argument to NewBinding.
51+type BindingOpt func(*Binding)
52+
53+// NewBinding returns a new keybinding from a set of BindingOpt options.
54+func NewBinding(opts ...BindingOpt) Binding {
55+ b := &Binding{}
56+ for _, opt := range opts {
57+ opt(b)
58+ }
59+ return *b
60+}
61+
62+// WithKeys initializes a keybinding with the given keystrokes.
63+func WithKeys(keys ...string) BindingOpt {
64+ return func(b *Binding) {
65+ b.keys = keys
66+ }
67+}
68+
69+// WithHelp initializes a keybinding with the given help text.
70+func WithHelp(key, desc string) BindingOpt {
71+ return func(b *Binding) {
72+ b.help = Help{Key: key, Desc: desc}
73+ }
74+}
75+
76+// WithDisabled initializes a disabled keybinding.
77+func WithDisabled() BindingOpt {
78+ return func(b *Binding) {
79+ b.disabled = true
80+ }
81+}
82+
83+// SetKeys sets the keys for the keybinding.
84+func (b *Binding) SetKeys(keys ...string) {
85+ b.keys = keys
86+}
87+
88+// Keys returns the keys for the keybinding.
89+func (b Binding) Keys() []string {
90+ return b.keys
91+}
92+
93+// SetHelp sets the help text for the keybinding.
94+func (b *Binding) SetHelp(key, desc string) {
95+ b.help = Help{Key: key, Desc: desc}
96+}
97+
98+// Help returns the Help information for the keybinding.
99+func (b Binding) Help() Help {
100+ return b.help
101+}
102+
103+// Enabled returns whether or not the keybinding is enabled. Disabled
104+// keybindings won't be activated and won't show up in help. Keybindings are
105+// enabled by default.
106+func (b Binding) Enabled() bool {
107+ return !b.disabled && b.keys != nil
108+}
109+
110+// SetEnabled enables or disables the keybinding.
111+func (b *Binding) SetEnabled(v bool) {
112+ b.disabled = !v
113+}
114+
115+// Unbind removes the keys and help from this binding, effectively nullifying
116+// it. This is a step beyond disabling it, since applications can enable
117+// or disable key bindings based on application state.
118+func (b *Binding) Unbind() {
119+ b.keys = nil
120+ b.help = Help{}
121+}
122+
123+// Help is help information for a given keybinding.
124+type Help struct {
125+ Key string
126+ Desc string
127+}
128+
129+// Matches checks if the given key matches the given bindings.
130+func Matches[Key fmt.Stringer](k Key, b ...Binding) bool {
131+ keys := k.String()
132+ for _, binding := range b {
133+ for _, v := range binding.keys {
134+ if keys == v && binding.Enabled() {
135+ return true
136+ }
137+ }
138+ }
139+ return false
140+}
vendor/charm.land/bubbles/v2/spinner/spinner.gonew file+211
@@ -0,0 +1,211 @@
1+// Package spinner provides a spinner component for Bubble Tea applications.
2+package spinner
3+
4+import (
5+ "sync/atomic"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "charm.land/lipgloss/v2"
10+)
11+
12+// Internal ID management. Used during animating to ensure that frame messages
13+// are received only by spinner components that sent them.
14+var lastID int64
15+
16+func nextID() int {
17+ return int(atomic.AddInt64(&lastID, 1))
18+}
19+
20+// Spinner is a set of frames used in animating the spinner.
21+type Spinner struct {
22+ Frames []string
23+ FPS time.Duration
24+}
25+
26+// Some spinners to choose from. You could also make your own.
27+var (
28+ Line = Spinner{
29+ Frames: []string{"|", "/", "-", "\\"},
30+ FPS: time.Second / 10, //nolint:mnd
31+ }
32+ Dot = Spinner{
33+ Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
34+ FPS: time.Second / 10, //nolint:mnd
35+ }
36+ MiniDot = Spinner{
37+ Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
38+ FPS: time.Second / 12, //nolint:mnd
39+ }
40+ Jump = Spinner{
41+ Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
42+ FPS: time.Second / 10, //nolint:mnd
43+ }
44+ Pulse = Spinner{
45+ Frames: []string{"█", "▓", "▒", "░"},
46+ FPS: time.Second / 8, //nolint:mnd
47+ }
48+ Points = Spinner{
49+ Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
50+ FPS: time.Second / 7, //nolint:mnd
51+ }
52+ Globe = Spinner{
53+ Frames: []string{"🌍", "🌎", "🌏"},
54+ FPS: time.Second / 4, //nolint:mnd
55+ }
56+ Moon = Spinner{
57+ Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
58+ FPS: time.Second / 8, //nolint:mnd
59+ }
60+ Monkey = Spinner{
61+ Frames: []string{"🙈", "🙉", "🙊"},
62+ FPS: time.Second / 3, //nolint:mnd
63+ }
64+ Meter = Spinner{
65+ Frames: []string{
66+ "▱▱▱",
67+ "▰▱▱",
68+ "▰▰▱",
69+ "▰▰▰",
70+ "▰▰▱",
71+ "▰▱▱",
72+ "▱▱▱",
73+ },
74+ FPS: time.Second / 7, //nolint:mnd
75+ }
76+ Hamburger = Spinner{
77+ Frames: []string{"☱", "☲", "☴", "☲"},
78+ FPS: time.Second / 3, //nolint:mnd
79+ }
80+ Ellipsis = Spinner{
81+ Frames: []string{"", ".", "..", "..."},
82+ FPS: time.Second / 3, //nolint:mnd
83+ }
84+)
85+
86+// Model contains the state for the spinner. Use New to create new models
87+// rather than using Model as a struct literal.
88+type Model struct {
89+ // Spinner settings to use. See type Spinner.
90+ Spinner Spinner
91+
92+ // Style sets the styling for the spinner. Most of the time you'll just
93+ // want foreground and background coloring, and potentially some padding.
94+ //
95+ // For an introduction to styling with Lip Gloss see:
96+ // https://github.com/charmbracelet/lipgloss
97+ Style lipgloss.Style
98+
99+ frame int
100+ id int
101+ tag int
102+}
103+
104+// ID returns the spinner's unique ID.
105+func (m Model) ID() int {
106+ return m.id
107+}
108+
109+// New returns a model with default values.
110+func New(opts ...Option) Model {
111+ m := Model{
112+ Spinner: Line,
113+ id: nextID(),
114+ }
115+
116+ for _, opt := range opts {
117+ opt(&m)
118+ }
119+
120+ return m
121+}
122+
123+// TickMsg indicates that the timer has ticked and we should render a frame.
124+type TickMsg struct {
125+ Time time.Time
126+ tag int
127+ ID int
128+}
129+
130+// Update is the Tea update function.
131+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
132+ switch msg := msg.(type) {
133+ case TickMsg:
134+ // If an ID is set, and the ID doesn't belong to this spinner, reject
135+ // the message.
136+ if msg.ID > 0 && msg.ID != m.id {
137+ return m, nil
138+ }
139+
140+ // If a tag is set, and it's not the one we expect, reject the message.
141+ // This prevents the spinner from receiving too many messages and
142+ // thus spinning too fast.
143+ if msg.tag > 0 && msg.tag != m.tag {
144+ return m, nil
145+ }
146+
147+ m.frame++
148+ if m.frame >= len(m.Spinner.Frames) {
149+ m.frame = 0
150+ }
151+
152+ m.tag++
153+ return m, m.tick(m.id, m.tag)
154+ default:
155+ return m, nil
156+ }
157+}
158+
159+// View renders the model's view.
160+func (m Model) View() string {
161+ if m.frame >= len(m.Spinner.Frames) {
162+ return "(error)"
163+ }
164+
165+ return m.Style.Render(m.Spinner.Frames[m.frame])
166+}
167+
168+// Tick is the command used to advance the spinner one frame. Use this command
169+// to effectively start the spinner.
170+func (m Model) Tick() tea.Msg {
171+ return TickMsg{
172+ // The time at which the tick occurred.
173+ Time: time.Now(),
174+
175+ // The ID of the spinner that this message belongs to. This can be
176+ // helpful when routing messages, however bear in mind that spinners
177+ // will ignore messages that don't contain ID by default.
178+ ID: m.id,
179+
180+ tag: m.tag,
181+ }
182+}
183+
184+func (m Model) tick(id, tag int) tea.Cmd {
185+ return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
186+ return TickMsg{
187+ Time: t,
188+ ID: id,
189+ tag: tag,
190+ }
191+ })
192+}
193+
194+// Option is used to set options in New. For example:
195+//
196+// spinner := New(WithSpinner(Dot))
197+type Option func(*Model)
198+
199+// WithSpinner is an option to set the spinner. Pass this to [Spinner.New].
200+func WithSpinner(spinner Spinner) Option {
201+ return func(m *Model) {
202+ m.Spinner = spinner
203+ }
204+}
205+
206+// WithStyle is an option to set the spinner style. Pass this to [Spinner.New].
207+func WithStyle(style lipgloss.Style) Option {
208+ return func(m *Model) {
209+ m.Style = style
210+ }
211+}
vendor/charm.land/bubbles/v2/textarea/textarea.gonew file+1897
@@ -0,0 +1,1897 @@
1+// Package textarea provides a multi-line text input component for Bubble Tea
2+// applications.
3+package textarea
4+
5+import (
6+ "crypto/sha256"
7+ "fmt"
8+ "image/color"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "time"
13+ "unicode"
14+
15+ "charm.land/bubbles/v2/cursor"
16+ "charm.land/bubbles/v2/internal/memoization"
17+ "charm.land/bubbles/v2/internal/runeutil"
18+ "charm.land/bubbles/v2/key"
19+ "charm.land/bubbles/v2/viewport"
20+ tea "charm.land/bubbletea/v2"
21+ "charm.land/lipgloss/v2"
22+ "github.com/atotto/clipboard"
23+ "github.com/charmbracelet/x/ansi"
24+ rw "github.com/mattn/go-runewidth"
25+ "github.com/rivo/uniseg"
26+)
27+
28+const (
29+ minHeight = 1
30+ defaultHeight = 6
31+ defaultWidth = 40
32+ defaultCharLimit = 0 // no limit
33+ defaultMaxHeight = 99
34+ defaultMaxWidth = 500
35+
36+ // XXX: in v2, make max lines dynamic and default max lines configurable.
37+ maxLines = 10000
38+)
39+
40+// Internal messages for clipboard operations.
41+type (
42+ pasteMsg string
43+ pasteErrMsg struct{ error }
44+)
45+
46+// KeyMap is the key bindings for different actions within the textarea.
47+type KeyMap struct {
48+ CharacterBackward key.Binding
49+ CharacterForward key.Binding
50+ DeleteAfterCursor key.Binding
51+ DeleteBeforeCursor key.Binding
52+ DeleteCharacterBackward key.Binding
53+ DeleteCharacterForward key.Binding
54+ DeleteWordBackward key.Binding
55+ DeleteWordForward key.Binding
56+ InsertNewline key.Binding
57+ LineEnd key.Binding
58+ LineNext key.Binding
59+ LinePrevious key.Binding
60+ LineStart key.Binding
61+ PageUp key.Binding
62+ PageDown key.Binding
63+ Paste key.Binding
64+ WordBackward key.Binding
65+ WordForward key.Binding
66+ InputBegin key.Binding
67+ InputEnd key.Binding
68+
69+ UppercaseWordForward key.Binding
70+ LowercaseWordForward key.Binding
71+ CapitalizeWordForward key.Binding
72+
73+ TransposeCharacterBackward key.Binding
74+}
75+
76+// DefaultKeyMap returns the default set of key bindings for navigating and acting
77+// upon the textarea.
78+func DefaultKeyMap() KeyMap {
79+ return KeyMap{
80+ CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")),
81+ CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")),
82+ WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")),
83+ WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")),
84+ LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")),
85+ LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")),
86+ DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")),
87+ DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")),
88+ DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")),
89+ DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")),
90+ InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")),
91+ DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")),
92+ DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")),
93+ LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")),
94+ LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")),
95+ PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
96+ PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdown", "page down")),
97+ Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")),
98+ InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")),
99+ InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")),
100+
101+ CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")),
102+ LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")),
103+ UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")),
104+
105+ TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")),
106+ }
107+}
108+
109+// LineInfo is a helper for keeping track of line information regarding
110+// soft-wrapped lines.
111+type LineInfo struct {
112+ // Width is the number of columns in the line.
113+ Width int
114+
115+ // CharWidth is the number of characters in the line to account for
116+ // double-width runes.
117+ CharWidth int
118+
119+ // Height is the number of rows in the line.
120+ Height int
121+
122+ // StartColumn is the index of the first column of the line.
123+ StartColumn int
124+
125+ // ColumnOffset is the number of columns that the cursor is offset from the
126+ // start of the line.
127+ ColumnOffset int
128+
129+ // RowOffset is the number of rows that the cursor is offset from the start
130+ // of the line.
131+ RowOffset int
132+
133+ // CharOffset is the number of characters that the cursor is offset
134+ // from the start of the line. This will generally be equivalent to
135+ // ColumnOffset, but will be different there are double-width runes before
136+ // the cursor.
137+ CharOffset int
138+}
139+
140+// PromptInfo is a struct that can be used to store information about the
141+// prompt.
142+type PromptInfo struct {
143+ LineNumber int
144+ Focused bool
145+}
146+
147+// CursorStyle is the style for real and virtual cursors.
148+type CursorStyle struct {
149+ // Style styles the cursor block.
150+ //
151+ // For real cursors, the foreground color set here will be used as the
152+ // cursor color.
153+ Color color.Color
154+
155+ // Shape is the cursor shape. The following shapes are available:
156+ //
157+ // - tea.CursorBlock
158+ // - tea.CursorUnderline
159+ // - tea.CursorBar
160+ //
161+ // This is only used for real cursors.
162+ Shape tea.CursorShape
163+
164+ // CursorBlink determines whether or not the cursor should blink.
165+ Blink bool
166+
167+ // BlinkSpeed is the speed at which the virtual cursor blinks. This has no
168+ // effect on real cursors as well as no effect if the cursor is set not to
169+ // [CursorBlink].
170+ //
171+ // By default, the blink speed is set to about 500ms.
172+ BlinkSpeed time.Duration
173+}
174+
175+// Styles are the styles for the textarea, separated into focused and blurred
176+// states. The appropriate styles will be chosen based on the focus state of
177+// the textarea.
178+type Styles struct {
179+ Focused StyleState
180+ Blurred StyleState
181+ Cursor CursorStyle
182+}
183+
184+// StyleState that will be applied to the text area.
185+//
186+// StyleState can be applied to focused and unfocused states to change the styles
187+// depending on the focus state.
188+//
189+// For an introduction to styling with Lip Gloss see:
190+// https://github.com/charmbracelet/lipgloss
191+type StyleState struct {
192+ Base lipgloss.Style
193+ Text lipgloss.Style
194+ LineNumber lipgloss.Style
195+ CursorLineNumber lipgloss.Style
196+ CursorLine lipgloss.Style
197+ EndOfBuffer lipgloss.Style
198+ Placeholder lipgloss.Style
199+ Prompt lipgloss.Style
200+}
201+
202+func (s StyleState) computedCursorLine() lipgloss.Style {
203+ return s.CursorLine.Inherit(s.Base).Inline(true)
204+}
205+
206+func (s StyleState) computedCursorLineNumber() lipgloss.Style {
207+ return s.CursorLineNumber.
208+ Inherit(s.CursorLine).
209+ Inherit(s.Base).
210+ Inline(true)
211+}
212+
213+func (s StyleState) computedEndOfBuffer() lipgloss.Style {
214+ return s.EndOfBuffer.Inherit(s.Base).Inline(true)
215+}
216+
217+func (s StyleState) computedLineNumber() lipgloss.Style {
218+ return s.LineNumber.Inherit(s.Base).Inline(true)
219+}
220+
221+func (s StyleState) computedPlaceholder() lipgloss.Style {
222+ return s.Placeholder.Inherit(s.Base).Inline(true)
223+}
224+
225+func (s StyleState) computedPrompt() lipgloss.Style {
226+ return s.Prompt.Inherit(s.Base).Inline(true)
227+}
228+
229+func (s StyleState) computedText() lipgloss.Style {
230+ return s.Text.Inherit(s.Base).Inline(true)
231+}
232+
233+// line is the input to the text wrapping function. This is stored in a struct
234+// so that it can be hashed and memoized.
235+type line struct {
236+ runes []rune
237+ width int
238+}
239+
240+// Hash returns a hash of the line.
241+func (w line) Hash() string {
242+ v := fmt.Sprintf("%s:%d", string(w.runes), w.width)
243+ return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
244+}
245+
246+// Model is the Bubble Tea model for this text area element.
247+type Model struct {
248+ Err error
249+
250+ // General settings.
251+ cache *memoization.MemoCache[line, [][]rune]
252+
253+ // Prompt is printed at the beginning of each line.
254+ //
255+ // When changing the value of Prompt after the model has been
256+ // initialized, ensure that SetWidth() gets called afterwards.
257+ //
258+ // See also [SetPromptFunc] for a dynamic prompt.
259+ Prompt string
260+
261+ // Placeholder is the text displayed when the user
262+ // hasn't entered anything yet.
263+ Placeholder string
264+
265+ // ShowLineNumbers, if enabled, causes line numbers to be printed
266+ // after the prompt.
267+ ShowLineNumbers bool
268+
269+ // EndOfBufferCharacter is displayed at the end of the input.
270+ EndOfBufferCharacter rune
271+
272+ // KeyMap encodes the keybindings recognized by the widget.
273+ KeyMap KeyMap
274+
275+ // virtualCursor manages the virtual cursor.
276+ virtualCursor cursor.Model
277+
278+ // CharLimit is the maximum number of characters this input element will
279+ // accept. If 0 or less, there's no limit.
280+ CharLimit int
281+
282+ // MaxHeight is the maximum height of the text area in rows. If 0 or less,
283+ // there's no limit.
284+ MaxHeight int
285+
286+ // MaxWidth is the maximum width of the text area in columns. If 0 or less,
287+ // there's no limit.
288+ MaxWidth int
289+
290+ // DynamicHeight, when true, causes the textarea to automatically grow
291+ // and shrink its height to fit the content. The height is clamped between
292+ // MinHeight and MaxHeight.
293+ DynamicHeight bool
294+
295+ // MinHeight is the minimum height of the text area in rows when
296+ // DynamicHeight is enabled. If 0 or less, defaults to 1.
297+ MinHeight int
298+
299+ // MaxContentHeight is the maximum content height in visual rows
300+ // (accounting for soft wraps). When set (> 0), input is blocked once
301+ // the total visual lines reach this limit, while MaxHeight controls
302+ // only the visible viewport height. When 0, the content guard falls
303+ // back to the legacy MaxHeight behavior (blocking at MaxHeight
304+ // logical lines) for backward compatibility.
305+ MaxContentHeight int
306+
307+ // Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles]
308+ // to work with this value publicly.
309+ styles Styles
310+
311+ // useVirtualCursor determines whether or not to use the virtual cursor.
312+ // Use [SetVirtualCursor] and [VirtualCursor] to work with this this
313+ // value publicly.
314+ useVirtualCursor bool
315+
316+ // If promptFunc is set, it replaces Prompt as a generator for
317+ // prompt strings at the beginning of each line.
318+ promptFunc func(PromptInfo) string
319+
320+ // promptWidth is the width of the prompt.
321+ promptWidth int
322+
323+ // width is the maximum number of characters that can be displayed at once.
324+ // If 0 or less this setting is ignored.
325+ width int
326+
327+ // height is the maximum number of lines that can be displayed at once. It
328+ // essentially treats the text field like a vertically scrolling viewport
329+ // if there are more lines than the permitted height.
330+ height int
331+
332+ // Underlying text value.
333+ value [][]rune
334+
335+ // focus indicates whether user input focus should be on this input
336+ // component. When false, ignore keyboard input and hide the cursor.
337+ focus bool
338+
339+ // Cursor column.
340+ col int
341+
342+ // Cursor row.
343+ row int
344+
345+ // Last character offset, used to maintain state when the cursor is moved
346+ // vertically such that we can maintain the same navigating position.
347+ lastCharOffset int
348+
349+ // viewport is the vertically-scrollable viewport of the multi-line text
350+ // input.
351+ viewport *viewport.Model
352+
353+ // rune sanitizer for input.
354+ rsan runeutil.Sanitizer
355+}
356+
357+// New creates a new model with default settings.
358+func New() Model {
359+ vp := viewport.New()
360+ vp.KeyMap = viewport.KeyMap{}
361+ cur := cursor.New()
362+
363+ styles := DefaultDarkStyles()
364+
365+ m := Model{
366+ CharLimit: defaultCharLimit,
367+ MaxHeight: defaultMaxHeight,
368+ MaxWidth: defaultMaxWidth,
369+ Prompt: lipgloss.ThickBorder().Left + " ",
370+ styles: styles,
371+ cache: memoization.NewMemoCache[line, [][]rune](maxLines),
372+ EndOfBufferCharacter: ' ',
373+ ShowLineNumbers: true,
374+ useVirtualCursor: true,
375+ virtualCursor: cur,
376+ KeyMap: DefaultKeyMap(),
377+
378+ value: make([][]rune, minHeight, maxLines),
379+ focus: false,
380+ col: 0,
381+ row: 0,
382+
383+ viewport: &vp,
384+ }
385+
386+ m.SetHeight(defaultHeight)
387+ m.SetWidth(defaultWidth)
388+
389+ return m
390+}
391+
392+// DefaultStyles returns the default styles for focused and blurred states for
393+// the textarea.
394+func DefaultStyles(isDark bool) Styles {
395+ lightDark := lipgloss.LightDark(isDark)
396+
397+ var s Styles
398+ s.Focused = StyleState{
399+ Base: lipgloss.NewStyle(),
400+ CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
401+ CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
402+ EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
403+ LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
404+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
405+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
406+ Text: lipgloss.NewStyle(),
407+ }
408+ s.Blurred = StyleState{
409+ Base: lipgloss.NewStyle(),
410+ CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
411+ CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
412+ EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
413+ LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
414+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
415+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
416+ Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
417+ }
418+ s.Cursor = CursorStyle{
419+ Color: lipgloss.Color("7"),
420+ Shape: tea.CursorBlock,
421+ Blink: true,
422+ }
423+ return s
424+}
425+
426+// DefaultLightStyles returns the default styles for a light background.
427+func DefaultLightStyles() Styles {
428+ return DefaultStyles(false)
429+}
430+
431+// DefaultDarkStyles returns the default styles for a dark background.
432+func DefaultDarkStyles() Styles {
433+ return DefaultStyles(true)
434+}
435+
436+// Styles returns the current styles for the textarea.
437+func (m Model) Styles() Styles {
438+ return m.styles
439+}
440+
441+// SetStyles updates styling for the textarea.
442+func (m *Model) SetStyles(s Styles) {
443+ m.styles = s
444+ m.updateVirtualCursorStyle()
445+}
446+
447+// VirtualCursor returns whether or not the virtual cursor is enabled.
448+func (m Model) VirtualCursor() bool {
449+ return m.useVirtualCursor
450+}
451+
452+// SetVirtualCursor sets whether or not to use the virtual cursor.
453+func (m *Model) SetVirtualCursor(v bool) {
454+ m.useVirtualCursor = v
455+ m.updateVirtualCursorStyle()
456+}
457+
458+// updateVirtualCursorStyle sets styling on the virtual cursor based on the
459+// textarea's style settings.
460+func (m *Model) updateVirtualCursorStyle() {
461+ if !m.useVirtualCursor {
462+ m.virtualCursor.SetMode(cursor.CursorHide)
463+ return
464+ }
465+
466+ m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.styles.Cursor.Color)
467+
468+ // By default, the blink speed of the cursor is set to a default
469+ // internally.
470+ if m.styles.Cursor.Blink {
471+ if m.styles.Cursor.BlinkSpeed > 0 {
472+ m.virtualCursor.BlinkSpeed = m.styles.Cursor.BlinkSpeed
473+ }
474+ m.virtualCursor.SetMode(cursor.CursorBlink)
475+ return
476+ }
477+ m.virtualCursor.SetMode(cursor.CursorStatic)
478+}
479+
480+// SetValue sets the value of the text input.
481+func (m *Model) SetValue(s string) {
482+ m.Reset()
483+ m.InsertString(s)
484+ m.recalculateHeight()
485+}
486+
487+// InsertString inserts a string at the cursor position.
488+func (m *Model) InsertString(s string) {
489+ m.insertRunesFromUserInput([]rune(s))
490+ m.recalculateHeight()
491+}
492+
493+// InsertRune inserts a rune at the cursor position.
494+func (m *Model) InsertRune(r rune) {
495+ m.insertRunesFromUserInput([]rune{r})
496+ m.recalculateHeight()
497+}
498+
499+// insertRunesFromUserInput inserts runes at the current cursor position.
500+func (m *Model) insertRunesFromUserInput(runes []rune) {
501+ // Clean up any special characters in the input provided by the
502+ // clipboard. This avoids bugs due to e.g. tab characters and
503+ // whatnot.
504+ runes = m.san().Sanitize(runes)
505+
506+ if m.CharLimit > 0 {
507+ availSpace := m.CharLimit - m.Length()
508+ // If the char limit's been reached, cancel.
509+ if availSpace <= 0 {
510+ return
511+ }
512+ // If there's not enough space to paste the whole thing cut the pasted
513+ // runes down so they'll fit.
514+ if availSpace < len(runes) {
515+ runes = runes[:availSpace]
516+ }
517+ }
518+
519+ // Split the input into lines.
520+ var lines [][]rune
521+ lstart := 0
522+ for i := range runes {
523+ if runes[i] == '\n' {
524+ // Queue a line to become a new row in the text area below.
525+ // Beware to clamp the max capacity of the slice, to ensure no
526+ // data from different rows get overwritten when later edits
527+ // will modify this line.
528+ lines = append(lines, runes[lstart:i:i])
529+ lstart = i + 1
530+ }
531+ }
532+ if lstart <= len(runes) {
533+ // The last line did not end with a newline character.
534+ // Take it now.
535+ lines = append(lines, runes[lstart:])
536+ }
537+
538+ // Obey the maximum line limit.
539+ if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines {
540+ allowedHeight := max(0, maxLines-len(m.value)+1)
541+ lines = lines[:allowedHeight]
542+ }
543+
544+ // Obey MaxContentHeight in visual rows when set.
545+ if m.MaxContentHeight > 0 {
546+ budget := m.MaxContentHeight - m.totalVisualLines()
547+ // Trim lines from the end until we fit within the budget.
548+ for len(lines) > 1 && m.visualLinesForInsert(lines) > budget {
549+ lines = lines[:len(lines)-1]
550+ }
551+ if m.visualLinesForInsert(lines) > budget {
552+ return
553+ }
554+ }
555+
556+ if len(lines) == 0 {
557+ // Nothing left to insert.
558+ return
559+ }
560+
561+ // Save the remainder of the original line at the current
562+ // cursor position.
563+ tail := make([]rune, len(m.value[m.row][m.col:]))
564+ copy(tail, m.value[m.row][m.col:])
565+
566+ // Paste the first line at the current cursor position.
567+ m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...)
568+ m.col += len(lines[0])
569+
570+ if numExtraLines := len(lines) - 1; numExtraLines > 0 {
571+ // Add the new lines.
572+ // We try to reuse the slice if there's already space.
573+ var newGrid [][]rune
574+ if cap(m.value) >= len(m.value)+numExtraLines {
575+ // Can reuse the extra space.
576+ newGrid = m.value[:len(m.value)+numExtraLines]
577+ } else {
578+ // No space left; need a new slice.
579+ newGrid = make([][]rune, len(m.value)+numExtraLines)
580+ copy(newGrid, m.value[:m.row+1])
581+ }
582+ // Add all the rows that were after the cursor in the original
583+ // grid at the end of the new grid.
584+ copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
585+ m.value = newGrid
586+ // Insert all the new lines in the middle.
587+ for _, l := range lines[1:] {
588+ m.row++
589+ m.value[m.row] = l
590+ m.col = len(l)
591+ }
592+ }
593+
594+ // Finally add the tail at the end of the last line inserted.
595+ m.value[m.row] = append(m.value[m.row], tail...)
596+
597+ m.SetCursorColumn(m.col)
598+}
599+
600+// Value returns the value of the text input.
601+func (m Model) Value() string {
602+ if m.value == nil {
603+ return ""
604+ }
605+
606+ var v strings.Builder
607+ for _, l := range m.value {
608+ v.WriteString(string(l))
609+ v.WriteByte('\n')
610+ }
611+
612+ return strings.TrimSuffix(v.String(), "\n")
613+}
614+
615+// Length returns the number of characters currently in the text input.
616+func (m *Model) Length() int {
617+ var l int
618+ for _, row := range m.value {
619+ l += uniseg.StringWidth(string(row))
620+ }
621+ // We add len(m.value) to include the newline characters.
622+ return l + len(m.value) - 1
623+}
624+
625+// LineCount returns the number of lines that are currently in the text input.
626+func (m *Model) LineCount() int {
627+ return len(m.value)
628+}
629+
630+// Line returns the 0-indexed row position of the cursor.
631+func (m Model) Line() int {
632+ return m.row
633+}
634+
635+// Column returns the 0-indexed column position of the cursor.
636+func (m Model) Column() int {
637+ return m.col
638+}
639+
640+// ScrollYOffset returns the Y offset (top row) index of the current view, which
641+// can be used to calculate the current scroll position.
642+func (m Model) ScrollYOffset() int {
643+ return m.viewport.YOffset()
644+}
645+
646+// ScrollPercent returns the amount of the textarea that is currently scrolled
647+// through, clamped between 0 and 1.
648+func (m Model) ScrollPercent() float64 {
649+ return m.viewport.ScrollPercent()
650+}
651+
652+// setCursorLineRelative moves the cursor by the given number of lines. Negative
653+// values move the cursor up, positive values move the cursor down.
654+func (m *Model) setCursorLineRelative(delta int) {
655+ if delta == 0 {
656+ return
657+ }
658+
659+ li := m.LineInfo()
660+ charOffset := max(m.lastCharOffset, li.CharOffset)
661+ m.lastCharOffset = charOffset
662+
663+ // 2 columns to account for the trailing space wrapping.
664+ const trailingSpace = 2
665+
666+ if delta > 0 { //nolint:nestif
667+ // Moving down.
668+ for range delta {
669+ if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
670+ m.row++
671+ m.col = 0
672+ } else {
673+ // Move the cursor to the start of the next virtual line.
674+ m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1)
675+ }
676+ li = m.LineInfo()
677+ }
678+ } else {
679+ // Moving up.
680+ for range -delta {
681+ if li.RowOffset <= 0 && m.row > 0 {
682+ m.row--
683+ m.col = len(m.value[m.row])
684+ } else {
685+ // Move the cursor to the end of the previous line.
686+ m.col = li.StartColumn - trailingSpace
687+ }
688+ li = m.LineInfo()
689+ }
690+ }
691+
692+ nli := m.LineInfo()
693+ m.col = nli.StartColumn
694+
695+ if nli.Width <= 0 {
696+ m.repositionView()
697+ return
698+ }
699+
700+ offset := 0
701+ for offset < charOffset {
702+ if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 {
703+ break
704+ }
705+ offset += rw.RuneWidth(m.value[m.row][m.col])
706+ m.col++
707+ }
708+ m.repositionView()
709+}
710+
711+// CursorDown moves the cursor down by one line.
712+func (m *Model) CursorDown() {
713+ m.setCursorLineRelative(1)
714+}
715+
716+// CursorUp moves the cursor up by one line.
717+func (m *Model) CursorUp() {
718+ m.setCursorLineRelative(-1)
719+}
720+
721+// SetCursorColumn moves the cursor to the given position. If the position is
722+// out of bounds the cursor will be moved to the start or end accordingly.
723+func (m *Model) SetCursorColumn(col int) {
724+ m.col = clamp(col, 0, len(m.value[m.row]))
725+ // Any time that we move the cursor horizontally we need to reset the last
726+ // offset so that the horizontal position when navigating is adjusted.
727+ m.lastCharOffset = 0
728+}
729+
730+// CursorStart moves the cursor to the start of the input field.
731+func (m *Model) CursorStart() {
732+ m.SetCursorColumn(0)
733+}
734+
735+// CursorEnd moves the cursor to the end of the input field.
736+func (m *Model) CursorEnd() {
737+ m.SetCursorColumn(len(m.value[m.row]))
738+}
739+
740+// Focused returns the focus state on the model.
741+func (m Model) Focused() bool {
742+ return m.focus
743+}
744+
745+// activeStyle returns the appropriate set of styles to use depending on
746+// whether the textarea is focused or blurred.
747+func (m Model) activeStyle() *StyleState {
748+ if m.focus {
749+ return &m.styles.Focused
750+ }
751+ return &m.styles.Blurred
752+}
753+
754+// Focus sets the focus state on the model. When the model is in focus it can
755+// receive keyboard input and the cursor will be hidden.
756+func (m *Model) Focus() tea.Cmd {
757+ m.focus = true
758+ return m.virtualCursor.Focus()
759+}
760+
761+// Blur removes the focus state on the model. When the model is blurred it can
762+// not receive keyboard input and the cursor will be hidden.
763+func (m *Model) Blur() {
764+ m.focus = false
765+ m.virtualCursor.Blur()
766+}
767+
768+// Reset sets the input to its default state with no input.
769+func (m *Model) Reset() {
770+ m.value = make([][]rune, minHeight, maxLines)
771+ m.col = 0
772+ m.row = 0
773+ m.viewport.GotoTop()
774+ m.SetCursorColumn(0)
775+ m.recalculateHeight()
776+}
777+
778+// Word returns the word at the cursor position.
779+// A word is delimited by spaces or line-breaks.
780+func (m *Model) Word() string {
781+ line := m.value[m.row]
782+ col := m.col - 1
783+
784+ if col < 0 {
785+ return ""
786+ }
787+
788+ // If cursor is beyond the line, return empty string
789+ if col >= len(line) {
790+ return ""
791+ }
792+
793+ // If cursor is on a space, return empty string
794+ if unicode.IsSpace(line[col]) {
795+ return ""
796+ }
797+
798+ // Find the start of the word by moving left
799+ start := col
800+ for start > 0 && !unicode.IsSpace(line[start-1]) {
801+ start--
802+ }
803+
804+ // Find the end of the word by moving right
805+ end := col
806+ for end < len(line) && !unicode.IsSpace(line[end]) {
807+ end++
808+ }
809+
810+ return string(line[start:end])
811+}
812+
813+// san initializes or retrieves the rune sanitizer.
814+func (m *Model) san() runeutil.Sanitizer {
815+ if m.rsan == nil {
816+ // Textinput has all its input on a single line so collapse
817+ // newlines/tabs to single spaces.
818+ m.rsan = runeutil.NewSanitizer()
819+ }
820+ return m.rsan
821+}
822+
823+// deleteBeforeCursor deletes all text before the cursor. Returns whether or
824+// not the cursor blink should be reset.
825+func (m *Model) deleteBeforeCursor() {
826+ m.value[m.row] = m.value[m.row][m.col:]
827+ m.SetCursorColumn(0)
828+}
829+
830+// deleteAfterCursor deletes all text after the cursor. Returns whether or not
831+// the cursor blink should be reset. If input is masked delete everything after
832+// the cursor so as not to reveal word breaks in the masked input.
833+func (m *Model) deleteAfterCursor() {
834+ m.value[m.row] = m.value[m.row][:m.col]
835+ m.SetCursorColumn(len(m.value[m.row]))
836+}
837+
838+// transposeLeft exchanges the runes at the cursor and immediately
839+// before. No-op if the cursor is at the beginning of the line. If
840+// the cursor is not at the end of the line yet, moves the cursor to
841+// the right.
842+func (m *Model) transposeLeft() {
843+ if m.col == 0 || len(m.value[m.row]) < 2 {
844+ return
845+ }
846+ if m.col >= len(m.value[m.row]) {
847+ m.SetCursorColumn(m.col - 1)
848+ }
849+ m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1]
850+ if m.col < len(m.value[m.row]) {
851+ m.SetCursorColumn(m.col + 1)
852+ }
853+}
854+
855+// deleteWordLeft deletes the word left to the cursor. Returns whether or not
856+// the cursor blink should be reset.
857+func (m *Model) deleteWordLeft() {
858+ if m.col == 0 || len(m.value[m.row]) == 0 {
859+ return
860+ }
861+
862+ // Linter note: it's critical that we acquire the initial cursor position
863+ // here prior to altering it via SetCursor() below. As such, moving this
864+ // call into the corresponding if clause does not apply here.
865+ oldCol := m.col
866+
867+ m.SetCursorColumn(m.col - 1)
868+ for unicode.IsSpace(m.value[m.row][m.col]) {
869+ if m.col <= 0 {
870+ break
871+ }
872+ // ignore series of whitespace before cursor
873+ m.SetCursorColumn(m.col - 1)
874+ }
875+
876+ for m.col > 0 {
877+ if !unicode.IsSpace(m.value[m.row][m.col]) {
878+ m.SetCursorColumn(m.col - 1)
879+ } else {
880+ if m.col > 0 {
881+ // keep the previous space
882+ m.SetCursorColumn(m.col + 1)
883+ }
884+ break
885+ }
886+ }
887+
888+ if oldCol > len(m.value[m.row]) {
889+ m.value[m.row] = m.value[m.row][:m.col]
890+ } else {
891+ m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...)
892+ }
893+}
894+
895+// deleteWordRight deletes the word right to the cursor.
896+func (m *Model) deleteWordRight() {
897+ if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 {
898+ return
899+ }
900+
901+ oldCol := m.col
902+
903+ for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) {
904+ // ignore series of whitespace after cursor
905+ m.SetCursorColumn(m.col + 1)
906+ }
907+
908+ for m.col < len(m.value[m.row]) {
909+ if !unicode.IsSpace(m.value[m.row][m.col]) {
910+ m.SetCursorColumn(m.col + 1)
911+ } else {
912+ break
913+ }
914+ }
915+
916+ if m.col > len(m.value[m.row]) {
917+ m.value[m.row] = m.value[m.row][:oldCol]
918+ } else {
919+ m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
920+ }
921+
922+ m.SetCursorColumn(oldCol)
923+}
924+
925+// characterRight moves the cursor one character to the right.
926+func (m *Model) characterRight() {
927+ if m.col < len(m.value[m.row]) {
928+ m.SetCursorColumn(m.col + 1)
929+ } else {
930+ if m.row < len(m.value)-1 {
931+ m.row++
932+ m.CursorStart()
933+ }
934+ }
935+}
936+
937+// characterLeft moves the cursor one character to the left.
938+// If insideLine is set, the cursor is moved to the last
939+// character in the previous line, instead of one past that.
940+func (m *Model) characterLeft(insideLine bool) {
941+ if m.col == 0 && m.row != 0 {
942+ m.row--
943+ m.CursorEnd()
944+ if !insideLine {
945+ return
946+ }
947+ }
948+ if m.col > 0 {
949+ m.SetCursorColumn(m.col - 1)
950+ }
951+}
952+
953+// wordLeft moves the cursor one word to the left. Returns whether or not the
954+// cursor blink should be reset. If input is masked, move input to the start
955+// so as not to reveal word breaks in the masked input.
956+func (m *Model) wordLeft() {
957+ for {
958+ m.characterLeft(true /* insideLine */)
959+ if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) {
960+ break
961+ }
962+ }
963+
964+ for m.col > 0 {
965+ if unicode.IsSpace(m.value[m.row][m.col-1]) {
966+ break
967+ }
968+ m.SetCursorColumn(m.col - 1)
969+ }
970+}
971+
972+// wordRight moves the cursor one word to the right. Returns whether or not the
973+// cursor blink should be reset. If the input is masked, move input to the end
974+// so as not to reveal word breaks in the masked input.
975+func (m *Model) wordRight() {
976+ m.doWordRight(func(int, int) { /* nothing */ })
977+}
978+
979+func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
980+ // Skip spaces forward.
981+ for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) {
982+ if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
983+ // End of text.
984+ break
985+ }
986+ m.characterRight()
987+ }
988+
989+ charIdx := 0
990+ for m.col < len(m.value[m.row]) {
991+ if unicode.IsSpace(m.value[m.row][m.col]) {
992+ break
993+ }
994+ fn(charIdx, m.col)
995+ m.SetCursorColumn(m.col + 1)
996+ charIdx++
997+ }
998+}
999+
1000+// uppercaseRight changes the word to the right to uppercase.
1001+func (m *Model) uppercaseRight() {
1002+ m.doWordRight(func(_ int, i int) {
1003+ m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i])
1004+ })
1005+}
1006+
1007+// lowercaseRight changes the word to the right to lowercase.
1008+func (m *Model) lowercaseRight() {
1009+ m.doWordRight(func(_ int, i int) {
1010+ m.value[m.row][i] = unicode.ToLower(m.value[m.row][i])
1011+ })
1012+}
1013+
1014+// capitalizeRight changes the word to the right to title case.
1015+func (m *Model) capitalizeRight() {
1016+ m.doWordRight(func(charIdx int, i int) {
1017+ if charIdx == 0 {
1018+ m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i])
1019+ }
1020+ })
1021+}
1022+
1023+// LineInfo returns the number of characters from the start of the
1024+// (soft-wrapped) line and the (soft-wrapped) line width.
1025+func (m Model) LineInfo() LineInfo {
1026+ grid := m.memoizedWrap(m.value[m.row], m.width)
1027+
1028+ // Find out which line we are currently on. This can be determined by the
1029+ // m.col and counting the number of runes that we need to skip.
1030+ var counter int
1031+ for i, line := range grid {
1032+ // We've found the line that we are on
1033+ if counter+len(line) == m.col && i+1 < len(grid) {
1034+ // We wrap around to the next line if we are at the end of the
1035+ // previous line so that we can be at the very beginning of the row
1036+ return LineInfo{
1037+ CharOffset: 0,
1038+ ColumnOffset: 0,
1039+ Height: len(grid),
1040+ RowOffset: i + 1,
1041+ StartColumn: m.col,
1042+ Width: len(grid[i+1]),
1043+ CharWidth: uniseg.StringWidth(string(line)),
1044+ }
1045+ }
1046+
1047+ if counter+len(line) >= m.col {
1048+ return LineInfo{
1049+ CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])),
1050+ ColumnOffset: m.col - counter,
1051+ Height: len(grid),
1052+ RowOffset: i,
1053+ StartColumn: counter,
1054+ Width: len(line),
1055+ CharWidth: uniseg.StringWidth(string(line)),
1056+ }
1057+ }
1058+
1059+ counter += len(line)
1060+ }
1061+ return LineInfo{}
1062+}
1063+
1064+// repositionView repositions the view of the viewport based on the defined
1065+// scrolling behavior.
1066+func (m *Model) repositionView() {
1067+ minimum := m.viewport.YOffset()
1068+ maximum := minimum + m.viewport.Height() - 1
1069+ if row := m.cursorLineNumber(); row < minimum {
1070+ m.viewport.ScrollUp(minimum - row)
1071+ } else if row > maximum {
1072+ m.viewport.ScrollDown(row - maximum)
1073+ }
1074+}
1075+
1076+// Width returns the width of the textarea.
1077+func (m Model) Width() int {
1078+ return m.width
1079+}
1080+
1081+// MoveToBegin moves the cursor to the beginning of the input.
1082+func (m *Model) MoveToBegin() {
1083+ m.row = 0
1084+ m.SetCursorColumn(0)
1085+ m.repositionView()
1086+}
1087+
1088+// MoveToEnd moves the cursor to the end of the input.
1089+func (m *Model) MoveToEnd() {
1090+ m.row = len(m.value) - 1
1091+ m.SetCursorColumn(len(m.value[m.row]))
1092+ m.repositionView()
1093+}
1094+
1095+// PageUp moves the cursor up by one page. First call snaps to the first visible
1096+// line, subsequent calls move up by a full page.
1097+func (m *Model) PageUp() {
1098+ // If not on the first visible line, snap to it.
1099+ if offset := m.viewport.YOffset() - m.cursorLineNumber(); offset < 0 {
1100+ m.setCursorLineRelative(offset)
1101+ return
1102+ }
1103+
1104+ // Already on first visible line, move up by a full page.
1105+ m.setCursorLineRelative(-m.height)
1106+}
1107+
1108+// PageDown moves the cursor down by one page. First call snaps to the last
1109+// visible line, subsequent calls move down by a full page.
1110+func (m *Model) PageDown() {
1111+ // If not on the last visible line, snap to it.
1112+ if offset := m.cursorLineNumber() - m.viewport.YOffset(); offset < m.height-1 {
1113+ m.setCursorLineRelative(m.height - 1 - offset)
1114+ return
1115+ }
1116+
1117+ // Already on last visible line, move down by a full page.
1118+ m.setCursorLineRelative(m.height)
1119+}
1120+
1121+// SetWidth sets the width of the textarea to fit exactly within the given width.
1122+// This means that the textarea will account for the width of the prompt and
1123+// whether or not line numbers are being shown.
1124+//
1125+// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers,
1126+// It is important that the width of the textarea be exactly the given width
1127+// and no more.
1128+func (m *Model) SetWidth(w int) {
1129+ // Update prompt width only if there is no prompt function as
1130+ // [SetPromptFunc] updates the prompt width when it is called.
1131+ if m.promptFunc == nil {
1132+ // XXX: Do we even need this or can we calculate the prompt width
1133+ // at render time?
1134+ m.promptWidth = uniseg.StringWidth(m.Prompt)
1135+ }
1136+
1137+ // Add base style borders and padding to reserved outer width.
1138+ reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize()
1139+
1140+ // Add prompt width to reserved inner width.
1141+ reservedInner := m.promptWidth
1142+
1143+ // Add line number width to reserved inner width.
1144+ if m.ShowLineNumbers {
1145+ // XXX: this was originally documented as needing "1 cell" but was,
1146+ // in practice, effectively hardcoded to 2 cells. We can, and should,
1147+ // reduce this to one gap and update the tests accordingly.
1148+ const gap = 2
1149+
1150+ // Number of digits plus 1 cell for the margin.
1151+ reservedInner += numDigits(m.MaxHeight) + gap
1152+ }
1153+
1154+ // Input width must be at least one more than the reserved inner and outer
1155+ // width. This gives us a minimum input width of 1.
1156+ minWidth := reservedInner + reservedOuter + 1
1157+ inputWidth := max(w, minWidth)
1158+
1159+ // Input width must be no more than maximum width.
1160+ if m.MaxWidth > 0 {
1161+ inputWidth = min(inputWidth, m.MaxWidth)
1162+ }
1163+
1164+ // Since the width of the viewport and input area is dependent on the width of
1165+ // borders, prompt and line numbers, we need to calculate it by subtracting
1166+ // the reserved width from them.
1167+
1168+ m.viewport.SetWidth(inputWidth - reservedOuter)
1169+ m.width = inputWidth - reservedOuter - reservedInner
1170+ m.recalculateHeight()
1171+}
1172+
1173+// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
1174+//
1175+// If the function returns a prompt that is shorter than the specified
1176+// promptWidth, it will be padded to the left. If it returns a prompt that is
1177+// longer, display artifacts may occur; the caller is responsible for computing
1178+// an adequate promptWidth.
1179+func (m *Model) SetPromptFunc(promptWidth int, fn func(PromptInfo) string) {
1180+ m.promptFunc = fn
1181+ m.promptWidth = promptWidth
1182+}
1183+
1184+// Height returns the current height of the textarea.
1185+func (m Model) Height() int {
1186+ return m.height
1187+}
1188+
1189+// SetHeight sets the height of the textarea.
1190+func (m *Model) SetHeight(h int) {
1191+ if m.MaxHeight > 0 {
1192+ m.height = clamp(h, minHeight, m.MaxHeight)
1193+ m.viewport.SetHeight(clamp(h, minHeight, m.MaxHeight))
1194+ } else {
1195+ m.height = max(h, minHeight)
1196+ m.viewport.SetHeight(max(h, minHeight))
1197+ }
1198+
1199+ m.repositionView()
1200+}
1201+
1202+// Update is the Bubble Tea update loop.
1203+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
1204+ if !m.focus {
1205+ m.virtualCursor.Blur()
1206+ return m, nil
1207+ }
1208+
1209+ // Used to determine if the cursor should blink.
1210+ oldRow, oldCol := m.cursorLineNumber(), m.col
1211+
1212+ var cmds []tea.Cmd
1213+
1214+ if m.value[m.row] == nil {
1215+ m.value[m.row] = make([]rune, 0)
1216+ }
1217+
1218+ if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
1219+ m.cache = memoization.NewMemoCache[line, [][]rune](m.MaxHeight)
1220+ }
1221+
1222+ switch msg := msg.(type) {
1223+ case tea.PasteMsg:
1224+ m.insertRunesFromUserInput([]rune(msg.Content))
1225+ case tea.KeyPressMsg:
1226+ switch {
1227+ case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
1228+ m.col = clamp(m.col, 0, len(m.value[m.row]))
1229+ if m.col >= len(m.value[m.row]) {
1230+ m.mergeLineBelow(m.row)
1231+ break
1232+ }
1233+ m.deleteAfterCursor()
1234+ case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
1235+ m.col = clamp(m.col, 0, len(m.value[m.row]))
1236+ if m.col <= 0 {
1237+ m.mergeLineAbove(m.row)
1238+ break
1239+ }
1240+ m.deleteBeforeCursor()
1241+ case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
1242+ m.col = clamp(m.col, 0, len(m.value[m.row]))
1243+ if m.col <= 0 {
1244+ m.mergeLineAbove(m.row)
1245+ break
1246+ }
1247+ if len(m.value[m.row]) > 0 {
1248+ m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
1249+ if m.col > 0 {
1250+ m.SetCursorColumn(m.col - 1)
1251+ }
1252+ }
1253+ case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
1254+ if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
1255+ m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
1256+ }
1257+ if m.col >= len(m.value[m.row]) {
1258+ m.mergeLineBelow(m.row)
1259+ break
1260+ }
1261+ case key.Matches(msg, m.KeyMap.DeleteWordBackward):
1262+ if m.col <= 0 {
1263+ m.mergeLineAbove(m.row)
1264+ break
1265+ }
1266+ m.deleteWordLeft()
1267+ case key.Matches(msg, m.KeyMap.DeleteWordForward):
1268+ m.col = clamp(m.col, 0, len(m.value[m.row]))
1269+ if m.col >= len(m.value[m.row]) {
1270+ m.mergeLineBelow(m.row)
1271+ break
1272+ }
1273+ m.deleteWordRight()
1274+ case key.Matches(msg, m.KeyMap.InsertNewline):
1275+ if m.atContentLimit() {
1276+ return m, nil
1277+ }
1278+ m.col = clamp(m.col, 0, len(m.value[m.row]))
1279+ m.splitLine(m.row, m.col)
1280+ case key.Matches(msg, m.KeyMap.LineEnd):
1281+ m.CursorEnd()
1282+ case key.Matches(msg, m.KeyMap.LineStart):
1283+ m.CursorStart()
1284+ case key.Matches(msg, m.KeyMap.CharacterForward):
1285+ m.characterRight()
1286+ case key.Matches(msg, m.KeyMap.LineNext):
1287+ m.CursorDown()
1288+ case key.Matches(msg, m.KeyMap.WordForward):
1289+ m.wordRight()
1290+ case key.Matches(msg, m.KeyMap.Paste):
1291+ return m, Paste
1292+ case key.Matches(msg, m.KeyMap.CharacterBackward):
1293+ m.characterLeft(false /* insideLine */)
1294+ case key.Matches(msg, m.KeyMap.LinePrevious):
1295+ m.CursorUp()
1296+ case key.Matches(msg, m.KeyMap.WordBackward):
1297+ m.wordLeft()
1298+ case key.Matches(msg, m.KeyMap.InputBegin):
1299+ m.MoveToBegin()
1300+ case key.Matches(msg, m.KeyMap.InputEnd):
1301+ m.MoveToEnd()
1302+ case key.Matches(msg, m.KeyMap.PageUp):
1303+ m.PageUp()
1304+ case key.Matches(msg, m.KeyMap.PageDown):
1305+ m.PageDown()
1306+ case key.Matches(msg, m.KeyMap.LowercaseWordForward):
1307+ m.lowercaseRight()
1308+ case key.Matches(msg, m.KeyMap.UppercaseWordForward):
1309+ m.uppercaseRight()
1310+ case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
1311+ m.capitalizeRight()
1312+ case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
1313+ m.transposeLeft()
1314+
1315+ default:
1316+ m.insertRunesFromUserInput([]rune(msg.Text))
1317+ }
1318+
1319+ case pasteMsg:
1320+ m.insertRunesFromUserInput([]rune(msg))
1321+
1322+ case pasteErrMsg:
1323+ m.Err = msg
1324+ }
1325+
1326+ m.recalculateHeight()
1327+
1328+ // Make sure we set the content of the viewport before updating it.
1329+ view := m.view()
1330+ m.viewport.SetContent(view)
1331+ vp, cmd := m.viewport.Update(msg)
1332+ m.viewport = &vp
1333+ cmds = append(cmds, cmd)
1334+
1335+ if m.useVirtualCursor {
1336+ m.virtualCursor, cmd = m.virtualCursor.Update(msg)
1337+
1338+ // If the cursor has moved, reset the blink state. This is a small UX
1339+ // nuance that makes cursor movement obvious and feel snappy.
1340+ newRow, newCol := m.cursorLineNumber(), m.col
1341+ if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink {
1342+ m.virtualCursor.IsBlinked = false
1343+ cmd = m.virtualCursor.Blink()
1344+ }
1345+ cmds = append(cmds, cmd)
1346+ }
1347+
1348+ m.repositionView()
1349+
1350+ return m, tea.Batch(cmds...)
1351+}
1352+
1353+func (m *Model) view() string {
1354+ if len(m.Value()) == 0 && m.row == 0 && m.col == 0 && m.Placeholder != "" {
1355+ return m.placeholderView()
1356+ }
1357+ m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine()
1358+
1359+ var (
1360+ s strings.Builder
1361+ style lipgloss.Style
1362+ newLines int
1363+ widestLineNumber int
1364+ lineInfo = m.LineInfo()
1365+ styles = m.activeStyle()
1366+ )
1367+
1368+ displayLine := 0
1369+ for l, line := range m.value {
1370+ wrappedLines := m.memoizedWrap(line, m.width)
1371+
1372+ if m.row == l {
1373+ style = styles.computedCursorLine()
1374+ } else {
1375+ style = styles.computedText()
1376+ }
1377+
1378+ for wl, wrappedLine := range wrappedLines {
1379+ prompt := m.promptView(displayLine)
1380+ s.WriteString(style.Render(prompt))
1381+ displayLine++
1382+
1383+ var ln string
1384+ if m.ShowLineNumbers {
1385+ if wl == 0 { // normal line
1386+ isCursorLine := m.row == l
1387+ s.WriteString(m.lineNumberView(l+1, isCursorLine))
1388+ } else { // soft wrapped line
1389+ isCursorLine := m.row == l
1390+ s.WriteString(m.lineNumberView(-1, isCursorLine))
1391+ }
1392+ }
1393+
1394+ // Note the widest line number for padding purposes later.
1395+ lnw := uniseg.StringWidth(ln)
1396+ if lnw > widestLineNumber {
1397+ widestLineNumber = lnw
1398+ }
1399+
1400+ strwidth := uniseg.StringWidth(string(wrappedLine))
1401+ padding := m.width - strwidth
1402+ // If the trailing space causes the line to be wider than the
1403+ // width, we should not draw it to the screen since it will result
1404+ // in an extra space at the end of the line which can look off when
1405+ // the cursor line is showing.
1406+ if strwidth > m.width {
1407+ // The character causing the line to be wider than the width is
1408+ // guaranteed to be a space since any other character would
1409+ // have been wrapped.
1410+ wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " "))
1411+ padding -= m.width - strwidth
1412+ }
1413+ if m.row == l && lineInfo.RowOffset == wl {
1414+ s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset])))
1415+ if m.col >= len(line) && lineInfo.CharOffset >= m.width {
1416+ m.virtualCursor.SetChar(" ")
1417+ s.WriteString(m.virtualCursor.View())
1418+ } else {
1419+ m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset]))
1420+ s.WriteString(style.Render(m.virtualCursor.View()))
1421+ s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:])))
1422+ }
1423+ } else {
1424+ s.WriteString(style.Render(string(wrappedLine)))
1425+ }
1426+ s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
1427+ s.WriteRune('\n')
1428+ newLines++
1429+ }
1430+ }
1431+
1432+ // Always show at least `m.Height` lines at all times.
1433+ // To do this we can simply pad out a few extra new lines in the view.
1434+ for range m.height {
1435+ s.WriteString(m.promptView(displayLine))
1436+ displayLine++
1437+
1438+ // Write end of buffer content
1439+ leftGutter := string(m.EndOfBufferCharacter)
1440+ rightGapWidth := m.Width() - uniseg.StringWidth(leftGutter) + widestLineNumber
1441+ rightGap := strings.Repeat(" ", max(0, rightGapWidth))
1442+ s.WriteString(styles.computedEndOfBuffer().Render(leftGutter + rightGap))
1443+ s.WriteRune('\n')
1444+ }
1445+
1446+ return s.String()
1447+}
1448+
1449+// View renders the text area in its current state.
1450+func (m Model) View() string {
1451+ // XXX: This is a workaround for the case where the viewport hasn't
1452+ // been initialized yet like during the initial render. In that case,
1453+ // we need to render the view again because Update hasn't been called
1454+ // yet to set the content of the viewport.
1455+ m.viewport.SetContent(m.view())
1456+ view := m.viewport.View()
1457+ styles := m.activeStyle()
1458+ return styles.Base.Render(view)
1459+}
1460+
1461+// promptView renders a single line of the prompt.
1462+func (m Model) promptView(displayLine int) (prompt string) {
1463+ prompt = m.Prompt
1464+ if m.promptFunc != nil {
1465+ prompt = m.promptFunc(PromptInfo{
1466+ LineNumber: displayLine,
1467+ Focused: m.focus,
1468+ })
1469+ width := lipgloss.Width(prompt)
1470+ if width < m.promptWidth {
1471+ prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
1472+ }
1473+ }
1474+
1475+ return m.activeStyle().computedPrompt().Render(prompt)
1476+}
1477+
1478+// lineNumberView renders the line number.
1479+//
1480+// If the argument is less than 0, a space styled as a line number is returned
1481+// instead. Such cases are used for soft-wrapped lines.
1482+//
1483+// The second argument indicates whether this line number is for a 'cursorline'
1484+// line number.
1485+func (m Model) lineNumberView(n int, isCursorLine bool) (str string) {
1486+ if !m.ShowLineNumbers {
1487+ return ""
1488+ }
1489+
1490+ if n <= 0 {
1491+ str = " "
1492+ } else {
1493+ str = strconv.Itoa(n)
1494+ }
1495+
1496+ // XXX: is textStyle really necessary here?
1497+ textStyle := m.activeStyle().computedText()
1498+ lineNumberStyle := m.activeStyle().computedLineNumber()
1499+ if isCursorLine {
1500+ textStyle = m.activeStyle().computedCursorLine()
1501+ lineNumberStyle = m.activeStyle().computedCursorLineNumber()
1502+ }
1503+
1504+ // Format line number dynamically based on the maximum number of lines.
1505+ digits := len(strconv.Itoa(m.MaxHeight))
1506+ str = fmt.Sprintf(" %*v ", digits, str)
1507+
1508+ return textStyle.Render(lineNumberStyle.Render(str))
1509+}
1510+
1511+// placeholderView returns the prompt and placeholder, if any.
1512+func (m Model) placeholderView() string {
1513+ var (
1514+ s strings.Builder
1515+ p = m.Placeholder
1516+ styles = m.activeStyle()
1517+ )
1518+ // word wrap lines
1519+ pwordwrap := ansi.Wordwrap(p, m.width, "")
1520+ // hard wrap lines (handles lines that could not be word wrapped)
1521+ pwrap := ansi.Hardwrap(pwordwrap, m.width, true)
1522+ // split string by new lines
1523+ plines := strings.Split(strings.TrimSpace(pwrap), "\n")
1524+
1525+ for i := range m.height {
1526+ isLineNumber := len(plines) > i
1527+
1528+ lineStyle := styles.computedPlaceholder()
1529+ if len(plines) > i {
1530+ lineStyle = styles.computedCursorLine()
1531+ }
1532+
1533+ // render prompt
1534+ prompt := m.promptView(i)
1535+ s.WriteString(lineStyle.Render(prompt))
1536+
1537+ // when show line numbers enabled:
1538+ // - render line number for only the cursor line
1539+ // - indent other placeholder lines
1540+ // this is consistent with vim with line numbers enabled
1541+ if m.ShowLineNumbers {
1542+ var ln int
1543+
1544+ switch {
1545+ case i == 0:
1546+ ln = i + 1
1547+ fallthrough
1548+ case len(plines) > i:
1549+ s.WriteString(m.lineNumberView(ln, isLineNumber))
1550+ default:
1551+ }
1552+ }
1553+
1554+ switch {
1555+ // first line
1556+ case i == 0:
1557+ // first character of first line as cursor with character
1558+ m.virtualCursor.TextStyle = styles.computedPlaceholder()
1559+
1560+ ch, rest, _, _ := uniseg.FirstGraphemeClusterInString(plines[0], 0)
1561+ m.virtualCursor.SetChar(ch)
1562+ s.WriteString(lineStyle.Render(m.virtualCursor.View()))
1563+
1564+ // the rest of the first line
1565+ s.WriteString(lineStyle.Render(styles.computedPlaceholder().Render(rest)))
1566+
1567+ // extend the first line with spaces to fill the width, so that
1568+ // the entire line is filled when cursorline is enabled.
1569+ gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(plines[0])))
1570+ s.WriteString(lineStyle.Render(gap))
1571+ // remaining lines
1572+ case len(plines) > i:
1573+ // current line placeholder text
1574+ if len(plines) > i {
1575+ placeholderLine := plines[i]
1576+ gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))
1577+ s.WriteString(lineStyle.Render(placeholderLine + gap))
1578+ }
1579+ default:
1580+ // end of line buffer character
1581+ eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
1582+ s.WriteString(eob)
1583+ }
1584+
1585+ // terminate with new line
1586+ s.WriteRune('\n')
1587+ }
1588+
1589+ m.viewport.SetContent(s.String())
1590+ return styles.Base.Render(m.viewport.View())
1591+}
1592+
1593+// Blink returns the blink command for the virtual cursor.
1594+func Blink() tea.Msg {
1595+ return cursor.Blink()
1596+}
1597+
1598+// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
1599+// program. This requires that [Model.VirtualCursor] is set to false.
1600+//
1601+// Note that you will almost certainly also need to adjust the offset cursor
1602+// position per the textarea's per the textarea's position in the terminal.
1603+//
1604+// Example:
1605+//
1606+// // In your top-level View function:
1607+// f := tea.NewFrame(m.textarea.View())
1608+// f.Cursor = m.textarea.Cursor()
1609+// f.Cursor.Position.X += offsetX
1610+// f.Cursor.Position.Y += offsetY
1611+func (m Model) Cursor() *tea.Cursor {
1612+ if m.useVirtualCursor || !m.Focused() {
1613+ return nil
1614+ }
1615+
1616+ lineInfo := m.LineInfo()
1617+ w := lipgloss.Width
1618+ baseStyle := m.activeStyle().Base
1619+
1620+ xOffset := lineInfo.CharOffset +
1621+ w(m.promptView(0)) +
1622+ w(m.lineNumberView(0, false)) +
1623+ baseStyle.GetMarginLeft() +
1624+ baseStyle.GetPaddingLeft() +
1625+ baseStyle.GetBorderLeftSize()
1626+
1627+ yOffset := m.cursorLineNumber() -
1628+ m.viewport.YOffset() +
1629+ baseStyle.GetMarginTop() +
1630+ baseStyle.GetPaddingTop() +
1631+ baseStyle.GetBorderTopSize()
1632+
1633+ c := tea.NewCursor(xOffset, yOffset)
1634+ c.Blink = m.styles.Cursor.Blink
1635+ c.Color = m.styles.Cursor.Color
1636+ c.Shape = m.styles.Cursor.Shape
1637+ return c
1638+}
1639+
1640+func (m Model) memoizedWrap(runes []rune, width int) [][]rune {
1641+ input := line{runes: runes, width: width}
1642+ if v, ok := m.cache.Get(input); ok {
1643+ return v
1644+ }
1645+ v := wrap(runes, width)
1646+ m.cache.Set(input, v)
1647+ return v
1648+}
1649+
1650+// cursorLineNumber returns the line number that the cursor is on.
1651+// This accounts for soft wrapped lines.
1652+func (m Model) cursorLineNumber() int {
1653+ line := 0
1654+ for i := range m.row {
1655+ // Calculate the number of lines that the current line will be split
1656+ // into.
1657+ line += len(m.memoizedWrap(m.value[i], m.width))
1658+ }
1659+ line += m.LineInfo().RowOffset
1660+ return line
1661+}
1662+
1663+// totalVisualLines returns the total number of display lines across all
1664+// logical lines, accounting for soft wraps.
1665+func (m *Model) totalVisualLines() int {
1666+ n := 0
1667+ for _, line := range m.value {
1668+ n += len(m.memoizedWrap(line, m.width))
1669+ }
1670+ return n
1671+}
1672+
1673+// recalculateHeight recomputes and applies the textarea height based on
1674+// content when DynamicHeight is enabled. It is a no-op otherwise.
1675+func (m *Model) recalculateHeight() {
1676+ if !m.DynamicHeight {
1677+ return
1678+ }
1679+ minH := max(m.MinHeight, minHeight)
1680+ total := m.totalVisualLines()
1681+ h := max(total, minH)
1682+ if m.MaxHeight > 0 {
1683+ h = min(h, m.MaxHeight)
1684+ }
1685+ if maxOffset := total - h; m.viewport.YOffset() > maxOffset {
1686+ m.viewport.SetYOffset(max(0, maxOffset))
1687+ }
1688+ m.SetHeight(h)
1689+}
1690+
1691+// atContentLimit reports whether the textarea has reached its content limit.
1692+// When MaxContentHeight is set (> 0), it checks total visual lines.
1693+// Otherwise it falls back to the legacy MaxHeight logical-line check for
1694+// backward compatibility.
1695+func (m *Model) atContentLimit() bool {
1696+ if m.MaxContentHeight > 0 {
1697+ return m.totalVisualLines() >= m.MaxContentHeight
1698+ }
1699+ return m.MaxHeight > 0 && len(m.value) >= m.MaxHeight
1700+}
1701+
1702+// visualLinesForInsert estimates how many additional visual lines would result
1703+// from inserting the given lines at the current cursor position. The first
1704+// element merges into the current line; subsequent elements become new lines.
1705+func (m *Model) visualLinesForInsert(lines [][]rune) int {
1706+ if len(lines) == 0 {
1707+ return 0
1708+ }
1709+
1710+ // The current row's visual line count before insertion.
1711+ currentRowVisual := len(m.memoizedWrap(m.value[m.row], m.width))
1712+
1713+ // Simulate merging the first paste line into the current row.
1714+ merged := make([]rune, m.col+len(lines[0]))
1715+ copy(merged, m.value[m.row][:m.col])
1716+ copy(merged[m.col:], lines[0])
1717+ if len(lines) == 1 {
1718+ merged = append(merged, m.value[m.row][m.col:]...)
1719+ }
1720+ delta := len(m.memoizedWrap(merged, m.width)) - currentRowVisual
1721+
1722+ // Each additional line is a new logical line.
1723+ for i, content := range lines {
1724+ if i == len(lines)-1 {
1725+ content = append(content, m.value[m.row][m.col:]...)
1726+ }
1727+ delta += len(m.memoizedWrap(content, m.width))
1728+ }
1729+
1730+ return delta
1731+}
1732+
1733+// mergeLineBelow merges the current line the cursor is on with the line below.
1734+func (m *Model) mergeLineBelow(row int) {
1735+ if row >= len(m.value)-1 {
1736+ return
1737+ }
1738+
1739+ // To perform a merge, we will need to combine the two lines and then
1740+ m.value[row] = append(m.value[row], m.value[row+1]...)
1741+
1742+ // Shift all lines up by one
1743+ for i := row + 1; i < len(m.value)-1; i++ {
1744+ m.value[i] = m.value[i+1]
1745+ }
1746+
1747+ // And, remove the last line
1748+ if len(m.value) > 0 {
1749+ m.value = m.value[:len(m.value)-1]
1750+ }
1751+}
1752+
1753+// mergeLineAbove merges the current line the cursor is on with the line above.
1754+func (m *Model) mergeLineAbove(row int) {
1755+ if row <= 0 {
1756+ return
1757+ }
1758+
1759+ m.col = len(m.value[row-1])
1760+ m.row = m.row - 1
1761+
1762+ // To perform a merge, we will need to combine the two lines and then
1763+ m.value[row-1] = append(m.value[row-1], m.value[row]...)
1764+
1765+ // Shift all lines up by one
1766+ for i := row; i < len(m.value)-1; i++ {
1767+ m.value[i] = m.value[i+1]
1768+ }
1769+
1770+ // And, remove the last line
1771+ if len(m.value) > 0 {
1772+ m.value = m.value[:len(m.value)-1]
1773+ }
1774+}
1775+
1776+func (m *Model) splitLine(row, col int) {
1777+ // To perform a split, take the current line and keep the content before
1778+ // the cursor, take the content after the cursor and make it the content of
1779+ // the line underneath, and shift the remaining lines down by one
1780+ head, tailSrc := m.value[row][:col], m.value[row][col:]
1781+ tail := make([]rune, len(tailSrc))
1782+ copy(tail, tailSrc)
1783+
1784+ m.value = append(m.value[:row+1], m.value[row:]...)
1785+
1786+ m.value[row] = head
1787+ m.value[row+1] = tail
1788+
1789+ m.col = 0
1790+ m.row++
1791+}
1792+
1793+// Paste is a command for pasting from the clipboard into the text input.
1794+func Paste() tea.Msg {
1795+ str, err := clipboard.ReadAll()
1796+ if err != nil {
1797+ return pasteErrMsg{err}
1798+ }
1799+ return pasteMsg(str)
1800+}
1801+
1802+func wrap(runes []rune, width int) [][]rune {
1803+ var (
1804+ lines = [][]rune{{}}
1805+ word = []rune{}
1806+ row int
1807+ spaces int
1808+ )
1809+
1810+ // Word wrap the runes
1811+ for _, r := range runes {
1812+ if unicode.IsSpace(r) {
1813+ spaces++
1814+ } else {
1815+ word = append(word, r)
1816+ }
1817+
1818+ if spaces > 0 { //nolint:nestif
1819+ if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width {
1820+ row++
1821+ lines = append(lines, []rune{})
1822+ lines[row] = append(lines[row], word...)
1823+ lines[row] = append(lines[row], repeatSpaces(spaces)...)
1824+ spaces = 0
1825+ word = nil
1826+ } else {
1827+ lines[row] = append(lines[row], word...)
1828+ lines[row] = append(lines[row], repeatSpaces(spaces)...)
1829+ spaces = 0
1830+ word = nil
1831+ }
1832+ } else {
1833+ // If the last character is a double-width rune, then we may not be able to add it to this line
1834+ // as it might cause us to go past the width.
1835+ lastCharLen := rw.RuneWidth(word[len(word)-1])
1836+ if uniseg.StringWidth(string(word))+lastCharLen > width {
1837+ // If the current line has any content, let's move to the next
1838+ // line because the current word fills up the entire line.
1839+ if len(lines[row]) > 0 {
1840+ row++
1841+ lines = append(lines, []rune{})
1842+ }
1843+ lines[row] = append(lines[row], word...)
1844+ word = nil
1845+ }
1846+ }
1847+ }
1848+
1849+ if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width {
1850+ lines = append(lines, []rune{})
1851+ lines[row+1] = append(lines[row+1], word...)
1852+ // We add an extra space at the end of the line to account for the
1853+ // trailing space at the end of the previous soft-wrapped lines so that
1854+ // behaviour when navigating is consistent and so that we don't need to
1855+ // continually add edges to handle the last line of the wrapped input.
1856+ spaces++
1857+ lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...)
1858+ } else {
1859+ lines[row] = append(lines[row], word...)
1860+ spaces++
1861+ lines[row] = append(lines[row], repeatSpaces(spaces)...)
1862+ }
1863+
1864+ return lines
1865+}
1866+
1867+func repeatSpaces(n int) []rune {
1868+ return []rune(strings.Repeat(string(' '), n))
1869+}
1870+
1871+// numDigits returns the number of digits in an integer.
1872+func numDigits(n int) int {
1873+ if n == 0 {
1874+ return 1
1875+ }
1876+ count := 0
1877+ num := abs(n)
1878+ for num > 0 {
1879+ count++
1880+ num /= 10
1881+ }
1882+ return count
1883+}
1884+
1885+func clamp(v, low, high int) int {
1886+ if high < low {
1887+ low, high = high, low
1888+ }
1889+ return min(high, max(low, v))
1890+}
1891+
1892+func abs(n int) int {
1893+ if n < 0 {
1894+ return -n
1895+ }
1896+ return n
1897+}
vendor/charm.land/bubbles/v2/textinput/styles.gonew file+96
@@ -0,0 +1,96 @@
1+package textinput
2+
3+import (
4+ "image/color"
5+ "time"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+)
10+
11+// DefaultStyles returns the default styles for focused and blurred states for
12+// the textarea.
13+func DefaultStyles(isDark bool) Styles {
14+ lightDark := lipgloss.LightDark(isDark)
15+
16+ var s Styles
17+ s.Focused = StyleState{
18+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
19+ Suggestion: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
20+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
21+ Text: lipgloss.NewStyle(),
22+ }
23+ s.Blurred = StyleState{
24+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
25+ Suggestion: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
26+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
27+ Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
28+ }
29+ s.Cursor = CursorStyle{
30+ Color: lipgloss.Color("7"),
31+ Shape: tea.CursorBlock,
32+ Blink: true,
33+ }
34+ return s
35+}
36+
37+// DefaultLightStyles returns the default styles for a light background.
38+func DefaultLightStyles() Styles {
39+ return DefaultStyles(false)
40+}
41+
42+// DefaultDarkStyles returns the default styles for a dark background.
43+func DefaultDarkStyles() Styles {
44+ return DefaultStyles(true)
45+}
46+
47+// Styles are the styles for the textarea, separated into focused and blurred
48+// states. The appropriate styles will be chosen based on the focus state of
49+// the textarea.
50+type Styles struct {
51+ Focused StyleState
52+ Blurred StyleState
53+ Cursor CursorStyle
54+}
55+
56+// StyleState that will be applied to the text area.
57+//
58+// StyleState can be applied to focused and unfocused states to change the styles
59+// depending on the focus state.
60+//
61+// For an introduction to styling with Lip Gloss see:
62+// https://github.com/charmbracelet/lipgloss
63+type StyleState struct {
64+ Text lipgloss.Style
65+ Placeholder lipgloss.Style
66+ Suggestion lipgloss.Style
67+ Prompt lipgloss.Style
68+}
69+
70+// CursorStyle is the style for real and virtual cursors.
71+type CursorStyle struct {
72+ // Style styles the cursor block.
73+ //
74+ // For real cursors, the foreground color set here will be used as the
75+ // cursor color.
76+ Color color.Color
77+
78+ // Shape is the cursor shape. The following shapes are available:
79+ //
80+ // - tea.CursorBlock
81+ // - tea.CursorUnderline
82+ // - tea.CursorBar
83+ //
84+ // This is only used for real cursors.
85+ Shape tea.CursorShape
86+
87+ // CursorBlink determines whether or not the cursor should blink.
88+ Blink bool
89+
90+ // BlinkSpeed is the speed at which the virtual cursor blinks. This has no
91+ // effect on real cursors as well as no effect if the cursor is set not to
92+ // [CursorBlink].
93+ //
94+ // By default, the blink speed is set to about 500ms.
95+ BlinkSpeed time.Duration
96+}
vendor/charm.land/bubbles/v2/textinput/textinput.gonew file+968

This diff is too big to show here.

vendor/charm.land/bubbles/v2/viewport/highlight.gonew file+141

This diff is too big to show here.

vendor/charm.land/bubbles/v2/viewport/keymap.gonew file+58

This diff is too big to show here.

vendor/charm.land/bubbles/v2/viewport/viewport.gonew file+765

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/.gitattributesnew file+1

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/.gitignorenew file+23

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/.golangci.ymlnew file+47

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/.goreleaser.ymlnew file+5

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/README.mdnew file+402

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/Taskfile.yamlnew file+38

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/UPGRADE_GUIDE_V2.mdnew file+573

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/clipboard.gonew file+70

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/color.gonew file+91

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/commands.gonew file+175

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/cursed_renderer.gonew file+860

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/cursor.gonew file+28

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/environ.gonew file+34

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/exec.gonew file+129

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/focus.gonew file+9

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/input.gonew file+54

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/key.gonew file+371

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/keyboard.gonew file+59

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/logging.gonew file+53

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/mod.gonew file+27

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/mouse.gonew file+144

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/nil_renderer.gonew file+53

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/options.gonew file+168

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/paste.gonew file+20

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/profile.gonew file+15

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/raw.gonew file+37

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/renderer.gonew file+104

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/screen.gonew file+68

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/signals_unix.gonew file+33

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/signals_windows.gonew file+10

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/tea.gonew file+1439

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/termcap.gonew file+48

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/termios_bsd.gonew file+13

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/termios_other.gonew file+8

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/termios_unix.gonew file+14

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/termios_windows.gonew file+11

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/tty.gonew file+136

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/tty_unix.gonew file+47

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/tty_windows.gonew file+64

This diff is too big to show here.

vendor/charm.land/bubbletea/v2/xterm.gonew file+22

This diff is too big to show here.

vendor/charm.land/catwalk/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/aihubmix.jsonnew file+3746

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/alibaba-singapore.jsonnew file+141

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/alibaba-united-states.jsonnew file+45

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/anthropic.jsonnew file+183

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/avian.jsonnew file+125

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/azure.jsonnew file+225

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/baseten.jsonnew file+187

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/bedrock-europe.jsonnew file+115

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/bedrock-united-states.jsonnew file+143

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/cerebras.jsonnew file+42

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/chutes.jsonnew file+227

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/copilot.jsonnew file+310

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/cortecs.jsonnew file+1697

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/deepseek.jsonnew file+45

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/fireworks.jsonnew file+171

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/gemini.jsonnew file+91

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/groq.jsonnew file+36

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/huggingface.jsonnew file+267

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/ionet.jsonnew file+335

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/kimi.jsonnew file+23

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/minimax-china.jsonnew file+91

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/minimax.jsonnew file+91

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/moonshot.jsonnew file+131

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/nebius.jsonnew file+419

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/neuralwatt.jsonnew file+155

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/openai.jsonnew file+352

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/opencode-go.jsonnew file+351

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/opencode-zen.jsonnew file+908

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/openrouter.jsonnew file+3831

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/qiniucloud.jsonnew file+179

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/scaleway.jsonnew file+141

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/synthetic.jsonnew file+197

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/venice.jsonnew file+1173

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/vercel.jsonnew file+3072

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/vertexai.jsonnew file+170

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/xai.jsonnew file+59

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/zai.jsonnew file+136

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/zhipu-coding.jsonnew file+136

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/configs/zhipu.jsonnew file+120

This diff is too big to show here.

vendor/charm.land/catwalk/internal/providers/providers.gonew file+349

This diff is too big to show here.

vendor/charm.land/catwalk/pkg/catwalk/client.gonew file+79

This diff is too big to show here.

vendor/charm.land/catwalk/pkg/catwalk/pkg.gonew file+2

This diff is too big to show here.

vendor/charm.land/catwalk/pkg/catwalk/provider.gonew file+157

This diff is too big to show here.

vendor/charm.land/catwalk/pkg/embedded/embedded.gonew file+13

This diff is too big to show here.

vendor/charm.land/fang/v2/.gitignorenew file+3

This diff is too big to show here.

vendor/charm.land/fang/v2/.golangci.ymlnew file+47

This diff is too big to show here.

vendor/charm.land/fang/v2/.goreleaser.ymlnew file+5

This diff is too big to show here.

vendor/charm.land/fang/v2/LICENSE.mdnew file+21

This diff is too big to show here.

vendor/charm.land/fang/v2/README.mdnew file+92

This diff is too big to show here.

vendor/charm.land/fang/v2/Taskfile.ymlnew file+7

This diff is too big to show here.

vendor/charm.land/fang/v2/UPGRADE_GUIDE_V2.mdnew file+127

This diff is too big to show here.

vendor/charm.land/fang/v2/fang.gonew file+208

This diff is too big to show here.

vendor/charm.land/fang/v2/fang_other.gonew file+10

This diff is too big to show here.

vendor/charm.land/fang/v2/fang_windows.gonew file+30

This diff is too big to show here.

vendor/charm.land/fang/v2/help.gonew file+482

This diff is too big to show here.

vendor/charm.land/fang/v2/theme.gonew file+222

This diff is too big to show here.

vendor/charm.land/fantasy/.editorconfignew file+18

This diff is too big to show here.

vendor/charm.land/fantasy/.gitattributesnew file+1

This diff is too big to show here.

vendor/charm.land/fantasy/.gitignorenew file+42

This diff is too big to show here.

vendor/charm.land/fantasy/.golangci.ymlnew file+44

This diff is too big to show here.

vendor/charm.land/fantasy/.goreleaser.ymlnew file+6

This diff is too big to show here.

vendor/charm.land/fantasy/AGENTS.mdnew file+29

This diff is too big to show here.

vendor/charm.land/fantasy/LICENSEnew file+177

This diff is too big to show here.

vendor/charm.land/fantasy/NOTICEnew file+13

This diff is too big to show here.

vendor/charm.land/fantasy/README.mdnew file+95

This diff is too big to show here.

vendor/charm.land/fantasy/Taskfile.yamlnew file+45

This diff is too big to show here.

vendor/charm.land/fantasy/agent.gonew file+1702

This diff is too big to show here.

vendor/charm.land/fantasy/content.gonew file+625

This diff is too big to show here.

vendor/charm.land/fantasy/content_json.gonew file+1046

This diff is too big to show here.

vendor/charm.land/fantasy/crush.jsonnew file+6

This diff is too big to show here.

vendor/charm.land/fantasy/cspell.jsonnew file+1

This diff is too big to show here.

vendor/charm.land/fantasy/doc.gonew file+2

This diff is too big to show here.

vendor/charm.land/fantasy/errors.gonew file+155

This diff is too big to show here.

vendor/charm.land/fantasy/jsonrepair/jsonrepair.gonew file+1648

This diff is too big to show here.

vendor/charm.land/fantasy/model.gonew file+261

This diff is too big to show here.

vendor/charm.land/fantasy/model_json.gonew file+156

This diff is too big to show here.

vendor/charm.land/fantasy/object.gonew file+236

This diff is too big to show here.

vendor/charm.land/fantasy/object/object.gonew file+620

This diff is too big to show here.

vendor/charm.land/fantasy/provider.gonew file+11

This diff is too big to show here.

vendor/charm.land/fantasy/provider_registry.gonew file+106

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/anthropic.gonew file+1707

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/bedrock.gonew file+15

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/call_useragent.gonew file+14

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/computer_use.gonew file+427

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/error.gonew file+67

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/google.gonew file+11

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/provider_options.gonew file+283

This diff is too big to show here.

vendor/charm.land/fantasy/providers/anthropic/sanitize.gonew file+33

This diff is too big to show here.

vendor/charm.land/fantasy/providers/azure/README.mdnew file+6

This diff is too big to show here.

vendor/charm.land/fantasy/providers/azure/azure.gonew file+125

This diff is too big to show here.

vendor/charm.land/fantasy/providers/bedrock/README.mdnew file+10

This diff is too big to show here.

vendor/charm.land/fantasy/providers/bedrock/bedrock.gonew file+87

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/README.mdnew file+63

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/auth.gonew file+15

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/call_useragent.gonew file+53

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/error.gonew file+51

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/google.gonew file+1488

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/provider_options.gonew file+146

This diff is too big to show here.

vendor/charm.land/fantasy/providers/google/slice.gonew file+11

This diff is too big to show here.

vendor/charm.land/fantasy/providers/internal/httpheaders/httpheaders.gonew file+60

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/call_useragent.gonew file+31

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/error.gonew file+92

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/language_model.gonew file+990

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/language_model_hooks.gonew file+668

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/openai.gonew file+220

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/provider_options.gonew file+217

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/responses_language_model.gonew file+1678

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openai/responses_options.gonew file+395

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openaicompat/language_model_hooks.gonew file+516

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openaicompat/openaicompat.gonew file+140

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openaicompat/provider_options.gonew file+161

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openrouter/language_model_hooks.gonew file+1063

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openrouter/openrouter.gonew file+121

This diff is too big to show here.

vendor/charm.land/fantasy/providers/openrouter/provider_options.gonew file+218

This diff is too big to show here.

vendor/charm.land/fantasy/providers/vercel/language_model_hooks.gonew file+1074

This diff is too big to show here.

vendor/charm.land/fantasy/providers/vercel/provider_options.gonew file+195

This diff is too big to show here.

vendor/charm.land/fantasy/providers/vercel/vercel.gonew file+122

This diff is too big to show here.

vendor/charm.land/fantasy/retry.gonew file+155

This diff is too big to show here.

vendor/charm.land/fantasy/schema/schema.gonew file+442

This diff is too big to show here.

vendor/charm.land/fantasy/tool.gonew file+176

This diff is too big to show here.

vendor/charm.land/fantasy/util.gonew file+22

This diff is too big to show here.

vendor/charm.land/fantasy/version.gonew file+12

This diff is too big to show here.

vendor/charm.land/fantasy/version.txtnew file+1

This diff is too big to show here.

vendor/charm.land/glamour/v2/.editorconfignew file+18

This diff is too big to show here.

vendor/charm.land/glamour/v2/.gitattributesnew file+2

This diff is too big to show here.

vendor/charm.land/glamour/v2/.gitignorenew file+2

This diff is too big to show here.

vendor/charm.land/glamour/v2/.golangci.ymlnew file+44

This diff is too big to show here.

vendor/charm.land/glamour/v2/.goreleaser.ymlnew file+6

This diff is too big to show here.

vendor/charm.land/glamour/v2/CONTRIBUTING.mdnew file+23

This diff is too big to show here.

vendor/charm.land/glamour/v2/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/glamour/v2/README.mdnew file+129

This diff is too big to show here.

vendor/charm.land/glamour/v2/Taskfile.ymlnew file+25

This diff is too big to show here.

vendor/charm.land/glamour/v2/UPGRADE_GUIDE_V2.mdnew file+279

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/ansi.gonew file+3

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/baseelement.gonew file+157

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/blockelement.gonew file+66

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/blockstack.gonew file+95

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/codeblock.gonew file+151

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/codespan.gonew file+15

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/context.gonew file+38

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/elements.gonew file+481

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/emphasis.gonew file+47

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/heading.gonew file+80

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/image.gonew file+57

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/link.gonew file+112

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/listitem.gonew file+29

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/margin.gonew file+231

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/paragraph.gonew file+63

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/renderer.gonew file+166

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/style.gonew file+252

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/table.gonew file+206

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/table_links.gonew file+234

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/task.gonew file+27

This diff is too big to show here.

vendor/charm.land/glamour/v2/ansi/templatehelper.gonew file+86

This diff is too big to show here.

vendor/charm.land/glamour/v2/examples.shnew file+19

This diff is too big to show here.

vendor/charm.land/glamour/v2/gallery.shnew file+23

This diff is too big to show here.

vendor/charm.land/glamour/v2/glamour.gonew file+294

This diff is too big to show here.

vendor/charm.land/glamour/v2/internal/autolink/autolink.gonew file+55

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/README.mdnew file+565

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/ascii.jsonnew file+87

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/dark.jsonnew file+191

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/dracula.gonew file+216

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/dracula.jsonnew file+188

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/light.jsonnew file+190

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/notty.jsonnew file+87

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/pink.jsonnew file+90

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/styles.gonew file+691

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/tokyo-night.gonew file+209

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/tokyo-night.jsonnew file+182

This diff is too big to show here.

vendor/charm.land/glamour/v2/styles/tokyo_night.jsonnew file+182

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/.editorconfignew file+18

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/.gitattributesnew file+1

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/.gitignorenew file+3

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/.golangci.ymlnew file+50

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/.goreleaser.ymlnew file+5

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/README.mdnew file+996

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/Taskfile.yamlnew file+24

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/UPGRADE_GUIDE_V2.mdnew file+504

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/align.gonew file+82

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/ansi_unix.gonew file+8

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/ansi_windows.gonew file+30

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/blending.gonew file+196

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/borders.gonew file+587

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/canvas.gonew file+88

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/color.gonew file+359

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/get.gonew file+657

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/join.gonew file+175

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/layer.gonew file+327

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/lipgloss.gonew file+3

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/position.gonew file+134

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/query.gonew file+92

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/ranges.gonew file+48

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/runes.gonew file+43

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/set.gonew file+932

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/size.gonew file+40

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/style.gonew file+637

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/table/resizing.gonew file+489

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/table/rows.gonew file+130

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/table/table.gonew file+568

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/table/util.gonew file+44

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/terminal.gonew file+124

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/tree/children.gonew file+100

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/tree/enumerator.gonew file+74

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/tree/renderer.gonew file+165

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/tree/tree.gonew file+407

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/unset.gonew file+359

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/whitespace.gonew file+76

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/wrap.gonew file+113

This diff is too big to show here.

vendor/charm.land/lipgloss/v2/writer.gonew file+160

This diff is too big to show here.

vendor/charm.land/log/v2/.gitignorenew file+17

This diff is too big to show here.

vendor/charm.land/log/v2/.golangci.ymlnew file+50

This diff is too big to show here.

vendor/charm.land/log/v2/.goreleaser.ymlnew file+6

This diff is too big to show here.

vendor/charm.land/log/v2/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/log/v2/README.mdnew file+367

This diff is too big to show here.

vendor/charm.land/log/v2/UPGRADE_GUIDE_V2.mdnew file+320

This diff is too big to show here.

vendor/charm.land/log/v2/context.gonew file+23

This diff is too big to show here.

vendor/charm.land/log/v2/doc.gonew file+3

This diff is too big to show here.

vendor/charm.land/log/v2/formatter.gonew file+27

This diff is too big to show here.

vendor/charm.land/log/v2/json.gonew file+157

This diff is too big to show here.

vendor/charm.land/log/v2/level.gonew file+65

This diff is too big to show here.

vendor/charm.land/log/v2/logfmt.gonew file+32

This diff is too big to show here.

vendor/charm.land/log/v2/logger.gonew file+413

This diff is too big to show here.

vendor/charm.land/log/v2/logger_121.gonew file+72

This diff is too big to show here.

vendor/charm.land/log/v2/logger_no121.gonew file+69

This diff is too big to show here.

vendor/charm.land/log/v2/options.gonew file+61

This diff is too big to show here.

vendor/charm.land/log/v2/pkg.gonew file+248

This diff is too big to show here.

vendor/charm.land/log/v2/stdlog.gonew file+67

This diff is too big to show here.

vendor/charm.land/log/v2/styles.gonew file+83

This diff is too big to show here.

vendor/charm.land/log/v2/text.gonew file+272

This diff is too big to show here.

vendor/charm.land/x/vcr/LICENSEnew file+21

This diff is too big to show here.

vendor/charm.land/x/vcr/doc.gonew file+3

This diff is too big to show here.

vendor/charm.land/x/vcr/hooks.gonew file+33

This diff is too big to show here.

vendor/charm.land/x/vcr/marshaler.gonew file+19

This diff is too big to show here.

vendor/charm.land/x/vcr/matcher.gonew file+70

This diff is too big to show here.

vendor/charm.land/x/vcr/recorder.gonew file+70

This diff is too big to show here.

vendor/cloud.google.com/go/LICENSEnew file+202

This diff is too big to show here.

vendor/cloud.google.com/go/auth/CHANGES.mdnew file+483

This diff is too big to show here.

vendor/cloud.google.com/go/auth/LICENSEnew file+202

This diff is too big to show here.

vendor/cloud.google.com/go/auth/README.mdnew file+40

This diff is too big to show here.

vendor/cloud.google.com/go/auth/auth.gonew file+618

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/compute.gonew file+102

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/detect.gonew file+471

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/doc.gonew file+45

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/filetypes.gonew file+329

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/aws_provider.gonew file+531

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/executable_provider.gonew file+284

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/externalaccount.gonew file+431

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/file_provider.gonew file+78

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/info.gonew file+74

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/programmatic_provider.gonew file+30

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/url_provider.gonew file+93

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccount/x509_provider.gonew file+220

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/externalaccountuser/externalaccountuser.gonew file+115

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/gdch/gdch.gonew file+198

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/impersonate/idtoken.gonew file+105

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/impersonate/impersonate.gonew file+168

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/internal/stsexchange/sts_exchange.gonew file+167

This diff is too big to show here.

vendor/cloud.google.com/go/auth/credentials/selfsignedjwt.gonew file+89

This diff is too big to show here.

vendor/cloud.google.com/go/auth/grpctransport/dial_socketopt.gonew file+62

This diff is too big to show here.

vendor/cloud.google.com/go/auth/grpctransport/directpath.gonew file+177

This diff is too big to show here.

vendor/cloud.google.com/go/auth/grpctransport/grpctransport.gonew file+675

This diff is too big to show here.

vendor/cloud.google.com/go/auth/grpctransport/pool.gonew file+119

This diff is too big to show here.

vendor/cloud.google.com/go/auth/httptransport/httptransport.gonew file+254

This diff is too big to show here.

vendor/cloud.google.com/go/auth/httptransport/transport.gonew file+548

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/compute/compute.gonew file+65

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/compute/manufacturer.gonew file+22

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/compute/manufacturer_linux.gonew file+23

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/compute/manufacturer_windows.gonew file+46

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/credsfile/credsfile.gonew file+63

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/credsfile/filetype.gonew file+159

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/credsfile/parse.gonew file+99

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/internal.gonew file+285

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/jwt/jwt.gonew file+208

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/retry/retry.gonew file+117

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/cba.gonew file+361

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/cert/default_cert.gonew file+65

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/cert/enterprise_cert.gonew file+54

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/cert/secureconnect_cert.gonew file+124

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/cert/workload_cert.gonew file+138

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/headers/headers.gonew file+61

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/s2a.gonew file+138

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/transport/transport.gonew file+137

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/trustboundary/external_accounts_config_providers.gonew file+100

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/trustboundary/trust_boundary.gonew file+392

This diff is too big to show here.

vendor/cloud.google.com/go/auth/internal/version.gonew file+20

This diff is too big to show here.

vendor/cloud.google.com/go/auth/oauth2adapt/CHANGES.mdnew file+82

This diff is too big to show here.

vendor/cloud.google.com/go/auth/oauth2adapt/LICENSEnew file+202

This diff is too big to show here.

vendor/cloud.google.com/go/auth/oauth2adapt/oauth2adapt.gonew file+200

This diff is too big to show here.

vendor/cloud.google.com/go/auth/threelegged.gonew file+382

This diff is too big to show here.

vendor/cloud.google.com/go/civil/civil.gonew file+500

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/CHANGES.mdnew file+115

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/LICENSEnew file+202

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/README.mdnew file+27

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/log.gonew file+149

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/metadata.gonew file+937

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/retry.gonew file+117

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/retry_linux.gonew file+31

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/syscheck.gonew file+28

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/syscheck_linux.gonew file+30

This diff is too big to show here.

vendor/cloud.google.com/go/compute/metadata/syscheck_windows.gonew file+39

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/.gitignorenew file+1

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/ARCHITECTURE.mdnew file+244

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/LICENSEnew file+61

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/README.mdnew file+86

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/constants.gonew file+65

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/internal/winrt/data/xml/dom/xmldocument.gonew file+73

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/internal/winrt/ui/notifications/toastnotification.gonew file+88

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/internal/winrt/ui/notifications/toastnotificationmanager.gonew file+53

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/internal/winrt/ui/notifications/toastnotificationmanagerforuser.gonew file+68

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/internal/winrt/ui/notifications/toastnotifier.gonew file+64

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/tmpl/powershell.go.tmplnew file+14

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/tmpl/tmpl.gonew file+28

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/tmpl/xml.go.tmplnew file+34

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/toast.gonew file+225

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/bind.gonew file+86

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/bind_noop.gonew file+19

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/bind_windows.gonew file+174

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/impl.gonew file+179

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/procs.gonew file+37

This diff is too big to show here.

vendor/git.sr.ht/~jackmordaunt/go-toast/wintoast/registry.gonew file+110

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/CHANGELOG.mdnew file+849

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/LICENSE.txtnew file+21

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/README.mdnew file+39

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/ci.ymlnew file+29

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud/cloud.gonew file+44

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud/doc.gonew file+53

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/core.gonew file+173

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/doc.gonew file+264

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/errors.gonew file+17

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/etag.gonew file+57

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/exported/exported.gonew file+175

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/exported/pipeline.gonew file+77

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/exported/request.gonew file+260

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/exported/response_error.gonew file+201

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/log/log.gonew file+50

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/async/async.gonew file+159

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/body/body.gonew file+135

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/fake/fake.gonew file+133

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/loc/loc.gonew file+123

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/op/op.gonew file+148

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/poller.gonew file+24

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/pollers/util.gonew file+212

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/shared/constants.gonew file+44

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/internal/shared/shared.gonew file+149

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/log/doc.gonew file+10

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/log/log.gonew file+55

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/policy/doc.gonew file+10

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/policy/policy.gonew file+198

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/doc.gonew file+10

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/errors.gonew file+27

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/pager.gonew file+138

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/pipeline.gonew file+94

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_api_version.gonew file+75

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_bearer_token.gonew file+236

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_body_download.gonew file+72

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_http_header.gonew file+40

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_http_trace.gonew file+154

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_include_response.gonew file+35

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_key_credential.gonew file+64

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_logging.gonew file+264

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_request_id.gonew file+34

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_retry.gonew file+276

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_sas_credential.gonew file+55

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/policy_telemetry.gonew file+83

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/poller.gonew file+396

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/request.gonew file+281

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/response.gonew file+109

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/transport_default_dialer_other.gonew file+15

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/transport_default_dialer_wasm.gonew file+15

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime/transport_default_http_client.gonew file+48

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming/doc.gonew file+9

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming/progress.gonew file+89

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/tracing/constants.gonew file+41

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/azcore/tracing/tracing.gonew file+191

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/LICENSE.txtnew file+21

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/diag/diag.gonew file+51

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/diag/doc.gonew file+7

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/errorinfo/doc.gonew file+7

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/errorinfo/errorinfo.gonew file+46

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/exported/exported.gonew file+129

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/log/doc.gonew file+7

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/log/log.gonew file+104

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/poller/util.gonew file+155

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/temporal/resource.gonew file+123

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/uuid/doc.gonew file+7

This diff is too big to show here.

vendor/github.com/Azure/azure-sdk-for-go/sdk/internal/uuid/uuid.gonew file+76

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/.gitignorenew file+14

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/CONTRIBUTING.mdnew file

No changes to the content.

vendor/github.com/JohannesKaufmann/html-to-markdown/LICENSEnew file+21

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/README.mdnew file+242

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/SECURITY.mdnew file+6

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/commonmark.gonew file+393

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/escape/escape.gonew file+65

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/from.gonew file+464

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/logo_five_years.pngnew file

Binary file not shown.

vendor/github.com/JohannesKaufmann/html-to-markdown/markdown.gonew file+212

This diff is too big to show here.

vendor/github.com/JohannesKaufmann/html-to-markdown/utils.gonew file+533

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/.gitignorenew file+16

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/.travis.ymlnew file+9

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/LICENSEnew file+21

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/Makefilenew file+32

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/README.mdnew file+232

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/depth.gonew file+129

This diff is too big to show here.

vendor/github.com/KyleBanks/depth/pkg.gonew file+184

This diff is too big to show here.

vendor/github.com/MakeNowJust/heredoc/LICENSEnew file+21

This diff is too big to show here.

vendor/github.com/MakeNowJust/heredoc/README.mdnew file+52

This diff is too big to show here.

vendor/github.com/MakeNowJust/heredoc/heredoc.gonew file+105

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/.gitattributesnew file+1

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/.gitignorenew file+10

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/.golangci.ymlnew file+147

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/CODEOWNERSnew file+1

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/LICENSEnew file+22

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/README.mdnew file+89

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/SECURITY.mdnew file+41

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/backup.gonew file+287

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/doc.gonew file+22

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/ea.gonew file+137

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/file.gonew file+320

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/fileinfo.gonew file+106

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/hvsock.gonew file+582

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/fs/doc.gonew file+2

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/fs/fs.gonew file+262

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/fs/security.gonew file+12

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/fs/zsyscall_windows.gonew file+61

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/socket/rawaddr.gonew file+20

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/socket/socket.gonew file+177

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/socket/zsyscall_windows.gonew file+69

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/internal/stringbuffer/wstring.gonew file+132

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/pipe.gonew file+586

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/pkg/guid/guid.gonew file+232

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/pkg/guid/guid_nonwindows.gonew file+16

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/pkg/guid/guid_windows.gonew file+13

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/pkg/guid/variant_string.gonew file+27

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/privilege.gonew file+196

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/reparse.gonew file+131

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/sd.gonew file+133

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/syscall.gonew file+5

This diff is too big to show here.

vendor/github.com/Microsoft/go-winio/zsyscall_windows.gonew file+378

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/.gitattributesnew file+1

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/.gitignorenew file+16

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/LICENSEnew file+12

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/README.mdnew file+218

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/array.gonew file+124

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/doc.gonew file+123

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/expand.gonew file+70

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/filter.gonew file+163

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/iteration.gonew file+61

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/manipulation.gonew file+679

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/property.gonew file+275

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/query.gonew file+49

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/traversal.gonew file+704

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/type.gonew file+203

This diff is too big to show here.

vendor/github.com/PuerkitoBio/goquery/utilities.gonew file+177

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/.editorconfignew file+17

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/.gitignorenew file+28

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/.golangci.ymlnew file+122

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/.goreleaser.ymlnew file+34

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/AGENTS.mdnew file+11

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/BUILD.bitnew file+93

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/COPYINGnew file+118

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/Dockerfilenew file+64

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/Justfilenew file+59

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/README.mdnew file+309

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/biome.jsonnew file+6

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/chroma.jpgnew file

Binary file not shown.

vendor/github.com/alecthomas/chroma/v2/coalesce.gonew file+35

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/colour.gonew file+192

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/delegate.gonew file+161

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/doc.gonew file+7

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/emitters.gonew file+233

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatter.gonew file+43

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/api.gonew file+57

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/html/html.gonew file+692

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/json.gonew file+39

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/svg/font_liberation_mono.gonew file+51

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/svg/svg.gonew file+222

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/tokens.gonew file+18

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/tty_indexed.gonew file+284

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/formatters/tty_truecolour.gonew file+76

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/iterator.gonew file+93

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexer.gonew file+179

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/README.mdnew file+46

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/caddyfile.gonew file+275

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/cl.gonew file+243

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/dns.gonew file+17

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/emacs.gonew file+533

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/abap.xmlnew file+154

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/abnf.xmlnew file+66

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/actionscript.xmlnew file+68

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/actionscript_3.xmlnew file+163

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ada.xmlnew file+321

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/agda.xmlnew file+66

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/al.xmlnew file+75

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/alloy.xmlnew file+58

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ampl.xmlnew file+98

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/angular2.xmlnew file+109

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/antlr.xmlnew file+317

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/apacheconf.xmlnew file+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/apl.xmlnew file+59

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/applescript.xmlnew file+151

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/arangodb_aql.xmlnew file+174

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/arduino.xmlnew file+322

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/armasm.xmlnew file+126

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/arturo.xmlnew file+119

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/atl.xmlnew file+165

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/autohotkey.xmlnew file+78

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/autoit.xmlnew file+70

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/awk.xmlnew file+95

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ballerina.xmlnew file+97

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bash.xmlnew file+222

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bash_session.xmlnew file+25

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/batchfile.xmlnew file+660

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/beef.xmlnew file+120

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bibtex.xmlnew file+152

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bicep.xmlnew file+84

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/blitzbasic.xmlnew file+141

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bnf.xmlnew file+28

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/bqn.xmlnew file+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/brainfuck.xmlnew file+51

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/c#.xmlnew file+121

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/c++.xmlnew file+331

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/c.xmlnew file+260

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/c3.xmlnew file+374

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cap_n_proto.xmlnew file+122

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cassandra_cql.xmlnew file+137

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ceylon.xmlnew file+151

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cfengine3.xmlnew file+197

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cfstatement.xmlnew file+92

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/chaiscript.xmlnew file+134

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/chapel.xmlnew file+143

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cheetah.xmlnew file+55

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/clojure.xmlnew file+71

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cmake.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cobol.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/coffeescript.xmlnew file+210

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/common_lisp.xmlnew file+184

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/coq.xmlnew file+136

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/core.xmlnew file+79

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/crystal.xmlnew file+762

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/css.xmlnew file+323

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/csv.xmlnew file+53

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cue.xmlnew file+85

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/cython.xmlnew file+372

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/d.xmlnew file+133

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/dart.xmlnew file+213

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/dax.xmlnew file+39

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/desktop_entry.xmlnew file+17

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/devicetree.xmlnew file+251

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/diff.xmlnew file+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/django_jinja.xmlnew file+153

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/dns.xmlnew file+44

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/docker.xmlnew file+68

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/dtd.xmlnew file+168

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/dylan.xmlnew file+176

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ebnf.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/elixir.xmlnew file+744

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/elm.xmlnew file+119

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/emacslisp.xmlnew file+132

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/erb.xmlnew file+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/erlang.xmlnew file+166

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/factor.xmlnew file+412

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/fennel.xmlnew file+68

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/fish.xmlnew file+159

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/forth.xmlnew file+78

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/fortran.xmlnew file+102

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/fortranfixed.xmlnew file+71

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/fsharp.xmlnew file+245

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gas.xmlnew file+150

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gdscript.xmlnew file+259

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gdscript3.xmlnew file+270

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gemfile_lock.xmlnew file+81

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gettext.xmlnew file+24

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gherkin.xmlnew file+263

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gleam.xmlnew file+100

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/glsl.xmlnew file+65

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/gnuplot.xmlnew file+289

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/go_template.xmlnew file+114

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/graphql.xmlnew file+92

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/groff.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/groovy.xmlnew file+135

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/handlebars.xmlnew file+147

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hare.xmlnew file+98

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/haskell.xmlnew file+275

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hcl.xmlnew file+143

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hexdump.xmlnew file+189

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hlb.xmlnew file+131

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hlsl.xmlnew file+110

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/holyc.xmlnew file+252

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/html.xmlnew file+159

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/hy.xmlnew file+104

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/idris.xmlnew file+216

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/igor.xmlnew file+47

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ini.xmlnew file+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/io.xmlnew file+71

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/iscdhcpd.xmlnew file+96

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/j.xmlnew file+157

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/janet.xmlnew file+48

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/java.xmlnew file+206

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/javascript.xmlnew file+160

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/json.xmlnew file+117

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/jsonata.xmlnew file+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/jsonnet.xmlnew file+138

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/julia.xmlnew file+400

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/jungle.xmlnew file+98

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/kakoune.xmlnew file+96

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/kdl.xmlnew file+75

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/kotlin.xmlnew file+234

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lateralus.xmlnew file+184

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lean.xmlnew file+56

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lighttpd_configuration_file.xmlnew file+42

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lilypond.xmlnew file+133

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/llvm.xmlnew file+73

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lox.xmlnew file+78

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/lua.xmlnew file+158

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/luau.xmlnew file+173

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/makefile.xmlnew file+131

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mako.xmlnew file+120

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mason.xmlnew file+89

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/materialize_sql_dialect.xmlnew file+155

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mathematica.xmlnew file+60

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/matlab.xmlnew file+114

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mcfunction.xmlnew file+138

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/meson.xmlnew file+86

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/metal.xmlnew file+270

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/microcad.xmlnew file+139

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/minizinc.xmlnew file+82

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mlir.xmlnew file+73

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/modelica.xmlnew file+106

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/modula-2.xmlnew file+245

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mojo.xmlnew file+228

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/monkeyc.xmlnew file+153

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/moonbit.xmlnew file+75

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/moonscript.xmlnew file+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/morrowindscript.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/myghty.xmlnew file+77

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/mysql.xmlnew file+121

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nasm.xmlnew file+126

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/natural.xmlnew file+143

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ndisasm.xmlnew file+123

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/newspeak.xmlnew file+121

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nginx_configuration_file.xmlnew file+101

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nim.xmlnew file+211

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nix.xmlnew file+258

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nsis.xmlnew file+59

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/nu.xmlnew file+126

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/objective-c.xmlnew file+510

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/objectpascal.xmlnew file+142

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ocaml.xmlnew file+153

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/octave.xmlnew file+101

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/odin.xmlnew file+127

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/onesenterprise.xmlnew file+92

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/openedge_abl.xmlnew file+101

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/openscad.xmlnew file+96

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/org_mode.xmlnew file+329

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/pacmanconf.xmlnew file+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/perl.xmlnew file+400

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/php.xmlnew file+258

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/pig.xmlnew file+105

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/pkgconfig.xmlnew file+73

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/pl_pgsql.xmlnew file+119

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/plaintext.xmlnew file+21

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/plutus_core.xmlnew file+105

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/pony.xmlnew file+135

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/postgresql_sql_dialect.xmlnew file+155

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/postscript.xmlnew file+93

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/povray.xmlnew file+58

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/powerquery.xmlnew file+51

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/powershell.xmlnew file+230

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/prolog.xmlnew file+115

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/promela.xmlnew file+119

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/promql.xmlnew file+123

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/properties.xmlnew file+45

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/protocol_buffer.xmlnew file+124

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/prql.xmlnew file+161

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/psl.xmlnew file+213

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/puppet.xmlnew file+100

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/python.xmlnew file+595

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/python_2.xmlnew file+356

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/qbasic.xmlnew file+173

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/qml.xmlnew file+113

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/r.xmlnew file+128

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/racket.xmlnew file+260

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ragel.xmlnew file+149

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/react.xmlnew file+236

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/reasonml.xmlnew file+147

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/reg.xmlnew file+68

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rego.xmlnew file+94

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rexx.xmlnew file+127

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rgbasm.xmlnew file+239

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ring.xmlnew file+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rpgle.xmlnew file+176

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rpm_spec.xmlnew file+58

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ruby.xmlnew file+728

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/rust.xmlnew file+375

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sas.xmlnew file+191

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sass.xmlnew file+362

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/scala.xmlnew file+274

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/scdoc.xmlnew file+115

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/scheme.xmlnew file+106

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/scilab.xmlnew file+98

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/scss.xmlnew file+373

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sed.xmlnew file+92

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sieve.xmlnew file+61

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/smali.xmlnew file+73

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/smalltalk.xmlnew file+294

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/smarty.xmlnew file+79

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/snbt.xmlnew file+58

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/snobol.xmlnew file+95

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/solidity.xmlnew file+291

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sourcepawn.xmlnew file+59

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/spade.xmlnew file+292

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sparql.xmlnew file+160

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/sql.xmlnew file+90

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/squidconf.xmlnew file+63

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/standard_ml.xmlnew file+548

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/stas.xmlnew file+85

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/stylus.xmlnew file+132

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/swift.xmlnew file+207

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/systemd.xmlnew file+63

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/systemverilog.xmlnew file+181

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tablegen.xmlnew file+69

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tal.xmlnew file+43

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tasm.xmlnew file+135

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tcl.xmlnew file+272

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tcsh.xmlnew file+121

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/templ.xmlnew file+315

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/termcap.xmlnew file+75

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/terminfo.xmlnew file+84

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/terraform.xmlnew file+149

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tex.xmlnew file+113

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/thrift.xmlnew file+154

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/toml.xmlnew file+45

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/tradingview.xmlnew file+81

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/transact-sql.xmlnew file+137

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/turing.xmlnew file+82

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/turtle.xmlnew file+170

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/twig.xml+155

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/txtpb.xml+162

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/typescript.xml+302

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/typoscript.xml+178

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/typoscriptcssdata.xml+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/typoscripthtmldata.xml+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/typst.xml+108

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/ucode.xml+147

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/v.xml+355

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/v_shell.xml+365

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/vala.xml+72

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/vb_net.xml+162

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/verilog.xml+158

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/vhdl.xml+171

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/vhs.xml+48

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/viml.xml+85

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/vue.xml+295

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/wat.xml+149

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/wdte.xml+43

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/webgpu_shading_language.xml+142

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/webvtt.xml+283

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/whiley.xml+57

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/xml.xml+96

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/xorg.xml+35

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/yaml.xml+132

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/yang.xml+99

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/z80_assembly.xml+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/zed.xml+51

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/embedded/zig.xml+187

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/erb.go+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/gemtext.go+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/genshi.go+118

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/go.go+81

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/haxe.go+647

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/html.go+8

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/http.go+131

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/lexers.go+85

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/markdown.go+107

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/markless.go+168

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/mysql.go+33

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/php.go+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/raku.go+1710

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/rst.go+89

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/svelte.go+70

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/typoscript.go+85

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/yaml_jinja.go+20

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/lexers/zed.go+24

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/mutators.go+201

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/pygments-lexers.txt+605

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/quick/quick.go+44

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/regexp.go+533

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/registry.go+228

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/remap.go+94

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/renovate.json5+25

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/serialise.go+483

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/style.go+530

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/abap.xml+11

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/algol.xml+18

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/algol_nu.xml+18

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/api.go+95

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/arduino.xml+18

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/ashen.xml+69

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/aura-theme-dark-soft.xml+107

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/aura-theme-dark.xml+107

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/autumn.xml+36

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/average.xml+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/base16-snazzy.xml+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/borland.xml+26

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/bw.xml+23

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/catppuccin-frappe.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/catppuccin-latte.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/catppuccin-macchiato.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/catppuccin-mocha.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/colorful.xml+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/compat.go+69

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/darcula.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/doom-one.xml+51

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/doom-one2.xml+64

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/dracula.xml+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/emacs.xml+44

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/evergarden.xml+33

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/friendly.xml+44

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/fruity.xml+19

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/github-dark.xml+46

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/github.xml+39

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/gruvbox-light.xml+34

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/gruvbox.xml+34

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/hr_high_contrast.xml+12

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/hrdark.xml+10

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/igor.xml+9

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/kanagawa-dragon.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/kanagawa-lotus.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/kanagawa-wave.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/lovelace.xml+53

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/manni.xml+44

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/modus-operandi.xml+13

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/modus-vivendi.xml+13

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/monokai.xml+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/monokailight.xml+26

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/murphy.xml+52

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/native.xml+35

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/nord.xml+46

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/nordic.xml+46

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/onedark.xml+25

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/onesenterprise.xml+10

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/paraiso-dark.xml+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/paraiso-light.xml+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/pastie.xml+45

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/perldoc.xml+37

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/pygments.xml+42

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rainbow_dash.xml+40

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rose-pine-dawn.xml+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rose-pine-moon.xml+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rose-pine.xml+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rpgle.xml+30

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/rrt.xml+19

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/solarized-dark.xml+39

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/solarized-dark256.xml+41

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/solarized-light.xml+17

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/swapoff.xml+18

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/tango.xml+72

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/tokyonight-day.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/tokyonight-moon.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/tokyonight-night.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/tokyonight-storm.xml+83

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/trac.xml+35

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/vim.xml+29

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/vs.xml+16

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/vulcan.xml+74

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/witchhazel.xml+31

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/xcode-dark.xml+31

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/styles/xcode.xml+22

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/table.py+31

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/test.jsonl+2

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/tokentype_enumer.go+588

This diff is too big to show here.

vendor/github.com/alecthomas/chroma/v2/types.go+357

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/.gitignore+2

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/LICENSE+19

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/README.md+19

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/backward_references.go+185

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/backward_references_hq.go+796

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/bit_cost.go+436

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/bit_reader.go+266

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/bitwriter.go+56

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/block_splitter.go+144

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/block_splitter_command.go+434

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/block_splitter_distance.go+433

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/block_splitter_literal.go+433

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/brotli_bit_stream.go+1435

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/cluster.go+30

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/cluster_command.go+164

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/cluster_distance.go+326

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/cluster_literal.go+326

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/command.go+254

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/compress_fragment.go+834

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/compress_fragment_two_pass.go+775

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/constants.go+77

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/context.go+2176

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/decode.go+2581

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/dictionary.go+122890

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/dictionary_hash.go+32779

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/encode.go+1220

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/encoder.go+177

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/encoder_dict.go+22

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/encoder_fast.go+197

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/entropy_encode.go+592

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/entropy_encode_static.go+4399

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/fast_log.go+290

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/find_match_length.go+45

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/LICENSE+27

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/README.md+2

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/gzip.go+65

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/huffman_bit_writer.go+517

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/huffman_code.go+346

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/token.go+80

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/flate/writer.go+66

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/h10.go+287

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/h5.go+214

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/h6.go+216

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/hash.go+342

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/hash_composite.go+93

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/hash_forgetful_chain.go+252

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/hash_longest_match_quickly.go+214

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/hash_rolling.go+168

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/histogram.go+226

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/http.go+189

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/huffman.go+653

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/literal_cost.go+182

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/bargain1.go+212

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/bargain2.go+248

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/bargain3.go+278

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/emitter.go+34

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/m0.go+175

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/m4.go+339

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/matchfinder.go+103

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/pathfinder.go+328

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/textencoder.go+53

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/trio.go+326

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/zdfast.go+258

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/zfast.go+206

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/matchfinder/zm.go+301

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/memory.go+66

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/metablock.go+574

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/metablock_command.go+165

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/metablock_distance.go+165

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/metablock_literal.go+165

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/params.go+37

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/platform.go+103

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/prefix.go+30

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/prefix_dec.go+723

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/quality.go+196

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/reader.go+111

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/ringbuffer.go+134

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/state.go+294

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/static_dict.go+662

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/static_dict_lut.go+75094

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/symbol_list.go+22

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/transform.go+641

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/utf8_util.go+70

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/util.go+7

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/write_bits.go+52

This diff is too big to show here.

vendor/github.com/andybalholm/brotli/writer.go+162

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/.travis.yml+14

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/LICENSE+24

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/README.md+144

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/parser.go+889

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/pseudo_classes.go+458

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/selector.go+586

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/serialize.go+176

This diff is too big to show here.

vendor/github.com/andybalholm/cascadia/specificity.go+26

This diff is too big to show here.

vendor/github.com/atotto/clipboard/.travis.yml+22

This diff is too big to show here.

vendor/github.com/atotto/clipboard/LICENSE+27

This diff is too big to show here.

vendor/github.com/atotto/clipboard/README.md+48

This diff is too big to show here.

vendor/github.com/atotto/clipboard/clipboard.go+20

This diff is too big to show here.

vendor/github.com/atotto/clipboard/clipboard_darwin.go+52

This diff is too big to show here.

vendor/github.com/atotto/clipboard/clipboard_plan9.go+42

This diff is too big to show here.

vendor/github.com/atotto/clipboard/clipboard_unix.go+149

This diff is too big to show here.

vendor/github.com/atotto/clipboard/clipboard_windows.go+157

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/NOTICE.txt+3

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/accountid_endpoint_mode.go+18

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/checksum.go+33

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/config.go+254

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/context.go+22

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/credential_cache.go+235

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/credentials.go+234

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/defaults/auto.go+38

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/defaults/configuration.go+43

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/defaults/defaults.go+50

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/defaults/doc.go+2

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/defaultsmode.go+95

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/doc.go+62

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/endpoints.go+247

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/errors.go+9

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/from_ptr.go+365

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/logging.go+119

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/logging_generate.go+95

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/metadata.go+213

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/middleware.go+168

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/osname.go+24

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/osname_go115.go+24

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/recursion_detection.go+94

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/request_id.go+27

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/request_id_retriever.go+57

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/middleware/user_agent.go+398

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/CHANGELOG.md+203

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/debug.go+144

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/decode.go+218

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/encode.go+167

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/error.go+23

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi/headers.go+24

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi/middleware.go+71

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi/transport.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi/transport_go117.go+12

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/header.go+175

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/header_value.go+521

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/message.go+99

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/array.go+61

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/encoder.go+80

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/map.go+78

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/middleware.go+62

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/object.go+68

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/query/value.go+117

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/restjson/decoder_util.go+85

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/protocol/xml/error_utils.go+48

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/ratelimit/none.go+20

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/ratelimit/token_bucket.go+96

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/ratelimit/token_rate_limit.go+83

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/request.go+25

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/restrict_file_permissions.go+21

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/adaptive.go+156

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/adaptive_ratelimit.go+158

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/adaptive_token_bucket.go+83

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/attempt_metrics.go+51

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/doc.go+80

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/errors.go+20

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/jitter_backoff.go+124

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/metadata.go+52

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/middleware.go+487

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/retry.go+103

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/retryable_error.go+228

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/standard.go+343

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/throttle_error.go+60

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retry/timeout_error.go+52

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/retryer.go+127

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/runtime.go+14

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/cache.go+115

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/const.go+40

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/header_rules.go+82

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/headers.go+70

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/hmac.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/host.go+75

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/scope.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/time.go+36

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4/util.go+80

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/v4/middleware.go+420

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/v4/presign_middleware.go+127

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/v4/stream.go+86

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/signer/v4/v4.go+564

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/to_ptr.go+297

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/transport/http/client.go+355

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/transport/http/content_type.go+42

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/transport/http/response_error.go+33

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/transport/http/response_error_middleware.go+56

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/transport/http/timeout_read_closer.go+109

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/types.go+42

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/aws/version.go+8

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/CHANGELOG.md+1097

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/auth_scheme_preference.go+19

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/config.go+237

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/defaultsmode.go+47

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/doc.go+20

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/env_config.go+966

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/generate.go+4

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/errors.go+22

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/ini.go+56

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/parse.go+109

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/sections.go+157

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/strings.go+89

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/token.go+32

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/tokenize.go+92

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/internal/ini/value.go+93

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/load_options.go+1371

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/local.go+51

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/provider.go+802

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/resolve.go+458

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/resolve_bearer_token.go+122

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/resolve_credentials.go+650

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/config/shared_config.go+1704

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/CHANGELOG.md+990

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/doc.go+4

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds/doc.go+58

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds/provider.go+241

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client/auth.go+48

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client/client.go+165

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client/endpoints.go+20

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client/middleware.go+164

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/provider.go+207

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/logincreds/dpop.go+150

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/logincreds/file.go+14

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/logincreds/provider.go+184

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/logincreds/token.go+110

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/processcreds/doc.go+92

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/processcreds/provider.go+296

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ssocreds/doc.go+81

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ssocreds/sso_cached_token.go+232

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ssocreds/sso_credentials_provider.go+165

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/ssocreds/sso_token_provider.go+147

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/static_provider.go+63

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/stscreds/assume_role_provider.go+338

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/credentials/stscreds/web_identity_provider.go+181

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/CHANGELOG.md+584

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_client.go+358

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetDynamicData.go+77

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetIAMInfo.go+103

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetInstanceIdentityDocument.go+110

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetMetadata.go+77

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetRegion.go+73

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetToken.go+119

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/api_op_GetUserData.go+61

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/auth.go+48

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/doc.go+12

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/endpoints.go+20

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config/resolvers.go+114

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/request_middleware.go+313

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/feature/ec2/imds/token_provider.go+261

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/auth.go+45

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/scheme.go+191

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/bearer_token_adapter.go+43

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/bearer_token_signer_adapter.go+35

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/credentials_adapter.go+46

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/smithy.go+2

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/v4signer_adapter.go+57

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/auth/smithy/v4signer_adapter_eventstream.go+51

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/configsources/CHANGELOG.md+544

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/configsources/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/configsources/config.go+65

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/configsources/endpoints.go+57

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/configsources/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/context/context.go+65

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/arn.go+94

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/doc.go+3

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/generate.go+7

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/host.go+51

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/partition.go+76

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/partitions.go+496

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn/partitions.json+267

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/endpoints.go+201

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2/CHANGELOG.md+519

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2/endpoints.go+302

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/rand/rand.go+33

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sdk/interfaces.go+9

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sdk/time.go+74

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sdkio/byte.go+12

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/shareddefaults/shared_config.go+47

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/strings/strings.go+11

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight/LICENSE+28

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight/docs.go+7

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight/singleflight.go+210

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/timeconv/duration.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/CHANGELOG.md+486

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/credentials.go+141

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/error.go+17

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/crypto/compare.go+30

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/crypto/ecc.go+113

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/const.go+36

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/header_rules.go+82

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/headers.go+68

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/hmac.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/host.go+75

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/time.go+36

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/internal/v4/util.go+64

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/middleware.go+118

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/presign_middleware.go+117

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/smithy.go+92

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/internal/v4a/v4a.go+520

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding/CHANGELOG.md+220

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding/accept_encoding_gzip.go+176

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding/doc.go+22

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/CHANGELOG.md+571

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/context.go+56

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/doc.go+3

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url/middleware.go+110

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/CHANGELOG.md+85

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_client.go+945

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_CreateOAuth2Token.go+212

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_DeleteConsoleAuthorizationConfiguration.go+168

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_DeleteResourcePermissionStatement.go+196

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_GetConsoleAuthorizationConfiguration.go+168

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_GetResourcePolicy.go+155

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_ListResourcePermissionStatements.go+261

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_PutConsoleAuthorizationConfiguration.go+168

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/api_op_PutResourcePermissionStatement.go+216

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/auth.go+361

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/deserializers.go+2474

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/doc.go+9

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/endpoints.go+854

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/generated.json+40

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/internal/endpoints/endpoints.go+333

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/options.go+242

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/serializers.go+724

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/types/enums.go+46

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/types/errors.go+247

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/types/types.go+162

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/signin/validators.go+111

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/CHANGELOG.md+792

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/api_client.go+929

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/api_op_GetRoleCredentials.go+165

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/api_op_ListAccountRoles.go+263

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/api_op_ListAccounts.go+261

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/api_op_Logout.go+164

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/auth.go+379

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/deserializers.go+1171

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/doc.go+27

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/endpoints.go+570

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/generated.json+35

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints/endpoints.go+628

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/options.go+238

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/serializers.go+309

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/types/errors.go+115

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/types/types.go+63

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sso/validators.go+175

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/CHANGELOG.md+790

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/api_client.go+929

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/api_op_CreateToken.go+235

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/api_op_CreateTokenWithIAM.go+282

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/api_op_RegisterClient.go+206

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/api_op_StartDeviceAuthorization.go+188

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/auth.go+373

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/deserializers.go+2244

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/doc.go+49

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/endpoints.go+570

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/generated.json+36

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints/endpoints.go+628

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/options.go+238

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/serializers.go+512

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/types/enums.go+44

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/types/errors.go+430

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/types/types.go+25

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/ssooidc/validators.go+184

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/CHANGELOG.md+832

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/LICENSE.txt+202

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_client.go+1115

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_AssumeRole.go+544

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_AssumeRoleWithSAML.go+455

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_AssumeRoleWithWebIdentity.go+472

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_AssumeRoot.go+229

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_DecodeAuthorizationMessage.go+189

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetAccessKeyInfo.go+180

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetCallerIdentity.go+192

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetDelegatedAccessToken.go+169

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetFederationToken.go+393

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetSessionToken.go+239

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/api_op_GetWebIdentityToken.go+192

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/auth.go+377

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/deserializers.go+3456

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/doc.go+13

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/endpoints.go+683

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/generated.json+45

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints/endpoints.go+585

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/options.go+286

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/serializers.go+1198

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/types/errors.go+363

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/types/types.go+144

This diff is too big to show here.

vendor/github.com/aws/aws-sdk-go-v2/service/sts/validators.go+433

This diff is too big to show here.

vendor/github.com/aws/smithy-go/.gitignore+29

This diff is too big to show here.

vendor/github.com/aws/smithy-go/.travis.yml+28

This diff is too big to show here.

vendor/github.com/aws/smithy-go/AGENTS.md+172

This diff is too big to show here.

vendor/github.com/aws/smithy-go/CHANGELOG.md+454

This diff is too big to show here.

vendor/github.com/aws/smithy-go/CODE_OF_CONDUCT.md+4

This diff is too big to show here.

vendor/github.com/aws/smithy-go/CONTRIBUTING.md+90

This diff is too big to show here.

vendor/github.com/aws/smithy-go/LICENSE+175

This diff is too big to show here.

vendor/github.com/aws/smithy-go/Makefile+130

This diff is too big to show here.

vendor/github.com/aws/smithy-go/NOTICE+1

This diff is too big to show here.

vendor/github.com/aws/smithy-go/README.md+107

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/auth.go+3

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/bearer/docs.go+3

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/bearer/middleware.go+104

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/bearer/token.go+50

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/bearer/token_cache.go+208

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/identity.go+47

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/option.go+25

This diff is too big to show here.

vendor/github.com/aws/smithy-go/auth/scheme_id.go+20

This diff is too big to show here.

vendor/github.com/aws/smithy-go/changelog-template.json+9

This diff is too big to show here.

vendor/github.com/aws/smithy-go/context/suppress_expired.go+81

This diff is too big to show here.

vendor/github.com/aws/smithy-go/doc.go+2

This diff is too big to show here.

vendor/github.com/aws/smithy-go/document.go+10

This diff is too big to show here.

vendor/github.com/aws/smithy-go/document/doc.go+12

This diff is too big to show here.

vendor/github.com/aws/smithy-go/document/document.go+239

This diff is too big to show here.

vendor/github.com/aws/smithy-go/document/errors.go+75

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/doc.go+4

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/encoding.go+40

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/httpbinding/encode.go+123

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/httpbinding/header.go+122

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/httpbinding/path_replace.go+108

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/httpbinding/query.go+107

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/httpbinding/uri.go+111

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/array.go+35

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/constants.go+15

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/decoder_util.go+139

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/encoder.go+30

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/escape.go+198

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/object.go+40

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/json/value.go+154

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/array.go+49

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/constants.go+10

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/doc.go+49

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/element.go+91

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/encoder.go+51

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/error_utils.go+51

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/escape.go+137

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/map.go+53

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/value.go+302

This diff is too big to show here.

vendor/github.com/aws/smithy-go/encoding/xml/xml_decoder.go+154

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/endpoint.go+23

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/bdd/evaluate.go+35

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/rulesfn/doc.go+4

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/rulesfn/split.go+16

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/rulesfn/string_slice.go+18

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/rulesfn/strings.go+25

This diff is too big to show here.

vendor/github.com/aws/smithy-go/endpoints/private/rulesfn/uri.go+133

This diff is too big to show here.

vendor/github.com/aws/smithy-go/errors.go+137

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/const.go+24

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/debug.go+144

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/decode.go+218

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/deserializer.go+294

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/encode.go+167

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/error.go+23

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/header.go+175

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/header_value.go+521

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/message.go+99

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/serializer.go+228

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/signer.go+82

This diff is too big to show here.

vendor/github.com/aws/smithy-go/eventstream/types.go+26

This diff is too big to show here.

vendor/github.com/aws/smithy-go/go_module_metadata.go+6

This diff is too big to show here.

vendor/github.com/aws/smithy-go/internal/sync/singleflight/LICENSE+28

This diff is too big to show here.

vendor/github.com/aws/smithy-go/internal/sync/singleflight/docs.go+8

This diff is too big to show here.

vendor/github.com/aws/smithy-go/internal/sync/singleflight/singleflight.go+210

This diff is too big to show here.

vendor/github.com/aws/smithy-go/io/byte.go+12

This diff is too big to show here.

vendor/github.com/aws/smithy-go/io/doc.go+2

This diff is too big to show here.

vendor/github.com/aws/smithy-go/io/reader.go+16

This diff is too big to show here.

vendor/github.com/aws/smithy-go/io/ringbuffer.go+94

This diff is too big to show here.

vendor/github.com/aws/smithy-go/local-mod-replace.sh+39

This diff is too big to show here.

vendor/github.com/aws/smithy-go/logging/logger.go+82

This diff is too big to show here.

vendor/github.com/aws/smithy-go/metrics/metrics.go+136

This diff is too big to show here.

vendor/github.com/aws/smithy-go/metrics/nop.go+98

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/context.go+41

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/doc.go+67

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/eventstream_middleware.go+21

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/logging.go+46

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/metadata.go+65

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/middleware.go+71

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/ordered_group.go+270

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/stack.go+209

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/stack_values.go+100

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/step_build.go+298

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/step_deserialize.go+300

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/step_finalize.go+298

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/step_initialize.go+299

This diff is too big to show here.

vendor/github.com/aws/smithy-go/middleware/step_serialize.go+305

This diff is too big to show here.

vendor/github.com/aws/smithy-go/modman.toml+9

This diff is too big to show here.

vendor/github.com/aws/smithy-go/private/requestcompression/gzip.go+30

This diff is too big to show here.

vendor/github.com/aws/smithy-go/private/requestcompression/middleware_capture_request_compression.go+52

This diff is too big to show here.

vendor/github.com/aws/smithy-go/private/requestcompression/request_compression.go+103

This diff is too big to show here.

vendor/github.com/aws/smithy-go/properties.go+69

This diff is too big to show here.

vendor/github.com/aws/smithy-go/ptr/doc.go+5

This diff is too big to show here.

vendor/github.com/aws/smithy-go/ptr/from_ptr.go+601

This diff is too big to show here.

vendor/github.com/aws/smithy-go/ptr/gen_scalars.go+83

This diff is too big to show here.

vendor/github.com/aws/smithy-go/ptr/to_ptr.go+499

This diff is too big to show here.

vendor/github.com/aws/smithy-go/rand/doc.go+3

This diff is too big to show here.

vendor/github.com/aws/smithy-go/rand/rand.go+31

This diff is too big to show here.

vendor/github.com/aws/smithy-go/rand/uuid.go+87

This diff is too big to show here.

vendor/github.com/aws/smithy-go/schema.go+328

This diff is too big to show here.

vendor/github.com/aws/smithy-go/schema_ext.go+37

This diff is too big to show here.

vendor/github.com/aws/smithy-go/serde.go+229

This diff is too big to show here.

vendor/github.com/aws/smithy-go/sync/error.go+53

This diff is too big to show here.

vendor/github.com/aws/smithy-go/time/time.go+134

This diff is too big to show here.

vendor/github.com/aws/smithy-go/tracing/context.go+96

This diff is too big to show here.

vendor/github.com/aws/smithy-go/tracing/nop.go+32

This diff is too big to show here.

vendor/github.com/aws/smithy-go/tracing/tracing.go+95

This diff is too big to show here.

vendor/github.com/aws/smithy-go/trait.go+21

This diff is too big to show here.

vendor/github.com/aws/smithy-go/traits/http.go+69

This diff is too big to show here.

vendor/github.com/aws/smithy-go/traits/index.go+107

This diff is too big to show here.

vendor/github.com/aws/smithy-go/traits/serde.go+56

This diff is too big to show here.

vendor/github.com/aws/smithy-go/traits/traits.go+72

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/auth.go+30

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/auth_schemes.go+45

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/checksum_middleware.go+70

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/client.go+161

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/doc.go+5

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/eventstream.go+209

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/eventstream_middleware.go+69

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/headerlist.go+163

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/host.go+89

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/interceptor.go+321

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/interceptor_middleware.go+325

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/internal/io/safe.go+75

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/md5_checksum.go+25

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/metrics.go+204

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_close_response_body.go+79

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_content_length.go+84

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_header_comment.go+81

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_headers.go+167

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_http_logging.go+75

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_metadata.go+51

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/middleware_min_proto.go+79

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/properties.go+80

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/protocol.go+27

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/request.go+188

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/response.go+34

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/time.go+13

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/url.go+44

This diff is too big to show here.

vendor/github.com/aws/smithy-go/transport/http/user_agent.go+37

This diff is too big to show here.

vendor/github.com/aws/smithy-go/type_registry.go+70

This diff is too big to show here.

vendor/github.com/aws/smithy-go/validation.go+140

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/LICENSE-BSD+27

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/LICENSE-MIT+21

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/Makefile+4

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/README.md+142

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/diff.go+177

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/export.go+16

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/common.go+179

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/doc.go+156

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/export.go+11

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/git.sh+33

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/labels.go+55

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/old.go+475

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/lcs/sequence.go+70

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/merge.go+81

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/ndiff.go+118

This diff is too big to show here.

vendor/github.com/aymanbagabas/go-udiff/unified.go+314

This diff is too big to show here.

vendor/github.com/aymerick/douceur/LICENSE+22

This diff is too big to show here.

vendor/github.com/aymerick/douceur/css/declaration.go+60

This diff is too big to show here.

vendor/github.com/aymerick/douceur/css/rule.go+230

This diff is too big to show here.

vendor/github.com/aymerick/douceur/css/stylesheet.go+25

This diff is too big to show here.

vendor/github.com/aymerick/douceur/parser/parser.go+409

This diff is too big to show here.

vendor/github.com/bahlo/generic-list-go/LICENSE+27

This diff is too big to show here.

vendor/github.com/bahlo/generic-list-go/README.md+5

This diff is too big to show here.

vendor/github.com/bahlo/generic-list-go/list.go+235

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/.codecov.yml+10

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/.gitignore+32

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/LICENSE+22

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/README.md+495

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/UPGRADING.md+63

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/doublestar.go+13

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/glob.go+497

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/globoptions.go+188

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/globwalk.go+438

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/match.go+419

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/utils.go+165

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/utils_unix.go+19

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/utils_windows.go+43

This diff is too big to show here.

vendor/github.com/bmatcuk/doublestar/v4/validate.go+80

This diff is too big to show here.

vendor/github.com/buger/jsonparser/.gitignore+12

This diff is too big to show here.

vendor/github.com/buger/jsonparser/.travis.yml+12

This diff is too big to show here.

vendor/github.com/buger/jsonparser/Dockerfile+12

This diff is too big to show here.

vendor/github.com/buger/jsonparser/LICENSE+21

This diff is too big to show here.

vendor/github.com/buger/jsonparser/Makefile+36

This diff is too big to show here.

vendor/github.com/buger/jsonparser/README.md+361

This diff is too big to show here.

vendor/github.com/buger/jsonparser/bytes.go+47

This diff is too big to show here.

vendor/github.com/buger/jsonparser/bytes_safe.go+25

This diff is too big to show here.

vendor/github.com/buger/jsonparser/bytes_unsafe.go+44

This diff is too big to show here.

vendor/github.com/buger/jsonparser/escape.go+173

This diff is too big to show here.

vendor/github.com/buger/jsonparser/fuzz.go+117

This diff is too big to show here.

vendor/github.com/buger/jsonparser/oss-fuzz-build.sh+47

This diff is too big to show here.

vendor/github.com/buger/jsonparser/parser.go+1318

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/LICENSE.txt+22

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/README.md+74

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/testall.sh+10

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash.go+243

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_amd64.s+209

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_arm64.s+183

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_asm.go+15

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_other.go+76

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_safe.go+16

This diff is too big to show here.

vendor/github.com/cespare/xxhash/v2/xxhash_unsafe.go+58

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/.gitignore+2

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/LICENSE+21

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/Makefile+101

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/README.md+193

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/adapters.go+102

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/dirent.go+73

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/dirent_portable.go+134

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/dirent_unix.go+146

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/entry_filter_portable.go+37

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/entry_filter_unix.go+59

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/entry_filter_windows.go+156

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/fastwalk.go+697

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/fastwalk_darwin.go+122

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/fastwalk_portable.go+65

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/fastwalk_unix.go+131

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/go.LICENSE+27

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent.go+85

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_aix.go+29

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_dragonfly.go+54

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_freebsd.go+46

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_js.go+27

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_linux.go+50

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_netbsd.go+46

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_openbsd.go+46

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/dirent_solaris.go+29

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/dirent/doc.go+2

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/fmtdirent/fmtdirent_go120.go+31

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/internal/fmtdirent/fmtdirent_go121.go+15

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/zsyscall_darwin.go+93

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/zsyscall_darwin_amd64.1_13.s+22

This diff is too big to show here.

vendor/github.com/charlievieth/fastwalk/zsyscall_darwin_arm64.1_13.s+22

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/.gitignore+4

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/.release-please-manifest.json+3

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/.stats.yml+4

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/Brewfile+1

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/CHANGELOG.md+641

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/CONTRIBUTING.md+65

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/LICENSE+8

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/README.md+999

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/SECURITY.md+27

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/aliases.go+72

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/api.md+545

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/bedrock/bedrock.go+295

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/beta.go+374

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betafile.go+273

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betamessage.go+11734

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betamessagebatch.go+925

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betamessageutil.go+459

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betamodel.go+151

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betaskill.go+362

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betaskillversion.go+373

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/betatoolrunner.go+435

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/client.go+157

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/completion.go+196

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/field.go+46

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apierror/apierror.go+58

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiform/encoder.go+473

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiform/form.go+5

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiform/richparam.go+21

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiform/tag.go+51

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/decoder.go+709

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/encoder.go+392

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/enum.go+145

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/field.go+23

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/port.go+120

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/registry.go+59

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/subfield.go+68

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/tag.go+47

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apijson/union.go+209

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiquery/encoder.go+414

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiquery/query.go+55

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiquery/richparam.go+20

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/apiquery/tag.go+44

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/decode.go+1325

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/encode.go+1396

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/fold.go+48

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/indent.go+182

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/scanner.go+610

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/sentinel/null.go+47

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/shims/shims.go+113

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/stream.go+512

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/tables.go+218

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/tags.go+38

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/encoding/json/time.go+62

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/paramutil/field.go+30

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/paramutil/union.go+49

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/requestconfig/requestconfig.go+638

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/internal/version.go+5

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/message.go+9436

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/messagebatch.go+827

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/messageutil.go+413

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/model.go+151

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/option/middleware.go+38

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/option/requestoption.go+285

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/jsonl/jsonl.go+62

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/pagination/pagination.go+351

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/param/encoder.go+109

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/param/null.go+19

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/param/option.go+122

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/param/param.go+189

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/respjson/respjson.go+88

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/packages/ssestream/ssestream.go+198

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/release-please-config.json+68

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/schemautil.go+244

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/shared/constant/constants.go+487

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/shared/shared.go+346

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/tools.md+292

This diff is too big to show here.

vendor/github.com/charmbracelet/anthropic-sdk-go/vertex/vertex.go+134

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/.golangci.yml+47

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/.goreleaser.yml+6

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/README.md+109

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/doc.go+4

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/env.go+294

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/env_other.go+8

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/env_windows.go+40

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/profile.go+108

This diff is too big to show here.

vendor/github.com/charmbracelet/colorprofile/writer.go+171

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/.gitignore+4

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/.release-please-manifest.json+3

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/.stats.yml+4

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/Brewfile+1

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/CHANGELOG.md+1171

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/CONTRIBUTING.md+65

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/LICENSE+201

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/MIGRATION.md+284

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/README.md+1007

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/SECURITY.md+29

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/aliases.go+585

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/api.md+774

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/audio.go+61

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/audiospeech.go+179

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/audiotranscription.go+950

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/audiotranslation.go+120

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/azure/azure.go+269

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/batch.go+464

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/beta.go+36

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betaassistant.go+2284

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betachatkit.go+125

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betachatkitsession.go+83

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betachatkitthread.go+1564

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betathread.go+1574

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betathreadmessage.go+1717

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betathreadrun.go+974

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/betathreadrunstep.go+1396

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/chat.go+30

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/chatcompletion.go+3521

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/chatcompletionmessage.go+100

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/client.go+185

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/completion.go+436

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/container.go+648

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/containerfile.go+287

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/containerfilecontent.go+50

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/conversations/aliases.go+585

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/conversations/api.md+31

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/conversations/conversation.go+493

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/conversations/item.go+1082

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/embedding.go+207

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/field.go+45

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/file.go+352

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuning.go+35

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningalpha.go+29

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningalphagrader.go+675

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningcheckpoint.go+29

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningcheckpointpermission.go+352

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningjob.go+884

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningjobcheckpoint.go+152

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/finetuningmethod.go+1487

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/grader.go+28

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/gradergradermodel.go+1753

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/image.go+1337

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apierror/apierror.go+58

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiform/encoder.go+473

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiform/form.go+5

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiform/richparam.go+20

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiform/tag.go+72

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/decoder.go+688

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/encoder.go+371

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/enum.go+140

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/field.go+23

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/port.go+120

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/registry.go+51

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/subfield.go+67

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/tag.go+69

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apijson/union.go+208

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiquery/encoder.go+394

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiquery/query.go+55

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiquery/richparam.go+20

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/apiquery/tag.go+44

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/decode.go+1324

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/encode.go+1396

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/fold.go+48

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/indent.go+182

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/scanner.go+610

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/sentinel/null.go+46

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/shims/shims.go+113

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/stream.go+512

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/tables.go+218

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/tags.go+38

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/encoding/json/time.go+61

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/paramutil/field.go+30

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/paramutil/union.go+48

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/requestconfig/requestconfig.go+644

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/internal/version.go+5

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/model.go+137

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/moderation.go+489

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/option/middleware.go+38

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/option/requestoption.go+299

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/pagination/pagination.go+329

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/param/encoder.go+109

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/param/null.go+19

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/param/option.go+122

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/param/param.go+186

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/respjson/respjson.go+88

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/packages/ssestream/ssestream.go+247

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/polling.go+129

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/realtime/aliases.go+585

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/realtime/api.md+55

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/realtime/call.go+133

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/realtime/clientsecret.go+1826

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/realtime/realtime.go+1979

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/release-please-config.json+68

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/responses/aliases.go+585

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/responses/api.md+240

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/responses/inputitem.go+135

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/responses/inputtoken.go+336

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/responses/response.go+21786

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/shared/constant/constants.go+1143

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/shared/shared.go+1183

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/skill.go+292

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/skillcontent.go+46

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/skillversion.go+283

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/skillversioncontent.go+50

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/streamaccumulator.go+215

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/upload.go+238

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/uploadpart.go+115

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/vectorstore.go+911

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/vectorstorefile.go+535

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/vectorstorefilebatch.go+427

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/video.go+677

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/webhooks/aliases.go+585

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/webhooks/api.md+20

This diff is too big to show here.

vendor/github.com/charmbracelet/openai-go/webhooks/webhook.go+1306

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/.gitattributes+1

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/.golangci.yml+48

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/.goreleaser.yml+5

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/README.md+161

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/TUTORIAL.md+175

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/border.go+231

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/buffer.go+831

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/cancelreader_other.go+16

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/cancelreader_windows.go+139

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/cell.go+465

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/console.go+219

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/console_unix.go+13

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/console_windows.go+13

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/cursor.go+22

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/decoder.go+2102

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/doc.go+46

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/environ.go+32

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/event.go+567

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/internal/casso/math.go+172

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/internal/casso/solver.go+275

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/internal/lru/lru.go+69

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/key.go+762

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/key_table.go+664

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/layout/cache.go+29

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/layout/constraint.go+174

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/layout/flex.go+210

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/layout/layout.go+973

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/layout/padding.go+66

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/logger.go+6

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/mouse.go+150

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll.go+38

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_bsd.go+165

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_default.go+12

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_fallback.go+158

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_linux.go+175

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_select.go+156

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_solaris.go+12

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/poll_windows.go+217

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/screen/context.go+379

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/screen/screen.go+117

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/styled.go+337

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/tabstop.go+142

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal.go+394

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_bsdly.go+10

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_bsdly_other.go+8

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_other.go+22

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_reader.go+503

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_reader_other.go+13

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_reader_windows.go+336

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_renderer.go+1639

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_renderer_hardscroll.go+243

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_renderer_hashmap.go+298

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_screen.go+837

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_tabdly.go+10

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_tabdly_other.go+8

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_unix.go+56

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/terminal_windows.go+77

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/tty.go+42

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/tty_other.go+26

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/tty_unix.go+39

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/tty_windows.go+39

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/utils.go+15

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/uv.go+406

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/winch.go+62

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/winch_other.go+17

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/winch_unix.go+50

This diff is too big to show here.

vendor/github.com/charmbracelet/ultraviolet/window.go+147

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/ansi.go+11

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/ascii.go+8

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/background.go+178

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/c0.go+79

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/c1.go+72

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/charset.go+55

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/clipboard.go+75

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/color.go+784

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/ctrl.go+156

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/cursor.go+637

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/cwd.go+26

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/doc.go+7

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/finalterm.go+67

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/focus.go+9

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/graphics.go+62

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/hyperlink.go+28

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/inband.go+24

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/iterm2.go+18

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/keypad.go+28

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty.go+90

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty/decoder.go+86

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty/encoder.go+64

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty/graphics.go+414

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty/options.go+373

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/kitty/writer.go+187

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/method.go+196

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/mode.go+690

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/mode_deprecated.go+495

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/modes.go+65

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/mouse.go+172

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/notification.go+32

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/palette.go+34

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser.go+417

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser/const.go+79

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser/seq.go+136

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser/transition_table.go+273

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser_decode.go+525

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser_handler.go+60

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/parser_sync.go+29

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/passthrough.go+60

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/paste.go+7

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/progress.go+49

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/reset.go+11

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/screen.go+410

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/sgr.go+79

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/status.go+168

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/style.go+819

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/termcap.go+41

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/title.go+48

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/truncate.go+290

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/urxvt.go+17

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/util.go+92

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/width.go+107

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/winop.go+59

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/wrap.go+472

This diff is too big to show here.

vendor/github.com/charmbracelet/x/ansi/xterm.go+138

This diff is too big to show here.

vendor/github.com/charmbracelet/x/editor/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/editor/editor.go+140

This diff is too big to show here.

vendor/github.com/charmbracelet/x/etag/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/etag/etag.go+48

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/charmtone/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/charmtone/charmtone.go+443

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/golden/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/golden/golden.go+83

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/ordered/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/ordered/ordered.go+42

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/slice/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/slice/slice.go+148

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/strings/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/strings/contains.go+16

This diff is too big to show here.

vendor/github.com/charmbracelet/x/exp/strings/join.go+134

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/config/config.go+118

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/config/lsps.json+367

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/config/overrides.go+85

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/client.go+875

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/language.go+158

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol.go+50

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/LICENSE+27

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/interface.go+132

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/pattern_interfaces.go+79

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/tables.go+31

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/tsdocument-changes.go+83

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/tsjson.go+3072

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/tsprotocol.go+6963

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/protocol/uri.go+192

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/sync.go+240

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/lsp/types.go+93

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/transport/connection.go+130

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/transport/router.go+107

This diff is too big to show here.

vendor/github.com/charmbracelet/x/powernap/pkg/transport/transport.go+214

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term.go+51

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_other.go+39

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_plan9.go+118

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_unix.go+96

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_unix_bsd.go+11

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_unix_other.go+11

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/term_windows.go+87

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/terminal.go+12

This diff is too big to show here.

vendor/github.com/charmbracelet/x/term/util.go+47

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/bit_bsd.go+7

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/bit_darwin.go+7

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/bit_other.go+7

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/syscalls_bsd.go+11

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/syscalls_darwin.go+10

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/syscalls_linux.go+14

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/termios.go+249

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/termios_bsd.go+13

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/termios_linux.go+13

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/termios_other.go+15

This diff is too big to show here.

vendor/github.com/charmbracelet/x/termios/termios_solaris.go+26

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/LICENSE+21

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/doc.go+4

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/syscall_windows.go+14

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/types.go+221

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/types_windows.go+142

This diff is too big to show here.

vendor/github.com/charmbracelet/x/windows/zsyscall_windows.go+77

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/.gitignore+3

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/AGENTS.md+51

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/CHANGELOG.md+129

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/LICENSE+21

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/README.md+190

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/gen.go+3

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/graphemes.go+73

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/options.go+30

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/trie.go+1699

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/truncate.go+149

This diff is too big to show here.

vendor/github.com/clipperhouse/displaywidth/width.go+239

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/LICENSE+21

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/README.md+120

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/ansi.go+138

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/ansi8.go+79

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/iterator.go+144

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/reader.go+25

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/splitfunc.go+205

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/graphemes/trie.go+1717

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/README.md+121

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/bleve_compat.go+104

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/iterator.go+126

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/joiners.go+41

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/reader.go+34

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/seek.go+43

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/splitfunc.go+269

This diff is too big to show here.

vendor/github.com/clipperhouse/uax29/v2/words/trie.go+3774

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/.golangci.yml+60

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/CHANGELOG.md+453

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/COPYING.md+447

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/LICENSE.BSD+28

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/LICENSE.MPL-2.0+373

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/README.md+184

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/VERSION+1

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/codecov.yml+29

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/doc.go+47

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/internal/consts/consts.go+15

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/join.go+169

This diff is too big to show here.

vendor/github.com/cyphar/filepath-securejoin/vfs.go+37

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/LICENSE+15

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/bypass.go+145

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/bypasssafe.go+38

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/common.go+341

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/config.go+306

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/doc.go+211

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/dump.go+509

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/format.go+419

This diff is too big to show here.

vendor/github.com/davecgh/go-spew/spew/spew.go+148

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/.gitignore+29

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/LICENSE.md+22

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/README.md+182

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/helper.go+36

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/id.go+45

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/id_bsd.go+42

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/id_darwin.go+37

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/id_linux.go+27

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/id_windows.go+23

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/logo.png

This diff is too big to show here.

vendor/github.com/denisbrodbeck/machineid/makefile+12

This diff is too big to show here.

vendor/github.com/disintegration/imaging/.travis.yml+12

This diff is too big to show here.

vendor/github.com/disintegration/imaging/LICENSE+21

This diff is too big to show here.

vendor/github.com/disintegration/imaging/README.md+226

This diff is too big to show here.

vendor/github.com/disintegration/imaging/adjust.go+253

This diff is too big to show here.

vendor/github.com/disintegration/imaging/convolution.go+148

This diff is too big to show here.

vendor/github.com/disintegration/imaging/doc.go+7

This diff is too big to show here.

vendor/github.com/disintegration/imaging/effects.go+169

This diff is too big to show here.

vendor/github.com/disintegration/imaging/histogram.go+52

This diff is too big to show here.

vendor/github.com/disintegration/imaging/io.go+444

This diff is too big to show here.

vendor/github.com/disintegration/imaging/resize.go+595

This diff is too big to show here.

vendor/github.com/disintegration/imaging/scanner.go+285

This diff is too big to show here.

vendor/github.com/disintegration/imaging/tools.go+249

This diff is too big to show here.

vendor/github.com/disintegration/imaging/transform.go+268

This diff is too big to show here.

vendor/github.com/disintegration/imaging/utils.go+167

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/.gitignore+29

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/ATTRIB+133

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/LICENSE+21

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/README.md+283

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/Taskfile.yml+64

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/bufferpool.go+76

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/fastclock.go+141

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/helpers/indexof.go+394

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/helpers/math.go+15

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/helpers/runes.go+58

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/helpers/searchvalues.go+180

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/match.go+455

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/options.go+157

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/regexp.go+717

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/regexp_codegen.go+70

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/replace.go+357

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/runner.go+2197

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/split.go+67

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/stringprefixfilter.go+375

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/charclass.go+1507

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/code.go+316

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/escape.go+94

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/fuzz.go+21

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/optimizations.go+672

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/parser.go+2529

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/prefix.go+943

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/prefixanalyzer.go+1491

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/replacerdata.go+87

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/tree.go+2362

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/unicode_alias_tables.go+6058

This diff is too big to show here.

vendor/github.com/dlclark/regexp2/v2/syntax/writer.go+500

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/.travis.yml+21

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/LICENSE+21

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/README.markdown+124

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/big.go+31

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/bigbytes.go+189

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/bytes.go+143

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/comma.go+116

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/commaf.go+41

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/ftoa.go+49

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/humanize.go+8

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/number.go+192

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/ordinals.go+25

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/si.go+127

This diff is too big to show here.

vendor/github.com/dustin/go-humanize/times.go+117

This diff is too big to show here.

vendor/github.com/ebitengine/purego/.gitignore+1

This diff is too big to show here.

vendor/github.com/ebitengine/purego/LICENSE+201

This diff is too big to show here.

vendor/github.com/ebitengine/purego/README.md+119

This diff is too big to show here.

vendor/github.com/ebitengine/purego/abi_amd64.h+99

This diff is too big to show here.

vendor/github.com/ebitengine/purego/abi_arm64.h+39

This diff is too big to show here.

vendor/github.com/ebitengine/purego/abi_loong64.h+60

This diff is too big to show here.

vendor/github.com/ebitengine/purego/cgo.go+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlerror.go+17

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn.go+99

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_android.go+34

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_darwin.go+20

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_freebsd.go+14

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_linux.go+16

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_netbsd.go+15

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_nocgo_freebsd.go+11

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_nocgo_linux.go+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_nocgo_netbsd.go+9

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_playground.go+24

This diff is too big to show here.

vendor/github.com/ebitengine/purego/dlfcn_stubs.s+22

This diff is too big to show here.

vendor/github.com/ebitengine/purego/func.go+564

This diff is too big to show here.

vendor/github.com/ebitengine/purego/gen.go+6

This diff is too big to show here.

vendor/github.com/ebitengine/purego/go_runtime.go+13

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/cgo/dlfcn_cgo_unix.go+56

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/cgo/empty.go+6

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/cgo/syscall_cgo_unix.go+55

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/abi_amd64.h+99

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/abi_arm64.h+39

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/abi_loong64.h+60

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/abi_ppc64x.h+195

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_386.s+29

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_amd64.s+39

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_arm.s+52

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_arm64.s+36

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_loong64.s+40

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_ppc64le.s+82

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_riscv64.s+78

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/asm_s390x.s+55

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/callbacks.go+93

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/doc.go+32

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/fakecgo.go+14

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/freebsd.go+27

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_darwin.go+88

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_freebsd.go+100

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_libinit.go+72

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_linux.go+100

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_netbsd.go+106

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_setenv.go+18

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/go_util.go+38

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/iscgo.go+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/libcgo.go+39

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/libcgo_darwin.go+26

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/libcgo_freebsd.go+20

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/libcgo_linux.go+20

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/libcgo_netbsd.go+26

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/netbsd.go+23

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/setenv.go+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_386.s+107

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_amd64.s+107

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_arm.s+81

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_arm64.s+84

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_loong64.s+88

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_ppc64le.s+227

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/trampolines_riscv64.s+72

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/zsymbols.go+165

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/zsymbols_darwin.go+59

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/zsymbols_freebsd.go+48

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/zsymbols_linux.go+48

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/zsymbols_netbsd.go+59

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/ztrampolines_darwin.s+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/ztrampolines_freebsd.s+16

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/ztrampolines_linux.s+16

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/ztrampolines_netbsd.s+19

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/fakecgo/ztrampolines_stubs.s+55

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/strings/strings.go+40

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/xreflect/reflect_go124.go+15

This diff is too big to show here.

vendor/github.com/ebitengine/purego/internal/xreflect/reflect_go125.go+12

This diff is too big to show here.

vendor/github.com/ebitengine/purego/is_ios.go+13

This diff is too big to show here.

vendor/github.com/ebitengine/purego/nocgo.go+25

This diff is too big to show here.

vendor/github.com/ebitengine/purego/objc/objc_block_darwin.go+272

This diff is too big to show here.

vendor/github.com/ebitengine/purego/objc/objc_runtime_darwin.go+709

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_386.go+41

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_amd64.go+286

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_arm.go+85

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_arm64.go+549

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_loong64.go+213

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_ppc64le.go+143

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_riscv64.go+143

This diff is too big to show here.

vendor/github.com/ebitengine/purego/struct_s390x.go+143

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_386.s+147

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_amd64.s+170

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_arm.s+142

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_arm64.s+97

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_loong64.s+96

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_ppc64le.s+120

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_riscv64.s+101

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_s390x.s+114

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_386.s+226

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_arm.s+89

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_arm64.s+70

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_loong64.s+75

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_ppc64le.s+114

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_riscv64.s+79

This diff is too big to show here.

vendor/github.com/ebitengine/purego/sys_unix_s390x.s+109

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall.go+83

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_32bit.go+109

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_cgo_linux.go+21

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_sysv.go+317

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_sysv_others.go+28

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_sysv_stackargs.go+33

This diff is too big to show here.

vendor/github.com/ebitengine/purego/syscall_windows.go+46

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_386.s+4014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_amd64.s+2014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_arm.s+4014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_arm64.s+4014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_loong64.s+4014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_ppc64le.s+4014

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_riscv64.s+4051

This diff is too big to show here.

vendor/github.com/ebitengine/purego/zcallback_s390x.s+4015

This diff is too big to show here.

vendor/github.com/esiqveland/notify/.gitignore+1

This diff is too big to show here.

vendor/github.com/esiqveland/notify/LICENSE.txt+27

This diff is too big to show here.

vendor/github.com/esiqveland/notify/README.md+54

This diff is too big to show here.

vendor/github.com/esiqveland/notify/doc.go+18

This diff is too big to show here.

vendor/github.com/esiqveland/notify/notification.go+547

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/.gitignore

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/LICENSE.txt+19

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/Makefile+10

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/README.md+95

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/capture_metrics.go+121

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/docs.go+10

This diff is too big to show here.

vendor/github.com/felixge/httpsnoop/wrap_generated.go+16179

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/.cirrus.yml+14

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/.gitignore+10

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/.mailmap+2

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/CHANGELOG.md+602

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md+145

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/LICENSE+25

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/README.md+182

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/backend_fen.go+467

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/backend_inotify.go+583

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/backend_kqueue.go+705

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/backend_other.go+22

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/backend_windows.go+680

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/fsnotify.go+496

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/darwin.go+39

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_darwin.go+57

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_dragonfly.go+33

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_freebsd.go+42

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_kqueue.go+32

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_linux.go+56

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_netbsd.go+25

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_openbsd.go+28

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_solaris.go+45

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/debug_windows.go+40

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/freebsd.go+31

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/internal.go+2

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/unix.go+31

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/unix2.go+7

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/internal/windows.go+41

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/shared.go+64

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/staticcheck.conf+3

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/system_bsd.go+7

This diff is too big to show here.

vendor/github.com/fsnotify/fsnotify/system_darwin.go+8

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/LICENSE+23

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/README.md+41

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/alert_darwin.go+8

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/alert_js.go+12

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/alert_unix.go+17

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/alert_unsupported.go+8

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/alert_windows.go+43

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beeep.go+55

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beep_darwin.go+32

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beep_js.go+39

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beep_unix.go+100

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beep_unsupported.go+15

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/beep_windows.go+52

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_darwin.go+131

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_js.go+63

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_unix.go+181

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_unix_nodbus.go+78

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_unsupported.go+8

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_windows.go+157

This diff is too big to show here.

vendor/github.com/gen2brain/beeep/notify_windows_compat.go+109

This diff is too big to show here.

vendor/github.com/go-git/gcfg/.gitignore+1

This diff is too big to show here.

vendor/github.com/go-git/gcfg/LICENSE+28

This diff is too big to show here.

vendor/github.com/go-git/gcfg/Makefile+17

This diff is too big to show here.

vendor/github.com/go-git/gcfg/README+4

This diff is too big to show here.

vendor/github.com/go-git/gcfg/doc.go+145

This diff is too big to show here.

vendor/github.com/go-git/gcfg/errors.go+41

This diff is too big to show here.

vendor/github.com/go-git/gcfg/read.go+273

This diff is too big to show here.

vendor/github.com/go-git/gcfg/scanner/errors.go+121

This diff is too big to show here.

vendor/github.com/go-git/gcfg/scanner/scanner.go+334

This diff is too big to show here.

vendor/github.com/go-git/gcfg/set.go+334

This diff is too big to show here.

vendor/github.com/go-git/gcfg/token/position.go+435

This diff is too big to show here.

vendor/github.com/go-git/gcfg/token/serialize.go+56

This diff is too big to show here.

vendor/github.com/go-git/gcfg/token/token.go+83

This diff is too big to show here.

vendor/github.com/go-git/gcfg/types/bool.go+23

This diff is too big to show here.

vendor/github.com/go-git/gcfg/types/doc.go+4

This diff is too big to show here.

vendor/github.com/go-git/gcfg/types/enum.go+44

This diff is too big to show here.

vendor/github.com/go-git/gcfg/types/int.go+86

This diff is too big to show here.

vendor/github.com/go-git/gcfg/types/scan.go+23

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/.gitignore+4

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/LICENSE+201

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/Makefile+18

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/README.md+77

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/fs.go+210

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/helper/chroot/chroot.go+444

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/helper/polyfill/polyfill.go+114

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/memfs/memory.go+428

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/memfs/storage.go+251

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os.go+132

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_bound.go+351

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_chroot.go+126

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_js.go+25

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_options.go+3

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_plan9.go+91

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_posix.go+38

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_wasip1.go+34

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/osfs/os_windows.go+58

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/util/glob.go+111

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/util/util.go+282

This diff is too big to show here.

vendor/github.com/go-git/go-billy/v5/util/walk.go+72

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/LICENSE+201

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/branch.go+123

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/config.go+727

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/modules.go+191

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/optbool.go+82

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/refspec.go+155

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/config/url.go+81

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/path_util/path_util.go+29

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/pathutil/dotgit.go+21

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/pathutil/hfs.go+99

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/pathutil/ntfs.go+187

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/pathutil/tree.go+66

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/internal/url/url.go+72

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/error.go+35

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/common.go+109

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/decoder.go+37

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/doc.go+122

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/encoder.go+82

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/format.go+53

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/option.go+127

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/config/section.go+181

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/gitignore/dir.go+148

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/gitignore/doc.go+70

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/gitignore/matcher.go+30

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/format/gitignore/pattern.go+155

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/hash.go+84

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/hash/hash.go+60

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/hash/hash_sha1.go+15

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/hash/hash_sha256.go+15

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/memory.go+72

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/object.go+111

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/reference.go+315

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/plumbing/revision.go+11

This diff is too big to show here.

vendor/github.com/go-git/go-git/v5/utils/ioutil/common.go+210

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/AUTHORS+3

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/CONTRIBUTORS+3

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/LICENSE+27

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/README.md+176

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/alias.go+943

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/api.png

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal.go+569

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_any.go+289

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_default.go+2043

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_embedded.go+230

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_funcs.go+428

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_methods.go+378

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/arshal_time.go+795

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/doc.go+247

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/errors.go+465

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/fields.go+656

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/fold.go+58

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/intern.go+88

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/internal.go+42

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonflags/flags.go+223

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonopts/options.go+217

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonopts/options_format.go+124

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonwire/decode.go+606

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonwire/encode.go+290

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/internal/jsonwire/wire.go+209

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/alias.go+555

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/decode.go+1218

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/doc.go+111

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/encode.go+993

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/errors.go+213

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/export.go+91

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/options.go+304

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/pools.go+164

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/quote.go+41

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/state.go+828

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/token.go+697

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/jsontext/value.go+414

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/migrate.sh+60

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/options.go+290

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/options_format.go+122

This diff is too big to show here.

vendor/github.com/go-json-experiment/json/options_format_global.go+30

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/.gitignore+1

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/CHANGELOG.md+82

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/LICENSE+22

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/README.md+41

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/decode.go+254

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/doc.go+6

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/encode.go+322

This diff is too big to show here.

vendor/github.com/go-logfmt/logfmt/jsonstring.go+277

This diff is too big to show here.

vendor/github.com/go-logr/logr/.golangci.yaml+28

This diff is too big to show here.

vendor/github.com/go-logr/logr/CHANGELOG.md+6

This diff is too big to show here.

vendor/github.com/go-logr/logr/CONTRIBUTING.md+17

This diff is too big to show here.

vendor/github.com/go-logr/logr/LICENSE+201

This diff is too big to show here.

vendor/github.com/go-logr/logr/README.md+407

This diff is too big to show here.

vendor/github.com/go-logr/logr/SECURITY.md+18

This diff is too big to show here.

vendor/github.com/go-logr/logr/context.go+33

This diff is too big to show here.

vendor/github.com/go-logr/logr/context_noslog.go+49

This diff is too big to show here.

vendor/github.com/go-logr/logr/context_slog.go+83

This diff is too big to show here.

vendor/github.com/go-logr/logr/discard.go+24

This diff is too big to show here.

vendor/github.com/go-logr/logr/funcr/funcr.go+914

This diff is too big to show here.

vendor/github.com/go-logr/logr/funcr/slogsink.go+105

This diff is too big to show here.

vendor/github.com/go-logr/logr/logr.go+520

This diff is too big to show here.

vendor/github.com/go-logr/logr/sloghandler.go+192

This diff is too big to show here.

vendor/github.com/go-logr/logr/slogr.go+100

This diff is too big to show here.

vendor/github.com/go-logr/logr/slogsink.go+120

This diff is too big to show here.

vendor/github.com/go-logr/stdr/LICENSE+201

This diff is too big to show here.

vendor/github.com/go-logr/stdr/README.md+6

This diff is too big to show here.

vendor/github.com/go-logr/stdr/stdr.go+170

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/.travis.yml+8

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/ChangeLog.md+49

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/LICENSE+21

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/README.md+46

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/SECURITY.md+13

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/appveyor.yml+68

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/com.go+386

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/com_func.go+174

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/connect.go+192

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/constants.go+153

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/error.go+51

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/error_func.go+8

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/error_windows.go+24

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/guid.go+284

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpoint.go+20

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpoint_func.go+21

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpoint_windows.go+43

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpointcontainer.go+17

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpointcontainer_func.go+11

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iconnectionpointcontainer_windows.go+25

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/idispatch.go+94

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/idispatch_func.go+19

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/idispatch_windows.go+203

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/ienumvariant.go+19

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/ienumvariant_func.go+19

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/ienumvariant_windows.go+63

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iinspectable.go+18

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iinspectable_func.go+15

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iinspectable_windows.go+72

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iprovideclassinfo.go+21

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iprovideclassinfo_func.go+7

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iprovideclassinfo_windows.go+21

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/itypeinfo.go+34

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/itypeinfo_func.go+7

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/itypeinfo_windows.go+21

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iunknown.go+57

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iunknown_func.go+19

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/iunknown_windows.go+58

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/ole.go+190

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/safearray.go+27

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/safearray_func.go+211

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/safearray_windows.go+337

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/safearrayconversion.go+140

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/safearrayslices.go+33

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/utility.go+101

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variables.go+15

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant.go+105

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_386.go+11

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_amd64.go+12

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_arm.go+11

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_arm64.go+13

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_date_386.go+22

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_date_amd64.go+20

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_date_arm.go+22

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_date_arm64.go+23

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_ppc64le.go+12

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/variant_s390x.go+12

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/vt_string.go+58

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/winrt.go+99

This diff is too big to show here.

vendor/github.com/go-ole/go-ole/winrt_doc.go+36

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/.editorconfig+26

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/.gitignore+1

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/.travis.yml+15

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md+74

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/LICENSE+202

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/README.md+15

This diff is too big to show here.

vendor/github.com/go-openapi/jsonpointer/pointer.go+390

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/.gitignore+1

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/.golangci.yml+41

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/.travis.yml+24

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md+74

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/LICENSE+202

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/README.md+15

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/internal/normalize_url.go+63

This diff is too big to show here.

vendor/github.com/go-openapi/jsonreference/reference.go+158

This diff is too big to show here.

vendor/github.com/go-openapi/spec/.editorconfig+26

This diff is too big to show here.

vendor/github.com/go-openapi/spec/.gitignore+2

This diff is too big to show here.

vendor/github.com/go-openapi/spec/.golangci.yml+42

This diff is too big to show here.

vendor/github.com/go-openapi/spec/.travis.yml+31

This diff is too big to show here.

vendor/github.com/go-openapi/spec/CODE_OF_CONDUCT.md+74

This diff is too big to show here.

vendor/github.com/go-openapi/spec/LICENSE+202

This diff is too big to show here.

vendor/github.com/go-openapi/spec/README.md+34

This diff is too big to show here.

4778 more files changed are not listed.