on main help sign in
Merge pull request #95 from charmbracelet/onboarding
4f145e5Kujtim Hoxha
cmd/root.go+3−139
@@ -6,7 +6,6 @@ import (
6 "io"
6 "io"
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9− "sync"
10 "time"
9 "time"
11
10
12 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
@@ -16,7 +15,6 @@ import (
16 "github.com/charmbracelet/crush/internal/format"
15 "github.com/charmbracelet/crush/internal/format"
17 "github.com/charmbracelet/crush/internal/llm/agent"
16 "github.com/charmbracelet/crush/internal/llm/agent"
18 "github.com/charmbracelet/crush/internal/log"
17 "github.com/charmbracelet/crush/internal/log"
19− "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/tui"
18 "github.com/charmbracelet/crush/internal/tui"
21 "github.com/charmbracelet/crush/internal/version"
19 "github.com/charmbracelet/crush/internal/version"
22 "github.com/charmbracelet/fang"
20 "github.com/charmbracelet/fang"
@@ -124,76 +122,17 @@ to assist developers in writing, debugging, and understanding code directly from
124 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
122 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
125 )
123 )
126
124
127− // Setup the subscriptions, this will send services events to the TUI
125+ go app.Subscribe(program)
128− ch, cancelSubs := setupSubscriptions(app, ctx)
129
126
130− // Create a context for the TUI message handler
131− tuiCtx, tuiCancel := context.WithCancel(ctx)
132− var tuiWg sync.WaitGroup
133− tuiWg.Add(1)
134−
135− // Set up message handling for the TUI
136− go func() {
137− defer tuiWg.Done()
138− defer log.RecoverPanic("TUI-message-handler", func() {
139− attemptTUIRecovery(program)
140− })
141−
142− for {
143− select {
144− case <-tuiCtx.Done():
145− slog.Info("TUI message handler shutting down")
146− return
147− case msg, ok := <-ch:
148− if !ok {
149− slog.Info("TUI message channel closed")
150− return
151− }
152− program.Send(msg)
153− }
154− }
155− }()
156−
157− // Cleanup function for when the program exits
158− cleanup := func() {
159− // Shutdown the app
160− app.Shutdown()
161−
162− // Cancel subscriptions first
163− cancelSubs()
164−
165− // Then cancel TUI message handler
166− tuiCancel()
167−
168− // Wait for TUI message handler to finish
169− tuiWg.Wait()
170−
171− slog.Info("All goroutines cleaned up")
172− }
173−
174− // Run the TUI
175− result, err := program.Run()
127+ if _, err := program.Run(); err != nil {
176− cleanup()
177−
178− if err != nil {
179 slog.Error(fmt.Sprintf("TUI run error: %v", err))
128 slog.Error(fmt.Sprintf("TUI run error: %v", err))
180 return fmt.Errorf("TUI error: %v", err)
129 return fmt.Errorf("TUI error: %v", err)
181 }
130 }
182−
131+ app.Shutdown()
183− slog.Info(fmt.Sprintf("TUI exited with result: %v", result))
184 return nil
132 return nil
185 },
133 },
186}
134}
187
135
188−// attemptTUIRecovery tries to recover the TUI after a panic
189−func attemptTUIRecovery(program *tea.Program) {
190− slog.Info("Attempting to recover TUI after panic")
191−
192− // We could try to restart the TUI or gracefully exit
193− // For now, we'll just quit the program to avoid further issues
194− program.Quit()
195−}
196−
197func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
136func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
198 go func() {
137 go func() {
199 defer log.RecoverPanic("MCP-goroutine", nil)
138 defer log.RecoverPanic("MCP-goroutine", nil)
@@ -208,81 +147,6 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
208 }()
147 }()
209}
148}
210
149
211−func setupSubscriber[T any](
212− ctx context.Context,
213− wg *sync.WaitGroup,
214− name string,
215− subscriber func(context.Context) <-chan pubsub.Event[T],
216− outputCh chan<- tea.Msg,
217−) {
218− wg.Add(1)
219− go func() {
220− defer wg.Done()
221− defer log.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
222−
223− subCh := subscriber(ctx)
224−
225− for {
226− select {
227− case event, ok := <-subCh:
228− if !ok {
229− slog.Info("subscription channel closed", "name", name)
230− return
231− }
232−
233− var msg tea.Msg = event
234−
235− select {
236− case outputCh <- msg:
237− case <-time.After(2 * time.Second):
238− slog.Warn("message dropped due to slow consumer", "name", name)
239− case <-ctx.Done():
240− slog.Info("subscription canceled", "name", name)
241− return
242− }
243− case <-ctx.Done():
244− slog.Info("subscription canceled", "name", name)
245− return
246− }
247− }
248− }()
249−}
250−
251−func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
252− ch := make(chan tea.Msg, 100)
253−
254− wg := sync.WaitGroup{}
255− ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
256−
257− setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
258− setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
259− setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
260− setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
261− setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch)
262−
263− cleanupFunc := func() {
264− slog.Info("Cancelling all subscriptions")
265− cancel() // Signal all goroutines to stop
266−
267− waitCh := make(chan struct{})
268− go func() {
269− defer log.RecoverPanic("subscription-cleanup", nil)
270− wg.Wait()
271− close(waitCh)
272− }()
273−
274− select {
275− case <-waitCh:
276− slog.Info("All subscription goroutines completed successfully")
277− close(ch) // Only close after all writers are confirmed done
278− case <-time.After(5 * time.Second):
279− slog.Warn("Timed out waiting for some subscription goroutines to complete")
280− close(ch)
281− }
282− }
283− return ch, cleanupFunc
284−}
285−
286func Execute() {
150func Execute() {
287 if err := fang.Execute(
151 if err := fang.Execute(
288 context.Background(),
152 context.Background(),
go.mod+8−14
@@ -2,9 +2,9 @@ module github.com/charmbracelet/crush
2
2
3go 1.24.3
3go 1.24.3
4
4
5−replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891
5+replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595
6
6
7−replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093
7+replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
8
8
9require (
9require (
10 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
10 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -43,22 +43,12 @@ require (
43 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
43 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
44 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
44 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
45 github.com/stretchr/testify v1.10.0
45 github.com/stretchr/testify v1.10.0
46+ github.com/tidwall/sjson v1.2.5
46 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
47 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
47 gopkg.in/natefinch/lumberjack.v2 v2.2.1
48 gopkg.in/natefinch/lumberjack.v2 v2.2.1
48 mvdan.cc/sh/v3 v3.11.0
49 mvdan.cc/sh/v3 v3.11.0
49)
50)
50
51
51−require (
52− github.com/go-logfmt/logfmt v0.6.0 // indirect
53− github.com/spf13/cast v1.7.1 // indirect
54− gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
55−)
56−
57−require (
58− github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect
59− github.com/charmbracelet/x/termios v0.1.1 // indirect
60−)
61−
62require (
52require (
63 cloud.google.com/go v0.116.0 // indirect
53 cloud.google.com/go v0.116.0 // indirect
64 cloud.google.com/go/auth v0.13.0 // indirect
54 cloud.google.com/go/auth v0.13.0 // indirect
@@ -85,15 +75,18 @@ require (
85 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
75 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
86 github.com/aymerick/douceur v0.2.0 // indirect
76 github.com/aymerick/douceur v0.2.0 // indirect
87 github.com/charmbracelet/colorprofile v0.3.1 // indirect
77 github.com/charmbracelet/colorprofile v0.3.1 // indirect
78+ github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect
88 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
79 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
89 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
80 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
90 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/term v0.2.1
82+ github.com/charmbracelet/x/termios v0.1.1 // indirect
91 github.com/charmbracelet/x/windows v0.2.1 // indirect
83 github.com/charmbracelet/x/windows v0.2.1 // indirect
92 github.com/davecgh/go-spew v1.1.1 // indirect
84 github.com/davecgh/go-spew v1.1.1 // indirect
93 github.com/disintegration/gift v1.1.2 // indirect
85 github.com/disintegration/gift v1.1.2 // indirect
94 github.com/dlclark/regexp2 v1.11.4 // indirect
86 github.com/dlclark/regexp2 v1.11.4 // indirect
95 github.com/dustin/go-humanize v1.0.1 // indirect
87 github.com/dustin/go-humanize v1.0.1 // indirect
96 github.com/felixge/httpsnoop v1.0.4 // indirect
88 github.com/felixge/httpsnoop v1.0.4 // indirect
89+ github.com/go-logfmt/logfmt v0.6.0 // indirect
97 github.com/go-logr/logr v1.4.2 // indirect
90 github.com/go-logr/logr v1.4.2 // indirect
98 github.com/go-logr/stdr v1.2.2 // indirect
91 github.com/go-logr/stdr v1.2.2 // indirect
99 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
92 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
@@ -121,12 +114,12 @@ require (
121 github.com/rivo/uniseg v0.4.7
114 github.com/rivo/uniseg v0.4.7
122 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
115 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
123 github.com/sethvargo/go-retry v0.3.0 // indirect
116 github.com/sethvargo/go-retry v0.3.0 // indirect
117+ github.com/spf13/cast v1.7.1 // indirect
124 github.com/spf13/pflag v1.0.6 // indirect
118 github.com/spf13/pflag v1.0.6 // indirect
125 github.com/tetratelabs/wazero v1.9.0 // indirect
119 github.com/tetratelabs/wazero v1.9.0 // indirect
126 github.com/tidwall/gjson v1.18.0 // indirect
120 github.com/tidwall/gjson v1.18.0 // indirect
127 github.com/tidwall/match v1.1.1 // indirect
121 github.com/tidwall/match v1.1.1 // indirect
128 github.com/tidwall/pretty v1.2.1 // indirect
122 github.com/tidwall/pretty v1.2.1 // indirect
129− github.com/tidwall/sjson v1.2.5 // indirect
130 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
131 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
132 github.com/yuin/goldmark v1.7.8 // indirect
125 github.com/yuin/goldmark v1.7.8 // indirect
@@ -148,5 +141,6 @@ require (
148 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
141 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
149 google.golang.org/grpc v1.71.0 // indirect
142 google.golang.org/grpc v1.71.0 // indirect
150 google.golang.org/protobuf v1.36.6 // indirect
143 google.golang.org/protobuf v1.36.6 // indirect
144+ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
151 gopkg.in/yaml.v3 v3.0.1 // indirect
145 gopkg.in/yaml.v3 v3.0.1 // indirect
152)
146)
go.sum+4−8
@@ -68,20 +68,18 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
69github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
69github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
71−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
72−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM=
74github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
75−github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 h1:wh6N1dR4XkDh6XsiZh1/tImJAZvYB0yVLmaUKvJXvK0=
73+github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 h1:wLMjzOqrwoM7Em9UR9sGbn4375G8WuxcwFB3kjZiqHo=
76−github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891/go.mod h1:SwBB+WoaQVMMOM9hknbN/7FNT86kgKG0LSHGTmLphX8=
74+github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
77github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
75github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
78github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
76github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
79github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
77github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
80github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
78github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
81github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
79github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
82github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
80github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
83−github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 h1:c9vOmNJQUwy/lp/pNOB5ZDMhOuXJ3Y2LL9uZMYGgJxQ=
81+github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=
84−github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093/go.mod h1:XmxjFJcMEfYIHa4Mw4ra+uMjploDkTlkKIs7wLt9v4Q=
82+github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb/go.mod h1:wEc/TRrTAIDJYjVCg3+y8WeKaN+F88gpYfGbUuP6W3A=
85github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
83github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
86github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
84github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
87github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 h1:+LLFCLxtb/sHegwY3zYdFAbaOgI/I9pv/pxdUlI1Q9s=
85github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 h1:+LLFCLxtb/sHegwY3zYdFAbaOgI/I9pv/pxdUlI1Q9s=
@@ -90,8 +88,6 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh
90github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
88github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
91github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
89github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
92github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
90github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
93−github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4=
94−github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
95github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
91github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
96github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
92github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
97github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
93github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
internal/app/app.go+143−28
@@ -10,11 +10,14 @@ import (
10 "sync"
10 "sync"
11 "time"
11 "time"
12
12
13+ tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/db"
15 "github.com/charmbracelet/crush/internal/db"
15 "github.com/charmbracelet/crush/internal/format"
16 "github.com/charmbracelet/crush/internal/format"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/llm/agent"
18 "github.com/charmbracelet/crush/internal/llm/agent"
19+ "github.com/charmbracelet/crush/internal/log"
20+ "github.com/charmbracelet/crush/internal/pubsub"
18
21
19 "github.com/charmbracelet/crush/internal/lsp"
22 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/message"
@@ -36,9 +39,18 @@ type App struct {
36
39
37 watcherCancelFuncs []context.CancelFunc
40 watcherCancelFuncs []context.CancelFunc
38 cancelFuncsMutex sync.Mutex
41 cancelFuncsMutex sync.Mutex
39− watcherWG sync.WaitGroup
42+ lspWatcherWG sync.WaitGroup
40
43
41 config *config.Config
44 config *config.Config
45+
46+ serviceEventsWG *sync.WaitGroup
47+ eventsCtx context.Context
48+ events chan tea.Msg
49+ tuiWG *sync.WaitGroup
50+
51+ // global context and cleanup functions
52+ globalCtx context.Context
53+ cleanupFuncs []func()
42}
54}
43
55
44func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
56func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
@@ -53,32 +65,29 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
53 History: files,
65 History: files,
54 Permissions: permission.NewPermissionService(cfg.WorkingDir()),
66 Permissions: permission.NewPermissionService(cfg.WorkingDir()),
55 LSPClients: make(map[string]*lsp.Client),
67 LSPClients: make(map[string]*lsp.Client),
68+
69+ globalCtx: ctx,
70+
56− config: cfg,
71+ config: cfg,
72+
73+ events: make(chan tea.Msg, 100),
74+ serviceEventsWG: &sync.WaitGroup{},
75+ tuiWG: &sync.WaitGroup{},
57 }
76 }
58
77
78+ app.setupEvents()
79+
59 // Initialize LSP clients in the background
80 // Initialize LSP clients in the background
60 go app.initLSPClients(ctx)
81 go app.initLSPClients(ctx)
61
82
62 // TODO: remove the concept of agent config most likely
83 // TODO: remove the concept of agent config most likely
63− coderAgentCfg := cfg.Agents["coder"]
84+ if cfg.IsConfigured() {
64− if coderAgentCfg.ID == "" {
85+ if err := app.InitCoderAgent(); err != nil {
65− return nil, fmt.Errorf("coder agent configuration is missing")
86+ return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
66− }
87+ }
67−
88+ } else {
68− var err error
89+ slog.Warn("No agent configuration found")
69− app.CoderAgent, err = agent.NewAgent(
70− coderAgentCfg,
71− app.Permissions,
72− app.Sessions,
73− app.Messages,
74− app.History,
75− app.LSPClients,
76− )
77− if err != nil {
78− slog.Error("Failed to create coder agent", "err", err)
79− return nil, err
80 }
90 }
81−
82 return app, nil
91 return app, nil
83}
92}
84
93
@@ -147,32 +156,138 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
147 return nil
156 return nil
148}
157}
149
158
159+func (app *App) UpdateAgentModel() error {
160+ return app.CoderAgent.UpdateModel()
161+}
162+
163+func (app *App) setupEvents() {
164+ ctx, cancel := context.WithCancel(app.globalCtx)
165+ app.eventsCtx = ctx
166+ setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
167+ setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
168+ setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
169+ setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
170+ cleanupFunc := func() {
171+ cancel()
172+ app.serviceEventsWG.Wait()
173+ }
174+ app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
175+}
176+
177+func setupSubscriber[T any](
178+ ctx context.Context,
179+ wg *sync.WaitGroup,
180+ name string,
181+ subscriber func(context.Context) <-chan pubsub.Event[T],
182+ outputCh chan<- tea.Msg,
183+) {
184+ wg.Add(1)
185+ go func() {
186+ defer wg.Done()
187+ subCh := subscriber(ctx)
188+ for {
189+ select {
190+ case event, ok := <-subCh:
191+ if !ok {
192+ slog.Debug("subscription channel closed", "name", name)
193+ return
194+ }
195+ var msg tea.Msg = event
196+ select {
197+ case outputCh <- msg:
198+ case <-time.After(2 * time.Second):
199+ slog.Warn("message dropped due to slow consumer", "name", name)
200+ case <-ctx.Done():
201+ slog.Debug("subscription cancelled", "name", name)
202+ return
203+ }
204+ case <-ctx.Done():
205+ slog.Debug("subscription cancelled", "name", name)
206+ return
207+ }
208+ }
209+ }()
210+}
211+
212+func (app *App) InitCoderAgent() error {
213+ coderAgentCfg := app.config.Agents["coder"]
214+ if coderAgentCfg.ID == "" {
215+ return fmt.Errorf("coder agent configuration is missing")
216+ }
217+ var err error
218+ app.CoderAgent, err = agent.NewAgent(
219+ coderAgentCfg,
220+ app.Permissions,
221+ app.Sessions,
222+ app.Messages,
223+ app.History,
224+ app.LSPClients,
225+ )
226+ if err != nil {
227+ slog.Error("Failed to create coder agent", "err", err)
228+ return err
229+ }
230+ setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events)
231+ return nil
232+}
233+
234+func (app *App) Subscribe(program *tea.Program) {
235+ defer log.RecoverPanic("app.Subscribe", func() {
236+ slog.Info("TUI subscription panic - attempting graceful shutdown")
237+ program.Quit()
238+ })
239+
240+ app.tuiWG.Add(1)
241+ tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
242+ app.cleanupFuncs = append(app.cleanupFuncs, func() {
243+ slog.Debug("Cancelling TUI message handler")
244+ tuiCancel()
245+ app.tuiWG.Wait()
246+ })
247+ defer app.tuiWG.Done()
248+ for {
249+ select {
250+ case <-tuiCtx.Done():
251+ slog.Debug("TUI message handler shutting down")
252+ return
253+ case msg, ok := <-app.events:
254+ if !ok {
255+ slog.Debug("TUI message channel closed")
256+ return
257+ }
258+ program.Send(msg)
259+ }
260+ }
261+}
262+
150// Shutdown performs a clean shutdown of the application
263// Shutdown performs a clean shutdown of the application
151func (app *App) Shutdown() {
264func (app *App) Shutdown() {
152− // Cancel all watcher goroutines
153 app.cancelFuncsMutex.Lock()
265 app.cancelFuncsMutex.Lock()
154 for _, cancel := range app.watcherCancelFuncs {
266 for _, cancel := range app.watcherCancelFuncs {
155 cancel()
267 cancel()
156 }
268 }
157 app.cancelFuncsMutex.Unlock()
269 app.cancelFuncsMutex.Unlock()
158− app.watcherWG.Wait()
270+ app.lspWatcherWG.Wait()
159
271
160− // Perform additional cleanup for LSP clients
161 app.clientsMutex.RLock()
272 app.clientsMutex.RLock()
162 clients := make(map[string]*lsp.Client, len(app.LSPClients))
273 clients := make(map[string]*lsp.Client, len(app.LSPClients))
163 maps.Copy(clients, app.LSPClients)
274 maps.Copy(clients, app.LSPClients)
164 app.clientsMutex.RUnlock()
275 app.clientsMutex.RUnlock()
165
276
166 for name, client := range clients {
277 for name, client := range clients {
167− shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
278+ shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
168 if err := client.Shutdown(shutdownCtx); err != nil {
279 if err := client.Shutdown(shutdownCtx); err != nil {
169 slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
280 slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
170 }
281 }
171 cancel()
282 cancel()
172 }
283 }
284+ if app.CoderAgent != nil {
173− app.CoderAgent.CancelAll()
285+ app.CoderAgent.CancelAll()
174−}
286+ }
175
287
176−func (app *App) UpdateAgentModel() error {
288+ for _, cleanup := range app.cleanupFuncs {
177− return app.CoderAgent.UpdateModel()
289+ if cleanup != nil {
290+ cleanup()
291+ }
292+ }
178}
293}
internal/app/lsp.go+2−2
@@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
71 app.cancelFuncsMutex.Unlock()
71 app.cancelFuncsMutex.Unlock()
72
72
73 // Add the watcher to a WaitGroup to track active goroutines
73 // Add the watcher to a WaitGroup to track active goroutines
74− app.watcherWG.Add(1)
74+ app.lspWatcherWG.Add(1)
75
75
76 // Add to map with mutex protection before starting goroutine
76 // Add to map with mutex protection before starting goroutine
77 app.clientsMutex.Lock()
77 app.clientsMutex.Lock()
@@ -83,7 +83,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
83
83
84// runWorkspaceWatcher executes the workspace watcher for an LSP client
84// runWorkspaceWatcher executes the workspace watcher for an LSP client
85func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
85func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
86− defer app.watcherWG.Done()
86+ defer app.lspWatcherWG.Done()
87 defer log.RecoverPanic("LSP-"+name, func() {
87 defer log.RecoverPanic("LSP-"+name, func() {
88 // Try to restart the client
88 // Try to restart the client
89 app.restartLSPClient(ctx, name)
89 app.restartLSPClient(ctx, name)
internal/config/config.go+88−5
@@ -2,10 +2,12 @@ package config
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "os"
5 "slices"
6 "slices"
6 "strings"
7 "strings"
7
8
8 "github.com/charmbracelet/crush/internal/fur/provider"
9 "github.com/charmbracelet/crush/internal/fur/provider"
10+ "github.com/tidwall/sjson"
9)
11)
10
12
11const (
13const (
@@ -58,6 +60,8 @@ type SelectedModel struct {
58type ProviderConfig struct {
60type ProviderConfig struct {
59 // The provider's id.
61 // The provider's id.
60 ID string `json:"id,omitempty"`
62 ID string `json:"id,omitempty"`
63+ // The provider's name, used for display purposes.
64+ Name string `json:"name,omitempty"`
61 // The provider's API endpoint.
65 // The provider's API endpoint.
62 BaseURL string `json:"base_url,omitempty"`
66 BaseURL string `json:"base_url,omitempty"`
63 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
67 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
@@ -207,7 +211,9 @@ type Config struct {
207 // TODO: most likely remove this concept when I come back to it
211 // TODO: most likely remove this concept when I come back to it
208 Agents map[string]Agent `json:"-"`
212 Agents map[string]Agent `json:"-"`
209 // TODO: find a better way to do this this should probably not be part of the config
213 // TODO: find a better way to do this this should probably not be part of the config
210− resolver VariableResolver
214+ resolver VariableResolver
215+ dataConfigDir string `json:"-"`
216+ knownProviders []provider.Provider `json:"-"`
211}
217}
212
218
213func (c *Config) WorkingDir() string {
219func (c *Config) WorkingDir() string {
@@ -275,6 +281,14 @@ func (c *Config) SmallModel() *provider.Model {
275 return c.GetModel(model.Provider, model.Model)
281 return c.GetModel(model.Provider, model.Model)
276}
282}
277
283
284+func (c *Config) SetCompactMode(enabled bool) error {
285+ if c.Options == nil {
286+ c.Options = &Options{}
287+ }
288+ c.Options.TUI.CompactMode = enabled
289+ return c.SetConfigField("options.tui.compact_mode", enabled)
290+}
291+
278func (c *Config) Resolve(key string) (string, error) {
292func (c *Config) Resolve(key string) (string, error) {
279 if c.resolver == nil {
293 if c.resolver == nil {
280 return "", fmt.Errorf("no variable resolver configured")
294 return "", fmt.Errorf("no variable resolver configured")
@@ -282,9 +296,78 @@ func (c *Config) Resolve(key string) (string, error) {
282 return c.resolver.ResolveValue(key)
296 return c.resolver.ResolveValue(key)
283}
297}
284
298
285−// TODO: maybe handle this better
286−func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
299+func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
287− cfg := Get()
300+ c.Models[modelType] = model
288− cfg.Models[modelType] = model
301+ if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
302+ return fmt.Errorf("failed to update preferred model: %w", err)
303+ }
304+ return nil
305+}
306+
307+func (c *Config) SetConfigField(key string, value any) error {
308+ // read the data
309+ data, err := os.ReadFile(c.dataConfigDir)
310+ if err != nil {
311+ if os.IsNotExist(err) {
312+ data = []byte("{}")
313+ } else {
314+ return fmt.Errorf("failed to read config file: %w", err)
315+ }
316+ }
317+
318+ newValue, err := sjson.Set(string(data), key, value)
319+ if err != nil {
320+ return fmt.Errorf("failed to set config field %s: %w", key, err)
321+ }
322+ if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o644); err != nil {
323+ return fmt.Errorf("failed to write config file: %w", err)
324+ }
325+ return nil
326+}
327+
328+func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
329+ // First save to the config file
330+ err := c.SetConfigField("providers."+providerID+".api_key", apiKey)
331+ if err != nil {
332+ return fmt.Errorf("failed to save API key to config file: %w", err)
333+ }
334+
335+ if c.Providers == nil {
336+ c.Providers = make(map[string]ProviderConfig)
337+ }
338+
339+ providerConfig, exists := c.Providers[providerID]
340+ if exists {
341+ providerConfig.APIKey = apiKey
342+ c.Providers[providerID] = providerConfig
343+ return nil
344+ }
345+
346+ var foundProvider *provider.Provider
347+ for _, p := range c.knownProviders {
348+ if string(p.ID) == providerID {
349+ foundProvider = &p
350+ break
351+ }
352+ }
353+
354+ if foundProvider != nil {
355+ // Create new provider config based on known provider
356+ providerConfig = ProviderConfig{
357+ ID: providerID,
358+ Name: foundProvider.Name,
359+ BaseURL: foundProvider.APIEndpoint,
360+ Type: foundProvider.Type,
361+ APIKey: apiKey,
362+ Disable: false,
363+ ExtraHeaders: make(map[string]string),
364+ ExtraParams: make(map[string]string),
365+ Models: foundProvider.Models,
366+ }
367+ } else {
368+ return fmt.Errorf("provider with ID %s not found in known providers", providerID)
369+ }
370+ // Store the updated provider config
371+ c.Providers[providerID] = providerConfig
289 return nil
372 return nil
290}
373}
internal/config/init.go+8
@@ -103,3 +103,11 @@ func MarkProjectInitialized() error {
103
103
104 return nil
104 return nil
105}
105}
106+
107+func HasInitialDataConfig() bool {
108+ cfgPath := GlobalConfigData()
109+ if _, err := os.Stat(cfgPath); err != nil {
110+ return false
111+ }
112+ return true
113+}
internal/config/load.go+55−36
@@ -37,7 +37,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
37 // uses default config paths
37 // uses default config paths
38 configPaths := []string{
38 configPaths := []string{
39 globalConfig(),
39 globalConfig(),
40− globalConfigData(),
40+ GlobalConfigData(),
41 filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
41 filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
42 filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
42 filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
43 }
43 }
@@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) {
46 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
46 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
47 }
47 }
48
48
49+ cfg.dataConfigDir = GlobalConfigData()
50+
49 cfg.setDefaults(workingDir)
51 cfg.setDefaults(workingDir)
50
52
51 if debug {
53 if debug {
@@ -63,6 +65,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
63 if err != nil || len(providers) == 0 {
65 if err != nil || len(providers) == 0 {
64 return nil, fmt.Errorf("failed to load providers: %w", err)
66 return nil, fmt.Errorf("failed to load providers: %w", err)
65 }
67 }
68+ cfg.knownProviders = providers
66
69
67 env := env.New()
70 env := env.New()
68 // Configure providers
71 // Configure providers
@@ -162,6 +165,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
162 }
165 }
163 prepared := ProviderConfig{
166 prepared := ProviderConfig{
164 ID: string(p.ID),
167 ID: string(p.ID),
168+ Name: p.Name,
165 BaseURL: p.APIEndpoint,
169 BaseURL: p.APIEndpoint,
166 APIKey: p.APIKey,
170 APIKey: p.APIKey,
167 Type: p.Type,
171 Type: p.Type,
@@ -218,6 +222,9 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
218
222
219 // Make sure the provider ID is set
223 // Make sure the provider ID is set
220 providerConfig.ID = id
224 providerConfig.ID = id
225+ if providerConfig.Name == "" {
226+ providerConfig.Name = id // Use ID as name if not set
227+ }
221 // default to OpenAI if not set
228 // default to OpenAI if not set
222 if providerConfig.Type == "" {
229 if providerConfig.Type == "" {
223 providerConfig.Type = provider.TypeOpenAI
230 providerConfig.Type = provider.TypeOpenAI
@@ -229,9 +236,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
229 continue
236 continue
230 }
237 }
231 if providerConfig.APIKey == "" {
238 if providerConfig.APIKey == "" {
232− slog.Warn("Skipping custom provider due to missing API key", "provider", id)
239+ slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
233− delete(cfg.Providers, id)
234− continue
235 }
240 }
236 if providerConfig.BaseURL == "" {
241 if providerConfig.BaseURL == "" {
237 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
242 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
@@ -251,9 +256,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
251
256
252 apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
257 apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
253 if apiKey == "" || err != nil {
258 if apiKey == "" || err != nil {
254− slog.Warn("Skipping custom provider due to missing API key", "provider", id, "error", err)
259+ slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
255− delete(cfg.Providers, id)
256− continue
257 }
260 }
258 baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
261 baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
259 if baseURL == "" || err != nil {
262 if baseURL == "" || err != nil {
@@ -369,10 +372,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
369}
372}
370
373
371func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error {
374func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error {
372− large, small, err := cfg.defaultModelSelection(knownProviders)
375+ defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders)
373 if err != nil {
376 if err != nil {
374 return fmt.Errorf("failed to select default models: %w", err)
377 return fmt.Errorf("failed to select default models: %w", err)
375 }
378 }
379+ large, small := defaultLarge, defaultSmall
376
380
377 largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
381 largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
378 if largeModelConfigured {
382 if largeModelConfigured {
@@ -383,18 +387,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
383 large.Provider = largeModelSelected.Provider
387 large.Provider = largeModelSelected.Provider
384 }
388 }
385 model := cfg.GetModel(large.Provider, large.Model)
389 model := cfg.GetModel(large.Provider, large.Model)
390+ slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model)
391+ slog.Info("MOdel configured", "model", model)
386 if model == nil {
392 if model == nil {
387− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
393+ large = defaultLarge
394+ // override the model type to large
395+ err := cfg.UpdatePreferredModel(SelectedModelTypeLarge, large)
396+ if err != nil {
397+ return fmt.Errorf("failed to update preferred large model: %w", err)
388− }
398+ }
389− if largeModelSelected.MaxTokens > 0 {
390− large.MaxTokens = largeModelSelected.MaxTokens
391 } else {
399 } else {
400+ if largeModelSelected.MaxTokens > 0 {
401+ large.MaxTokens = largeModelSelected.MaxTokens
402+ } else {
392− large.MaxTokens = model.DefaultMaxTokens
403+ large.MaxTokens = model.DefaultMaxTokens
393− }
404+ }
394− if largeModelSelected.ReasoningEffort != "" {
405+ if largeModelSelected.ReasoningEffort != "" {
395− large.ReasoningEffort = largeModelSelected.ReasoningEffort
406+ large.ReasoningEffort = largeModelSelected.ReasoningEffort
407+ }
408+ large.Think = largeModelSelected.Think
396 }
409 }
397− large.Think = largeModelSelected.Think
398 }
410 }
399 smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
411 smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
400 if smallModelConfigured {
412 if smallModelConfigured {
@@ -407,25 +419,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
407
419
408 model := cfg.GetModel(small.Provider, small.Model)
420 model := cfg.GetModel(small.Provider, small.Model)
409 if model == nil {
421 if model == nil {
410− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
422+ small = defaultSmall
423+ // override the model type to small
424+ err := cfg.UpdatePreferredModel(SelectedModelTypeSmall, small)
425+ if err != nil {
426+ return fmt.Errorf("failed to update preferred small model: %w", err)
411− }
427+ }
412− if smallModelSelected.MaxTokens > 0 {
413− small.MaxTokens = smallModelSelected.MaxTokens
414 } else {
428 } else {
429+ if smallModelSelected.MaxTokens > 0 {
430+ small.MaxTokens = smallModelSelected.MaxTokens
431+ } else {
415− small.MaxTokens = model.DefaultMaxTokens
432+ small.MaxTokens = model.DefaultMaxTokens
433+ }
434+ small.ReasoningEffort = smallModelSelected.ReasoningEffort
435+ small.Think = smallModelSelected.Think
416 }
436 }
417− small.ReasoningEffort = smallModelSelected.ReasoningEffort
418− small.Think = smallModelSelected.Think
419− }
420−
421− // validate the selected models
422− largeModel := cfg.GetModel(large.Provider, large.Model)
423− if largeModel == nil {
424− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
425− }
426− smallModel := cfg.GetModel(small.Provider, small.Model)
427− if smallModel == nil {
428− return fmt.Errorf("small model %s not found for provider %s", small.Model, small.Provider)
429 }
437 }
430 cfg.Models[SelectedModelTypeLarge] = large
438 cfg.Models[SelectedModelTypeLarge] = large
431 cfg.Models[SelectedModelTypeSmall] = small
439 cfg.Models[SelectedModelTypeSmall] = small
@@ -512,9 +520,9 @@ func globalConfig() string {
512 return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
520 return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
513}
521}
514
522
515−// globalConfigData returns the path to the main data directory for the application.
523+// GlobalConfigData returns the path to the main data directory for the application.
516// this config is used when the app overrides configurations instead of updating the global config.
524// this config is used when the app overrides configurations instead of updating the global config.
517−func globalConfigData() string {
525+func GlobalConfigData() string {
518 xdgDataHome := os.Getenv("XDG_DATA_HOME")
526 xdgDataHome := os.Getenv("XDG_DATA_HOME")
519 if xdgDataHome != "" {
527 if xdgDataHome != "" {
520 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
528 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
@@ -533,3 +541,14 @@ func globalConfigData() string {
533
541
534 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
542 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
535}
543}
544+
545+func HomeDir() string {
546+ homeDir := os.Getenv("HOME")
547+ if homeDir == "" {
548+ homeDir = os.Getenv("USERPROFILE") // For Windows compatibility
549+ }
550+ if homeDir == "" {
551+ homeDir = os.Getenv("HOMEPATH") // Fallback for some environments
552+ }
553+ return homeDir
554+}
internal/config/load_test.go+6−3
@@ -484,7 +484,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
484}
484}
485
485
486func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
486func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
487− t.Run("custom provider with missing API key is removed", func(t *testing.T) {
487+ t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
488 cfg := &Config{
488 cfg := &Config{
489 Providers: map[string]ProviderConfig{
489 Providers: map[string]ProviderConfig{
490 "custom": {
490 "custom": {
@@ -493,6 +493,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
493 ID: "test-model",
493 ID: "test-model",
494 }},
494 }},
495 },
495 },
496+ "openai": {
497+ APIKey: "$MISSING",
498+ },
496 },
499 },
497 }
500 }
498 cfg.setDefaults("/tmp")
501 cfg.setDefaults("/tmp")
@@ -502,9 +505,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
502 err := cfg.configureProviders(env, resolver, []provider.Provider{})
505 err := cfg.configureProviders(env, resolver, []provider.Provider{})
503 assert.NoError(t, err)
506 assert.NoError(t, err)
504
507
505− assert.Len(t, cfg.Providers, 0)
508+ assert.Len(t, cfg.Providers, 1)
506 _, exists := cfg.Providers["custom"]
509 _, exists := cfg.Providers["custom"]
507− assert.False(t, exists)
510+ assert.True(t, exists)
508 })
511 })
509
512
510 t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
513 t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
internal/fsext/fileutil.go+2−11
@@ -17,23 +17,14 @@ import (
17 ignore "github.com/sabhiram/go-gitignore"
17 ignore "github.com/sabhiram/go-gitignore"
18)
18)
19
19
20−var (
21− rgPath string
20+var rgPath string
22− fzfPath string
23−)
24
21
25func init() {
22func init() {
26 var err error
23 var err error
27 rgPath, err = exec.LookPath("rg")
24 rgPath, err = exec.LookPath("rg")
28 if err != nil {
25 if err != nil {
29 if log.Initialized() {
26 if log.Initialized() {
30− slog.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
27+ slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
31− }
32− }
33− fzfPath, err = exec.LookPath("fzf")
34− if err != nil {
35− if log.Initialized() {
36− slog.Warn("FZF not found in $PATH. Some features might be limited or slower.")
37 }
28 }
38 }
29 }
39}
30}
internal/llm/prompt/initialize.gonew file+14
@@ -0,0 +1,14 @@
1+package prompt
2+
3+func Initialize() string {
4+ return `Please analyze this codebase and create a **CRUSH.md** file containing:
5+
6+- Build/lint/test commands - especially for running a single test
7+- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
8+
9+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
10+If there's already a **CRUSH.md**, improve it.
11+
12+If there are Cursor rules` + " (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.\n" +
13+ "Add the `.crush` directory to the `.gitignore` file if it's not already there."
14+}
internal/tui/components/chat/chat.go+27−11
@@ -14,8 +14,8 @@ import (
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
15 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
16 "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/core/list"
17+ "github.com/charmbracelet/crush/internal/tui/styles"
17 "github.com/charmbracelet/crush/internal/tui/util"
18 "github.com/charmbracelet/crush/internal/tui/util"
18− "github.com/charmbracelet/lipgloss/v2"
19)
19)
20
20
21type SendMsg struct {
21type SendMsg struct {
@@ -37,6 +37,9 @@ type MessageListCmp interface {
37 util.Model
37 util.Model
38 layout.Sizeable
38 layout.Sizeable
39 layout.Focusable
39 layout.Focusable
40+ layout.Help
41+
42+ SetSession(session.Session) tea.Cmd
40}
43}
41
44
42// messageListCmp implements MessageListCmp, providing a virtualized list
45// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -53,9 +56,9 @@ type messageListCmp struct {
53 defaultListKeyMap list.KeyMap
56 defaultListKeyMap list.KeyMap
54}
57}
55
58
56−// NewMessagesListCmp creates a new message list component with custom keybindings
59+// New creates a new message list component with custom keybindings
57// and reverse ordering (newest messages at bottom).
60// and reverse ordering (newest messages at bottom).
58−func NewMessagesListCmp(app *app.App) MessageListCmp {
61+func New(app *app.App) MessageListCmp {
59 defaultListKeyMap := list.DefaultKeyMap()
62 defaultListKeyMap := list.DefaultKeyMap()
60 listCmp := list.New(
63 listCmp := list.New(
61 list.WithGapSize(1),
64 list.WithGapSize(1),
@@ -70,7 +73,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
70 }
73 }
71}
74}
72
75
73−// Init initializes the component (no initialization needed).
76+// Init initializes the component.
74func (m *messageListCmp) Init() tea.Cmd {
77func (m *messageListCmp) Init() tea.Cmd {
75 return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
78 return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
76}
79}
@@ -102,16 +105,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102
105
103// View renders the message list or an initial screen if empty.
106// View renders the message list or an initial screen if empty.
104func (m *messageListCmp) View() string {
107func (m *messageListCmp) View() string {
105− return lipgloss.JoinVertical(
108+ t := styles.CurrentTheme()
106− lipgloss.Left,
109+ return t.S().Base.
110+ Padding(1).
111+ Width(m.width).
112+ Height(m.height).
113+ Render(
107− m.listCmp.View(),
114+ m.listCmp.View(),
108− )
115+ )
109}
116}
110
117
111// handleChildSession handles messages from child sessions (agent tools).
118// handleChildSession handles messages from child sessions (agent tools).
112func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
119func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
113 var cmds []tea.Cmd
120 var cmds []tea.Cmd
114− if len(event.Payload.ToolCalls()) == 0 {
121+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
115 return nil
122 return nil
116 }
123 }
117 items := m.listCmp.Items()
124 items := m.listCmp.Items()
@@ -151,6 +158,15 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
151 )
158 )
152 }
159 }
153 }
160 }
161+ for _, tr := range event.Payload.ToolResults() {
162+ for nestedInx, nestedTC := range nestedToolCalls {
163+ if nestedTC.GetToolCall().ID == tr.ToolCallID {
164+ nestedToolCalls[nestedInx].SetToolResult(tr)
165+ break
166+ }
167+ }
168+ }
169+
154 toolCall.SetNestedToolCalls(nestedToolCalls)
170 toolCall.SetNestedToolCalls(nestedToolCalls)
155 m.listCmp.UpdateItem(
171 m.listCmp.UpdateItem(
156 toolCallInx,
172 toolCallInx,
@@ -487,8 +503,8 @@ func (m *messageListCmp) GetSize() (int, int) {
487// SetSize updates the component dimensions and propagates to the list component.
503// SetSize updates the component dimensions and propagates to the list component.
488func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
504func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
489 m.width = width
505 m.width = width
490− m.height = height - 1
506+ m.height = height
491− return m.listCmp.SetSize(width, height-1)
507+ return m.listCmp.SetSize(width-2, height-2) // for padding
492}
508}
493
509
494// Blur implements MessageListCmp.
510// Blur implements MessageListCmp.
internal/tui/components/chat/editor/editor.go+27−11
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -26,6 +27,18 @@ import (
26 "github.com/charmbracelet/lipgloss/v2"
27 "github.com/charmbracelet/lipgloss/v2"
27)
28)
28
29
30+type Editor interface {
31+ util.Model
32+ layout.Sizeable
33+ layout.Focusable
34+ layout.Help
35+ layout.Positional
36+
37+ SetSession(session session.Session) tea.Cmd
38+ IsCompletionsOpen() bool
39+ Cursor() *tea.Cursor
40+}
41+
29type FileCompletionItem struct {
42type FileCompletionItem struct {
30 Path string // The file path
43 Path string // The file path
31}
44}
@@ -149,14 +162,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 var cmd tea.Cmd
162 var cmd tea.Cmd
150 var cmds []tea.Cmd
163 var cmds []tea.Cmd
151 switch msg := msg.(type) {
164 switch msg := msg.(type) {
152− case tea.KeyboardEnhancementsMsg:
153− m.keyMap.keyboard = msg
154− return m, nil
155− case chat.SessionSelectedMsg:
156− if msg.ID != m.session.ID {
157− m.session = msg
158− }
159− return m, nil
160 case filepicker.FilePickedMsg:
165 case filepicker.FilePickedMsg:
161 if len(m.attachments) >= maxAttachments {
166 if len(m.attachments) >= maxAttachments {
162 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
167 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
@@ -263,8 +268,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
263 value := m.textarea.Value()
268 value := m.textarea.Value()
264 if len(value) > 0 && value[len(value)-1] == '\\' {
269 if len(value) > 0 && value[len(value)-1] == '\\' {
265 // If the last character is a backslash, remove it and add a newline
270 // If the last character is a backslash, remove it and add a newline
266− m.textarea.SetValue(value[:len(value)-1] + "\n")
271+ m.textarea.SetValue(value[:len(value)-1])
267− return m, nil
268 } else {
272 } else {
269 // Otherwise, send the message
273 // Otherwise, send the message
270 return m, m.send()
274 return m, m.send()
@@ -387,7 +391,18 @@ func (c *editorCmp) Bindings() []key.Binding {
387 return c.keyMap.KeyBindings()
391 return c.keyMap.KeyBindings()
388}
392}
389
393
390−func NewEditorCmp(app *app.App) util.Model {
394+// TODO: most likely we do not need to have the session here
395+// we need to move some functionality to the page level
396+func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
397+ c.session = session
398+ return nil
399+}
400+
401+func (c *editorCmp) IsCompletionsOpen() bool {
402+ return c.isCompletionsOpen
403+}
404+
405+func New(app *app.App) Editor {
391 t := styles.CurrentTheme()
406 t := styles.CurrentTheme()
392 ta := textarea.New()
407 ta := textarea.New()
393 ta.SetStyles(t.S().TextArea)
408 ta.SetStyles(t.S().TextArea)
@@ -408,6 +423,7 @@ func NewEditorCmp(app *app.App) util.Model {
408 ta.Focus()
423 ta.Focus()
409
424
410 return &editorCmp{
425 return &editorCmp{
426+ // TODO: remove the app instance from here
411 app: app,
427 app: app,
412 textarea: ta,
428 textarea: ta,
413 keyMap: DefaultEditorKeyMap(),
429 keyMap: DefaultEditorKeyMap(),
internal/tui/components/chat/editor/keys.go+3−10
@@ -2,7 +2,6 @@ package editor
2
2
3import (
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6)
5)
7
6
8type EditorKeyMap struct {
7type EditorKeyMap struct {
@@ -10,8 +9,6 @@ type EditorKeyMap struct {
10 SendMessage key.Binding
9 SendMessage key.Binding
11 OpenEditor key.Binding
10 OpenEditor key.Binding
12 Newline key.Binding
11 Newline key.Binding
13−
14− keyboard tea.KeyboardEnhancementsMsg
15}
12}
16
13
17func DefaultEditorKeyMap() EditorKeyMap {
14func DefaultEditorKeyMap() EditorKeyMap {
@@ -25,8 +22,8 @@ func DefaultEditorKeyMap() EditorKeyMap {
25 key.WithHelp("enter", "send"),
22 key.WithHelp("enter", "send"),
26 ),
23 ),
27 OpenEditor: key.NewBinding(
24 OpenEditor: key.NewBinding(
28− key.WithKeys("ctrl+e"),
25+ key.WithKeys("ctrl+v"),
29− key.WithHelp("ctrl+e", "open editor"),
26+ key.WithHelp("ctrl+v", "open editor"),
30 ),
27 ),
31 Newline: key.NewBinding(
28 Newline: key.NewBinding(
32 key.WithKeys("shift+enter", "ctrl+j"),
29 key.WithKeys("shift+enter", "ctrl+j"),
@@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap {
40
37
41// KeyBindings implements layout.KeyMapProvider
38// KeyBindings implements layout.KeyMapProvider
42func (k EditorKeyMap) KeyBindings() []key.Binding {
39func (k EditorKeyMap) KeyBindings() []key.Binding {
43− newline := k.Newline
44− if k.keyboard.SupportsKeyDisambiguation() {
45− newline.SetHelp("shift+enter", newline.Help().Desc)
46− }
47 return []key.Binding{
40 return []key.Binding{
48 k.AddFile,
41 k.AddFile,
49 k.SendMessage,
42 k.SendMessage,
50 k.OpenEditor,
43 k.OpenEditor,
51− newline,
44+ k.Newline,
52 }
45 }
53}
46}
54
47
internal/tui/components/chat/header/header.go+10−7
@@ -11,7 +11,6 @@ import (
11 "github.com/charmbracelet/crush/internal/lsp/protocol"
11 "github.com/charmbracelet/crush/internal/lsp/protocol"
12 "github.com/charmbracelet/crush/internal/pubsub"
12 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
14− "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/lipgloss/v2"
16 "github.com/charmbracelet/lipgloss/v2"
@@ -19,7 +18,8 @@ import (
19
18
20type Header interface {
19type Header interface {
21 util.Model
20 util.Model
22− SetSession(session session.Session)
21+ SetSession(session session.Session) tea.Cmd
22+ SetWidth(width int) tea.Cmd
23 SetDetailsOpen(open bool)
23 SetDetailsOpen(open bool)
24}
24}
25
25
@@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd {
43
43
44func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45 switch msg := msg.(type) {
45 switch msg := msg.(type) {
46− case tea.WindowSizeMsg:
47− p.width = msg.Width - 2
48− case chat.SessionSelectedMsg:
49− p.session = msg
50 case pubsub.Event[session.Session]:
46 case pubsub.Event[session.Session]:
51 if msg.Type == pubsub.UpdatedEvent {
47 if msg.Type == pubsub.UpdatedEvent {
52 if p.session.ID == msg.Payload.ID {
48 if p.session.ID == msg.Payload.ID {
@@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) {
131}
127}
132
128
133// SetSession implements Header.
129// SetSession implements Header.
134−func (h *header) SetSession(session session.Session) {
130+func (h *header) SetSession(session session.Session) tea.Cmd {
135 h.session = session
131 h.session = session
132+ return nil
133+}
134+
135+// SetWidth implements Header.
136+func (h *header) SetWidth(width int) tea.Cmd {
137+ h.width = width
138+ return nil
136}
139}
internal/tui/components/chat/messages/messages.go+7
@@ -10,6 +10,7 @@ import (
10 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11
11
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() string {
296 infoMsg := t.S().Subtle.Render(duration.String())
297 infoMsg := t.S().Subtle.Render(duration.String())
297 icon := t.S().Subtle.Render(styles.ModelIcon)
298 icon := t.S().Subtle.Render(styles.ModelIcon)
298 model := config.Get().GetModel(m.message.Provider, m.message.Model)
299 model := config.Get().GetModel(m.message.Provider, m.message.Model)
300+ if model == nil {
301+ // This means the model is not configured anymore
302+ model = &provider.Model{
303+ Model: "Unknown Model",
304+ }
305+ }
299 modelFormatted := t.S().Muted.Render(model.Model)
306 modelFormatted := t.S().Muted.Render(model.Model)
300 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
307 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
301 return t.S().Base.PaddingLeft(2).Render(
308 return t.S().Base.PaddingLeft(2).Render(
internal/tui/components/chat/messages/renderer.go+17−3
@@ -112,10 +112,21 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
112}
112}
113
113
114// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
114// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
115−func (br baseRenderer) makeNestedHeader(_ *toolCallCmp, tool string, width int, params ...string) string {
115+func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
116 t := styles.CurrentTheme()
116 t := styles.CurrentTheme()
117+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
118+ if v.result.ToolCallID != "" {
119+ if v.result.IsError {
120+ icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
121+ } else {
122+ icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
123+ }
124+ } else if v.cancelled {
125+ icon = t.S().Muted.Render(styles.ToolPending)
126+ }
117 tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
127 tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
128+ prefix := fmt.Sprintf("%s %s ", icon, tool)
118− return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
129+ return prefix + renderParamList(true, width-lipgloss.Width(tool), params...)
119}
130}
120
131
121// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
132// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
@@ -542,7 +553,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
542
553
543 if v.result.ToolCallID == "" {
554 if v.result.ToolCallID == "" {
544 v.spinning = true
555 v.spinning = true
545− parts = append(parts, v.anim.View())
556+ parts = append(parts, "", v.anim.View())
546 } else {
557 } else {
547 v.spinning = false
558 v.spinning = false
548 }
559 }
@@ -634,6 +645,9 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
634
645
635func joinHeaderBody(header, body string) string {
646func joinHeaderBody(header, body string) string {
636 t := styles.CurrentTheme()
647 t := styles.CurrentTheme()
648+ if body == "" {
649+ return header
650+ }
637 body = t.S().Base.PaddingLeft(2).Render(body)
651 body = t.S().Base.PaddingLeft(2).Render(body)
638 return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
652 return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
639}
653}
internal/tui/components/chat/messages/tool.go+2−2
@@ -219,11 +219,11 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
219// renderPending displays the tool name with a loading animation for pending tool calls
219// renderPending displays the tool name with a loading animation for pending tool calls
220func (m *toolCallCmp) renderPending() string {
220func (m *toolCallCmp) renderPending() string {
221 t := styles.CurrentTheme()
221 t := styles.CurrentTheme()
222+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
222 if m.isNested {
223 if m.isNested {
223 tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
224 tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
224− return fmt.Sprintf("%s %s", tool, m.anim.View())
225+ return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
225 }
226 }
226− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
227 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
227 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
228 return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
228 return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
229}
229}
internal/tui/components/chat/sidebar/sidebar.go+33−29
@@ -13,7 +13,6 @@ import (
13 "github.com/charmbracelet/crush/internal/diff"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
15 "github.com/charmbracelet/crush/internal/history"
16−
17 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
19 "github.com/charmbracelet/crush/internal/pubsub"
18 "github.com/charmbracelet/crush/internal/pubsub"
@@ -29,10 +28,6 @@ import (
29 "github.com/charmbracelet/x/ansi"
28 "github.com/charmbracelet/x/ansi"
30)
29)
31
30
32−const (
33− logoBreakpoint = 65
34−)
35−
36type FileHistory struct {
31type FileHistory struct {
37 initialVersion history.File
32 initialVersion history.File
38 latestVersion history.File
33 latestVersion history.File
@@ -52,6 +47,7 @@ type Sidebar interface {
52 util.Model
47 util.Model
53 layout.Sizeable
48 layout.Sizeable
54 SetSession(session session.Session) tea.Cmd
49 SetSession(session session.Session) tea.Cmd
50+ SetCompactMode(bool)
55}
51}
56
52
57type sidebarCmp struct {
53type sidebarCmp struct {
@@ -66,7 +62,7 @@ type sidebarCmp struct {
66 files sync.Map
62 files sync.Map
67}
63}
68
64
69−func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
65+func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
70 return &sidebarCmp{
66 return &sidebarCmp{
71 lspClients: lspClients,
67 lspClients: lspClients,
72 history: history,
68 history: history,
@@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
75}
71}
76
72
77func (m *sidebarCmp) Init() tea.Cmd {
73func (m *sidebarCmp) Init() tea.Cmd {
78− m.logo = m.logoBlock(false)
79− m.cwd = cwd()
80 return nil
74 return nil
81}
75}
82
76
83func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 switch msg := msg.(type) {
78 switch msg := msg.(type) {
85− case chat.SessionSelectedMsg:
86− return m, m.SetSession(msg)
87 case SessionFilesMsg:
79 case SessionFilesMsg:
88 m.files = sync.Map{}
80 m.files = sync.Map{}
89 for _, file := range msg.Files {
81 for _, file := range msg.Files {
@@ -137,7 +129,16 @@ func (m *sidebarCmp) View() string {
137 m.mcpBlock(),
129 m.mcpBlock(),
138 )
130 )
139
131
132+ style := t.S().Base.
133+ Width(m.width).
134+ Height(m.height).
135+ Padding(1)
136+ if m.compactMode {
137+ style = style.PaddingTop(0)
138+ }
139+ return style.Render(
140− return lipgloss.JoinVertical(lipgloss.Left, parts...)
140+ lipgloss.JoinVertical(lipgloss.Left, parts...),
141+ )
141}
142}
142
143
143func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
144func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
@@ -230,12 +231,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
230}
231}
231
232
232func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233− if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234− m.logo = m.logoBlock(true)
234+ m.logo = m.logoBlock()
235− } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
235+ m.cwd = cwd()
236− m.logo = m.logoBlock(false)
237− }
238−
239 m.width = width
236 m.width = width
240 m.height = height
237 m.height = height
241 return nil
238 return nil
@@ -245,23 +242,27 @@ func (m *sidebarCmp) GetSize() (int, int) {
245 return m.width, m.height
242 return m.width, m.height
246}
243}
247
244
248−func (m *sidebarCmp) logoBlock(compact bool) string {
245+func (m *sidebarCmp) logoBlock() string {
249 t := styles.CurrentTheme()
246 t := styles.CurrentTheme()
250− return logo.Render(version.Version, compact, logo.Opts{
247+ return logo.Render(version.Version, true, logo.Opts{
251 FieldColor: t.Primary,
248 FieldColor: t.Primary,
252 TitleColorA: t.Secondary,
249 TitleColorA: t.Secondary,
253 TitleColorB: t.Primary,
250 TitleColorB: t.Primary,
254 CharmColor: t.Secondary,
251 CharmColor: t.Secondary,
255 VersionColor: t.Primary,
252 VersionColor: t.Primary,
253+ Width: m.width - 2,
256 })
254 })
257}
255}
258
256
257+func (m *sidebarCmp) getMaxWidth() int {
258+ return min(m.width-2, 58) // -2 for padding
259+}
260+
259func (m *sidebarCmp) filesBlock() string {
261func (m *sidebarCmp) filesBlock() string {
260− maxWidth := min(m.width, 58)
261 t := styles.CurrentTheme()
262 t := styles.CurrentTheme()
262
263
263 section := t.S().Subtle.Render(
264 section := t.S().Subtle.Render(
264− core.Section("Modified Files", maxWidth),
265+ core.Section("Modified Files", m.getMaxWidth()),
265 )
266 )
266
267
267 files := make([]SessionFile, 0)
268 files := make([]SessionFile, 0)
@@ -302,7 +303,7 @@ func (m *sidebarCmp) filesBlock() string {
302 filePath := file.FilePath
303 filePath := file.FilePath
303 filePath = strings.TrimPrefix(filePath, cwd)
304 filePath = strings.TrimPrefix(filePath, cwd)
304 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
305 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
305− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
306+ filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
306 fileList = append(fileList,
307 fileList = append(fileList,
307 core.Status(
308 core.Status(
308 core.StatusOpts{
309 core.StatusOpts{
@@ -311,7 +312,7 @@ func (m *sidebarCmp) filesBlock() string {
311 Title: filePath,
312 Title: filePath,
312 ExtraContent: extraContent,
313 ExtraContent: extraContent,
313 },
314 },
314− m.width,
315+ m.getMaxWidth(),
315 ),
316 ),
316 )
317 )
317 }
318 }
@@ -323,11 +324,10 @@ func (m *sidebarCmp) filesBlock() string {
323}
324}
324
325
325func (m *sidebarCmp) lspBlock() string {
326func (m *sidebarCmp) lspBlock() string {
326− maxWidth := min(m.width, 58)
327 t := styles.CurrentTheme()
327 t := styles.CurrentTheme()
328
328
329 section := t.S().Subtle.Render(
329 section := t.S().Subtle.Render(
330− core.Section("LSPs", maxWidth),
330+ core.Section("LSPs", m.getMaxWidth()),
331 )
331 )
332
332
333 lspList := []string{section, ""}
333 lspList := []string{section, ""}
@@ -385,7 +385,7 @@ func (m *sidebarCmp) lspBlock() string {
385 Description: l.LSP.Command,
385 Description: l.LSP.Command,
386 ExtraContent: strings.Join(errs, " "),
386 ExtraContent: strings.Join(errs, " "),
387 },
387 },
388− m.width,
388+ m.getMaxWidth(),
389 ),
389 ),
390 )
390 )
391 }
391 }
@@ -397,11 +397,10 @@ func (m *sidebarCmp) lspBlock() string {
397}
397}
398
398
399func (m *sidebarCmp) mcpBlock() string {
399func (m *sidebarCmp) mcpBlock() string {
400− maxWidth := min(m.width, 58)
401 t := styles.CurrentTheme()
400 t := styles.CurrentTheme()
402
401
403 section := t.S().Subtle.Render(
402 section := t.S().Subtle.Render(
404− core.Section("MCPs", maxWidth),
403+ core.Section("MCPs", m.getMaxWidth()),
405 )
404 )
406
405
407 mcpList := []string{section, ""}
406 mcpList := []string{section, ""}
@@ -428,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string {
428 Title: l.Name,
427 Title: l.Name,
429 Description: l.MCP.Command,
428 Description: l.MCP.Command,
430 },
429 },
431− m.width,
430+ m.getMaxWidth(),
432 ),
431 ),
433 )
432 )
434 }
433 }
@@ -511,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
511 return m.loadSessionFiles
510 return m.loadSessionFiles
512}
511}
513
512
513+// SetCompactMode sets the compact mode for the sidebar.
514+func (m *sidebarCmp) SetCompactMode(compact bool) {
515+ m.compactMode = compact
516+}
517+
514func cwd() string {
518func cwd() string {
515 cwd := config.Get().WorkingDir()
519 cwd := config.Get().WorkingDir()
516 t := styles.CurrentTheme()
520 t := styles.CurrentTheme()
internal/tui/components/chat/splash/keys.go+38−3
@@ -5,14 +5,49 @@ import (
5)
5)
6
6
7type KeyMap struct {
7type KeyMap struct {
8+ Select,
9+ Next,
10+ Previous,
11+ Yes,
12+ No,
13+ Tab,
14+ LeftRight,
8− Cancel key.Binding
15+ Back key.Binding
9}
16}
10
17
11func DefaultKeyMap() KeyMap {
18func DefaultKeyMap() KeyMap {
12 return KeyMap{
19 return KeyMap{
20+ Select: key.NewBinding(
21+ key.WithKeys("enter", "ctrl+y"),
22+ key.WithHelp("enter", "confirm"),
23+ ),
24+ Next: key.NewBinding(
25+ key.WithKeys("down", "ctrl+n"),
26+ key.WithHelp("↓", "next item"),
27+ ),
28+ Previous: key.NewBinding(
29+ key.WithKeys("up", "ctrl+p"),
30+ key.WithHelp("↑", "previous item"),
31+ ),
32+ Yes: key.NewBinding(
33+ key.WithKeys("y"),
34+ key.WithHelp("y", "yes"),
35+ ),
36+ No: key.NewBinding(
37+ key.WithKeys("n"),
38+ key.WithHelp("n", "no"),
39+ ),
40+ Tab: key.NewBinding(
41+ key.WithKeys("tab"),
42+ key.WithHelp("tab", "switch"),
43+ ),
44+ LeftRight: key.NewBinding(
45+ key.WithKeys("left", "right"),
46+ key.WithHelp("←/→", "switch"),
47+ ),
13− Cancel: key.NewBinding(
48+ Back: key.NewBinding(
14 key.WithKeys("esc"),
49 key.WithKeys("esc"),
15− key.WithHelp("esc", "cancel"),
50+ key.WithHelp("esc", "back"),
16 ),
51 ),
17 }
52 }
18}
53}
internal/tui/components/chat/splash/splash.go+564−19
@@ -1,9 +1,22 @@
1package splash
1package splash
2
2
3import (
3import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strings"
8+
4 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/fur/provider"
13+ "github.com/charmbracelet/crush/internal/llm/prompt"
14+ "github.com/charmbracelet/crush/internal/tui/components/chat"
15+ "github.com/charmbracelet/crush/internal/tui/components/completions"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
6 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
19+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
7 "github.com/charmbracelet/crush/internal/tui/components/logo"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
8 "github.com/charmbracelet/crush/internal/tui/styles"
21 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/crush/internal/tui/util"
22 "github.com/charmbracelet/crush/internal/tui/util"
@@ -15,23 +28,98 @@ type Splash interface {
15 util.Model
28 util.Model
16 layout.Sizeable
29 layout.Sizeable
17 layout.Help
30 layout.Help
31+ Cursor() *tea.Cursor
32+ // SetOnboarding controls whether the splash shows model selection UI
33+ SetOnboarding(bool)
34+ // SetProjectInit controls whether the splash shows project initialization prompt
35+ SetProjectInit(bool)
36+
37+ // Showing API key input
38+ IsShowingAPIKey() bool
18}
39}
19
40
41+const (
42+ SplashScreenPaddingY = 1 // Padding Y for the splash screen
43+
44+ LogoGap = 6
45+)
46+
47+// OnboardingCompleteMsg is sent when onboarding is complete
48+type OnboardingCompleteMsg struct{}
49+
20type splashCmp struct {
50type splashCmp struct {
21 width, height int
51 width, height int
22 keyMap KeyMap
52 keyMap KeyMap
23 logoRendered string
53 logoRendered string
54+
55+ // State
56+ isOnboarding bool
57+ needsProjectInit bool
58+ needsAPIKey bool
59+ selectedNo bool
60+
61+ listHeight int
62+ modelList *models.ModelListComponent
63+ apiKeyInput *models.APIKeyInput
64+ selectedModel *models.ModelOption
24}
65}
25
66
26func New() Splash {
67func New() Splash {
68+ keyMap := DefaultKeyMap()
69+ listKeyMap := list.DefaultKeyMap()
70+ listKeyMap.Down.SetEnabled(false)
71+ listKeyMap.Up.SetEnabled(false)
72+ listKeyMap.HalfPageDown.SetEnabled(false)
73+ listKeyMap.HalfPageUp.SetEnabled(false)
74+ listKeyMap.Home.SetEnabled(false)
75+ listKeyMap.End.SetEnabled(false)
76+ listKeyMap.DownOneItem = keyMap.Next
77+ listKeyMap.UpOneItem = keyMap.Previous
78+
79+ t := styles.CurrentTheme()
80+ inputStyle := t.S().Base.Padding(0, 1, 0, 1)
81+ modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
82+ apiKeyInput := models.NewAPIKeyInput()
83+
27 return &splashCmp{
84 return &splashCmp{
28 width: 0,
85 width: 0,
29 height: 0,
86 height: 0,
30− keyMap: DefaultKeyMap(),
87+ keyMap: keyMap,
31 logoRendered: "",
88 logoRendered: "",
89+ modelList: modelList,
90+ apiKeyInput: apiKeyInput,
91+ selectedNo: false,
32 }
92 }
33}
93}
34
94
95+func (s *splashCmp) SetOnboarding(onboarding bool) {
96+ s.isOnboarding = onboarding
97+ if onboarding {
98+ providers, err := config.Providers()
99+ if err != nil {
100+ return
101+ }
102+ filteredProviders := []provider.Provider{}
103+ simpleProviders := []string{
104+ "anthropic",
105+ "openai",
106+ "gemini",
107+ "xai",
108+ "openrouter",
109+ }
110+ for _, p := range providers {
111+ if slices.Contains(simpleProviders, string(p.ID)) {
112+ filteredProviders = append(filteredProviders, p)
113+ }
114+ }
115+ s.modelList.SetProviders(filteredProviders)
116+ }
117+}
118+
119+func (s *splashCmp) SetProjectInit(needsInit bool) {
120+ s.needsProjectInit = needsInit
121+}
122+
35// GetSize implements SplashPage.
123// GetSize implements SplashPage.
36func (s *splashCmp) GetSize() (int, int) {
124func (s *splashCmp) GetSize() (int, int) {
37 return s.width, s.height
125 return s.width, s.height
@@ -39,15 +127,20 @@ func (s *splashCmp) GetSize() (int, int) {
39
127
40// Init implements SplashPage.
128// Init implements SplashPage.
41func (s *splashCmp) Init() tea.Cmd {
129func (s *splashCmp) Init() tea.Cmd {
42− return nil
130+ return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
43}
131}
44
132
45// SetSize implements SplashPage.
133// SetSize implements SplashPage.
46func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
134func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
47− s.width = width
48 s.height = height
135 s.height = height
136+ if width != s.width {
137+ s.width = width
49− s.logoRendered = s.logoBlock()
138+ s.logoRendered = s.logoBlock()
50− return nil
139+ }
140+ // remove padding, logo height, gap, title space
141+ s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
142+ listWidth := min(60, width)
143+ return s.modelList.SetSize(listWidth, s.listHeight)
51}
144}
52
145
53// Update implements SplashPage.
146// Update implements SplashPage.
@@ -55,32 +148,484 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55 switch msg := msg.(type) {
148 switch msg := msg.(type) {
56 case tea.WindowSizeMsg:
149 case tea.WindowSizeMsg:
57 return s, s.SetSize(msg.Width, msg.Height)
150 return s, s.SetSize(msg.Width, msg.Height)
151+ case tea.KeyPressMsg:
152+ switch {
153+ case key.Matches(msg, s.keyMap.Back):
154+ if s.needsAPIKey {
155+ // Go back to model selection
156+ s.needsAPIKey = false
157+ s.selectedModel = nil
158+ return s, nil
159+ }
160+ case key.Matches(msg, s.keyMap.Select):
161+ if s.isOnboarding && !s.needsAPIKey {
162+ modelInx := s.modelList.SelectedIndex()
163+ items := s.modelList.Items()
164+ selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
165+ if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
166+ cmd := s.setPreferredModel(selectedItem)
167+ s.isOnboarding = false
168+ return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
169+ } else {
170+ // Provider not configured, show API key input
171+ s.needsAPIKey = true
172+ s.selectedModel = &selectedItem
173+ s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
174+ return s, nil
175+ }
176+ } else if s.needsAPIKey {
177+ // Handle API key submission
178+ apiKey := s.apiKeyInput.Value()
179+ if apiKey != "" {
180+ return s, s.saveAPIKeyAndContinue(apiKey)
181+ }
182+ } else if s.needsProjectInit {
183+ return s, s.initializeProject()
184+ }
185+ case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
186+ if s.needsProjectInit {
187+ s.selectedNo = !s.selectedNo
188+ return s, nil
189+ }
190+ case key.Matches(msg, s.keyMap.Yes):
191+ if s.needsProjectInit {
192+ return s, s.initializeProject()
193+ }
194+ case key.Matches(msg, s.keyMap.No):
195+ if s.needsProjectInit {
196+ s.needsProjectInit = false
197+ return s, util.CmdHandler(OnboardingCompleteMsg{})
198+ }
199+ default:
200+ if s.needsAPIKey {
201+ u, cmd := s.apiKeyInput.Update(msg)
202+ s.apiKeyInput = u.(*models.APIKeyInput)
203+ return s, cmd
204+ } else if s.isOnboarding {
205+ u, cmd := s.modelList.Update(msg)
206+ s.modelList = u
207+ return s, cmd
208+ }
209+ }
210+ case tea.PasteMsg:
211+ if s.needsAPIKey {
212+ u, cmd := s.apiKeyInput.Update(msg)
213+ s.apiKeyInput = u.(*models.APIKeyInput)
214+ return s, cmd
215+ } else if s.isOnboarding {
216+ var cmd tea.Cmd
217+ s.modelList, cmd = s.modelList.Update(msg)
218+ return s, cmd
219+ }
58 }
220 }
59 return s, nil
221 return s, nil
60}
222}
61
223
62−// View implements SplashPage.
224+func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
225+ if s.selectedModel == nil {
226+ return util.ReportError(fmt.Errorf("no model selected"))
227+ }
228+
229+ cfg := config.Get()
230+ err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
231+ if err != nil {
232+ return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
233+ }
234+
235+ // Reset API key state and continue with model selection
236+ s.needsAPIKey = false
237+ cmd := s.setPreferredModel(*s.selectedModel)
238+ s.isOnboarding = false
239+ s.selectedModel = nil
240+
241+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
242+}
243+
244+func (s *splashCmp) initializeProject() tea.Cmd {
245+ s.needsProjectInit = false
246+
247+ if err := config.MarkProjectInitialized(); err != nil {
248+ return util.ReportError(err)
249+ }
250+ var cmds []tea.Cmd
251+
252+ cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
253+ if !s.selectedNo {
254+ cmds = append(cmds,
255+ util.CmdHandler(chat.SessionClearedMsg{}),
256+ util.CmdHandler(chat.SendMsg{
257+ Text: prompt.Initialize(),
258+ }),
259+ )
260+ }
261+ return tea.Sequence(cmds...)
262+}
263+
264+func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
265+ cfg := config.Get()
266+ model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
267+ if model == nil {
268+ return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
269+ }
270+
271+ selectedModel := config.SelectedModel{
272+ Model: selectedItem.Model.ID,
273+ Provider: string(selectedItem.Provider.ID),
274+ ReasoningEffort: model.DefaultReasoningEffort,
275+ MaxTokens: model.DefaultMaxTokens,
276+ }
277+
278+ err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
279+ if err != nil {
280+ return util.ReportError(err)
281+ }
282+
283+ // Now lets automatically setup the small model
284+ knownProvider, err := s.getProvider(selectedItem.Provider.ID)
285+ if err != nil {
286+ return util.ReportError(err)
287+ }
288+ if knownProvider == nil {
289+ // for local provider we just use the same model
290+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
291+ if err != nil {
292+ return util.ReportError(err)
293+ }
294+ } else {
295+ smallModel := knownProvider.DefaultSmallModelID
296+ model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
297+ // should never happen
298+ if model == nil {
299+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
300+ if err != nil {
301+ return util.ReportError(err)
302+ }
303+ return nil
304+ }
305+ smallSelectedModel := config.SelectedModel{
306+ Model: smallModel,
307+ Provider: string(selectedItem.Provider.ID),
308+ ReasoningEffort: model.DefaultReasoningEffort,
309+ MaxTokens: model.DefaultMaxTokens,
310+ }
311+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
312+ if err != nil {
313+ return util.ReportError(err)
314+ }
315+ }
316+ return nil
317+}
318+
319+func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
320+ providers, err := config.Providers()
321+ if err != nil {
322+ return nil, err
323+ }
324+ for _, p := range providers {
325+ if p.ID == providerID {
326+ return &p, nil
327+ }
328+ }
329+ return nil, nil
330+}
331+
332+func (s *splashCmp) isProviderConfigured(providerID string) bool {
333+ cfg := config.Get()
334+ if _, ok := cfg.Providers[providerID]; ok {
335+ return true
336+ }
337+ return false
338+}
339+
63func (s *splashCmp) View() string {
340func (s *splashCmp) View() string {
341+ t := styles.CurrentTheme()
342+ var content string
343+ if s.needsAPIKey {
344+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
345+ apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
346+ apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
64− content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
347+ lipgloss.JoinVertical(
348+ lipgloss.Left,
349+ apiKeyView,
350+ ),
351+ )
352+ content = lipgloss.JoinVertical(
353+ lipgloss.Left,
354+ s.logoRendered,
355+ apiKeySelector,
356+ )
357+ } else if s.isOnboarding {
358+ modelListView := s.modelList.View()
359+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
360+ modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
361+ lipgloss.JoinVertical(
362+ lipgloss.Left,
363+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"),
364+ "",
365+ modelListView,
366+ ),
367+ )
368+ content = lipgloss.JoinVertical(
369+ lipgloss.Left,
370+ s.logoRendered,
371+ modelSelector,
372+ )
373+ } else if s.needsProjectInit {
374+ titleStyle := t.S().Base.Foreground(t.FgBase)
375+ bodyStyle := t.S().Base.Foreground(t.FgMuted)
376+ shortcutStyle := t.S().Base.Foreground(t.Success)
377+
378+ initText := lipgloss.JoinVertical(
379+ lipgloss.Left,
380+ titleStyle.Render("Would you like to initialize this project?"),
381+ "",
382+ bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
383+ bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
384+ "",
385+ bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
386+ "",
387+ bodyStyle.Render("Would you like to initialize now?"),
388+ )
389+
390+ yesButton := core.SelectableButton(core.ButtonOpts{
391+ Text: "Yep!",
392+ UnderlineIndex: 0,
393+ Selected: !s.selectedNo,
394+ })
395+
396+ noButton := core.SelectableButton(core.ButtonOpts{
397+ Text: "Nope",
398+ UnderlineIndex: 0,
399+ Selected: s.selectedNo,
400+ })
401+
402+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
403+ infoSection := s.infoSection()
404+
405+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection)
406+
407+ initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
408+ lipgloss.JoinVertical(
409+ lipgloss.Left,
410+ initText,
411+ "",
412+ buttons,
413+ ),
414+ )
415+
416+ content = lipgloss.JoinVertical(
417+ lipgloss.Left,
418+ s.logoRendered,
419+ infoSection,
420+ initContent,
421+ )
422+ } else {
423+ parts := []string{
424+ s.logoRendered,
425+ s.infoSection(),
426+ }
427+ content = lipgloss.JoinVertical(lipgloss.Left, parts...)
428+ }
429+
430+ return t.S().Base.
431+ Width(s.width).
432+ Height(s.height).
433+ PaddingTop(SplashScreenPaddingY).
434+ PaddingBottom(SplashScreenPaddingY).
65− return content
435+ Render(content)
436+}
437+
438+func (s *splashCmp) Cursor() *tea.Cursor {
439+ if s.needsAPIKey {
440+ cursor := s.apiKeyInput.Cursor()
441+ if cursor != nil {
442+ return s.moveCursor(cursor)
443+ }
444+ } else if s.isOnboarding {
445+ cursor := s.modelList.Cursor()
446+ if cursor != nil {
447+ return s.moveCursor(cursor)
448+ }
449+ } else {
450+ return nil
451+ }
452+ return nil
453+}
454+
455+func (s *splashCmp) infoSection() string {
456+ t := styles.CurrentTheme()
457+ return t.S().Base.PaddingLeft(2).Render(
458+ lipgloss.JoinVertical(
459+ lipgloss.Left,
460+ s.cwd(),
461+ "",
462+ lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
463+ "",
464+ ),
465+ )
66}
466}
67
467
68func (s *splashCmp) logoBlock() string {
468func (s *splashCmp) logoBlock() string {
69 t := styles.CurrentTheme()
469 t := styles.CurrentTheme()
70− const padding = 2
470+ return t.S().Base.Padding(0, 2).Width(s.width).Render(
71− return logo.Render(version.Version, false, logo.Opts{
471+ logo.Render(version.Version, false, logo.Opts{
72− FieldColor: t.Primary,
472+ FieldColor: t.Primary,
73− TitleColorA: t.Secondary,
473+ TitleColorA: t.Secondary,
74− TitleColorB: t.Primary,
474+ TitleColorB: t.Primary,
75− CharmColor: t.Secondary,
475+ CharmColor: t.Secondary,
76− VersionColor: t.Primary,
476+ VersionColor: t.Primary,
77− Width: s.width - padding,
477+ Width: s.width - 4,
478+ }),
479+ )
480+}
481+
482+func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
483+ if cursor == nil {
484+ return nil
78− })
485+ }
486+ // Calculate the correct Y offset based on current state
487+ logoHeight := lipgloss.Height(s.logoRendered)
488+ if s.needsAPIKey {
489+ infoSectionHeight := lipgloss.Height(s.infoSection())
490+ baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
491+ remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
492+ offset := baseOffset + remainingHeight
493+ cursor.Y += offset
494+ cursor.X = cursor.X + 1
495+ } else if s.isOnboarding {
496+ offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
497+ cursor.Y += offset
498+ cursor.X = cursor.X + 1
499+ }
500+
501+ return cursor
502+}
503+
504+func (s *splashCmp) logoGap() int {
505+ if s.height > 35 {
506+ return LogoGap
507+ }
508+ return 0
79}
509}
80
510
81// Bindings implements SplashPage.
511// Bindings implements SplashPage.
82func (s *splashCmp) Bindings() []key.Binding {
512func (s *splashCmp) Bindings() []key.Binding {
513+ if s.needsAPIKey {
83− return []key.Binding{
514+ return []key.Binding{
515+ s.keyMap.Select,
516+ s.keyMap.Back,
517+ }
518+ } else if s.isOnboarding {
519+ return []key.Binding{
520+ s.keyMap.Select,
521+ s.keyMap.Next,
522+ s.keyMap.Previous,
523+ }
524+ } else if s.needsProjectInit {
525+ return []key.Binding{
84− s.keyMap.Cancel,
526+ s.keyMap.Select,
527+ s.keyMap.Yes,
528+ s.keyMap.No,
529+ s.keyMap.Tab,
530+ s.keyMap.LeftRight,
531+ }
85 }
532 }
533+ return []key.Binding{}
534+}
535+
536+func (s *splashCmp) getMaxInfoWidth() int {
537+ return min(s.width-2, 40) // 2 for left padding
538+}
539+
540+func (s *splashCmp) cwd() string {
541+ cwd := config.Get().WorkingDir()
542+ t := styles.CurrentTheme()
543+ homeDir, err := os.UserHomeDir()
544+ if err == nil && cwd != homeDir {
545+ cwd = strings.ReplaceAll(cwd, homeDir, "~")
546+ }
547+ maxWidth := s.getMaxInfoWidth()
548+ return t.S().Muted.Width(maxWidth).Render(cwd)
549+}
550+
551+func LSPList(maxWidth int) []string {
552+ t := styles.CurrentTheme()
553+ lspList := []string{}
554+ lsp := config.Get().LSP.Sorted()
555+ if len(lsp) == 0 {
556+ return []string{t.S().Base.Foreground(t.Border).Render("None")}
557+ }
558+ for _, l := range lsp {
559+ iconColor := t.Success
560+ if l.LSP.Disabled {
561+ iconColor = t.FgMuted
562+ }
563+ lspList = append(lspList,
564+ core.Status(
565+ core.StatusOpts{
566+ IconColor: iconColor,
567+ Title: l.Name,
568+ Description: l.LSP.Command,
569+ },
570+ maxWidth,
571+ ),
572+ )
573+ }
574+ return lspList
575+}
576+
577+func (s *splashCmp) lspBlock() string {
578+ t := styles.CurrentTheme()
579+ maxWidth := s.getMaxInfoWidth() / 2
580+ section := t.S().Subtle.Render("LSPs")
581+ lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
582+ return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
583+ lipgloss.JoinVertical(
584+ lipgloss.Left,
585+ lspList...,
586+ ),
587+ )
588+}
589+
590+func MCPList(maxWidth int) []string {
591+ t := styles.CurrentTheme()
592+ mcpList := []string{}
593+ mcps := config.Get().MCP.Sorted()
594+ if len(mcps) == 0 {
595+ return []string{t.S().Base.Foreground(t.Border).Render("None")}
596+ }
597+ for _, l := range mcps {
598+ iconColor := t.Success
599+ if l.MCP.Disabled {
600+ iconColor = t.FgMuted
601+ }
602+ mcpList = append(mcpList,
603+ core.Status(
604+ core.StatusOpts{
605+ IconColor: iconColor,
606+ Title: l.Name,
607+ Description: l.MCP.Command,
608+ },
609+ maxWidth,
610+ ),
611+ )
612+ }
613+ return mcpList
614+}
615+
616+func (s *splashCmp) mcpBlock() string {
617+ t := styles.CurrentTheme()
618+ maxWidth := s.getMaxInfoWidth() / 2
619+ section := t.S().Subtle.Render("MCPs")
620+ mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
621+ return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
622+ lipgloss.JoinVertical(
623+ lipgloss.Left,
624+ mcpList...,
625+ ),
626+ )
627+}
628+
629+func (s *splashCmp) IsShowingAPIKey() bool {
630+ return s.needsAPIKey
86}
631}
internal/tui/components/completions/completions.go+7−3
@@ -70,8 +70,8 @@ func New() Completions {
70 list.WithHideFilterInput(true),
70 list.WithHideFilterInput(true),
71 )
71 )
72 return &completionsCmp{
72 return &completionsCmp{
73− width: 30,
73+ width: 0,
74− height: 10,
74+ height: 0,
75 list: l,
75 list: l,
76 query: "",
76 query: "",
77 keyMap: completionsKeyMap,
77 keyMap: completionsKeyMap,
@@ -89,6 +89,10 @@ func (c *completionsCmp) Init() tea.Cmd {
89// Update implements Completions.
89// Update implements Completions.
90func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 switch msg := msg.(type) {
91 switch msg := msg.(type) {
92+ case tea.WindowSizeMsg:
93+ c.width = min(msg.Width-c.x, 80)
94+ c.height = min(msg.Height-c.y, 15)
95+ return c, nil
92 case tea.KeyPressMsg:
96 case tea.KeyPressMsg:
93 switch {
97 switch {
94 case key.Matches(msg, c.keyMap.Up):
98 case key.Matches(msg, c.keyMap.Up):
@@ -135,7 +139,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
135 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
139 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
136 items = append(items, item)
140 items = append(items, item)
137 }
141 }
138− c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
142+ c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
139 cmds := []tea.Cmd{
143 cmds := []tea.Cmd{
140 c.list.SetSize(c.width, c.height),
144 c.list.SetSize(c.width, c.height),
141 c.list.SetItems(items),
145 c.list.SetItems(items),
internal/tui/components/completions/item.go+1
@@ -90,6 +90,7 @@ func (c *completionItemCmp) View() string {
90 if c.bgColor != nil {
90 if c.bgColor != nil {
91 titleStyle = titleStyle.Background(c.bgColor)
91 titleStyle = titleStyle.Background(c.bgColor)
92 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
92 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
93+ itemStyle = itemStyle.Background(c.bgColor)
93 }
94 }
94
95
95 if c.focus {
96 if c.focus {
internal/tui/components/completions/keys.go+1−1
@@ -22,7 +22,7 @@ func DefaultKeyMap() KeyMap {
22 key.WithHelp("up", "move up"),
22 key.WithHelp("up", "move up"),
23 ),
23 ),
24 Select: key.NewBinding(
24 Select: key.NewBinding(
25− key.WithKeys("enter"),
25+ key.WithKeys("enter", "tab", "ctrl+y"),
26 key.WithHelp("enter", "select"),
26 key.WithHelp("enter", "select"),
27 ),
27 ),
28 Cancel: key.NewBinding(
28 Cancel: key.NewBinding(
internal/tui/components/core/helpers.go → internal/tui/components/core/core.gorenamed+59
@@ -5,12 +5,40 @@ import (
5 "strings"
5 "strings"
6
6
7 "github.com/alecthomas/chroma/v2"
7 "github.com/alecthomas/chroma/v2"
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
10 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
9 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/ansi"
12)
14)
13
15
16+type KeyMapHelp interface {
17+ Help() help.KeyMap
18+}
19+
20+type simpleHelp struct {
21+ shortList []key.Binding
22+ fullList [][]key.Binding
23+}
24+
25+func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap {
26+ return &simpleHelp{
27+ shortList: shortList,
28+ fullList: fullList,
29+ }
30+}
31+
32+// FullHelp implements help.KeyMap.
33+func (s *simpleHelp) FullHelp() [][]key.Binding {
34+ return s.fullList
35+}
36+
37+// ShortHelp implements help.KeyMap.
38+func (s *simpleHelp) ShortHelp() []key.Binding {
39+ return s.shortList
40+}
41+
14func Section(text string, width int) string {
42func Section(text string, width int) string {
15 t := styles.CurrentTheme()
43 t := styles.CurrentTheme()
16 char := "─"
44 char := "─"
@@ -23,6 +51,22 @@ func Section(text string, width int) string {
23 return text
51 return text
24}
52}
25
53
54+func SectionWithInfo(text string, width int, info string) string {
55+ t := styles.CurrentTheme()
56+ char := "─"
57+ length := lipgloss.Width(text) + 1
58+ remainingWidth := width - length
59+
60+ if info != "" {
61+ remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info
62+ }
63+ lineStyle := t.S().Base.Foreground(t.Border)
64+ if remainingWidth > 0 {
65+ text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info
66+ }
67+ return text
68+}
69+
26func Title(title string, width int) string {
70func Title(title string, width int) string {
27 t := styles.CurrentTheme()
71 t := styles.CurrentTheme()
28 char := "╱"
72 char := "╱"
@@ -147,6 +191,21 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
147 return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
191 return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
148}
192}
149
193
194+// SelectableButtonsVertical creates a vertical row of selectable buttons
195+func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string {
196+ var parts []string
197+ for i, button := range buttons {
198+ parts = append(parts, SelectableButton(button))
199+ if i < len(buttons)-1 {
200+ for j := 0; j < spacing; j++ {
201+ parts = append(parts, "")
202+ }
203+ }
204+ }
205+
206+ return lipgloss.JoinVertical(lipgloss.Center, parts...)
207+}
208+
150func DiffFormatter() *diffview.DiffView {
209func DiffFormatter() *diffview.DiffView {
151 t := styles.CurrentTheme()
210 t := styles.CurrentTheme()
152 formatDiff := diffview.New()
211 formatDiff := diffview.New()
internal/tui/components/core/layout/container.godeleted−263
@@ -1,263 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/tui/styles"
7− "github.com/charmbracelet/crush/internal/tui/util"
8− "github.com/charmbracelet/lipgloss/v2"
9−)
10−
11−type Container interface {
12− util.Model
13− Sizeable
14− Help
15− Positional
16− Focusable
17−}
18−type container struct {
19− width int
20− height int
21− isFocused bool
22−
23− x, y int
24−
25− content util.Model
26−
27− // Style options
28− paddingTop int
29− paddingRight int
30− paddingBottom int
31− paddingLeft int
32−
33− borderTop bool
34− borderRight bool
35− borderBottom bool
36− borderLeft bool
37− borderStyle lipgloss.Border
38−}
39−
40−type ContainerOption func(*container)
41−
42−func NewContainer(content util.Model, options ...ContainerOption) Container {
43− c := &container{
44− content: content,
45− borderStyle: lipgloss.NormalBorder(),
46− }
47−
48− for _, option := range options {
49− option(c)
50− }
51−
52− return c
53−}
54−
55−func (c *container) Init() tea.Cmd {
56− return c.content.Init()
57−}
58−
59−func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60− switch msg := msg.(type) {
61− case tea.KeyPressMsg:
62− if c.IsFocused() {
63− u, cmd := c.content.Update(msg)
64− c.content = u.(util.Model)
65− return c, cmd
66− }
67− return c, nil
68− default:
69− u, cmd := c.content.Update(msg)
70− c.content = u.(util.Model)
71− return c, cmd
72− }
73−}
74−
75−func (c *container) Cursor() *tea.Cursor {
76− if cursor, ok := c.content.(util.Cursor); ok {
77− return cursor.Cursor()
78− }
79− return nil
80−}
81−
82−func (c *container) View() string {
83− t := styles.CurrentTheme()
84− width := c.width
85− height := c.height
86−
87− style := t.S().Base
88−
89− // Apply border if any side is enabled
90− if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
91− // Adjust width and height for borders
92− if c.borderTop {
93− height--
94− }
95− if c.borderBottom {
96− height--
97− }
98− if c.borderLeft {
99− width--
100− }
101− if c.borderRight {
102− width--
103− }
104− style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
105− style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
106− }
107− style = style.
108− Width(width).
109− Height(height).
110− PaddingTop(c.paddingTop).
111− PaddingRight(c.paddingRight).
112− PaddingBottom(c.paddingBottom).
113− PaddingLeft(c.paddingLeft)
114−
115− contentView := c.content.View()
116− return style.Render(contentView)
117−}
118−
119−func (c *container) SetSize(width, height int) tea.Cmd {
120− c.width = width
121− c.height = height
122−
123− // If the content implements Sizeable, adjust its size to account for padding and borders
124− if sizeable, ok := c.content.(Sizeable); ok {
125− // Calculate horizontal space taken by padding and borders
126− horizontalSpace := c.paddingLeft + c.paddingRight
127− if c.borderLeft {
128− horizontalSpace++
129− }
130− if c.borderRight {
131− horizontalSpace++
132− }
133−
134− // Calculate vertical space taken by padding and borders
135− verticalSpace := c.paddingTop + c.paddingBottom
136− if c.borderTop {
137− verticalSpace++
138− }
139− if c.borderBottom {
140− verticalSpace++
141− }
142−
143− // Set content size with adjusted dimensions
144− contentWidth := max(0, width-horizontalSpace)
145− contentHeight := max(0, height-verticalSpace)
146− return sizeable.SetSize(contentWidth, contentHeight)
147− }
148− return nil
149−}
150−
151−func (c *container) GetSize() (int, int) {
152− return c.width, c.height
153−}
154−
155−func (c *container) SetPosition(x, y int) tea.Cmd {
156− c.x = x
157− c.y = y
158− if positionable, ok := c.content.(Positional); ok {
159− return positionable.SetPosition(x, y)
160− }
161− return nil
162−}
163−
164−func (c *container) Bindings() []key.Binding {
165− if b, ok := c.content.(Help); ok {
166− return b.Bindings()
167− }
168− return nil
169−}
170−
171−// Blur implements Container.
172−func (c *container) Blur() tea.Cmd {
173− c.isFocused = false
174− if focusable, ok := c.content.(Focusable); ok {
175− return focusable.Blur()
176− }
177− return nil
178−}
179−
180−// Focus implements Container.
181−func (c *container) Focus() tea.Cmd {
182− c.isFocused = true
183− if focusable, ok := c.content.(Focusable); ok {
184− return focusable.Focus()
185− }
186− return nil
187−}
188−
189−// IsFocused implements Container.
190−func (c *container) IsFocused() bool {
191− isFocused := c.isFocused
192− if focusable, ok := c.content.(Focusable); ok {
193− isFocused = isFocused || focusable.IsFocused()
194− }
195− return isFocused
196−}
197−
198−// Padding options
199−func WithPadding(top, right, bottom, left int) ContainerOption {
200− return func(c *container) {
201− c.paddingTop = top
202− c.paddingRight = right
203− c.paddingBottom = bottom
204− c.paddingLeft = left
205− }
206−}
207−
208−func WithPaddingAll(padding int) ContainerOption {
209− return WithPadding(padding, padding, padding, padding)
210−}
211−
212−func WithPaddingHorizontal(padding int) ContainerOption {
213− return func(c *container) {
214− c.paddingLeft = padding
215− c.paddingRight = padding
216− }
217−}
218−
219−func WithPaddingVertical(padding int) ContainerOption {
220− return func(c *container) {
221− c.paddingTop = padding
222− c.paddingBottom = padding
223− }
224−}
225−
226−func WithBorder(top, right, bottom, left bool) ContainerOption {
227− return func(c *container) {
228− c.borderTop = top
229− c.borderRight = right
230− c.borderBottom = bottom
231− c.borderLeft = left
232− }
233−}
234−
235−func WithBorderAll() ContainerOption {
236− return WithBorder(true, true, true, true)
237−}
238−
239−func WithBorderHorizontal() ContainerOption {
240− return WithBorder(true, false, true, false)
241−}
242−
243−func WithBorderVertical() ContainerOption {
244− return WithBorder(false, true, false, true)
245−}
246−
247−func WithBorderStyle(style lipgloss.Border) ContainerOption {
248− return func(c *container) {
249− c.borderStyle = style
250− }
251−}
252−
253−func WithRoundedBorder() ContainerOption {
254− return WithBorderStyle(lipgloss.RoundedBorder())
255−}
256−
257−func WithThickBorder() ContainerOption {
258− return WithBorderStyle(lipgloss.ThickBorder())
259−}
260−
261−func WithDoubleBorder() ContainerOption {
262− return WithBorderStyle(lipgloss.DoubleBorder())
263−}
internal/tui/components/core/layout/layout.go+2−5
@@ -5,6 +5,8 @@ import (
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6)
6)
7
7
8+// TODO: move this to core
9+
8type Focusable interface {
10type Focusable interface {
9 Focus() tea.Cmd
11 Focus() tea.Cmd
10 Blur() tea.Cmd
12 Blur() tea.Cmd
@@ -23,8 +25,3 @@ type Help interface {
23type Positional interface {
25type Positional interface {
24 SetPosition(x, y int) tea.Cmd
26 SetPosition(x, y int) tea.Cmd
25}
27}
26−
27−// KeyMapProvider defines an interface for types that can provide their key bindings as a slice
28−type KeyMapProvider interface {
29− KeyBindings() []key.Binding
30−}
internal/tui/components/core/layout/split.godeleted−380
@@ -1,380 +0,0 @@
1−package layout
2−
3−import (
4− "log/slog"
5−
6− "github.com/charmbracelet/bubbles/v2/key"
7− tea "github.com/charmbracelet/bubbletea/v2"
8−
9− "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/crush/internal/tui/util"
11− "github.com/charmbracelet/lipgloss/v2"
12−)
13−
14−type LayoutPanel string
15−
16−const (
17− LeftPanel LayoutPanel = "left"
18− RightPanel LayoutPanel = "right"
19− BottomPanel LayoutPanel = "bottom"
20−)
21−
22−type SplitPaneLayout interface {
23− util.Model
24− Sizeable
25− Help
26− SetLeftPanel(panel Container) tea.Cmd
27− SetRightPanel(panel Container) tea.Cmd
28− SetBottomPanel(panel Container) tea.Cmd
29−
30− ClearLeftPanel() tea.Cmd
31− ClearRightPanel() tea.Cmd
32− ClearBottomPanel() tea.Cmd
33−
34− FocusPanel(panel LayoutPanel) tea.Cmd
35− SetOffset(x, y int)
36−}
37−
38−type splitPaneLayout struct {
39− width int
40− height int
41− xOffset int
42− yOffset int
43−
44− ratio float64
45− verticalRatio float64
46−
47− rightPanel Container
48− leftPanel Container
49− bottomPanel Container
50−
51− fixedBottomHeight int // Fixed height for the bottom panel, if any
52− fixedRightWidth int // Fixed width for the right panel, if any
53−}
54−
55−type SplitPaneOption func(*splitPaneLayout)
56−
57−func (s *splitPaneLayout) Init() tea.Cmd {
58− var cmds []tea.Cmd
59−
60− if s.leftPanel != nil {
61− cmds = append(cmds, s.leftPanel.Init())
62− }
63−
64− if s.rightPanel != nil {
65− cmds = append(cmds, s.rightPanel.Init())
66− }
67−
68− if s.bottomPanel != nil {
69− cmds = append(cmds, s.bottomPanel.Init())
70− }
71−
72− return tea.Batch(cmds...)
73−}
74−
75−func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76− var cmds []tea.Cmd
77− switch msg := msg.(type) {
78− case tea.WindowSizeMsg:
79− return s, s.SetSize(msg.Width, msg.Height)
80− }
81−
82− if s.rightPanel != nil {
83− u, cmd := s.rightPanel.Update(msg)
84− s.rightPanel = u.(Container)
85− if cmd != nil {
86− cmds = append(cmds, cmd)
87− }
88− }
89−
90− if s.leftPanel != nil {
91− u, cmd := s.leftPanel.Update(msg)
92− s.leftPanel = u.(Container)
93− if cmd != nil {
94− cmds = append(cmds, cmd)
95− }
96− }
97−
98− if s.bottomPanel != nil {
99− u, cmd := s.bottomPanel.Update(msg)
100− s.bottomPanel = u.(Container)
101− if cmd != nil {
102− cmds = append(cmds, cmd)
103− }
104− }
105−
106− return s, tea.Batch(cmds...)
107−}
108−
109−func (s *splitPaneLayout) Cursor() *tea.Cursor {
110− if s.bottomPanel != nil {
111− if c, ok := s.bottomPanel.(util.Cursor); ok {
112− return c.Cursor()
113− }
114− } else if s.rightPanel != nil {
115− if c, ok := s.rightPanel.(util.Cursor); ok {
116− return c.Cursor()
117− }
118− } else if s.leftPanel != nil {
119− if c, ok := s.leftPanel.(util.Cursor); ok {
120− return c.Cursor()
121− }
122− }
123− return nil
124−}
125−
126−func (s *splitPaneLayout) View() string {
127− var topSection string
128−
129− if s.leftPanel != nil && s.rightPanel != nil {
130− leftView := s.leftPanel.View()
131− rightView := s.rightPanel.View()
132− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
133− } else if s.leftPanel != nil {
134− topSection = s.leftPanel.View()
135− } else if s.rightPanel != nil {
136− topSection = s.rightPanel.View()
137− } else {
138− topSection = ""
139− }
140−
141− var finalView string
142−
143− if s.bottomPanel != nil && topSection != "" {
144− bottomView := s.bottomPanel.View()
145− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
146− } else if s.bottomPanel != nil {
147− finalView = s.bottomPanel.View()
148− } else {
149− finalView = topSection
150− }
151−
152− t := styles.CurrentTheme()
153−
154− style := t.S().Base.
155− Width(s.width).
156− Height(s.height)
157−
158− return style.Render(finalView)
159−}
160−
161−func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
162− s.width = width
163− s.height = height
164− slog.Info("Setting split pane size", "width", width, "height", height)
165−
166− var topHeight, bottomHeight int
167− var cmds []tea.Cmd
168− if s.bottomPanel != nil {
169− if s.fixedBottomHeight > 0 {
170− bottomHeight = s.fixedBottomHeight
171− topHeight = height - bottomHeight
172− } else {
173− topHeight = int(float64(height) * s.verticalRatio)
174− bottomHeight = height - topHeight
175− if bottomHeight <= 0 {
176− bottomHeight = 2
177− topHeight = height - bottomHeight
178− }
179− }
180− } else {
181− topHeight = height
182− bottomHeight = 0
183− }
184−
185− var leftWidth, rightWidth int
186− if s.leftPanel != nil && s.rightPanel != nil {
187− if s.fixedRightWidth > 0 {
188− rightWidth = s.fixedRightWidth
189− leftWidth = width - rightWidth
190− } else {
191− leftWidth = int(float64(width) * s.ratio)
192− rightWidth = width - leftWidth
193− if rightWidth <= 0 {
194− rightWidth = 2
195− leftWidth = width - rightWidth
196− }
197− }
198− } else if s.leftPanel != nil {
199− leftWidth = width
200− rightWidth = 0
201− } else if s.rightPanel != nil {
202− leftWidth = 0
203− rightWidth = width
204− }
205−
206− if s.leftPanel != nil {
207− cmd := s.leftPanel.SetSize(leftWidth, topHeight)
208− cmds = append(cmds, cmd)
209− if positional, ok := s.leftPanel.(Positional); ok {
210− cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
211− }
212− }
213−
214− if s.rightPanel != nil {
215− cmd := s.rightPanel.SetSize(rightWidth, topHeight)
216− cmds = append(cmds, cmd)
217− if positional, ok := s.rightPanel.(Positional); ok {
218− cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
219− }
220− }
221−
222− if s.bottomPanel != nil {
223− cmd := s.bottomPanel.SetSize(width, bottomHeight)
224− cmds = append(cmds, cmd)
225− if positional, ok := s.bottomPanel.(Positional); ok {
226− cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
227− }
228− }
229− return tea.Batch(cmds...)
230−}
231−
232−func (s *splitPaneLayout) GetSize() (int, int) {
233− return s.width, s.height
234−}
235−
236−func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
237− s.leftPanel = panel
238− if s.width > 0 && s.height > 0 {
239− return s.SetSize(s.width, s.height)
240− }
241− return nil
242−}
243−
244−func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
245− s.rightPanel = panel
246− if s.width > 0 && s.height > 0 {
247− return s.SetSize(s.width, s.height)
248− }
249− return nil
250−}
251−
252−func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
253− s.bottomPanel = panel
254− if s.width > 0 && s.height > 0 {
255− return s.SetSize(s.width, s.height)
256− }
257− return nil
258−}
259−
260−func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
261− s.leftPanel = nil
262− if s.width > 0 && s.height > 0 {
263− return s.SetSize(s.width, s.height)
264− }
265− return nil
266−}
267−
268−func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
269− s.rightPanel = nil
270− if s.width > 0 && s.height > 0 {
271− return s.SetSize(s.width, s.height)
272− }
273− return nil
274−}
275−
276−func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
277− s.bottomPanel = nil
278− if s.width > 0 && s.height > 0 {
279− return s.SetSize(s.width, s.height)
280− }
281− return nil
282−}
283−
284−func (s *splitPaneLayout) Bindings() []key.Binding {
285− if s.leftPanel != nil {
286− if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
287− return b.Bindings()
288− }
289− }
290− if s.rightPanel != nil {
291− if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
292− return b.Bindings()
293− }
294− }
295− if s.bottomPanel != nil {
296− if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
297− return b.Bindings()
298− }
299− }
300− return nil
301−}
302−
303−func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
304− panels := map[LayoutPanel]Container{
305− LeftPanel: s.leftPanel,
306− RightPanel: s.rightPanel,
307− BottomPanel: s.bottomPanel,
308− }
309− var cmds []tea.Cmd
310− for p, container := range panels {
311− if container == nil {
312− continue
313− }
314− if p == panel {
315− cmds = append(cmds, container.Focus())
316− } else {
317− cmds = append(cmds, container.Blur())
318− }
319− }
320− return tea.Batch(cmds...)
321−}
322−
323−// SetOffset implements SplitPaneLayout.
324−func (s *splitPaneLayout) SetOffset(x int, y int) {
325− s.xOffset = x
326− s.yOffset = y
327−}
328−
329−func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
330− layout := &splitPaneLayout{
331− ratio: 0.8,
332− verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
333− }
334− for _, option := range options {
335− option(layout)
336− }
337− return layout
338−}
339−
340−func WithLeftPanel(panel Container) SplitPaneOption {
341− return func(s *splitPaneLayout) {
342− s.leftPanel = panel
343− }
344−}
345−
346−func WithRightPanel(panel Container) SplitPaneOption {
347− return func(s *splitPaneLayout) {
348− s.rightPanel = panel
349− }
350−}
351−
352−func WithRatio(ratio float64) SplitPaneOption {
353− return func(s *splitPaneLayout) {
354− s.ratio = ratio
355− }
356−}
357−
358−func WithBottomPanel(panel Container) SplitPaneOption {
359− return func(s *splitPaneLayout) {
360− s.bottomPanel = panel
361− }
362−}
363−
364−func WithVerticalRatio(ratio float64) SplitPaneOption {
365− return func(s *splitPaneLayout) {
366− s.verticalRatio = ratio
367− }
368−}
369−
370−func WithFixedBottomHeight(height int) SplitPaneOption {
371− return func(s *splitPaneLayout) {
372− s.fixedBottomHeight = height
373− }
374−}
375−
376−func WithFixedRightWidth(width int) SplitPaneOption {
377− return func(s *splitPaneLayout) {
378− s.fixedRightWidth = width
379− }
380−}
internal/tui/components/core/list/keys.go+10−1
@@ -9,6 +9,8 @@ type KeyMap struct {
9 Up,
9 Up,
10 DownOneItem,
10 DownOneItem,
11 UpOneItem,
11 UpOneItem,
12+ PageDown,
13+ PageUp,
12 HalfPageDown,
14 HalfPageDown,
13 HalfPageUp,
15 HalfPageUp,
14 Home,
16 Home,
@@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap {
37 key.WithKeys("d"),
39 key.WithKeys("d"),
38 key.WithHelp("d", "half page down"),
40 key.WithHelp("d", "half page down"),
39 ),
41 ),
42+ PageDown: key.NewBinding(
43+ key.WithKeys("pgdown", " ", "f"),
44+ key.WithHelp("f/pgdn", "page down"),
45+ ),
46+ PageUp: key.NewBinding(
47+ key.WithKeys("pgup", "b"),
48+ key.WithHelp("b/pgup", "page up"),
40− HalfPageUp: key.NewBinding(
49+ ), HalfPageUp: key.NewBinding(
41 key.WithKeys("u"),
50 key.WithKeys("u"),
42 key.WithHelp("u", "half page up"),
51 key.WithHelp("u", "half page up"),
43 ),
52 ),
internal/tui/components/core/list/list.go+10
@@ -41,6 +41,8 @@ type ListModel interface {
41 SelectedIndex() int // Get the index of the currently selected item
41 SelectedIndex() int // Get the index of the currently selected item
42 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
42 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
43 Filter(string) tea.Cmd // Filter items based on a search term
43 Filter(string) tea.Cmd // Filter items based on a search term
44+ SetFilterPlaceholder(string) // Set the placeholder text for the filter input
45+ Cursor() *tea.Cursor // Get the current cursor position in the filter input
44}
46}
45
47
46// HasAnim interface identifies items that support animation.
48// HasAnim interface identifies items that support animation.
@@ -330,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
330 m.scrollDown(m.listHeight() / 2)
332 m.scrollDown(m.listHeight() / 2)
331 case key.Matches(msg, m.keyMap.HalfPageUp):
333 case key.Matches(msg, m.keyMap.HalfPageUp):
332 m.scrollUp(m.listHeight() / 2)
334 m.scrollUp(m.listHeight() / 2)
335+ case key.Matches(msg, m.keyMap.PageDown):
336+ m.scrollDown(m.listHeight())
337+ case key.Matches(msg, m.keyMap.PageUp):
338+ m.scrollUp(m.listHeight())
333 case key.Matches(msg, m.keyMap.Home):
339 case key.Matches(msg, m.keyMap.Home):
334 return m, m.goToTop()
340 return m, m.goToTop()
335 case key.Matches(msg, m.keyMap.End):
341 case key.Matches(msg, m.keyMap.End):
@@ -1359,3 +1365,7 @@ func (m *model) Focus() tea.Cmd {
1359func (m *model) IsFocused() bool {
1365func (m *model) IsFocused() bool {
1360 return m.isFocused
1366 return m.isFocused
1361}
1367}
1368+
1369+func (m *model) SetFilterPlaceholder(placeholder string) {
1370+ m.input.Placeholder = placeholder
1371+}
internal/tui/components/core/status/status.go+1−2
@@ -98,13 +98,12 @@ func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
98 m.keyMap = keyMap
98 m.keyMap = keyMap
99}
99}
100
100
101−func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
101+func NewStatusCmp() StatusCmp {
102 t := styles.CurrentTheme()
102 t := styles.CurrentTheme()
103 help := help.New()
103 help := help.New()
104 help.Styles = t.S().Help
104 help.Styles = t.S().Help
105 return &statusCmp{
105 return &statusCmp{
106 messageTTL: 5 * time.Second,
106 messageTTL: 5 * time.Second,
107 help: help,
107 help: help,
108− keyMap: keyMap,
109 }
108 }
110}
109}
internal/tui/components/dialogs/commands/commands.go+2−9
@@ -6,6 +6,7 @@ import (
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8
8
9+ "github.com/charmbracelet/crush/internal/llm/prompt"
9 "github.com/charmbracelet/crush/internal/tui/components/chat"
10 "github.com/charmbracelet/crush/internal/tui/components/chat"
10 "github.com/charmbracelet/crush/internal/tui/components/completions"
11 "github.com/charmbracelet/crush/internal/tui/components/completions"
11 "github.com/charmbracelet/crush/internal/tui/components/core"
12 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -239,16 +240,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
239 Title: "Initialize Project",
240 Title: "Initialize Project",
240 Description: "Create/Update the CRUSH.md memory file",
241 Description: "Create/Update the CRUSH.md memory file",
241 Handler: func(cmd Command) tea.Cmd {
242 Handler: func(cmd Command) tea.Cmd {
242− prompt := `Please analyze this codebase and create a CRUSH.md file containing:
243− 1. Build/lint/test commands - especially for running a single test
244− 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
245−
246− The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
247− If there's already a CRUSH.md, improve it.
248− If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
249− Add the .crush directory to the .gitignore file if it's not already there.`
250 return util.CmdHandler(chat.SendMsg{
243 return util.CmdHandler(chat.SendMsg{
251− Text: prompt,
244+ Text: prompt.Initialize(),
252 })
245 })
253 },
246 },
254 },
247 },
internal/tui/components/dialogs/commands/item.go+16−4
@@ -14,11 +14,12 @@ type ItemSection interface {
14 util.Model
14 util.Model
15 layout.Sizeable
15 layout.Sizeable
16 list.SectionHeader
16 list.SectionHeader
17+ SetInfo(info string)
17}
18}
18type itemSectionModel struct {
19type itemSectionModel struct {
19− width int
20+ width int
20− title string
21+ title string
21− noPadding bool // No padding for the section header
22+ info string
22}
23}
23
24
24func NewItemSection(title string) ItemSection {
25func NewItemSection(title string) ItemSection {
@@ -40,7 +41,14 @@ func (m *itemSectionModel) View() string {
40 title := ansi.Truncate(m.title, m.width-2, "…")
41 title := ansi.Truncate(m.title, m.width-2, "…")
41 style := t.S().Base.Padding(1, 1, 0, 1)
42 style := t.S().Base.Padding(1, 1, 0, 1)
42 title = t.S().Muted.Render(title)
43 title = t.S().Muted.Render(title)
44+ section := ""
45+ if m.info != "" {
46+ section = core.SectionWithInfo(title, m.width-2, m.info)
47+ } else {
48+ section = core.Section(title, m.width-2)
49+ }
50+
43− return style.Render(core.Section(title, m.width-2))
51+ return style.Render(section)
44}
52}
45
53
46func (m *itemSectionModel) GetSize() (int, int) {
54func (m *itemSectionModel) GetSize() (int, int) {
@@ -55,3 +63,7 @@ func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
55func (m *itemSectionModel) IsSectionHeader() bool {
63func (m *itemSectionModel) IsSectionHeader() bool {
56 return true
64 return true
57}
65}
66+
67+func (m *itemSectionModel) SetInfo(info string) {
68+ m.info = info
69+}
internal/tui/components/dialogs/init/init.godeleted−214
@@ -1,214 +0,0 @@
1−package init
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7−
8− "github.com/charmbracelet/crush/internal/config"
9− cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
10− "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14−)
15−
16−const InitDialogID dialogs.DialogID = "init"
17−
18−// InitDialogCmp is a component that asks the user if they want to initialize the project.
19−type InitDialogCmp interface {
20− dialogs.DialogModel
21−}
22−
23−type initDialogCmp struct {
24− wWidth, wHeight int
25− width, height int
26− selected int
27− keyMap KeyMap
28−}
29−
30−// NewInitDialogCmp creates a new InitDialogCmp.
31−func NewInitDialogCmp() InitDialogCmp {
32− return &initDialogCmp{
33− selected: 0,
34− keyMap: DefaultKeyMap(),
35− }
36−}
37−
38−// Init implements tea.Model.
39−func (m *initDialogCmp) Init() tea.Cmd {
40− return nil
41−}
42−
43−// Update implements tea.Model.
44−func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45− switch msg := msg.(type) {
46− case tea.WindowSizeMsg:
47− m.wWidth = msg.Width
48− m.wHeight = msg.Height
49− cmd := m.SetSize()
50− return m, cmd
51− case tea.KeyPressMsg:
52− switch {
53− case key.Matches(msg, m.keyMap.Close):
54− return m, tea.Batch(
55− util.CmdHandler(dialogs.CloseDialogMsg{}),
56− m.handleInitialization(false),
57− )
58− case key.Matches(msg, m.keyMap.ChangeSelection):
59− m.selected = (m.selected + 1) % 2
60− return m, nil
61− case key.Matches(msg, m.keyMap.Select):
62− return m, tea.Batch(
63− util.CmdHandler(dialogs.CloseDialogMsg{}),
64− m.handleInitialization(m.selected == 0),
65− )
66− case key.Matches(msg, m.keyMap.Y):
67− return m, tea.Batch(
68− util.CmdHandler(dialogs.CloseDialogMsg{}),
69− m.handleInitialization(true),
70− )
71− case key.Matches(msg, m.keyMap.N):
72− return m, tea.Batch(
73− util.CmdHandler(dialogs.CloseDialogMsg{}),
74− m.handleInitialization(false),
75− )
76− }
77− }
78− return m, nil
79−}
80−
81−func (m *initDialogCmp) renderButtons() string {
82− t := styles.CurrentTheme()
83− baseStyle := t.S().Base
84−
85− buttons := []core.ButtonOpts{
86− {
87− Text: "Yes",
88− UnderlineIndex: 0, // "Y"
89− Selected: m.selected == 0,
90− },
91− {
92− Text: "No",
93− UnderlineIndex: 0, // "N"
94− Selected: m.selected == 1,
95− },
96− }
97−
98− content := core.SelectableButtons(buttons, " ")
99−
100− return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
101−}
102−
103−func (m *initDialogCmp) renderContent() string {
104− t := styles.CurrentTheme()
105− baseStyle := t.S().Base
106−
107− explanation := t.S().Text.
108− Width(m.width - 4).
109− Render("Initialization generates a new CRUSH.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
110−
111− question := t.S().Text.
112− Width(m.width - 4).
113− Render("Would you like to initialize this project?")
114−
115− return baseStyle.Render(lipgloss.JoinVertical(
116− lipgloss.Left,
117− explanation,
118− "",
119− question,
120− ))
121−}
122−
123−func (m *initDialogCmp) render() string {
124− t := styles.CurrentTheme()
125− baseStyle := t.S().Base
126− title := core.Title("Initialize Project", m.width-4)
127−
128− content := m.renderContent()
129− buttons := m.renderButtons()
130−
131− dialogContent := lipgloss.JoinVertical(
132− lipgloss.Top,
133− title,
134− "",
135− content,
136− "",
137− buttons,
138− "",
139− )
140−
141− return baseStyle.
142− Padding(0, 1).
143− Border(lipgloss.RoundedBorder()).
144− BorderForeground(t.BorderFocus).
145− Width(m.width).
146− Render(dialogContent)
147−}
148−
149−// View implements tea.Model.
150−func (m *initDialogCmp) View() string {
151− return m.render()
152−}
153−
154−// SetSize sets the size of the component.
155−func (m *initDialogCmp) SetSize() tea.Cmd {
156− m.width = min(90, m.wWidth)
157− m.height = min(15, m.wHeight)
158− return nil
159−}
160−
161−// ID implements DialogModel.
162−func (m *initDialogCmp) ID() dialogs.DialogID {
163− return InitDialogID
164−}
165−
166−// Position implements DialogModel.
167−func (m *initDialogCmp) Position() (int, int) {
168− row := (m.wHeight / 2) - (m.height / 2)
169− col := (m.wWidth / 2) - (m.width / 2)
170− return row, col
171−}
172−
173−// handleInitialization handles the initialization logic when the dialog is closed.
174−func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd {
175− if initialize {
176− // Run the initialization command
177− prompt := `Please analyze this codebase and create a CRUSH.md file containing:
178−1. Build/lint/test commands - especially for running a single test
179−2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
180−
181−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
182−If there's already a CRUSH.md, improve it.
183−If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
184−Add the .crush directory to the .gitignore file if it's not already there.`
185−
186− // Mark the project as initialized
187− if err := config.MarkProjectInitialized(); err != nil {
188− return util.ReportError(err)
189− }
190−
191− return tea.Sequence(
192− util.CmdHandler(cmpChat.SessionClearedMsg{}),
193− util.CmdHandler(cmpChat.SendMsg{
194− Text: prompt,
195− }),
196− )
197− } else {
198− // Mark the project as initialized without running the command
199− if err := config.MarkProjectInitialized(); err != nil {
200− return util.ReportError(err)
201− }
202− }
203− return nil
204−}
205−
206−// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
207−type CloseInitDialogMsg struct {
208− Initialize bool
209−}
210−
211−// ShowInitDialogMsg is a message that is sent to show the init dialog.
212−type ShowInitDialogMsg struct {
213− Show bool
214−}
internal/tui/components/dialogs/init/keys.godeleted−69
@@ -1,69 +0,0 @@
1−package init
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− ChangeSelection,
9− Select,
10− Y,
11− N,
12− Close key.Binding
13−}
14−
15−func DefaultKeyMap() KeyMap {
16− return KeyMap{
17− ChangeSelection: key.NewBinding(
18− key.WithKeys("tab", "left", "right", "h", "l"),
19− key.WithHelp("tab/←/→", "toggle selection"),
20− ),
21− Select: key.NewBinding(
22− key.WithKeys("enter"),
23− key.WithHelp("enter", "confirm"),
24− ),
25− Y: key.NewBinding(
26− key.WithKeys("y"),
27− key.WithHelp("y", "yes"),
28− ),
29− N: key.NewBinding(
30− key.WithKeys("n"),
31− key.WithHelp("n", "no"),
32− ),
33− Close: key.NewBinding(
34− key.WithKeys("esc"),
35− key.WithHelp("esc", "cancel"),
36− ),
37− }
38−}
39−
40−// KeyBindings implements layout.KeyMapProvider
41−func (k KeyMap) KeyBindings() []key.Binding {
42− return []key.Binding{
43− k.ChangeSelection,
44− k.Select,
45− k.Y,
46− k.N,
47− k.Close,
48− }
49−}
50−
51−// FullHelp implements help.KeyMap.
52−func (k KeyMap) FullHelp() [][]key.Binding {
53− m := [][]key.Binding{}
54− slice := k.KeyBindings()
55− for i := 0; i < len(slice); i += 4 {
56− end := min(i+4, len(slice))
57− m = append(m, slice[i:end])
58− }
59− return m
60−}
61−
62−// ShortHelp implements help.KeyMap.
63−func (k KeyMap) ShortHelp() []key.Binding {
64− return []key.Binding{
65− k.ChangeSelection,
66− k.Select,
67− k.Close,
68− }
69−}
internal/tui/components/dialogs/models/apikey.gonew file+96
@@ -0,0 +1,96 @@
1+package models
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/textinput"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/lipgloss/v2"
12+)
13+
14+type APIKeyInput struct {
15+ input textinput.Model
16+ width int
17+ height int
18+ providerName string
19+}
20+
21+func NewAPIKeyInput() *APIKeyInput {
22+ t := styles.CurrentTheme()
23+
24+ ti := textinput.New()
25+ ti.Placeholder = "Enter your API key..."
26+ ti.SetWidth(50)
27+ ti.SetVirtualCursor(false)
28+ ti.Prompt = "> "
29+ ti.SetStyles(t.S().TextInput)
30+ ti.Focus()
31+
32+ return &APIKeyInput{
33+ input: ti,
34+ width: 60,
35+ providerName: "Provider",
36+ }
37+}
38+
39+func (a *APIKeyInput) SetProviderName(name string) {
40+ a.providerName = name
41+}
42+
43+func (a *APIKeyInput) Init() tea.Cmd {
44+ return textinput.Blink
45+}
46+
47+func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48+ switch msg := msg.(type) {
49+ case tea.WindowSizeMsg:
50+ a.width = msg.Width
51+ a.height = msg.Height
52+ }
53+
54+ var cmd tea.Cmd
55+ a.input, cmd = a.input.Update(msg)
56+ return a, cmd
57+}
58+
59+func (a *APIKeyInput) View() string {
60+ t := styles.CurrentTheme()
61+
62+ title := t.S().Base.
63+ Foreground(t.Primary).
64+ Bold(true).
65+ Render(fmt.Sprintf("Enter your %s API Key", a.providerName))
66+
67+ inputView := a.input.View()
68+
69+ dataPath := config.GlobalConfigData()
70+ dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1)
71+ helpText := t.S().Muted.
72+ Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
73+
74+ content := lipgloss.JoinVertical(
75+ lipgloss.Left,
76+ title,
77+ "",
78+ inputView,
79+ "",
80+ helpText,
81+ )
82+
83+ return content
84+}
85+
86+func (a *APIKeyInput) Cursor() *tea.Cursor {
87+ cursor := a.input.Cursor()
88+ if cursor != nil {
89+ cursor.Y += 2 // Adjust for title and spacing
90+ }
91+ return cursor
92+}
93+
94+func (a *APIKeyInput) Value() string {
95+ return a.input.Value()
96+}
internal/tui/components/dialogs/models/list.gonew file+202
@@ -0,0 +1,202 @@
1+package models
2+
3+import (
4+ "fmt"
5+ "slices"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/fur/provider"
10+ "github.com/charmbracelet/crush/internal/tui/components/completions"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
12+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+ "github.com/charmbracelet/lipgloss/v2"
16+)
17+
18+type ModelListComponent struct {
19+ list list.ListModel
20+ modelType int
21+ providers []provider.Provider
22+}
23+
24+func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
25+ modelList := list.New(
26+ list.WithFilterable(true),
27+ list.WithKeyMap(keyMap),
28+ list.WithInputStyle(inputStyle),
29+ list.WithFilterPlaceholder(inputPlaceholder),
30+ list.WithWrapNavigation(true),
31+ )
32+
33+ return &ModelListComponent{
34+ list: modelList,
35+ modelType: LargeModelType,
36+ }
37+}
38+
39+func (m *ModelListComponent) Init() tea.Cmd {
40+ var cmds []tea.Cmd
41+ if len(m.providers) == 0 {
42+ providers, err := config.Providers()
43+ m.providers = providers
44+ if err != nil {
45+ cmds = append(cmds, util.ReportError(err))
46+ }
47+ }
48+ cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType))
49+ return tea.Batch(cmds...)
50+}
51+
52+func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
53+ u, cmd := m.list.Update(msg)
54+ m.list = u.(list.ListModel)
55+ return m, cmd
56+}
57+
58+func (m *ModelListComponent) View() string {
59+ return m.list.View()
60+}
61+
62+func (m *ModelListComponent) Cursor() *tea.Cursor {
63+ return m.list.Cursor()
64+}
65+
66+func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
67+ return m.list.SetSize(width, height)
68+}
69+
70+func (m *ModelListComponent) Items() []util.Model {
71+ return m.list.Items()
72+}
73+
74+func (m *ModelListComponent) SelectedIndex() int {
75+ return m.list.SelectedIndex()
76+}
77+
78+func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
79+ t := styles.CurrentTheme()
80+ m.modelType = modelType
81+
82+ modelItems := []util.Model{}
83+ selectIndex := 0
84+
85+ cfg := config.Get()
86+ var currentModel config.SelectedModel
87+ if m.modelType == LargeModelType {
88+ currentModel = cfg.Models[config.SelectedModelTypeLarge]
89+ } else {
90+ currentModel = cfg.Models[config.SelectedModelTypeSmall]
91+ }
92+
93+ configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
94+ configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
95+
96+ // Create a map to track which providers we've already added
97+ addedProviders := make(map[string]bool)
98+
99+ // First, add any configured providers that are not in the known providers list
100+ // These should appear at the top of the list
101+ knownProviders := provider.KnownProviders()
102+ for providerID, providerConfig := range cfg.Providers {
103+ if providerConfig.Disable {
104+ continue
105+ }
106+
107+ // Check if this provider is not in the known providers list
108+ if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) {
109+ // Convert config provider to provider.Provider format
110+ configProvider := provider.Provider{
111+ Name: providerConfig.Name,
112+ ID: provider.InferenceProvider(providerID),
113+ Models: make([]provider.Model, len(providerConfig.Models)),
114+ }
115+
116+ // Convert models
117+ for i, model := range providerConfig.Models {
118+ configProvider.Models[i] = provider.Model{
119+ ID: model.ID,
120+ Model: model.Model,
121+ CostPer1MIn: model.CostPer1MIn,
122+ CostPer1MOut: model.CostPer1MOut,
123+ CostPer1MInCached: model.CostPer1MInCached,
124+ CostPer1MOutCached: model.CostPer1MOutCached,
125+ ContextWindow: model.ContextWindow,
126+ DefaultMaxTokens: model.DefaultMaxTokens,
127+ CanReason: model.CanReason,
128+ HasReasoningEffort: model.HasReasoningEffort,
129+ DefaultReasoningEffort: model.DefaultReasoningEffort,
130+ SupportsImages: model.SupportsImages,
131+ }
132+ }
133+
134+ // Add this unknown provider to the list
135+ name := configProvider.Name
136+ if name == "" {
137+ name = string(configProvider.ID)
138+ }
139+ section := commands.NewItemSection(name)
140+ section.SetInfo(configured)
141+ modelItems = append(modelItems, section)
142+ for _, model := range configProvider.Models {
143+ modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
144+ Provider: configProvider,
145+ Model: model,
146+ }))
147+ if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
148+ selectIndex = len(modelItems) - 1 // Set the selected index to the current model
149+ }
150+ }
151+ addedProviders[providerID] = true
152+ }
153+ }
154+
155+ // Then add the known providers from the predefined list
156+ for _, provider := range m.providers {
157+ // Skip if we already added this provider as an unknown provider
158+ if addedProviders[string(provider.ID)] {
159+ continue
160+ }
161+
162+ // Check if this provider is configured and not disabled
163+ if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable {
164+ continue
165+ }
166+
167+ name := provider.Name
168+ if name == "" {
169+ name = string(provider.ID)
170+ }
171+
172+ section := commands.NewItemSection(name)
173+ if _, ok := cfg.Providers[string(provider.ID)]; ok {
174+ section.SetInfo(configured)
175+ }
176+ modelItems = append(modelItems, section)
177+ for _, model := range provider.Models {
178+ modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
179+ Provider: provider,
180+ Model: model,
181+ }))
182+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
183+ selectIndex = len(modelItems) - 1 // Set the selected index to the current model
184+ }
185+ }
186+ }
187+
188+ return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
189+}
190+
191+// GetModelType returns the current model type
192+func (m *ModelListComponent) GetModelType() int {
193+ return m.modelType
194+}
195+
196+func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
197+ m.list.SetFilterPlaceholder(placeholder)
198+}
199+
200+func (m *ModelListComponent) SetProviders(providers []provider.Provider) {
201+ m.providers = providers
202+}
internal/tui/components/dialogs/models/models.go+19−134
@@ -1,8 +1,6 @@
1package models
1package models
2
2
3import (
3import (
4− "slices"
5−
6 "github.com/charmbracelet/bubbles/v2/help"
4 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/key"
5 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,7 +10,6 @@ import (
12 "github.com/charmbracelet/crush/internal/tui/components/core"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core/list"
11 "github.com/charmbracelet/crush/internal/tui/components/core/list"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
16 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
17 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/crush/internal/tui/util"
18 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
@@ -27,6 +24,9 @@ const (
27const (
24const (
28 LargeModelType int = iota
25 LargeModelType int = iota
29 SmallModelType
26 SmallModelType
27+
28+ largeModelInputPlaceholder = "Choose a model for large, complex tasks"
29+ smallModelInputPlaceholder = "Choose a model for small, simple tasks"
30)
30)
31
31
32// ModelSelectedMsg is sent when a model is selected
32// ModelSelectedMsg is sent when a model is selected
@@ -53,10 +53,9 @@ type modelDialogCmp struct {
53 wWidth int
53 wWidth int
54 wHeight int
54 wHeight int
55
55
56− modelList list.ListModel
56+ modelList *ModelListComponent
57 keyMap KeyMap
57 keyMap KeyMap
58 help help.Model
58 help help.Model
59− modelType int
60}
59}
61
60
62func NewModelDialogCmp() ModelDialog {
61func NewModelDialogCmp() ModelDialog {
@@ -75,12 +74,7 @@ func NewModelDialogCmp() ModelDialog {
75
74
76 t := styles.CurrentTheme()
75 t := styles.CurrentTheme()
77 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
76 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
78− modelList := list.New(
77+ modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
79− list.WithFilterable(true),
80− list.WithKeyMap(listKeyMap),
81− list.WithInputStyle(inputStyle),
82− list.WithWrapNavigation(true),
83− )
84 help := help.New()
78 help := help.New()
85 help.Styles = t.S().Help
79 help.Styles = t.S().Help
86
80
@@ -89,12 +83,10 @@ func NewModelDialogCmp() ModelDialog {
89 width: defaultWidth,
83 width: defaultWidth,
90 keyMap: DefaultKeyMap(),
84 keyMap: DefaultKeyMap(),
91 help: help,
85 help: help,
92− modelType: LargeModelType,
93 }
86 }
94}
87}
95
88
96func (m *modelDialogCmp) Init() tea.Cmd {
89func (m *modelDialogCmp) Init() tea.Cmd {
97− m.SetModelType(m.modelType)
98 return m.modelList.Init()
90 return m.modelList.Init()
99}
91}
100
92
@@ -103,7 +95,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 case tea.WindowSizeMsg:
95 case tea.WindowSizeMsg:
104 m.wWidth = msg.Width
96 m.wWidth = msg.Width
105 m.wHeight = msg.Height
97 m.wHeight = msg.Height
106− m.SetModelType(m.modelType)
107 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
98 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
108 case tea.KeyPressMsg:
99 case tea.KeyPressMsg:
109 switch {
100 switch {
@@ -116,7 +107,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
107 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
117
108
118 var modelType config.SelectedModelType
109 var modelType config.SelectedModelType
119− if m.modelType == LargeModelType {
110+ if m.modelList.GetModelType() == LargeModelType {
120 modelType = config.SelectedModelTypeLarge
111 modelType = config.SelectedModelTypeLarge
121 } else {
112 } else {
122 modelType = config.SelectedModelTypeSmall
113 modelType = config.SelectedModelTypeSmall
@@ -133,16 +124,18 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133 }),
124 }),
134 )
125 )
135 case key.Matches(msg, m.keyMap.Tab):
126 case key.Matches(msg, m.keyMap.Tab):
136− if m.modelType == LargeModelType {
127+ if m.modelList.GetModelType() == LargeModelType {
128+ m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
137− return m, m.SetModelType(SmallModelType)
129+ return m, m.modelList.SetModelType(SmallModelType)
138 } else {
130 } else {
131+ m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
139− return m, m.SetModelType(LargeModelType)
132+ return m, m.modelList.SetModelType(LargeModelType)
140 }
133 }
141 case key.Matches(msg, m.keyMap.Close):
134 case key.Matches(msg, m.keyMap.Close):
142 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
135 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
143 default:
136 default:
144 u, cmd := m.modelList.Update(msg)
137 u, cmd := m.modelList.Update(msg)
145− m.modelList = u.(list.ListModel)
138+ m.modelList = u
146 return m, cmd
139 return m, cmd
147 }
140 }
148 }
141 }
@@ -164,12 +157,10 @@ func (m *modelDialogCmp) View() string {
164}
157}
165
158
166func (m *modelDialogCmp) Cursor() *tea.Cursor {
159func (m *modelDialogCmp) Cursor() *tea.Cursor {
167− if cursor, ok := m.modelList.(util.Cursor); ok {
160+ cursor := m.modelList.Cursor()
168− cursor := cursor.Cursor()
169− if cursor != nil {
161+ if cursor != nil {
170− cursor = m.moveCursor(cursor)
162+ cursor = m.moveCursor(cursor)
171− return cursor
163+ return cursor
172− }
173 }
164 }
174 return nil
165 return nil
175}
166}
@@ -187,7 +178,8 @@ func (m *modelDialogCmp) listWidth() int {
187}
178}
188
179
189func (m *modelDialogCmp) listHeight() int {
180func (m *modelDialogCmp) listHeight() int {
190− listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
181+ items := m.modelList.Items()
182+ listHeigh := len(items) + 2 + 4
191 return min(listHeigh, m.wHeight/2)
183 return min(listHeigh, m.wHeight/2)
192}
184}
193
185
@@ -215,115 +207,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
215 choices := []string{"Large Task", "Small Task"}
207 choices := []string{"Large Task", "Small Task"}
216 iconSelected := "◉"
208 iconSelected := "◉"
217 iconUnselected := "○"
209 iconUnselected := "○"
218− if m.modelType == LargeModelType {
210+ if m.modelList.GetModelType() == LargeModelType {
219 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
211 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
220 }
212 }
221 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
213 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
222}
214}
223−
224−func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
225− m.modelType = modelType
226−
227− providers, err := config.Providers()
228− if err != nil {
229− return util.ReportError(err)
230− }
231−
232− modelItems := []util.Model{}
233− selectIndex := 0
234−
235− cfg := config.Get()
236− var currentModel config.SelectedModel
237− if m.modelType == LargeModelType {
238− currentModel = cfg.Models[config.SelectedModelTypeLarge]
239− } else {
240− currentModel = cfg.Models[config.SelectedModelTypeSmall]
241− }
242−
243− // Create a map to track which providers we've already added
244− addedProviders := make(map[string]bool)
245−
246− // First, add any configured providers that are not in the known providers list
247− // These should appear at the top of the list
248− knownProviders := provider.KnownProviders()
249− for providerID, providerConfig := range cfg.Providers {
250− if providerConfig.Disable {
251− continue
252− }
253−
254− // Check if this provider is not in the known providers list
255− if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) {
256− // Convert config provider to provider.Provider format
257− configProvider := provider.Provider{
258− Name: string(providerID), // Use provider ID as name for unknown providers
259− ID: provider.InferenceProvider(providerID),
260− Models: make([]provider.Model, len(providerConfig.Models)),
261− }
262−
263− // Convert models
264− for i, model := range providerConfig.Models {
265− configProvider.Models[i] = provider.Model{
266− ID: model.ID,
267− Model: model.Model,
268− CostPer1MIn: model.CostPer1MIn,
269− CostPer1MOut: model.CostPer1MOut,
270− CostPer1MInCached: model.CostPer1MInCached,
271− CostPer1MOutCached: model.CostPer1MOutCached,
272− ContextWindow: model.ContextWindow,
273− DefaultMaxTokens: model.DefaultMaxTokens,
274− CanReason: model.CanReason,
275− HasReasoningEffort: model.HasReasoningEffort,
276− DefaultReasoningEffort: model.DefaultReasoningEffort,
277− SupportsImages: model.SupportsImages,
278− }
279− }
280−
281− // Add this unknown provider to the list
282− name := configProvider.Name
283− if name == "" {
284− name = string(configProvider.ID)
285− }
286− modelItems = append(modelItems, commands.NewItemSection(name))
287− for _, model := range configProvider.Models {
288− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
289− Provider: configProvider,
290− Model: model,
291− }))
292− if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
293− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
294− }
295− }
296− addedProviders[providerID] = true
297− }
298− }
299−
300− // Then add the known providers from the predefined list
301− for _, provider := range providers {
302− // Skip if we already added this provider as an unknown provider
303− if addedProviders[string(provider.ID)] {
304− continue
305− }
306−
307− // Check if this provider is configured and not disabled
308− if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable {
309− continue
310− }
311−
312− name := provider.Name
313− if name == "" {
314− name = string(provider.ID)
315− }
316− modelItems = append(modelItems, commands.NewItemSection(name))
317− for _, model := range provider.Models {
318− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
319− Provider: provider,
320− Model: model,
321− }))
322− if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
323− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
324− }
325− }
326− }
327−
328− return tea.Sequence(m.modelList.SetItems(modelItems), m.modelList.SetSelected(selectIndex))
329−}
internal/tui/components/dialogs/permissions/permissions.go+12−14
@@ -199,6 +199,13 @@ func (p *permissionDialogCmp) renderButtons() string {
199 }
199 }
200
200
201 content := core.SelectableButtons(buttons, " ")
201 content := core.SelectableButtons(buttons, " ")
202+ if lipgloss.Width(content) > p.width-4 {
203+ content = core.SelectableButtonsVertical(buttons, 1)
204+ return baseStyle.AlignVertical(lipgloss.Center).
205+ AlignHorizontal(lipgloss.Center).
206+ Width(p.width - 4).
207+ Render(content)
208+ }
202
209
203 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
210 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
204}
211}
@@ -382,19 +389,10 @@ func (p *permissionDialogCmp) generateFetchContent() string {
382 t := styles.CurrentTheme()
389 t := styles.CurrentTheme()
383 baseStyle := t.S().Base.Background(t.BgSubtle)
390 baseStyle := t.S().Base.Background(t.BgSubtle)
384 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
391 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
385− content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
386−
387− // Use the cache for markdown rendering
388− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
389− r := styles.GetMarkdownRenderer(p.width - 4)
390− s, err := r.Render(content)
391− return s, err
392− })
393−
394 finalContent := baseStyle.
392 finalContent := baseStyle.
393+ Padding(1, 2).
395 Width(p.contentViewPort.Width()).
394 Width(p.contentViewPort.Width()).
396− Render(renderedContent)
395+ Render(pr.URL)
397−
398 return finalContent
396 return finalContent
399 }
397 }
400 return ""
398 return ""
@@ -452,8 +450,8 @@ func (p *permissionDialogCmp) render() string {
452 if p.supportsDiffView() {
450 if p.supportsDiffView() {
453 contentHelp = help.New().View(p.keyMap)
451 contentHelp = help.New().View(p.keyMap)
454 }
452 }
455− // Calculate content height dynamically based on window size
456
453
454+ // Calculate content height dynamically based on window size
457 strs := []string{
455 strs := []string{
458 title,
456 title,
459 "",
457 "",
@@ -491,7 +489,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
491
489
492 switch p.permission.ToolName {
490 switch p.permission.ToolName {
493 case tools.BashToolName:
491 case tools.BashToolName:
494− p.width = int(float64(p.wWidth) * 0.4)
492+ p.width = int(float64(p.wWidth) * 0.8)
495 p.height = int(float64(p.wHeight) * 0.3)
493 p.height = int(float64(p.wHeight) * 0.3)
496 case tools.EditToolName:
494 case tools.EditToolName:
497 p.width = int(float64(p.wWidth) * 0.8)
495 p.width = int(float64(p.wWidth) * 0.8)
@@ -500,7 +498,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
500 p.width = int(float64(p.wWidth) * 0.8)
498 p.width = int(float64(p.wWidth) * 0.8)
501 p.height = int(float64(p.wHeight) * 0.8)
499 p.height = int(float64(p.wHeight) * 0.8)
502 case tools.FetchToolName:
500 case tools.FetchToolName:
503− p.width = int(float64(p.wWidth) * 0.4)
501+ p.width = int(float64(p.wWidth) * 0.8)
504 p.height = int(float64(p.wHeight) * 0.3)
502 p.height = int(float64(p.wHeight) * 0.3)
505 default:
503 default:
506 p.width = int(float64(p.wWidth) * 0.7)
504 p.width = int(float64(p.wWidth) * 0.7)
internal/tui/components/logo/logo.go+11−2
@@ -85,7 +85,7 @@ func Render(version string, compact bool, o Opts) string {
85 }
85 }
86
86
87 // Right field.
87 // Right field.
88− rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
88+ rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
89 const stepDownAt = 0
89 const stepDownAt = 0
90 rightField := new(strings.Builder)
90 rightField := new(strings.Builder)
91 for i := range fieldHeight {
91 for i := range fieldHeight {
@@ -98,7 +98,16 @@ func Render(version string, compact bool, o Opts) string {
98
98
99 // Return the wide version.
99 // Return the wide version.
100 const hGap = " "
100 const hGap = " "
101− return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
101+ logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
102+ if o.Width > 0 {
103+ // Truncate the logo to the specified width.
104+ lines := strings.Split(logo, "\n")
105+ for i, line := range lines {
106+ lines[i] = ansi.Truncate(line, o.Width, "")
107+ }
108+ logo = strings.Join(lines, "\n")
109+ }
110+ return logo
102}
111}
103
112
104// renderWord renders letterforms to fork a word.
113// renderWord renders letterforms to fork a word.
internal/tui/keys.go+2−61
@@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap {
20 key.WithHelp("ctrl+c", "quit"),
20 key.WithHelp("ctrl+c", "quit"),
21 ),
21 ),
22 Help: key.NewBinding(
22 Help: key.NewBinding(
23− key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
23+ key.WithKeys("ctrl+g"),
24− key.WithHelp("ctrl+?", "more"),
24+ key.WithHelp("ctrl+g", "more"),
25 ),
25 ),
26 Commands: key.NewBinding(
26 Commands: key.NewBinding(
27 key.WithKeys("ctrl+p"),
27 key.WithKeys("ctrl+p"),
@@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap {
33 ),
33 ),
34 }
34 }
35}
35}
36−
37−// FullHelp implements help.KeyMap.
38−func (k KeyMap) FullHelp() [][]key.Binding {
39− m := [][]key.Binding{}
40− slice := []key.Binding{
41− k.Commands,
42− k.Sessions,
43− k.Quit,
44− k.Help,
45− }
46− slice = k.prependEscAndTab(slice)
47− slice = append(slice, k.pageBindings...)
48− // remove duplicates
49− seen := make(map[string]bool)
50− cleaned := []key.Binding{}
51− for _, b := range slice {
52− if !seen[b.Help().Key] {
53− seen[b.Help().Key] = true
54− cleaned = append(cleaned, b)
55− }
56− }
57−
58− for i := 0; i < len(cleaned); i += 3 {
59− end := min(i+3, len(cleaned))
60− m = append(m, cleaned[i:end])
61− }
62− return m
63−}
64−
65−func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding {
66− var cancel key.Binding
67− var tab key.Binding
68− for _, b := range k.pageBindings {
69− if b.Help().Key == "esc" {
70− cancel = b
71− }
72− if b.Help().Key == "tab" {
73− tab = b
74− }
75− }
76− if tab.Help().Key != "" {
77− bindings = append([]key.Binding{tab}, bindings...)
78− }
79− if cancel.Help().Key != "" {
80− bindings = append([]key.Binding{cancel}, bindings...)
81− }
82− return bindings
83−}
84−
85−// ShortHelp implements help.KeyMap.
86−func (k KeyMap) ShortHelp() []key.Binding {
87− bindings := []key.Binding{
88− k.Commands,
89− k.Sessions,
90− k.Quit,
91− k.Help,
92− }
93− return k.prependEscAndTab(bindings)
94−}
internal/tui/page/chat/chat.go+673−262
@@ -2,21 +2,30 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5− "strings"
5+ "runtime"
6 "time"
6 "time"
7
7
8+ "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/spinner"
9 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
12 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
17 "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/crush/internal/tui/components/anim"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
23+ "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
24+ "github.com/charmbracelet/crush/internal/tui/components/completions"
25+ "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20 "github.com/charmbracelet/crush/internal/tui/page"
29 "github.com/charmbracelet/crush/internal/tui/page"
21 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/crush/internal/tui/util"
@@ -26,154 +35,223 @@ import (
26
35
27var ChatPageID page.PageID = "chat"
36var ChatPageID page.PageID = "chat"
28
37
29−const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
30−
31type (
38type (
32 OpenFilePickerMsg struct{}
39 OpenFilePickerMsg struct{}
33 ChatFocusedMsg struct {
40 ChatFocusedMsg struct {
34− Focused bool // True if the chat input is focused, false otherwise
41+ Focused bool
35 }
42 }
36 CancelTimerExpiredMsg struct{}
43 CancelTimerExpiredMsg struct{}
37)
44)
38
45
46+type PanelType string
47+
48+const (
49+ PanelTypeChat PanelType = "chat"
50+ PanelTypeEditor PanelType = "editor"
51+ PanelTypeSplash PanelType = "splash"
52+)
53+
54+const (
55+ CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
56+ EditorHeight = 5 // Height of the editor input area including padding
57+ SideBarWidth = 31 // Width of the sidebar
58+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
59+ HeaderHeight = 1 // Height of the header
60+
61+ // Layout constants for borders and padding
62+ BorderWidth = 1 // Width of component borders
63+ LeftRightBorders = 2 // Left + right border width (1 + 1)
64+ TopBottomBorders = 2 // Top + bottom border width (1 + 1)
65+ DetailsPositioning = 2 // Positioning adjustment for details panel
66+
67+ // Timing constants
68+ CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
69+)
70+
39type ChatPage interface {
71type ChatPage interface {
40 util.Model
72 util.Model
41 layout.Help
73 layout.Help
42 IsChatFocused() bool
74 IsChatFocused() bool
43}
75}
44
76
77+// cancelTimerCmd creates a command that expires the cancel timer
78+func cancelTimerCmd() tea.Cmd {
79+ return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
80+ return CancelTimerExpiredMsg{}
81+ })
82+}
83+
45type chatPage struct {
84type chatPage struct {
46− wWidth, wHeight int // Window dimensions
85+ width, height int
86+ detailsWidth, detailsHeight int
47− app *app.App
87+ app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
48
89
49− layout layout.SplitPaneLayout
90+ // Layout state
91+ compact bool
92+ forceCompact bool
93+ focusedPane PanelType
50
94
95+ // Session
51 session session.Session
96 session session.Session
97+ keyMap KeyMap
98+
99+ // Components
100+ header header.Header
101+ sidebar sidebar.Sidebar
102+ chat chat.MessageListCmp
103+ editor editor.Editor
104+ splash splash.Splash
105+
106+ // Simple state flags
107+ showingDetails bool
108+ isCanceling bool
109+ splashFullScreen bool
110+ isOnboarding bool
111+ isProjectInit bool
112+}
52
113
53− keyMap KeyMap
114+func New(app *app.App) ChatPage {
54−
115+ return &chatPage{
55− chatFocused bool
116+ app: app,
56−
117+ keyMap: DefaultKeyMap(),
57− compactMode bool
118+ header: header.New(app.LSPClients),
58− forceCompactMode bool // Force compact mode regardless of window size
119+ sidebar: sidebar.New(app.History, app.LSPClients, false),
59− showDetails bool // Show details in the header
120+ chat: chat.New(app),
60− header header.Header
121+ editor: editor.New(app),
61− compactSidebar layout.Container
122+ splash: splash.New(),
62−
123+ focusedPane: PanelTypeSplash,
63− cancelPending bool // True if ESC was pressed once and waiting for second press
124+ }
64}
125}
65
126
66func (p *chatPage) Init() tea.Cmd {
127func (p *chatPage) Init() tea.Cmd {
128+ cfg := config.Get()
129+ compact := cfg.Options.TUI.CompactMode
130+ p.compact = compact
131+ p.forceCompact = compact
132+ p.sidebar.SetCompactMode(p.compact)
133+
134+ // Set splash state based on config
135+ if !config.HasInitialDataConfig() {
136+ // First-time setup: show model selection
137+ p.splash.SetOnboarding(true)
138+ p.isOnboarding = true
139+ p.splashFullScreen = true
140+ } else if b, _ := config.ProjectNeedsInitialization(); b {
141+ // Project needs CRUSH.md initialization
142+ p.splash.SetProjectInit(true)
143+ p.isProjectInit = true
144+ p.splashFullScreen = true
145+ } else {
146+ // Ready to chat: focus editor, splash in background
147+ p.focusedPane = PanelTypeEditor
148+ p.splashFullScreen = false
149+ }
150+
67 return tea.Batch(
151 return tea.Batch(
68− p.layout.Init(),
152+ p.header.Init(),
69− p.compactSidebar.Init(),
153+ p.sidebar.Init(),
70− p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
154+ p.chat.Init(),
155+ p.editor.Init(),
156+ p.splash.Init(),
71 )
157 )
72}
158}
73
159
74−// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
75−func (p *chatPage) cancelTimerCmd() tea.Cmd {
76− return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
77− return CancelTimerExpiredMsg{}
78− })
79−}
80−
81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 var cmds []tea.Cmd
161 var cmds []tea.Cmd
83 switch msg := msg.(type) {
162 switch msg := msg.(type) {
84 case tea.KeyboardEnhancementsMsg:
163 case tea.KeyboardEnhancementsMsg:
85− m, cmd := p.layout.Update(msg)
164+ p.keyboardEnhancements = msg
86− p.layout = m.(layout.SplitPaneLayout)
87− return p, cmd
88− case CancelTimerExpiredMsg:
89− p.cancelPending = false
90 return p, nil
165 return p, nil
91 case tea.WindowSizeMsg:
166 case tea.WindowSizeMsg:
92− h, cmd := p.header.Update(msg)
167+ return p, p.SetSize(msg.Width, msg.Height)
93− cmds = append(cmds, cmd)
168+ case CancelTimerExpiredMsg:
94− p.header = h.(header.Header)
169+ p.isCanceling = false
95− cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
170+ return p, nil
96− // the mode is only relevant when there is a session
171+ case chat.SendMsg:
97− if p.session.ID != "" {
172+ return p, p.sendMessage(msg.Text, msg.Attachments)
98− // Only auto-switch to compact mode if not forced
173+ case chat.SessionSelectedMsg:
99− if !p.forceCompactMode {
174+ return p, p.setSession(msg)
100− if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
175+ case commands.ToggleCompactModeMsg:
101− p.wWidth = msg.Width
176+ p.forceCompact = !p.forceCompact
102− p.wHeight = msg.Height
177+ var cmd tea.Cmd
103− cmds = append(cmds, p.setCompactMode(true))
178+ if p.forceCompact {
104− return p, tea.Batch(cmds...)
179+ p.setCompactMode(true)
180+ cmd = p.updateCompactConfig(true)
105− } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
181+ } else if p.width >= CompactModeBreakpoint {
106− p.wWidth = msg.Width
107− p.wHeight = msg.Height
108− return p, p.setCompactMode(false)
182+ p.setCompactMode(false)
109− }
183+ cmd = p.updateCompactConfig(false)
110− }
111− }
112− p.wWidth = msg.Width
113− p.wHeight = msg.Height
114− layoutHeight := msg.Height
115− if p.compactMode {
116− // make space for the header
117− layoutHeight -= 1
118 }
184 }
119− cmd = p.layout.SetSize(msg.Width, layoutHeight)
185+ return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
186+ case pubsub.Event[session.Session]:
187+ u, cmd := p.header.Update(msg)
188+ p.header = u.(header.Header)
189+ cmds = append(cmds, cmd)
190+ u, cmd = p.sidebar.Update(msg)
191+ p.sidebar = u.(sidebar.Sidebar)
192+ cmds = append(cmds, cmd)
193+ return p, tea.Batch(cmds...)
194+ case chat.SessionClearedMsg:
195+ u, cmd := p.header.Update(msg)
196+ p.header = u.(header.Header)
197+ cmds = append(cmds, cmd)
198+ u, cmd = p.sidebar.Update(msg)
199+ p.sidebar = u.(sidebar.Sidebar)
200+ cmds = append(cmds, cmd)
201+ u, cmd = p.chat.Update(msg)
202+ p.chat = u.(chat.MessageListCmp)
203+ cmds = append(cmds, cmd)
204+ return p, tea.Batch(cmds...)
205+ case filepicker.FilePickedMsg,
206+ completions.CompletionsClosedMsg,
207+ completions.SelectCompletionMsg:
208+ u, cmd := p.editor.Update(msg)
209+ p.editor = u.(editor.Editor)
210+ cmds = append(cmds, cmd)
211+ return p, tea.Batch(cmds...)
212+
213+ case pubsub.Event[message.Message],
214+ anim.StepMsg,
215+ spinner.TickMsg:
216+ u, cmd := p.chat.Update(msg)
217+ p.chat = u.(chat.MessageListCmp)
218+ cmds = append(cmds, cmd)
219+ return p, tea.Batch(cmds...)
220+
221+ case pubsub.Event[history.File], sidebar.SessionFilesMsg:
222+ u, cmd := p.sidebar.Update(msg)
223+ p.sidebar = u.(sidebar.Sidebar)
120 cmds = append(cmds, cmd)
224 cmds = append(cmds, cmd)
121 return p, tea.Batch(cmds...)
225 return p, tea.Batch(cmds...)
122
226
123− case chat.SendMsg:
124− cmd := p.sendMessage(msg.Text, msg.Attachments)
125− if cmd != nil {
126− return p, cmd
127− }
128− case commands.ToggleCompactModeMsg:
129− // Only allow toggling if window width is larger than compact breakpoint
130− if p.wWidth > CompactModeBreakpoint {
131− p.forceCompactMode = !p.forceCompactMode
132− // If force compact mode is enabled, switch to compact mode
133− // If force compact mode is disabled, switch based on window size
134− if p.forceCompactMode {
135− return p, p.setCompactMode(true)
136− } else {
137− // Return to auto mode based on window size
138− shouldBeCompact := p.wWidth <= CompactModeBreakpoint
139− return p, p.setCompactMode(shouldBeCompact)
140− }
141− }
142 case commands.CommandRunCustomMsg:
227 case commands.CommandRunCustomMsg:
143− // Check if the agent is busy before executing custom commands
144 if p.app.CoderAgent.IsBusy() {
228 if p.app.CoderAgent.IsBusy() {
145 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
229 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
146 }
230 }
147
231
148− // Handle custom command execution
149 cmd := p.sendMessage(msg.Content, nil)
232 cmd := p.sendMessage(msg.Content, nil)
150 if cmd != nil {
233 if cmd != nil {
151 return p, cmd
234 return p, cmd
152 }
235 }
153− case chat.SessionSelectedMsg:
236+ case splash.OnboardingCompleteMsg:
154− if p.session.ID == "" {
237+ p.splashFullScreen = false
155− cmd := p.setMessages()
238+ if b, _ := config.ProjectNeedsInitialization(); b {
156− if cmd != nil {
239+ p.splash.SetProjectInit(true)
157− cmds = append(cmds, cmd)
240+ p.splashFullScreen = true
158− }
241+ return p, p.SetSize(p.width, p.height)
159 }
242 }
160− needsModeChange := p.session.ID == ""
243+ err := p.app.InitCoderAgent()
161− p.session = msg
244+ if err != nil {
162− p.header.SetSession(msg)
245+ return p, util.ReportError(err)
163− if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
164− cmds = append(cmds, p.setCompactMode(true))
165 }
246 }
247+ p.isOnboarding = false
248+ p.isProjectInit = false
249+ p.focusedPane = PanelTypeEditor
250+ return p, p.SetSize(p.width, p.height)
166 case tea.KeyPressMsg:
251 case tea.KeyPressMsg:
167 switch {
252 switch {
168 case key.Matches(msg, p.keyMap.NewSession):
253 case key.Matches(msg, p.keyMap.NewSession):
169− p.session = session.Session{}
170− return p, tea.Batch(
254+ return p, p.newSession()
171− p.clearMessages(),
172− util.CmdHandler(chat.SessionClearedMsg{}),
173− p.setCompactMode(false),
174− p.layout.FocusPanel(layout.BottomPanel),
175− util.CmdHandler(ChatFocusedMsg{Focused: false}),
176− )
177 case key.Matches(msg, p.keyMap.AddAttachment):
255 case key.Matches(msg, p.keyMap.AddAttachment):
178 agentCfg := config.Get().Agents["coder"]
256 agentCfg := config.Get().Agents["coder"]
179 model := config.Get().GetModelByType(agentCfg.Model)
257 model := config.Get().GetModelByType(agentCfg.Model)
@@ -184,171 +262,290 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184 }
262 }
185 case key.Matches(msg, p.keyMap.Tab):
263 case key.Matches(msg, p.keyMap.Tab):
186 if p.session.ID == "" {
264 if p.session.ID == "" {
265+ u, cmd := p.splash.Update(msg)
266+ p.splash = u.(splash.Splash)
187− return p, nil
267+ return p, cmd
188− }
189− p.chatFocused = !p.chatFocused
190− if p.chatFocused {
191− cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
192− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
193− } else {
194− cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
195− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
196 }
268 }
197− return p, tea.Batch(cmds...)
269+ p.changeFocus()
270+ return p, nil
198 case key.Matches(msg, p.keyMap.Cancel):
271 case key.Matches(msg, p.keyMap.Cancel):
199− if p.session.ID != "" {
272+ if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
200− if p.cancelPending {
201− // Second ESC press - actually cancel the session
202− p.cancelPending = false
203− p.app.CoderAgent.Cancel(p.session.ID)
204− return p, nil
205− } else {
206− // First ESC press - start the timer
207− p.cancelPending = true
208− return p, p.cancelTimerCmd()
273+ return p, p.cancel()
209− }
210 }
274 }
211 case key.Matches(msg, p.keyMap.Details):
275 case key.Matches(msg, p.keyMap.Details):
212− if p.session.ID == "" || !p.compactMode {
213− return p, nil // No session to show details for
214− }
215− p.showDetails = !p.showDetails
276+ p.showDetails()
216− p.header.SetDetailsOpen(p.showDetails)
217− if p.showDetails {
218− return p, tea.Batch()
219− }
220−
221 return p, nil
277 return p, nil
222 }
278 }
279+
280+ switch p.focusedPane {
281+ case PanelTypeChat:
282+ u, cmd := p.chat.Update(msg)
283+ p.chat = u.(chat.MessageListCmp)
284+ cmds = append(cmds, cmd)
285+ case PanelTypeEditor:
286+ u, cmd := p.editor.Update(msg)
287+ p.editor = u.(editor.Editor)
288+ cmds = append(cmds, cmd)
289+ case PanelTypeSplash:
290+ u, cmd := p.splash.Update(msg)
291+ p.splash = u.(splash.Splash)
292+ cmds = append(cmds, cmd)
293+ }
294+ case tea.PasteMsg:
295+ switch p.focusedPane {
296+ case PanelTypeEditor:
297+ u, cmd := p.editor.Update(msg)
298+ p.editor = u.(editor.Editor)
299+ cmds = append(cmds, cmd)
300+ return p, tea.Batch(cmds...)
301+ case PanelTypeChat:
302+ u, cmd := p.chat.Update(msg)
303+ p.chat = u.(chat.MessageListCmp)
304+ cmds = append(cmds, cmd)
305+ return p, tea.Batch(cmds...)
306+ case PanelTypeSplash:
307+ u, cmd := p.splash.Update(msg)
308+ p.splash = u.(splash.Splash)
309+ cmds = append(cmds, cmd)
310+ return p, tea.Batch(cmds...)
311+ }
223 }
312 }
224− u, cmd := p.layout.Update(msg)
225− cmds = append(cmds, cmd)
226− p.layout = u.(layout.SplitPaneLayout)
227− h, cmd := p.header.Update(msg)
228− p.header = h.(header.Header)
229− cmds = append(cmds, cmd)
230− s, cmd := p.compactSidebar.Update(msg)
231− p.compactSidebar = s.(layout.Container)
232− cmds = append(cmds, cmd)
233 return p, tea.Batch(cmds...)
313 return p, tea.Batch(cmds...)
234}
314}
235
315
236−func (p *chatPage) setMessages() tea.Cmd {
316+func (p *chatPage) Cursor() *tea.Cursor {
237− messagesContainer := layout.NewContainer(
317+ switch p.focusedPane {
238− chat.NewMessagesListCmp(p.app),
318+ case PanelTypeEditor:
239− layout.WithPadding(1, 1, 0, 1),
319+ return p.editor.Cursor()
240− )
320+ case PanelTypeSplash:
241− return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
321+ return p.splash.Cursor()
322+ default:
323+ return nil
324+ }
242}
325}
243
326
244−func (p *chatPage) setSidebar() tea.Cmd {
327+func (p *chatPage) View() string {
245− sidebarContainer := sidebarCmp(p.app, false, p.session)
328+ var chatView string
246− sidebarContainer.Init()
329+ t := styles.CurrentTheme()
247− return p.layout.SetRightPanel(sidebarContainer)
330+
331+ if p.session.ID == "" {
332+ splashView := p.splash.View()
333+ // Full screen during onboarding or project initialization
334+ if p.splashFullScreen {
335+ chatView = splashView
336+ } else {
337+ // Show splash + editor for new message state
338+ editorView := p.editor.View()
339+ chatView = lipgloss.JoinVertical(
340+ lipgloss.Left,
341+ t.S().Base.Render(splashView),
342+ editorView,
343+ )
344+ }
345+ } else {
346+ messagesView := p.chat.View()
347+ editorView := p.editor.View()
348+ if p.compact {
349+ headerView := p.header.View()
350+ chatView = lipgloss.JoinVertical(
351+ lipgloss.Left,
352+ headerView,
353+ messagesView,
354+ editorView,
355+ )
356+ } else {
357+ sidebarView := p.sidebar.View()
358+ messages := lipgloss.JoinHorizontal(
359+ lipgloss.Left,
360+ messagesView,
361+ sidebarView,
362+ )
363+ chatView = lipgloss.JoinVertical(
364+ lipgloss.Left,
365+ messages,
366+ p.editor.View(),
367+ )
368+ }
369+ }
370+
371+ layers := []*lipgloss.Layer{
372+ lipgloss.NewLayer(chatView).X(0).Y(0),
373+ }
374+
375+ if p.showingDetails {
376+ style := t.S().Base.
377+ Width(p.detailsWidth).
378+ Border(lipgloss.RoundedBorder()).
379+ BorderForeground(t.BorderFocus)
380+ version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
381+ details := style.Render(
382+ lipgloss.JoinVertical(
383+ lipgloss.Left,
384+ p.sidebar.View(),
385+ version,
386+ ),
387+ )
388+ layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
389+ }
390+ canvas := lipgloss.NewCanvas(
391+ layers...,
392+ )
393+ return canvas.Render()
248}
394}
249
395
250−func (p *chatPage) clearMessages() tea.Cmd {
396+func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
251− return p.layout.ClearLeftPanel()
397+ return func() tea.Msg {
398+ err := config.Get().SetCompactMode(compact)
399+ if err != nil {
400+ return util.InfoMsg{
401+ Type: util.InfoTypeError,
402+ Msg: "Failed to update compact mode configuration: " + err.Error(),
403+ }
404+ }
405+ return nil
406+ }
252}
407}
253
408
254−func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
409+func (p *chatPage) setCompactMode(compact bool) {
410+ if p.compact == compact {
411+ return
412+ }
255− p.compactMode = compact
413+ p.compact = compact
256− var cmds []tea.Cmd
257 if compact {
414 if compact {
258− // add offset for the header
415+ p.compact = true
259− p.layout.SetOffset(0, 1)
416+ p.sidebar.SetCompactMode(true)
260− // make space for the header
261− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
262− // remove the sidebar
263− cmds = append(cmds, p.layout.ClearRightPanel())
264− return tea.Batch(cmds...)
265 } else {
417 } else {
266− // remove the offset for the header
418+ p.compact = false
267− p.layout.SetOffset(0, 0)
419+ p.showingDetails = false
268− // restore the original size
420+ p.sidebar.SetCompactMode(false)
269− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
421+ }
270− // set the sidebar
422+}
271− cmds = append(cmds, p.setSidebar())
272− l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
273− p.layout = l.(layout.SplitPaneLayout)
274− cmds = append(cmds, cmd)
275
423
424+func (p *chatPage) handleCompactMode(newWidth int) {
425+ if p.forceCompact {
276− return tea.Batch(cmds...)
426+ return
427+ }
428+ if newWidth < CompactModeBreakpoint && !p.compact {
429+ p.setCompactMode(true)
430+ }
431+ if newWidth >= CompactModeBreakpoint && p.compact {
432+ p.setCompactMode(false)
277 }
433 }
278}
434}
279
435
280−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
436+func (p *chatPage) SetSize(width, height int) tea.Cmd {
437+ p.handleCompactMode(width)
438+ p.width = width
439+ p.height = height
281 var cmds []tea.Cmd
440 var cmds []tea.Cmd
441+
282 if p.session.ID == "" {
442 if p.session.ID == "" {
283− session, err := p.app.Sessions.Create(context.Background(), "New Session")
443+ if p.splashFullScreen {
284− if err != nil {
444+ cmds = append(cmds, p.splash.SetSize(width, height))
285− return util.ReportError(err)
445+ } else {
446+ cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
447+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
448+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
286 }
449 }
287−
450+ } else {
288− p.session = session
451+ if p.compact {
289− cmd := p.setMessages()
452+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
290− if cmd != nil {
453+ p.detailsWidth = width - DetailsPositioning
291− cmds = append(cmds, cmd)
454+ cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
455+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
456+ cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
457+ } else {
458+ cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
459+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
460+ cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
292 }
461 }
293− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
462+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
294− }
295−
296− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
297− if err != nil {
298− return util.ReportError(err)
299 }
463 }
300 return tea.Batch(cmds...)
464 return tea.Batch(cmds...)
301}
465}
302
466
303−func (p *chatPage) SetSize(width, height int) tea.Cmd {
467+func (p *chatPage) newSession() tea.Cmd {
468+ if p.session.ID == "" {
469+ return nil
470+ }
471+
472+ p.session = session.Session{}
473+ p.focusedPane = PanelTypeEditor
474+ p.isCanceling = false
475+ return tea.Batch(
476+ util.CmdHandler(chat.SessionClearedMsg{}),
304− return p.layout.SetSize(width, height)
477+ p.SetSize(p.width, p.height),
478+ )
305}
479}
306
480
307−func (p *chatPage) GetSize() (int, int) {
481+func (p *chatPage) setSession(session session.Session) tea.Cmd {
308− return p.layout.GetSize()
482+ if p.session.ID == session.ID {
483+ return nil
484+ }
485+
486+ var cmds []tea.Cmd
487+ p.session = session
488+
489+ cmds = append(cmds, p.SetSize(p.width, p.height))
490+ cmds = append(cmds, p.chat.SetSession(session))
491+ cmds = append(cmds, p.sidebar.SetSession(session))
492+ cmds = append(cmds, p.header.SetSession(session))
493+ cmds = append(cmds, p.editor.SetSession(session))
494+
495+ return tea.Sequence(cmds...)
309}
496}
310
497
311−func (p *chatPage) View() string {
498+func (p *chatPage) changeFocus() {
312− if !p.compactMode || p.session.ID == "" {
499+ if p.session.ID == "" {
313− // If not in compact mode or there is no session, we don't show the header
314− return p.layout.View()
500+ return
315 }
501 }
316− layoutView := p.layout.View()
502+ switch p.focusedPane {
317− chatView := strings.Join(
503+ case PanelTypeChat:
318− []string{
504+ p.focusedPane = PanelTypeEditor
319− p.header.View(),
505+ p.editor.Focus()
320− layoutView,
506+ p.chat.Blur()
321− }, "\n",
507+ case PanelTypeEditor:
322− )
508+ p.focusedPane = PanelTypeChat
323− layers := []*lipgloss.Layer{
509+ p.chat.Focus()
324− lipgloss.NewLayer(chatView).X(0).Y(0),
510+ p.editor.Blur()
325 }
511 }
326− if p.showDetails {
512+}
327− t := styles.CurrentTheme()
513+
328− style := t.S().Base.
514+func (p *chatPage) cancel() tea.Cmd {
329− Border(lipgloss.RoundedBorder()).
515+ if p.isCanceling {
330− BorderForeground(t.BorderFocus)
516+ p.isCanceling = false
331− version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
517+ p.app.CoderAgent.Cancel(p.session.ID)
332− details := style.Render(
518+ return nil
333− lipgloss.JoinVertical(
334− lipgloss.Left,
335− p.compactSidebar.View(),
336− version,
337− ),
338− )
339− layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
340 }
519 }
341− canvas := lipgloss.NewCanvas(
520+
342− layers...,
521+ p.isCanceling = true
343− )
522+ return cancelTimerCmd()
344− return canvas.Render()
345}
523}
346
524
347−func (p *chatPage) Cursor() *tea.Cursor {
525+func (p *chatPage) showDetails() {
348− if v, ok := p.layout.(util.Cursor); ok {
526+ if p.session.ID == "" || !p.compact {
349− return v.Cursor()
527+ return
528+ }
529+ p.showingDetails = !p.showingDetails
530+ p.header.SetDetailsOpen(p.showingDetails)
531+}
532+
533+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
534+ session := p.session
535+ var cmds []tea.Cmd
536+ if p.session.ID == "" {
537+ newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
538+ if err != nil {
539+ return util.ReportError(err)
540+ }
541+ session = newSession
542+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
543+ }
544+ _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
545+ if err != nil {
546+ return util.ReportError(err)
350 }
547 }
351− return nil
548+ return tea.Batch(cmds...)
352}
549}
353
550
354func (p *chatPage) Bindings() []key.Binding {
551func (p *chatPage) Bindings() []key.Binding {
@@ -356,9 +553,9 @@ func (p *chatPage) Bindings() []key.Binding {
356 p.keyMap.NewSession,
553 p.keyMap.NewSession,
357 p.keyMap.AddAttachment,
554 p.keyMap.AddAttachment,
358 }
555 }
359− if p.app.CoderAgent.IsBusy() {
556+ if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
360 cancelBinding := p.keyMap.Cancel
557 cancelBinding := p.keyMap.Cancel
361− if p.cancelPending {
558+ if p.isCanceling {
362 cancelBinding = key.NewBinding(
559 cancelBinding = key.NewBinding(
363 key.WithKeys("esc"),
560 key.WithKeys("esc"),
364 key.WithHelp("esc", "press again to cancel"),
561 key.WithHelp("esc", "press again to cancel"),
@@ -367,61 +564,275 @@ func (p *chatPage) Bindings() []key.Binding {
367 bindings = append([]key.Binding{cancelBinding}, bindings...)
564 bindings = append([]key.Binding{cancelBinding}, bindings...)
368 }
565 }
369
566
370− if p.chatFocused {
567+ switch p.focusedPane {
568+ case PanelTypeChat:
371 bindings = append([]key.Binding{
569 bindings = append([]key.Binding{
372 key.NewBinding(
570 key.NewBinding(
373 key.WithKeys("tab"),
571 key.WithKeys("tab"),
374 key.WithHelp("tab", "focus editor"),
572 key.WithHelp("tab", "focus editor"),
375 ),
573 ),
376 }, bindings...)
574 }, bindings...)
377− } else {
575+ bindings = append(bindings, p.chat.Bindings()...)
576+ case PanelTypeEditor:
378 bindings = append([]key.Binding{
577 bindings = append([]key.Binding{
379 key.NewBinding(
578 key.NewBinding(
380 key.WithKeys("tab"),
579 key.WithKeys("tab"),
381 key.WithHelp("tab", "focus chat"),
580 key.WithHelp("tab", "focus chat"),
382 ),
581 ),
383 }, bindings...)
582 }, bindings...)
583+ bindings = append(bindings, p.editor.Bindings()...)
584+ case PanelTypeSplash:
585+ bindings = append(bindings, p.splash.Bindings()...)
384 }
586 }
385
587
386− bindings = append(bindings, p.layout.Bindings()...)
387 return bindings
588 return bindings
388}
589}
389
590
390−func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
591+func (a *chatPage) Help() help.KeyMap {
391− padding := layout.WithPadding(1, 1, 1, 1)
592+ var shortList []key.Binding
392− if compact {
593+ var fullList [][]key.Binding
393− padding = layout.WithPadding(0, 1, 1, 1)
594+ switch {
394− }
595+ case a.isOnboarding && !a.splash.IsShowingAPIKey():
395− sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
596+ shortList = append(shortList,
396− if session.ID != "" {
597+ // Choose model
397− sidebar.SetSession(session)
598+ key.NewBinding(
599+ key.WithKeys("up", "down"),
600+ key.WithHelp("↑/↓", "choose"),
601+ ),
602+ // Accept selection
603+ key.NewBinding(
604+ key.WithKeys("enter", "ctrl+y"),
605+ key.WithHelp("enter", "accept"),
606+ ),
607+ // Quit
608+ key.NewBinding(
609+ key.WithKeys("ctrl+c"),
610+ key.WithHelp("ctrl+c", "quit"),
611+ ),
612+ )
613+ // keep them the same
614+ for _, v := range shortList {
615+ fullList = append(fullList, []key.Binding{v})
616+ }
617+ case a.isOnboarding && a.splash.IsShowingAPIKey():
618+ var pasteKey key.Binding
619+ if runtime.GOOS != "darwin" {
620+ pasteKey = key.NewBinding(
621+ key.WithKeys("ctrl+v"),
622+ key.WithHelp("ctrl+v", "paste API key"),
623+ )
624+ } else {
625+ pasteKey = key.NewBinding(
626+ key.WithKeys("cmd+v"),
627+ key.WithHelp("cmd+v", "paste API key"),
628+ )
629+ }
630+ shortList = append(shortList,
631+ // Go back
632+ key.NewBinding(
633+ key.WithKeys("esc"),
634+ key.WithHelp("esc", "back"),
635+ ),
636+ // Paste
637+ pasteKey,
638+ // Quit
639+ key.NewBinding(
640+ key.WithKeys("ctrl+c"),
641+ key.WithHelp("ctrl+c", "quit"),
642+ ),
643+ )
644+ // keep them the same
645+ for _, v := range shortList {
646+ fullList = append(fullList, []key.Binding{v})
647+ }
648+ case a.isProjectInit:
649+ shortList = append(shortList,
650+ key.NewBinding(
651+ key.WithKeys("ctrl+c"),
652+ key.WithHelp("ctrl+c", "quit"),
653+ ),
654+ )
655+ // keep them the same
656+ for _, v := range shortList {
657+ fullList = append(fullList, []key.Binding{v})
658+ }
659+ default:
660+ if a.editor.IsCompletionsOpen() {
661+ shortList = append(shortList,
662+ key.NewBinding(
663+ key.WithKeys("tab", "enter"),
664+ key.WithHelp("tab/enter", "complete"),
665+ ),
666+ key.NewBinding(
667+ key.WithKeys("esc"),
668+ key.WithHelp("esc", "cancel"),
669+ ),
670+ key.NewBinding(
671+ key.WithKeys("up", "down"),
672+ key.WithHelp("↑/↓", "choose"),
673+ ),
674+ )
675+ for _, v := range shortList {
676+ fullList = append(fullList, []key.Binding{v})
677+ }
678+ return core.NewSimpleHelp(shortList, fullList)
679+ }
680+ if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
681+ cancelBinding := key.NewBinding(
682+ key.WithKeys("esc"),
683+ key.WithHelp("esc", "cancel"),
684+ )
685+ if a.isCanceling {
686+ cancelBinding = key.NewBinding(
687+ key.WithKeys("esc"),
688+ key.WithHelp("esc", "press again to cancel"),
689+ )
690+ }
691+ shortList = append(shortList, cancelBinding)
692+ fullList = append(fullList,
693+ []key.Binding{
694+ cancelBinding,
695+ },
696+ )
697+ }
698+ globalBindings := []key.Binding{}
699+ // we are in a session
700+ if a.session.ID != "" {
701+ tabKey := key.NewBinding(
702+ key.WithKeys("tab"),
703+ key.WithHelp("tab", "focus chat"),
704+ )
705+ if a.focusedPane == PanelTypeChat {
706+ tabKey = key.NewBinding(
707+ key.WithKeys("tab"),
708+ key.WithHelp("tab", "focus editor"),
709+ )
710+ }
711+ shortList = append(shortList, tabKey)
712+ globalBindings = append(globalBindings, tabKey)
713+ }
714+ commandsBinding := key.NewBinding(
715+ key.WithKeys("ctrl+p"),
716+ key.WithHelp("ctrl+p", "commands"),
717+ )
718+ helpBinding := key.NewBinding(
719+ key.WithKeys("ctrl+g"),
720+ key.WithHelp("ctrl+g", "more"),
721+ )
722+ globalBindings = append(globalBindings, commandsBinding)
723+ globalBindings = append(globalBindings,
724+ key.NewBinding(
725+ key.WithKeys("ctrl+s"),
726+ key.WithHelp("ctrl+s", "sessions"),
727+ ),
728+ )
729+ if a.session.ID != "" {
730+ globalBindings = append(globalBindings,
731+ key.NewBinding(
732+ key.WithKeys("ctrl+n"),
733+ key.WithHelp("ctrl+n", "new sessions"),
734+ ))
735+ }
736+ shortList = append(shortList,
737+ // Commands
738+ commandsBinding,
739+ )
740+ fullList = append(fullList, globalBindings)
741+
742+ if a.focusedPane == PanelTypeChat {
743+ shortList = append(shortList,
744+ key.NewBinding(
745+ key.WithKeys("up", "down"),
746+ key.WithHelp("↑↓", "scroll"),
747+ ),
748+ )
749+ fullList = append(fullList,
750+ []key.Binding{
751+ key.NewBinding(
752+ key.WithKeys("up", "down"),
753+ key.WithHelp("↑↓", "scroll"),
754+ ),
755+ key.NewBinding(
756+ key.WithKeys("shift+up", "shift+down"),
757+ key.WithHelp("shift+↑↓", "next/prev item"),
758+ ),
759+ key.NewBinding(
760+ key.WithKeys("pgup", "b"),
761+ key.WithHelp("b/pgup", "page up"),
762+ ),
763+ key.NewBinding(
764+ key.WithKeys("pgdown", " ", "f"),
765+ key.WithHelp("f/pgdn", "page down"),
766+ ),
767+ },
768+ []key.Binding{
769+ key.NewBinding(
770+ key.WithKeys("u"),
771+ key.WithHelp("u", "half page up"),
772+ ),
773+ key.NewBinding(
774+ key.WithKeys("d"),
775+ key.WithHelp("d", "half page down"),
776+ ),
777+ key.NewBinding(
778+ key.WithKeys("g", "home"),
779+ key.WithHelp("g", "hone"),
780+ ),
781+ key.NewBinding(
782+ key.WithKeys("G", "end"),
783+ key.WithHelp("G", "end"),
784+ ),
785+ },
786+ )
787+ } else if a.focusedPane == PanelTypeEditor {
788+ newLineBinding := key.NewBinding(
789+ key.WithKeys("shift+enter", "ctrl+j"),
790+ // "ctrl+j" is a common keybinding for newline in many editors. If
791+ // the terminal supports "shift+enter", we substitute the help text
792+ // to reflect that.
793+ key.WithHelp("ctrl+j", "newline"),
794+ )
795+ if a.keyboardEnhancements.SupportsKeyDisambiguation() {
796+ newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
797+ }
798+ shortList = append(shortList, newLineBinding)
799+ fullList = append(fullList,
800+ []key.Binding{
801+ newLineBinding,
802+ key.NewBinding(
803+ key.WithKeys("ctrl+f"),
804+ key.WithHelp("ctrl+f", "add image"),
805+ ),
806+ key.NewBinding(
807+ key.WithKeys("/"),
808+ key.WithHelp("/", "add file"),
809+ ),
810+ key.NewBinding(
811+ key.WithKeys("ctrl+v"),
812+ key.WithHelp("ctrl+v", "open editor"),
813+ ),
814+ })
815+ }
816+ shortList = append(shortList,
817+ // Quit
818+ key.NewBinding(
819+ key.WithKeys("ctrl+c"),
820+ key.WithHelp("ctrl+c", "quit"),
821+ ),
822+ // Help
823+ helpBinding,
824+ )
825+ fullList = append(fullList, []key.Binding{
826+ key.NewBinding(
827+ key.WithKeys("ctrl+g"),
828+ key.WithHelp("ctrl+g", "less"),
829+ ),
830+ })
398 }
831 }
399
832
400− return layout.NewContainer(
833+ return core.NewSimpleHelp(shortList, fullList)
401− sidebar,
402− padding,
403− )
404−}
405−
406−func NewChatPage(app *app.App) ChatPage {
407− editorContainer := layout.NewContainer(
408− editor.NewEditorCmp(app),
409− )
410− return &chatPage{
411− app: app,
412− layout: layout.NewSplitPane(
413− layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
414− layout.WithBottomPanel(editorContainer),
415− layout.WithFixedBottomHeight(5),
416− layout.WithFixedRightWidth(31),
417− ),
418− compactSidebar: sidebarCmp(app, true, session.Session{}),
419− keyMap: DefaultKeyMap(),
420− header: header.New(app.LSPClients),
421− }
422}
834}
423
835
424−// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
425func (p *chatPage) IsChatFocused() bool {
836func (p *chatPage) IsChatFocused() bool {
426− return p.chatFocused
837+ return p.focusedPane == PanelTypeChat
427}
838}
internal/tui/tui.go+12−25
@@ -13,13 +13,13 @@ import (
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/status"
18 "github.com/charmbracelet/crush/internal/tui/components/core/status"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22− initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -88,22 +88,7 @@ func (a appModel) Init() tea.Cmd {
88 cmd = a.status.Init()
88 cmd = a.status.Init()
89 cmds = append(cmds, cmd)
89 cmds = append(cmds, cmd)
90
90
91− // Check if we should show the init dialog
92− cmds = append(cmds, func() tea.Msg {
91+ cmds = append(cmds, tea.EnableMouseAllMotion)
93− shouldShow, err := config.ProjectNeedsInitialization()
94− if err != nil {
95− return util.InfoMsg{
96− Type: util.InfoTypeError,
97− Msg: "Failed to check init status: " + err.Error(),
98− }
99− }
100− if shouldShow {
101− return dialogs.OpenDialogMsg{
102− Model: initDialog.NewInitDialogCmp(),
103− }
104− }
105− return nil
106− })
107
92
108 return tea.Batch(cmds...)
93 return tea.Batch(cmds...)
109}
94}
@@ -124,6 +109,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
124 }
109 }
125 return a, tea.Batch(cmds...)
110 return a, tea.Batch(cmds...)
126 case tea.WindowSizeMsg:
111 case tea.WindowSizeMsg:
112+ a.completions.Update(msg)
127 return a, a.handleWindowResize(msg.Width, msg.Height)
113 return a, a.handleWindowResize(msg.Width, msg.Height)
128
114
129 // Completions messages
115 // Completions messages
@@ -134,9 +120,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134
120
135 // Dialog messages
121 // Dialog messages
136 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
122 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
123+ u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
124+ a.completions = u.(completions.Completions)
137 u, dialogCmd := a.dialog.Update(msg)
125 u, dialogCmd := a.dialog.Update(msg)
138 a.dialog = u.(dialogs.DialogCmp)
126 a.dialog = u.(dialogs.DialogCmp)
139− return a, dialogCmd
127+ return a, tea.Batch(completionCmd, dialogCmd)
140 case commands.ShowArgumentsDialogMsg:
128 case commands.ShowArgumentsDialogMsg:
141 return a, util.CmdHandler(
129 return a, util.CmdHandler(
142 dialogs.OpenDialogMsg{
130 dialogs.OpenDialogMsg{
@@ -186,7 +174,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186
174
187 // Model Switch
175 // Model Switch
188 case models.ModelSelectedMsg:
176 case models.ModelSelectedMsg:
189− config.UpdatePreferredModel(msg.ModelType, msg.Model)
177+ config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
190
178
191 // Update the agent with the new model/provider configuration
179 // Update the agent with the new model/provider configuration
192 if err := a.app.UpdateAgentModel(); err != nil {
180 if err := a.app.UpdateAgentModel(); err != nil {
@@ -273,7 +261,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
273 var cmds []tea.Cmd
261 var cmds []tea.Cmd
274 a.wWidth, a.wHeight = width, height
262 a.wWidth, a.wHeight = width, height
275 if a.showingFullHelp {
263 if a.showingFullHelp {
276− height -= 4
264+ height -= 5
277 } else {
265 } else {
278 height -= 2
266 height -= 2
279 }
267 }
@@ -400,10 +388,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
400// View renders the complete application interface including pages, dialogs, and overlays.
388// View renders the complete application interface including pages, dialogs, and overlays.
401func (a *appModel) View() tea.View {
389func (a *appModel) View() tea.View {
402 page := a.pages[a.currentPage]
390 page := a.pages[a.currentPage]
403− if withHelp, ok := page.(layout.Help); ok {
391+ if withHelp, ok := page.(core.KeyMapHelp); ok {
404− a.keyMap.pageBindings = withHelp.Bindings()
392+ a.status.SetKeyMap(withHelp.Help())
405 }
393 }
406− a.status.SetKeyMap(a.keyMap)
407 pageView := page.View()
394 pageView := page.View()
408 components := []string{
395 components := []string{
409 pageView,
396 pageView,
@@ -456,14 +443,14 @@ func (a *appModel) View() tea.View {
456
443
457// New creates and initializes a new TUI application model.
444// New creates and initializes a new TUI application model.
458func New(app *app.App) tea.Model {
445func New(app *app.App) tea.Model {
459− chatPage := chat.NewChatPage(app)
446+ chatPage := chat.New(app)
460 keyMap := DefaultKeyMap()
447 keyMap := DefaultKeyMap()
461 keyMap.pageBindings = chatPage.Bindings()
448 keyMap.pageBindings = chatPage.Bindings()
462
449
463 model := &appModel{
450 model := &appModel{
464 currentPage: chat.ChatPageID,
451 currentPage: chat.ChatPageID,
465 app: app,
452 app: app,
466− status: status.NewStatusCmp(keyMap),
453+ status: status.NewStatusCmp(),
467 loadedPages: make(map[page.PageID]bool),
454 loadedPages: make(map[page.PageID]bool),
468 keyMap: keyMap,
455 keyMap: keyMap,
469
456
vendor/github.com/charmbracelet/bubbletea/v2/tea.go+15−4
@@ -624,6 +624,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
624 switch msg.Mode {
624 switch msg.Mode {
625 case ansi.AltScreenSaveCursorMode:
625 case ansi.AltScreenSaveCursorMode:
626 p.renderer.enterAltScreen()
626 p.renderer.enterAltScreen()
627+ // Main and alternate screen have their own Kitty keyboard
628+ // stack. We need to request keyboard enhancements again
629+ // when entering/exiting the alternate screen.
630+ p.requestKeyboardEnhancements()
627 case ansi.TextCursorEnableMode:
631 case ansi.TextCursorEnableMode:
628 p.renderer.showCursor()
632 p.renderer.showCursor()
629 case ansi.GraphemeClusteringMode:
633 case ansi.GraphemeClusteringMode:
@@ -645,6 +649,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
645 switch msg.Mode {
649 switch msg.Mode {
646 case ansi.AltScreenSaveCursorMode:
650 case ansi.AltScreenSaveCursorMode:
647 p.renderer.exitAltScreen()
651 p.renderer.exitAltScreen()
652+ // Main and alternate screen have their own Kitty keyboard
653+ // stack. We need to request keyboard enhancements again
654+ // when entering/exiting the alternate screen.
655+ p.requestKeyboardEnhancements()
648 case ansi.TextCursorEnableMode:
656 case ansi.TextCursorEnableMode:
649 p.renderer.hideCursor()
657 p.renderer.hideCursor()
650 default:
658 default:
@@ -1410,13 +1418,16 @@ func (p *Program) stopRenderer(kill bool) {
1410// requestKeyboardEnhancements tries to enable keyboard enhancements and read
1418// requestKeyboardEnhancements tries to enable keyboard enhancements and read
1411// the active keyboard enhancements from the terminal.
1419// the active keyboard enhancements from the terminal.
1412func (p *Program) requestKeyboardEnhancements() {
1420func (p *Program) requestKeyboardEnhancements() {
1421+ // XXX: We write to the renderer directly so that we synchronize with the
1422+ // alt-screen state of the renderer. This is because the main screen and
1423+ // alternate screen have their own Kitty keyboard state stack.
1413 if p.requestedEnhancements.modifyOtherKeys > 0 {
1424 if p.requestedEnhancements.modifyOtherKeys > 0 {
1414− p.execute(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd
1425+ _, _ = p.renderer.writeString(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd
1415− p.execute(ansi.QueryModifyOtherKeys)
1426+ _, _ = p.renderer.writeString(ansi.QueryModifyOtherKeys)
1416 }
1427 }
1417 if p.requestedEnhancements.kittyFlags > 0 {
1428 if p.requestedEnhancements.kittyFlags > 0 {
1418− p.execute(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags))
1429+ _, _ = p.renderer.writeString(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags))
1419− p.execute(ansi.RequestKittyKeyboard)
1430+ _, _ = p.renderer.writeString(ansi.RequestKittyKeyboard)
1420 }
1431 }
1421}
1432}
1422
1433
vendor/github.com/charmbracelet/lipgloss/v2/get.go+33
@@ -135,6 +135,16 @@ func (s Style) GetPaddingLeft() int {
135 return s.getAsInt(paddingLeftKey)
135 return s.getAsInt(paddingLeftKey)
136}
136}
137
137
138+// GetPaddingChar returns the style's padding character. If no value is set a
139+// space (`\u0020`) is returned.
140+func (s Style) GetPaddingChar() rune {
141+ char := s.getAsRune(paddingCharKey)
142+ if char == 0 {
143+ return ' '
144+ }
145+ return char
146+}
147+
138// GetHorizontalPadding returns the style's left and right padding. Unset
148// GetHorizontalPadding returns the style's left and right padding. Unset
139// values are measured as 0.
149// values are measured as 0.
140func (s Style) GetHorizontalPadding() int {
150func (s Style) GetHorizontalPadding() int {
@@ -186,6 +196,16 @@ func (s Style) GetMarginLeft() int {
186 return s.getAsInt(marginLeftKey)
196 return s.getAsInt(marginLeftKey)
187}
197}
188
198
199+// GetMarginChar returns the style's padding character. If no value is set a
200+// space (`\u0020`) is returned.
201+func (s Style) GetMarginChar() rune {
202+ char := s.getAsRune(marginCharKey)
203+ if char == 0 {
204+ return ' '
205+ }
206+ return char
207+}
208+
189// GetHorizontalMargins returns the style's left and right margins. Unset
209// GetHorizontalMargins returns the style's left and right margins. Unset
190// values are measured as 0.
210// values are measured as 0.
191func (s Style) GetHorizontalMargins() int {
211func (s Style) GetHorizontalMargins() int {
@@ -432,6 +452,19 @@ func (s Style) isSet(k propKey) bool {
432 return s.props.has(k)
452 return s.props.has(k)
433}
453}
434
454
455+func (s Style) getAsRune(k propKey) rune {
456+ if !s.isSet(k) {
457+ return 0
458+ }
459+ switch k { //nolint:exhaustive
460+ case paddingCharKey:
461+ return s.paddingChar
462+ case marginCharKey:
463+ return s.marginChar
464+ }
465+ return 0
466+}
467+
435func (s Style) getAsBool(k propKey, defaultVal bool) bool {
468func (s Style) getAsBool(k propKey, defaultVal bool) bool {
436 if !s.isSet(k) {
469 if !s.isSet(k) {
437 return defaultVal
470 return defaultVal
vendor/github.com/charmbracelet/lipgloss/v2/set.go+27
@@ -29,6 +29,8 @@ func (s *Style) set(key propKey, value any) {
29 s.paddingBottom = max(0, value.(int))
29 s.paddingBottom = max(0, value.(int))
30 case paddingLeftKey:
30 case paddingLeftKey:
31 s.paddingLeft = max(0, value.(int))
31 s.paddingLeft = max(0, value.(int))
32+ case paddingCharKey:
33+ s.paddingChar = value.(rune)
32 case marginTopKey:
34 case marginTopKey:
33 s.marginTop = max(0, value.(int))
35 s.marginTop = max(0, value.(int))
34 case marginRightKey:
36 case marginRightKey:
@@ -39,6 +41,8 @@ func (s *Style) set(key propKey, value any) {
39 s.marginLeft = max(0, value.(int))
41 s.marginLeft = max(0, value.(int))
40 case marginBackgroundKey:
42 case marginBackgroundKey:
41 s.marginBgColor = colorOrNil(value)
43 s.marginBgColor = colorOrNil(value)
44+ case marginCharKey:
45+ s.marginChar = value.(rune)
42 case borderStyleKey:
46 case borderStyleKey:
43 s.borderStyle = value.(Border)
47 s.borderStyle = value.(Border)
44 case borderTopForegroundKey:
48 case borderTopForegroundKey:
@@ -111,6 +115,8 @@ func (s *Style) setFrom(key propKey, i Style) {
111 s.set(paddingBottomKey, i.paddingBottom)
115 s.set(paddingBottomKey, i.paddingBottom)
112 case paddingLeftKey:
116 case paddingLeftKey:
113 s.set(paddingLeftKey, i.paddingLeft)
117 s.set(paddingLeftKey, i.paddingLeft)
118+ case paddingCharKey:
119+ s.set(paddingCharKey, i.paddingChar)
114 case marginTopKey:
120 case marginTopKey:
115 s.set(marginTopKey, i.marginTop)
121 s.set(marginTopKey, i.marginTop)
116 case marginRightKey:
122 case marginRightKey:
@@ -121,6 +127,8 @@ func (s *Style) setFrom(key propKey, i Style) {
121 s.set(marginLeftKey, i.marginLeft)
127 s.set(marginLeftKey, i.marginLeft)
122 case marginBackgroundKey:
128 case marginBackgroundKey:
123 s.set(marginBackgroundKey, i.marginBgColor)
129 s.set(marginBackgroundKey, i.marginBgColor)
130+ case marginCharKey:
131+ s.set(marginCharKey, i.marginChar)
124 case borderStyleKey:
132 case borderStyleKey:
125 s.set(borderStyleKey, i.borderStyle)
133 s.set(borderStyleKey, i.borderStyle)
126 case borderTopForegroundKey:
134 case borderTopForegroundKey:
@@ -320,6 +328,18 @@ func (s Style) PaddingBottom(i int) Style {
320 return s
328 return s
321}
329}
322
330
331+// PaddingChar sets the character used for padding. This is useful for
332+// rendering blocks with a specific character, such as a space or a dot.
333+// Example of using [NBSP] as padding to prevent line breaks:
334+//
335+// ```go
336+// s := lipgloss.NewStyle().PaddingChar(lipgloss.NBSP)
337+// ```
338+func (s Style) PaddingChar(r rune) Style {
339+ s.set(paddingCharKey, r)
340+ return s
341+}
342+
323// ColorWhitespace determines whether or not the background color should be
343// ColorWhitespace determines whether or not the background color should be
324// applied to the padding. This is true by default as it's more than likely the
344// applied to the padding. This is true by default as it's more than likely the
325// desired and expected behavior, but it can be disabled for certain graphic
345// desired and expected behavior, but it can be disabled for certain graphic
@@ -390,6 +410,13 @@ func (s Style) MarginBackground(c color.Color) Style {
390 return s
410 return s
391}
411}
392
412
413+// MarginChar sets the character used for the margin. This is useful for
414+// rendering blocks with a specific character, such as a space or a dot.
415+func (s Style) MarginChar(r rune) Style {
416+ s.set(marginCharKey, r)
417+ return s
418+}
419+
393// Border is shorthand for setting the border style and which sides should
420// Border is shorthand for setting the border style and which sides should
394// have a border at once. The variadic argument sides works as follows:
421// have a border at once. The variadic argument sides works as follows:
395//
422//
vendor/github.com/charmbracelet/lipgloss/v2/style.go+18−8
@@ -10,7 +10,8 @@ import (
10)
10)
11
11
12const (
12const (
13+ // NBSP is the non-breaking space rune.
13− nbsp = '\u00A0'
14+ NBSP = '\u00A0'
14 tabWidthDefault = 4
15 tabWidthDefault = 4
15)
16)
16
17
@@ -44,6 +45,7 @@ const (
44 paddingRightKey
45 paddingRightKey
45 paddingBottomKey
46 paddingBottomKey
46 paddingLeftKey
47 paddingLeftKey
48+ paddingCharKey
47
49
48 // Margins.
50 // Margins.
49 marginTopKey
51 marginTopKey
@@ -51,6 +53,7 @@ const (
51 marginBottomKey
53 marginBottomKey
52 marginLeftKey
54 marginLeftKey
53 marginBackgroundKey
55 marginBackgroundKey
56+ marginCharKey
54
57
55 // Border runes.
58 // Border runes.
56 borderStyleKey
59 borderStyleKey
@@ -128,12 +131,14 @@ type Style struct {
128 paddingRight int
131 paddingRight int
129 paddingBottom int
132 paddingBottom int
130 paddingLeft int
133 paddingLeft int
134+ paddingChar rune
131
135
132 marginTop int
136 marginTop int
133 marginRight int
137 marginRight int
134 marginBottom int
138 marginBottom int
135 marginLeft int
139 marginLeft int
136 marginBgColor color.Color
140 marginBgColor color.Color
141+ marginChar rune
137
142
138 borderStyle Border
143 borderStyle Border
139 borderTopFgColor color.Color
144 borderTopFgColor color.Color
@@ -387,23 +392,24 @@ func (s Style) Render(strs ...string) string {
387
392
388 // Padding
393 // Padding
389 if !inline { //nolint:nestif
394 if !inline { //nolint:nestif
395+ padChar := s.paddingChar
396+ if padChar == 0 {
397+ padChar = ' '
398+ }
390 if leftPadding > 0 {
399 if leftPadding > 0 {
391 var st *ansi.Style
400 var st *ansi.Style
392 if colorWhitespace || styleWhitespace {
401 if colorWhitespace || styleWhitespace {
393 st = &teWhitespace
402 st = &teWhitespace
394 }
403 }
395− str = padLeft(str, leftPadding, st, nbsp)
404+ str = padLeft(str, leftPadding, st, padChar)
396 }
405 }
397
406
398− // XXX: We use a non-breaking space to pad so that the padding is
399− // preserved when the string is copied and pasted.
400−
401 if rightPadding > 0 {
407 if rightPadding > 0 {
402 var st *ansi.Style
408 var st *ansi.Style
403 if colorWhitespace || styleWhitespace {
409 if colorWhitespace || styleWhitespace {
404 st = &teWhitespace
410 st = &teWhitespace
405 }
411 }
406− str = padRight(str, rightPadding, st, nbsp)
412+ str = padRight(str, rightPadding, st, padChar)
407 }
413 }
408
414
409 if topPadding > 0 {
415 if topPadding > 0 {
@@ -494,8 +500,12 @@ func (s Style) applyMargins(str string, inline bool) string {
494 }
500 }
495
501
496 // Add left and right margin
502 // Add left and right margin
503+ marginChar := s.marginChar
504+ if marginChar == 0 {
505+ marginChar = ' '
506+ }
497− str = padLeft(str, leftMargin, &style, ' ')
507+ str = padLeft(str, leftMargin, &style, marginChar)
498− str = padRight(str, rightMargin, &style, ' ')
508+ str = padRight(str, rightMargin, &style, marginChar)
499
509
500 // Top/bottom margin
510 // Top/bottom margin
501 if !inline {
511 if !inline {
vendor/github.com/charmbracelet/lipgloss/v2/unset.go+7
@@ -96,6 +96,13 @@ func (s Style) UnsetPadding() Style {
96 s.unset(paddingRightKey)
96 s.unset(paddingRightKey)
97 s.unset(paddingTopKey)
97 s.unset(paddingTopKey)
98 s.unset(paddingBottomKey)
98 s.unset(paddingBottomKey)
99+ s.unset(paddingCharKey)
100+ return s
101+}
102+
103+// UnsetPaddingChar removes the padding character style rule, if set.
104+func (s Style) UnsetPaddingChar() Style {
105+ s.unset(paddingCharKey)
99 return s
106 return s
100}
107}
101
108
vendor/modules.txt+5−5
@@ -254,8 +254,8 @@ github.com/charmbracelet/bubbles/v2/spinner
254github.com/charmbracelet/bubbles/v2/textarea
254github.com/charmbracelet/bubbles/v2/textarea
255github.com/charmbracelet/bubbles/v2/textinput
255github.com/charmbracelet/bubbles/v2/textinput
256github.com/charmbracelet/bubbles/v2/viewport
256github.com/charmbracelet/bubbles/v2/viewport
257−# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891
257+# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595
258−## explicit; go 1.24.3
258+## explicit; go 1.24.0
259github.com/charmbracelet/bubbletea/v2
259github.com/charmbracelet/bubbletea/v2
260# github.com/charmbracelet/colorprofile v0.3.1
260# github.com/charmbracelet/colorprofile v0.3.1
261## explicit; go 1.23.0
261## explicit; go 1.23.0
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
269github.com/charmbracelet/glamour/v2/ansi
269github.com/charmbracelet/glamour/v2/ansi
270github.com/charmbracelet/glamour/v2/internal/autolink
270github.com/charmbracelet/glamour/v2/internal/autolink
271github.com/charmbracelet/glamour/v2/styles
271github.com/charmbracelet/glamour/v2/styles
272−# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093
272+# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
273## explicit; go 1.24.2
273## explicit; go 1.24.2
274github.com/charmbracelet/lipgloss/v2
274github.com/charmbracelet/lipgloss/v2
275github.com/charmbracelet/lipgloss/v2/table
275github.com/charmbracelet/lipgloss/v2/table
@@ -838,5 +838,5 @@ mvdan.cc/sh/v3/fileutil
838mvdan.cc/sh/v3/interp
838mvdan.cc/sh/v3/interp
839mvdan.cc/sh/v3/pattern
839mvdan.cc/sh/v3/pattern
840mvdan.cc/sh/v3/syntax
840mvdan.cc/sh/v3/syntax
841−# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891
841+# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595
842−# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093
842+# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb