on main help sign in
Merge pull request #95 from charmbracelet/onboarding

Rewrite Chat and Onboarding

commit4f145e5081efbe167ba6ae00b10cbe76276c696f authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 10 Jul 2025, 21:09 (1 year ago) parentdd50968cdb0bda
49 files changed+2302−1807
cmd/root.go+3−139 go.mod+8−14 go.sum+4−8 internal/app/app.go+143−28 internal/app/lsp.go+2−2 internal/config/config.go+88−5 internal/config/init.go+8 internal/config/load.go+55−36 internal/config/load_test.go+6−3 internal/fsext/fileutil.go+2−11 internal/llm/prompt/initialize.go+14 internal/tui/components/chat/chat.go+27−11 internal/tui/components/chat/editor/editor.go+27−11 internal/tui/components/chat/editor/keys.go+3−10 internal/tui/components/chat/header/header.go+10−7 internal/tui/components/chat/messages/messages.go+7 internal/tui/components/chat/messages/renderer.go+17−3 internal/tui/components/chat/messages/tool.go+2−2 internal/tui/components/chat/sidebar/sidebar.go+33−29 internal/tui/components/chat/splash/keys.go+38−3 internal/tui/components/chat/splash/splash.go+564−19 internal/tui/components/completions/completions.go+7−3 internal/tui/components/completions/item.go+1 internal/tui/components/completions/keys.go+1−1 internal/tui/components/core/helpers.go → internal/tui/components/core/core.go+59 internal/tui/components/core/layout/container.go−263 internal/tui/components/core/layout/layout.go+2−5 internal/tui/components/core/layout/split.go−380 internal/tui/components/core/list/keys.go+10−1 internal/tui/components/core/list/list.go+10 internal/tui/components/core/status/status.go+1−2 internal/tui/components/dialogs/commands/commands.go+2−9 internal/tui/components/dialogs/commands/item.go+16−4 internal/tui/components/dialogs/init/init.go−214 internal/tui/components/dialogs/init/keys.go−69 internal/tui/components/dialogs/models/apikey.go+96 internal/tui/components/dialogs/models/list.go+202 internal/tui/components/dialogs/models/models.go+19−134 internal/tui/components/dialogs/permissions/permissions.go+12−14 internal/tui/components/logo/logo.go+11−2 internal/tui/keys.go+2−61 internal/tui/page/chat/chat.go+673−262 internal/tui/tui.go+12−25 vendor/github.com/charmbracelet/bubbletea/v2/tea.go+15−4 vendor/github.com/charmbracelet/lipgloss/v2/get.go+33 vendor/github.com/charmbracelet/lipgloss/v2/set.go+27 vendor/github.com/charmbracelet/lipgloss/v2/style.go+18−8 vendor/github.com/charmbracelet/lipgloss/v2/unset.go+7 vendor/modules.txt+5−5
cmd/root.go+3−139
@@ -6,7 +6,6 @@ import (
66 "io"
77 "log/slog"
88 "os"
9− "sync"
109 "time"
1110
1211 tea "github.com/charmbracelet/bubbletea/v2"
@@ -16,7 +15,6 @@ import (
1615 "github.com/charmbracelet/crush/internal/format"
1716 "github.com/charmbracelet/crush/internal/llm/agent"
1817 "github.com/charmbracelet/crush/internal/log"
19− "github.com/charmbracelet/crush/internal/pubsub"
2018 "github.com/charmbracelet/crush/internal/tui"
2119 "github.com/charmbracelet/crush/internal/version"
2220 "github.com/charmbracelet/fang"
@@ -124,76 +122,17 @@ to assist developers in writing, debugging, and understanding code directly from
124122 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
125123 )
126124
127− // Setup the subscriptions, this will send services events to the TUI
128− ch, cancelSubs := setupSubscriptions(app, ctx)
125+ go app.Subscribe(program)
129126
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()
176− cleanup()
177−
178− if err != nil {
127+ if _, err := program.Run(); err != nil {
179128 slog.Error(fmt.Sprintf("TUI run error: %v", err))
180129 return fmt.Errorf("TUI error: %v", err)
181130 }
182−
183− slog.Info(fmt.Sprintf("TUI exited with result: %v", result))
131+ app.Shutdown()
184132 return nil
185133 },
186134}
187135
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−
197136func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
198137 go func() {
199138 defer log.RecoverPanic("MCP-goroutine", nil)
@@ -208,81 +147,6 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
208147 }()
209148}
210149
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−
286150func Execute() {
287151 if err := fang.Execute(
288152 context.Background(),
go.mod+8−14
@@ -2,9 +2,9 @@ module github.com/charmbracelet/crush
22
33go 1.24.3
44
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
66
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
88
99require (
1010 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -43,22 +43,12 @@ require (
4343 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
4444 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
4545 github.com/stretchr/testify v1.10.0
46+ github.com/tidwall/sjson v1.2.5
4647 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
4748 gopkg.in/natefinch/lumberjack.v2 v2.2.1
4849 mvdan.cc/sh/v3 v3.11.0
4950)
5051
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−
6252require (
6353 cloud.google.com/go v0.116.0 // indirect
6454 cloud.google.com/go/auth v0.13.0 // indirect
@@ -85,15 +75,18 @@ require (
8575 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
8676 github.com/aymerick/douceur v0.2.0 // indirect
8777 github.com/charmbracelet/colorprofile v0.3.1 // indirect
78+ github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect
8879 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
8980 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
9081 github.com/charmbracelet/x/term v0.2.1
82+ github.com/charmbracelet/x/termios v0.1.1 // indirect
9183 github.com/charmbracelet/x/windows v0.2.1 // indirect
9284 github.com/davecgh/go-spew v1.1.1 // indirect
9385 github.com/disintegration/gift v1.1.2 // indirect
9486 github.com/dlclark/regexp2 v1.11.4 // indirect
9587 github.com/dustin/go-humanize v1.0.1 // indirect
9688 github.com/felixge/httpsnoop v1.0.4 // indirect
89+ github.com/go-logfmt/logfmt v0.6.0 // indirect
9790 github.com/go-logr/logr v1.4.2 // indirect
9891 github.com/go-logr/stdr v1.2.2 // indirect
9992 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
@@ -121,12 +114,12 @@ require (
121114 github.com/rivo/uniseg v0.4.7
122115 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
123116 github.com/sethvargo/go-retry v0.3.0 // indirect
117+ github.com/spf13/cast v1.7.1 // indirect
124118 github.com/spf13/pflag v1.0.6 // indirect
125119 github.com/tetratelabs/wazero v1.9.0 // indirect
126120 github.com/tidwall/gjson v1.18.0 // indirect
127121 github.com/tidwall/match v1.1.1 // indirect
128122 github.com/tidwall/pretty v1.2.1 // indirect
129− github.com/tidwall/sjson v1.2.5 // indirect
130123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
131124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
132125 github.com/yuin/goldmark v1.7.8 // indirect
@@ -148,5 +141,6 @@ require (
148141 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
149142 google.golang.org/grpc v1.71.0 // indirect
150143 google.golang.org/protobuf v1.36.6 // indirect
144+ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
151145 gopkg.in/yaml.v3 v3.0.1 // indirect
152146)
go.sum+4−8
@@ -68,20 +68,18 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
6868github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
6969github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
7070github.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=
7371github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM=
7472github.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=
76−github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891/go.mod h1:SwBB+WoaQVMMOM9hknbN/7FNT86kgKG0LSHGTmLphX8=
73+github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 h1:wLMjzOqrwoM7Em9UR9sGbn4375G8WuxcwFB3kjZiqHo=
74+github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
7775github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
7876github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
7977github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
8078github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
8179github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
8280github.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=
84−github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093/go.mod h1:XmxjFJcMEfYIHa4Mw4ra+uMjploDkTlkKIs7wLt9v4Q=
81+github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=
82+github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb/go.mod h1:wEc/TRrTAIDJYjVCg3+y8WeKaN+F88gpYfGbUuP6W3A=
8583github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
8684github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
8785github.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
9088github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
9189github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
9290github.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=
9591github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
9692github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
9793github.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 (
1010 "sync"
1111 "time"
1212
13+ tea "github.com/charmbracelet/bubbletea/v2"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/db"
1516 "github.com/charmbracelet/crush/internal/format"
1617 "github.com/charmbracelet/crush/internal/history"
1718 "github.com/charmbracelet/crush/internal/llm/agent"
19+ "github.com/charmbracelet/crush/internal/log"
20+ "github.com/charmbracelet/crush/internal/pubsub"
1821
1922 "github.com/charmbracelet/crush/internal/lsp"
2023 "github.com/charmbracelet/crush/internal/message"
@@ -36,9 +39,18 @@ type App struct {
3639
3740 watcherCancelFuncs []context.CancelFunc
3841 cancelFuncsMutex sync.Mutex
39− watcherWG sync.WaitGroup
42+ lspWatcherWG sync.WaitGroup
4043
4144 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()
4254}
4355
4456func 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) {
5365 History: files,
5466 Permissions: permission.NewPermissionService(cfg.WorkingDir()),
5567 LSPClients: make(map[string]*lsp.Client),
56− config: cfg,
68+
69+ globalCtx: ctx,
70+
71+ config: cfg,
72+
73+ events: make(chan tea.Msg, 100),
74+ serviceEventsWG: &sync.WaitGroup{},
75+ tuiWG: &sync.WaitGroup{},
5776 }
5877
78+ app.setupEvents()
79+
5980 // Initialize LSP clients in the background
6081 go app.initLSPClients(ctx)
6182
6283 // TODO: remove the concept of agent config most likely
63− coderAgentCfg := cfg.Agents["coder"]
64− if coderAgentCfg.ID == "" {
65− return nil, fmt.Errorf("coder agent configuration is missing")
66− }
67−
68− var err error
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
84+ if cfg.IsConfigured() {
85+ if err := app.InitCoderAgent(); err != nil {
86+ return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
87+ }
88+ } else {
89+ slog.Warn("No agent configuration found")
8090 }
81−
8291 return app, nil
8392}
8493
@@ -147,32 +156,138 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
147156 return nil
148157}
149158
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+
150263// Shutdown performs a clean shutdown of the application
151264func (app *App) Shutdown() {
152− // Cancel all watcher goroutines
153265 app.cancelFuncsMutex.Lock()
154266 for _, cancel := range app.watcherCancelFuncs {
155267 cancel()
156268 }
157269 app.cancelFuncsMutex.Unlock()
158− app.watcherWG.Wait()
270+ app.lspWatcherWG.Wait()
159271
160− // Perform additional cleanup for LSP clients
161272 app.clientsMutex.RLock()
162273 clients := make(map[string]*lsp.Client, len(app.LSPClients))
163274 maps.Copy(clients, app.LSPClients)
164275 app.clientsMutex.RUnlock()
165276
166277 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)
168279 if err := client.Shutdown(shutdownCtx); err != nil {
169280 slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
170281 }
171282 cancel()
172283 }
173− app.CoderAgent.CancelAll()
174−}
284+ if app.CoderAgent != nil {
285+ app.CoderAgent.CancelAll()
286+ }
175287
176−func (app *App) UpdateAgentModel() error {
177− return app.CoderAgent.UpdateModel()
288+ for _, cleanup := range app.cleanupFuncs {
289+ if cleanup != nil {
290+ cleanup()
291+ }
292+ }
178293}
internal/app/lsp.go+2−2
@@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
7171 app.cancelFuncsMutex.Unlock()
7272
7373 // Add the watcher to a WaitGroup to track active goroutines
74− app.watcherWG.Add(1)
74+ app.lspWatcherWG.Add(1)
7575
7676 // Add to map with mutex protection before starting goroutine
7777 app.clientsMutex.Lock()
@@ -83,7 +83,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
8383
8484// runWorkspaceWatcher executes the workspace watcher for an LSP client
8585func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
86− defer app.watcherWG.Done()
86+ defer app.lspWatcherWG.Done()
8787 defer log.RecoverPanic("LSP-"+name, func() {
8888 // Try to restart the client
8989 app.restartLSPClient(ctx, name)
internal/config/config.go+88−5
@@ -2,10 +2,12 @@ package config
22
33import (
44 "fmt"
5+ "os"
56 "slices"
67 "strings"
78
89 "github.com/charmbracelet/crush/internal/fur/provider"
10+ "github.com/tidwall/sjson"
911)
1012
1113const (
@@ -58,6 +60,8 @@ type SelectedModel struct {
5860type ProviderConfig struct {
5961 // The provider's id.
6062 ID string `json:"id,omitempty"`
63+ // The provider's name, used for display purposes.
64+ Name string `json:"name,omitempty"`
6165 // The provider's API endpoint.
6266 BaseURL string `json:"base_url,omitempty"`
6367 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
@@ -207,7 +211,9 @@ type Config struct {
207211 // TODO: most likely remove this concept when I come back to it
208212 Agents map[string]Agent `json:"-"`
209213 // 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:"-"`
211217}
212218
213219func (c *Config) WorkingDir() string {
@@ -275,6 +281,14 @@ func (c *Config) SmallModel() *provider.Model {
275281 return c.GetModel(model.Provider, model.Model)
276282}
277283
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+
278292func (c *Config) Resolve(key string) (string, error) {
279293 if c.resolver == nil {
280294 return "", fmt.Errorf("no variable resolver configured")
@@ -282,9 +296,78 @@ func (c *Config) Resolve(key string) (string, error) {
282296 return c.resolver.ResolveValue(key)
283297}
284298
285−// TODO: maybe handle this better
286−func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
287− cfg := Get()
288− cfg.Models[modelType] = model
299+func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
300+ c.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
289372 return nil
290373}
internal/config/init.go+8
@@ -103,3 +103,11 @@ func MarkProjectInitialized() error {
103103
104104 return nil
105105}
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) {
3737 // uses default config paths
3838 configPaths := []string{
3939 globalConfig(),
40− globalConfigData(),
40+ GlobalConfigData(),
4141 filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
4242 filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
4343 }
@@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) {
4646 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
4747 }
4848
49+ cfg.dataConfigDir = GlobalConfigData()
50+
4951 cfg.setDefaults(workingDir)
5052
5153 if debug {
@@ -63,6 +65,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
6365 if err != nil || len(providers) == 0 {
6466 return nil, fmt.Errorf("failed to load providers: %w", err)
6567 }
68+ cfg.knownProviders = providers
6669
6770 env := env.New()
6871 // Configure providers
@@ -162,6 +165,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
162165 }
163166 prepared := ProviderConfig{
164167 ID: string(p.ID),
168+ Name: p.Name,
165169 BaseURL: p.APIEndpoint,
166170 APIKey: p.APIKey,
167171 Type: p.Type,
@@ -218,6 +222,9 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
218222
219223 // Make sure the provider ID is set
220224 providerConfig.ID = id
225+ if providerConfig.Name == "" {
226+ providerConfig.Name = id // Use ID as name if not set
227+ }
221228 // default to OpenAI if not set
222229 if providerConfig.Type == "" {
223230 providerConfig.Type = provider.TypeOpenAI
@@ -229,9 +236,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn
229236 continue
230237 }
231238 if providerConfig.APIKey == "" {
232− slog.Warn("Skipping custom provider due to missing API key", "provider", id)
233− delete(cfg.Providers, id)
234− continue
239+ slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
235240 }
236241 if providerConfig.BaseURL == "" {
237242 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
251256
252257 apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
253258 if apiKey == "" || err != nil {
254− slog.Warn("Skipping custom provider due to missing API key", "provider", id, "error", err)
255− delete(cfg.Providers, id)
256− continue
259+ slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
257260 }
258261 baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
259262 if baseURL == "" || err != nil {
@@ -369,10 +372,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
369372}
370373
371374func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error {
372− large, small, err := cfg.defaultModelSelection(knownProviders)
375+ defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders)
373376 if err != nil {
374377 return fmt.Errorf("failed to select default models: %w", err)
375378 }
379+ large, small := defaultLarge, defaultSmall
376380
377381 largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
378382 if largeModelConfigured {
@@ -383,18 +387,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
383387 large.Provider = largeModelSelected.Provider
384388 }
385389 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)
386392 if model == nil {
387− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
388− }
389− if largeModelSelected.MaxTokens > 0 {
390− large.MaxTokens = largeModelSelected.MaxTokens
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)
398+ }
391399 } else {
392− large.MaxTokens = model.DefaultMaxTokens
393− }
394− if largeModelSelected.ReasoningEffort != "" {
395− large.ReasoningEffort = largeModelSelected.ReasoningEffort
400+ if largeModelSelected.MaxTokens > 0 {
401+ large.MaxTokens = largeModelSelected.MaxTokens
402+ } else {
403+ large.MaxTokens = model.DefaultMaxTokens
404+ }
405+ if largeModelSelected.ReasoningEffort != "" {
406+ large.ReasoningEffort = largeModelSelected.ReasoningEffort
407+ }
408+ large.Think = largeModelSelected.Think
396409 }
397− large.Think = largeModelSelected.Think
398410 }
399411 smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
400412 if smallModelConfigured {
@@ -407,25 +419,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
407419
408420 model := cfg.GetModel(small.Provider, small.Model)
409421 if model == nil {
410− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
411− }
412− if smallModelSelected.MaxTokens > 0 {
413− small.MaxTokens = smallModelSelected.MaxTokens
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)
427+ }
414428 } else {
415− small.MaxTokens = model.DefaultMaxTokens
429+ if smallModelSelected.MaxTokens > 0 {
430+ small.MaxTokens = smallModelSelected.MaxTokens
431+ } else {
432+ small.MaxTokens = model.DefaultMaxTokens
433+ }
434+ small.ReasoningEffort = smallModelSelected.ReasoningEffort
435+ small.Think = smallModelSelected.Think
416436 }
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)
429437 }
430438 cfg.Models[SelectedModelTypeLarge] = large
431439 cfg.Models[SelectedModelTypeSmall] = small
@@ -512,9 +520,9 @@ func globalConfig() string {
512520 return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
513521}
514522
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.
516524// this config is used when the app overrides configurations instead of updating the global config.
517−func globalConfigData() string {
525+func GlobalConfigData() string {
518526 xdgDataHome := os.Getenv("XDG_DATA_HOME")
519527 if xdgDataHome != "" {
520528 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
@@ -533,3 +541,14 @@ func globalConfigData() string {
533541
534542 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
535543}
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) {
484484}
485485
486486func 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) {
488488 cfg := &Config{
489489 Providers: map[string]ProviderConfig{
490490 "custom": {
@@ -493,6 +493,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
493493 ID: "test-model",
494494 }},
495495 },
496+ "openai": {
497+ APIKey: "$MISSING",
498+ },
496499 },
497500 }
498501 cfg.setDefaults("/tmp")
@@ -502,9 +505,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
502505 err := cfg.configureProviders(env, resolver, []provider.Provider{})
503506 assert.NoError(t, err)
504507
505− assert.Len(t, cfg.Providers, 0)
508+ assert.Len(t, cfg.Providers, 1)
506509 _, exists := cfg.Providers["custom"]
507− assert.False(t, exists)
510+ assert.True(t, exists)
508511 })
509512
510513 t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
internal/fsext/fileutil.go+2−11
@@ -17,23 +17,14 @@ import (
1717 ignore "github.com/sabhiram/go-gitignore"
1818)
1919
20−var (
21− rgPath string
22− fzfPath string
23−)
20+var rgPath string
2421
2522func init() {
2623 var err error
2724 rgPath, err = exec.LookPath("rg")
2825 if err != nil {
2926 if log.Initialized() {
30− slog.Warn("Ripgrep (rg) not found in $PATH. Some 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.")
27+ slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
3728 }
3829 }
3930}
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 (
1414 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
1515 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1616 "github.com/charmbracelet/crush/internal/tui/components/core/list"
17+ "github.com/charmbracelet/crush/internal/tui/styles"
1718 "github.com/charmbracelet/crush/internal/tui/util"
18− "github.com/charmbracelet/lipgloss/v2"
1919)
2020
2121type SendMsg struct {
@@ -37,6 +37,9 @@ type MessageListCmp interface {
3737 util.Model
3838 layout.Sizeable
3939 layout.Focusable
40+ layout.Help
41+
42+ SetSession(session.Session) tea.Cmd
4043}
4144
4245// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -53,9 +56,9 @@ type messageListCmp struct {
5356 defaultListKeyMap list.KeyMap
5457}
5558
56−// NewMessagesListCmp creates a new message list component with custom keybindings
59+// New creates a new message list component with custom keybindings
5760// and reverse ordering (newest messages at bottom).
58−func NewMessagesListCmp(app *app.App) MessageListCmp {
61+func New(app *app.App) MessageListCmp {
5962 defaultListKeyMap := list.DefaultKeyMap()
6063 listCmp := list.New(
6164 list.WithGapSize(1),
@@ -70,7 +73,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
7073 }
7174}
7275
73−// Init initializes the component (no initialization needed).
76+// Init initializes the component.
7477func (m *messageListCmp) Init() tea.Cmd {
7578 return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
7679}
@@ -102,16 +105,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102105
103106// View renders the message list or an initial screen if empty.
104107func (m *messageListCmp) View() string {
105− return lipgloss.JoinVertical(
106− lipgloss.Left,
107− m.listCmp.View(),
108− )
108+ t := styles.CurrentTheme()
109+ return t.S().Base.
110+ Padding(1).
111+ Width(m.width).
112+ Height(m.height).
113+ Render(
114+ m.listCmp.View(),
115+ )
109116}
110117
111118// handleChildSession handles messages from child sessions (agent tools).
112119func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
113120 var cmds []tea.Cmd
114− if len(event.Payload.ToolCalls()) == 0 {
121+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
115122 return nil
116123 }
117124 items := m.listCmp.Items()
@@ -151,6 +158,15 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
151158 )
152159 }
153160 }
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+
154170 toolCall.SetNestedToolCalls(nestedToolCalls)
155171 m.listCmp.UpdateItem(
156172 toolCallInx,
@@ -487,8 +503,8 @@ func (m *messageListCmp) GetSize() (int, int) {
487503// SetSize updates the component dimensions and propagates to the list component.
488504func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
489505 m.width = width
490− m.height = height - 1
491− return m.listCmp.SetSize(width, height-1)
506+ m.height = height
507+ return m.listCmp.SetSize(width-2, height-2) // for padding
492508}
493509
494510// Blur implements MessageListCmp.
internal/tui/components/chat/editor/editor.go+27−11
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/session"
1919 "github.com/charmbracelet/crush/internal/tui/components/chat"
2020 "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
2122 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
2223 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
2324 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -26,6 +27,18 @@ import (
2627 "github.com/charmbracelet/lipgloss/v2"
2728)
2829
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+
2942type FileCompletionItem struct {
3043 Path string // The file path
3144}
@@ -149,14 +162,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149162 var cmd tea.Cmd
150163 var cmds []tea.Cmd
151164 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
160165 case filepicker.FilePickedMsg:
161166 if len(m.attachments) >= maxAttachments {
162167 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) {
263268 value := m.textarea.Value()
264269 if len(value) > 0 && value[len(value)-1] == '\\' {
265270 // If the last character is a backslash, remove it and add a newline
266− m.textarea.SetValue(value[:len(value)-1] + "\n")
267− return m, nil
271+ m.textarea.SetValue(value[:len(value)-1])
268272 } else {
269273 // Otherwise, send the message
270274 return m, m.send()
@@ -387,7 +391,18 @@ func (c *editorCmp) Bindings() []key.Binding {
387391 return c.keyMap.KeyBindings()
388392}
389393
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 {
391406 t := styles.CurrentTheme()
392407 ta := textarea.New()
393408 ta.SetStyles(t.S().TextArea)
@@ -408,6 +423,7 @@ func NewEditorCmp(app *app.App) util.Model {
408423 ta.Focus()
409424
410425 return &editorCmp{
426+ // TODO: remove the app instance from here
411427 app: app,
412428 textarea: ta,
413429 keyMap: DefaultEditorKeyMap(),
internal/tui/components/chat/editor/keys.go+3−10
@@ -2,7 +2,6 @@ package editor
22
33import (
44 "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
65)
76
87type EditorKeyMap struct {
@@ -10,8 +9,6 @@ type EditorKeyMap struct {
109 SendMessage key.Binding
1110 OpenEditor key.Binding
1211 Newline key.Binding
13−
14− keyboard tea.KeyboardEnhancementsMsg
1512}
1613
1714func DefaultEditorKeyMap() EditorKeyMap {
@@ -25,8 +22,8 @@ func DefaultEditorKeyMap() EditorKeyMap {
2522 key.WithHelp("enter", "send"),
2623 ),
2724 OpenEditor: key.NewBinding(
28− key.WithKeys("ctrl+e"),
29− key.WithHelp("ctrl+e", "open editor"),
25+ key.WithKeys("ctrl+v"),
26+ key.WithHelp("ctrl+v", "open editor"),
3027 ),
3128 Newline: key.NewBinding(
3229 key.WithKeys("shift+enter", "ctrl+j"),
@@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap {
4037
4138// KeyBindings implements layout.KeyMapProvider
4239func (k EditorKeyMap) KeyBindings() []key.Binding {
43− newline := k.Newline
44− if k.keyboard.SupportsKeyDisambiguation() {
45− newline.SetHelp("shift+enter", newline.Help().Desc)
46− }
4740 return []key.Binding{
4841 k.AddFile,
4942 k.SendMessage,
5043 k.OpenEditor,
51− newline,
44+ k.Newline,
5245 }
5346}
5447
internal/tui/components/chat/header/header.go+10−7
@@ -11,7 +11,6 @@ import (
1111 "github.com/charmbracelet/crush/internal/lsp/protocol"
1212 "github.com/charmbracelet/crush/internal/pubsub"
1313 "github.com/charmbracelet/crush/internal/session"
14− "github.com/charmbracelet/crush/internal/tui/components/chat"
1514 "github.com/charmbracelet/crush/internal/tui/styles"
1615 "github.com/charmbracelet/crush/internal/tui/util"
1716 "github.com/charmbracelet/lipgloss/v2"
@@ -19,7 +18,8 @@ import (
1918
2019type Header interface {
2120 util.Model
22− SetSession(session session.Session)
21+ SetSession(session session.Session) tea.Cmd
22+ SetWidth(width int) tea.Cmd
2323 SetDetailsOpen(open bool)
2424}
2525
@@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd {
4343
4444func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4545 switch msg := msg.(type) {
46− case tea.WindowSizeMsg:
47− p.width = msg.Width - 2
48− case chat.SessionSelectedMsg:
49− p.session = msg
5046 case pubsub.Event[session.Session]:
5147 if msg.Type == pubsub.UpdatedEvent {
5248 if p.session.ID == msg.Payload.ID {
@@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) {
131127}
132128
133129// SetSession implements Header.
134−func (h *header) SetSession(session session.Session) {
130+func (h *header) SetSession(session session.Session) tea.Cmd {
135131 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
136139}
internal/tui/components/chat/messages/messages.go+7
@@ -10,6 +10,7 @@ import (
1010 "github.com/charmbracelet/lipgloss/v2"
1111
1212 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fur/provider"
1314 "github.com/charmbracelet/crush/internal/message"
1415 "github.com/charmbracelet/crush/internal/tui/components/anim"
1516 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() string {
296297 infoMsg := t.S().Subtle.Render(duration.String())
297298 icon := t.S().Subtle.Render(styles.ModelIcon)
298299 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+ }
299306 modelFormatted := t.S().Muted.Render(model.Model)
300307 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
301308 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 {
112112}
113113
114114// 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 {
116116 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+ }
117127 tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
118− return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
128+ prefix := fmt.Sprintf("%s %s ", icon, tool)
129+ return prefix + renderParamList(true, width-lipgloss.Width(tool), params...)
119130}
120131
121132// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
@@ -542,7 +553,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
542553
543554 if v.result.ToolCallID == "" {
544555 v.spinning = true
545− parts = append(parts, v.anim.View())
556+ parts = append(parts, "", v.anim.View())
546557 } else {
547558 v.spinning = false
548559 }
@@ -634,6 +645,9 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
634645
635646func joinHeaderBody(header, body string) string {
636647 t := styles.CurrentTheme()
648+ if body == "" {
649+ return header
650+ }
637651 body = t.S().Base.PaddingLeft(2).Render(body)
638652 return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
639653}
internal/tui/components/chat/messages/tool.go+2−2
@@ -219,11 +219,11 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
219219// renderPending displays the tool name with a loading animation for pending tool calls
220220func (m *toolCallCmp) renderPending() string {
221221 t := styles.CurrentTheme()
222+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
222223 if m.isNested {
223224 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())
225226 }
226− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
227227 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
228228 return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
229229}
internal/tui/components/chat/sidebar/sidebar.go+33−29
@@ -13,7 +13,6 @@ import (
1313 "github.com/charmbracelet/crush/internal/diff"
1414 "github.com/charmbracelet/crush/internal/fsext"
1515 "github.com/charmbracelet/crush/internal/history"
16−
1716 "github.com/charmbracelet/crush/internal/lsp"
1817 "github.com/charmbracelet/crush/internal/lsp/protocol"
1918 "github.com/charmbracelet/crush/internal/pubsub"
@@ -29,10 +28,6 @@ import (
2928 "github.com/charmbracelet/x/ansi"
3029)
3130
32−const (
33− logoBreakpoint = 65
34−)
35−
3631type FileHistory struct {
3732 initialVersion history.File
3833 latestVersion history.File
@@ -52,6 +47,7 @@ type Sidebar interface {
5247 util.Model
5348 layout.Sizeable
5449 SetSession(session session.Session) tea.Cmd
50+ SetCompactMode(bool)
5551}
5652
5753type sidebarCmp struct {
@@ -66,7 +62,7 @@ type sidebarCmp struct {
6662 files sync.Map
6763}
6864
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 {
7066 return &sidebarCmp{
7167 lspClients: lspClients,
7268 history: history,
@@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
7571}
7672
7773func (m *sidebarCmp) Init() tea.Cmd {
78− m.logo = m.logoBlock(false)
79− m.cwd = cwd()
8074 return nil
8175}
8276
8377func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8478 switch msg := msg.(type) {
85− case chat.SessionSelectedMsg:
86− return m, m.SetSession(msg)
8779 case SessionFilesMsg:
8880 m.files = sync.Map{}
8981 for _, file := range msg.Files {
@@ -137,7 +129,16 @@ func (m *sidebarCmp) View() string {
137129 m.mcpBlock(),
138130 )
139131
140− return lipgloss.JoinVertical(lipgloss.Left, parts...)
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+ lipgloss.JoinVertical(lipgloss.Left, parts...),
141+ )
141142}
142143
143144func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
@@ -230,12 +231,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
230231}
231232
232233func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233− if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234− m.logo = m.logoBlock(true)
235− } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
236− m.logo = m.logoBlock(false)
237− }
238−
234+ m.logo = m.logoBlock()
235+ m.cwd = cwd()
239236 m.width = width
240237 m.height = height
241238 return nil
@@ -245,23 +242,27 @@ func (m *sidebarCmp) GetSize() (int, int) {
245242 return m.width, m.height
246243}
247244
248−func (m *sidebarCmp) logoBlock(compact bool) string {
245+func (m *sidebarCmp) logoBlock() string {
249246 t := styles.CurrentTheme()
250− return logo.Render(version.Version, compact, logo.Opts{
247+ return logo.Render(version.Version, true, logo.Opts{
251248 FieldColor: t.Primary,
252249 TitleColorA: t.Secondary,
253250 TitleColorB: t.Primary,
254251 CharmColor: t.Secondary,
255252 VersionColor: t.Primary,
253+ Width: m.width - 2,
256254 })
257255}
258256
257+func (m *sidebarCmp) getMaxWidth() int {
258+ return min(m.width-2, 58) // -2 for padding
259+}
260+
259261func (m *sidebarCmp) filesBlock() string {
260− maxWidth := min(m.width, 58)
261262 t := styles.CurrentTheme()
262263
263264 section := t.S().Subtle.Render(
264− core.Section("Modified Files", maxWidth),
265+ core.Section("Modified Files", m.getMaxWidth()),
265266 )
266267
267268 files := make([]SessionFile, 0)
@@ -302,7 +303,7 @@ func (m *sidebarCmp) filesBlock() string {
302303 filePath := file.FilePath
303304 filePath = strings.TrimPrefix(filePath, cwd)
304305 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, "…")
306307 fileList = append(fileList,
307308 core.Status(
308309 core.StatusOpts{
@@ -311,7 +312,7 @@ func (m *sidebarCmp) filesBlock() string {
311312 Title: filePath,
312313 ExtraContent: extraContent,
313314 },
314− m.width,
315+ m.getMaxWidth(),
315316 ),
316317 )
317318 }
@@ -323,11 +324,10 @@ func (m *sidebarCmp) filesBlock() string {
323324}
324325
325326func (m *sidebarCmp) lspBlock() string {
326− maxWidth := min(m.width, 58)
327327 t := styles.CurrentTheme()
328328
329329 section := t.S().Subtle.Render(
330− core.Section("LSPs", maxWidth),
330+ core.Section("LSPs", m.getMaxWidth()),
331331 )
332332
333333 lspList := []string{section, ""}
@@ -385,7 +385,7 @@ func (m *sidebarCmp) lspBlock() string {
385385 Description: l.LSP.Command,
386386 ExtraContent: strings.Join(errs, " "),
387387 },
388− m.width,
388+ m.getMaxWidth(),
389389 ),
390390 )
391391 }
@@ -397,11 +397,10 @@ func (m *sidebarCmp) lspBlock() string {
397397}
398398
399399func (m *sidebarCmp) mcpBlock() string {
400− maxWidth := min(m.width, 58)
401400 t := styles.CurrentTheme()
402401
403402 section := t.S().Subtle.Render(
404− core.Section("MCPs", maxWidth),
403+ core.Section("MCPs", m.getMaxWidth()),
405404 )
406405
407406 mcpList := []string{section, ""}
@@ -428,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string {
428427 Title: l.Name,
429428 Description: l.MCP.Command,
430429 },
431− m.width,
430+ m.getMaxWidth(),
432431 ),
433432 )
434433 }
@@ -511,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
511510 return m.loadSessionFiles
512511}
513512
513+// SetCompactMode sets the compact mode for the sidebar.
514+func (m *sidebarCmp) SetCompactMode(compact bool) {
515+ m.compactMode = compact
516+}
517+
514518func cwd() string {
515519 cwd := config.Get().WorkingDir()
516520 t := styles.CurrentTheme()
internal/tui/components/chat/splash/keys.go+38−3
@@ -5,14 +5,49 @@ import (
55)
66
77type KeyMap struct {
8− Cancel key.Binding
8+ Select,
9+ Next,
10+ Previous,
11+ Yes,
12+ No,
13+ Tab,
14+ LeftRight,
15+ Back key.Binding
916}
1017
1118func DefaultKeyMap() KeyMap {
1219 return KeyMap{
13− Cancel: key.NewBinding(
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+ ),
48+ Back: key.NewBinding(
1449 key.WithKeys("esc"),
15− key.WithHelp("esc", "cancel"),
50+ key.WithHelp("esc", "back"),
1651 ),
1752 }
1853}
internal/tui/components/chat/splash/splash.go+564−19
@@ -1,9 +1,22 @@
11package splash
22
33import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strings"
8+
49 "github.com/charmbracelet/bubbles/v2/key"
510 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"
617 "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"
720 "github.com/charmbracelet/crush/internal/tui/components/logo"
821 "github.com/charmbracelet/crush/internal/tui/styles"
922 "github.com/charmbracelet/crush/internal/tui/util"
@@ -15,23 +28,98 @@ type Splash interface {
1528 util.Model
1629 layout.Sizeable
1730 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
1839}
1940
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+
2050type splashCmp struct {
2151 width, height int
2252 keyMap KeyMap
2353 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
2465}
2566
2667func 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+
2784 return &splashCmp{
2885 width: 0,
2986 height: 0,
30− keyMap: DefaultKeyMap(),
87+ keyMap: keyMap,
3188 logoRendered: "",
89+ modelList: modelList,
90+ apiKeyInput: apiKeyInput,
91+ selectedNo: false,
3292 }
3393}
3494
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+
35123// GetSize implements SplashPage.
36124func (s *splashCmp) GetSize() (int, int) {
37125 return s.width, s.height
@@ -39,15 +127,20 @@ func (s *splashCmp) GetSize() (int, int) {
39127
40128// Init implements SplashPage.
41129func (s *splashCmp) Init() tea.Cmd {
42− return nil
130+ return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
43131}
44132
45133// SetSize implements SplashPage.
46134func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
47− s.width = width
48135 s.height = height
49− s.logoRendered = s.logoBlock()
50− return nil
136+ if width != s.width {
137+ s.width = width
138+ s.logoRendered = s.logoBlock()
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)
51144}
52145
53146// Update implements SplashPage.
@@ -55,32 +148,484 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55148 switch msg := msg.(type) {
56149 case tea.WindowSizeMsg:
57150 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+ }
58220 }
59221 return s, nil
60222}
61223
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+
63340func (s *splashCmp) View() string {
64− content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
65− return content
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(
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).
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+ )
66466}
67467
68468func (s *splashCmp) logoBlock() string {
69469 t := styles.CurrentTheme()
70− const padding = 2
71− return logo.Render(version.Version, false, logo.Opts{
72− FieldColor: t.Primary,
73− TitleColorA: t.Secondary,
74− TitleColorB: t.Primary,
75− CharmColor: t.Secondary,
76− VersionColor: t.Primary,
77− Width: s.width - padding,
78− })
470+ return t.S().Base.Padding(0, 2).Width(s.width).Render(
471+ logo.Render(version.Version, false, logo.Opts{
472+ FieldColor: t.Primary,
473+ TitleColorA: t.Secondary,
474+ TitleColorB: t.Primary,
475+ CharmColor: t.Secondary,
476+ VersionColor: t.Primary,
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
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
79509}
80510
81511// Bindings implements SplashPage.
82512func (s *splashCmp) Bindings() []key.Binding {
83− return []key.Binding{
84− s.keyMap.Cancel,
513+ if s.needsAPIKey {
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{
526+ s.keyMap.Select,
527+ s.keyMap.Yes,
528+ s.keyMap.No,
529+ s.keyMap.Tab,
530+ s.keyMap.LeftRight,
531+ }
85532 }
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
86631}
internal/tui/components/completions/completions.go+7−3
@@ -70,8 +70,8 @@ func New() Completions {
7070 list.WithHideFilterInput(true),
7171 )
7272 return &completionsCmp{
73− width: 30,
74− height: 10,
73+ width: 0,
74+ height: 0,
7575 list: l,
7676 query: "",
7777 keyMap: completionsKeyMap,
@@ -89,6 +89,10 @@ func (c *completionsCmp) Init() tea.Cmd {
8989// Update implements Completions.
9090func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9191 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
9296 case tea.KeyPressMsg:
9397 switch {
9498 case key.Matches(msg, c.keyMap.Up):
@@ -135,7 +139,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
135139 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
136140 items = append(items, item)
137141 }
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
139143 cmds := []tea.Cmd{
140144 c.list.SetSize(c.width, c.height),
141145 c.list.SetItems(items),
internal/tui/components/completions/item.go+1
@@ -90,6 +90,7 @@ func (c *completionItemCmp) View() string {
9090 if c.bgColor != nil {
9191 titleStyle = titleStyle.Background(c.bgColor)
9292 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
93+ itemStyle = itemStyle.Background(c.bgColor)
9394 }
9495
9596 if c.focus {
internal/tui/components/completions/keys.go+1−1
@@ -22,7 +22,7 @@ func DefaultKeyMap() KeyMap {
2222 key.WithHelp("up", "move up"),
2323 ),
2424 Select: key.NewBinding(
25− key.WithKeys("enter"),
25+ key.WithKeys("enter", "tab", "ctrl+y"),
2626 key.WithHelp("enter", "select"),
2727 ),
2828 Cancel: key.NewBinding(
internal/tui/components/core/helpers.go → internal/tui/components/core/core.gorenamed+59
@@ -5,12 +5,40 @@ import (
55 "strings"
66
77 "github.com/alecthomas/chroma/v2"
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
810 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
911 "github.com/charmbracelet/crush/internal/tui/styles"
1012 "github.com/charmbracelet/lipgloss/v2"
1113 "github.com/charmbracelet/x/ansi"
1214)
1315
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+
1442func Section(text string, width int) string {
1543 t := styles.CurrentTheme()
1644 char := "─"
@@ -23,6 +51,22 @@ func Section(text string, width int) string {
2351 return text
2452}
2553
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+
2670func Title(title string, width int) string {
2771 t := styles.CurrentTheme()
2872 char := "╱"
@@ -147,6 +191,21 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
147191 return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
148192}
149193
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+
150209func DiffFormatter() *diffview.DiffView {
151210 t := styles.CurrentTheme()
152211 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 (
55 tea "github.com/charmbracelet/bubbletea/v2"
66)
77
8+// TODO: move this to core
9+
810type Focusable interface {
911 Focus() tea.Cmd
1012 Blur() tea.Cmd
@@ -23,8 +25,3 @@ type Help interface {
2325type Positional interface {
2426 SetPosition(x, y int) tea.Cmd
2527}
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 {
99 Up,
1010 DownOneItem,
1111 UpOneItem,
12+ PageDown,
13+ PageUp,
1214 HalfPageDown,
1315 HalfPageUp,
1416 Home,
@@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap {
3739 key.WithKeys("d"),
3840 key.WithHelp("d", "half page down"),
3941 ),
40− HalfPageUp: key.NewBinding(
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"),
49+ ), HalfPageUp: key.NewBinding(
4150 key.WithKeys("u"),
4251 key.WithHelp("u", "half page up"),
4352 ),
internal/tui/components/core/list/list.go+10
@@ -41,6 +41,8 @@ type ListModel interface {
4141 SelectedIndex() int // Get the index of the currently selected item
4242 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
4343 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
4446}
4547
4648// HasAnim interface identifies items that support animation.
@@ -330,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
330332 m.scrollDown(m.listHeight() / 2)
331333 case key.Matches(msg, m.keyMap.HalfPageUp):
332334 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())
333339 case key.Matches(msg, m.keyMap.Home):
334340 return m, m.goToTop()
335341 case key.Matches(msg, m.keyMap.End):
@@ -1359,3 +1365,7 @@ func (m *model) Focus() tea.Cmd {
13591365func (m *model) IsFocused() bool {
13601366 return m.isFocused
13611367}
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) {
9898 m.keyMap = keyMap
9999}
100100
101−func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
101+func NewStatusCmp() StatusCmp {
102102 t := styles.CurrentTheme()
103103 help := help.New()
104104 help.Styles = t.S().Help
105105 return &statusCmp{
106106 messageTTL: 5 * time.Second,
107107 help: help,
108− keyMap: keyMap,
109108 }
110109}
internal/tui/components/dialogs/commands/commands.go+2−9
@@ -6,6 +6,7 @@ import (
66 tea "github.com/charmbracelet/bubbletea/v2"
77 "github.com/charmbracelet/lipgloss/v2"
88
9+ "github.com/charmbracelet/crush/internal/llm/prompt"
910 "github.com/charmbracelet/crush/internal/tui/components/chat"
1011 "github.com/charmbracelet/crush/internal/tui/components/completions"
1112 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -239,16 +240,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
239240 Title: "Initialize Project",
240241 Description: "Create/Update the CRUSH.md memory file",
241242 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.`
250243 return util.CmdHandler(chat.SendMsg{
251− Text: prompt,
244+ Text: prompt.Initialize(),
252245 })
253246 },
254247 },
internal/tui/components/dialogs/commands/item.go+16−4
@@ -14,11 +14,12 @@ type ItemSection interface {
1414 util.Model
1515 layout.Sizeable
1616 list.SectionHeader
17+ SetInfo(info string)
1718}
1819type itemSectionModel struct {
19− width int
20− title string
21− noPadding bool // No padding for the section header
20+ width int
21+ title string
22+ info string
2223}
2324
2425func NewItemSection(title string) ItemSection {
@@ -40,7 +41,14 @@ func (m *itemSectionModel) View() string {
4041 title := ansi.Truncate(m.title, m.width-2, "…")
4142 style := t.S().Base.Padding(1, 1, 0, 1)
4243 title = t.S().Muted.Render(title)
43− return style.Render(core.Section(title, m.width-2))
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+
51+ return style.Render(section)
4452}
4553
4654func (m *itemSectionModel) GetSize() (int, int) {
@@ -55,3 +63,7 @@ func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
5563func (m *itemSectionModel) IsSectionHeader() bool {
5664 return true
5765}
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 @@
11package models
22
33import (
4− "slices"
5−
64 "github.com/charmbracelet/bubbles/v2/help"
75 "github.com/charmbracelet/bubbles/v2/key"
86 tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,7 +10,6 @@ import (
1210 "github.com/charmbracelet/crush/internal/tui/components/core"
1311 "github.com/charmbracelet/crush/internal/tui/components/core/list"
1412 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
1613 "github.com/charmbracelet/crush/internal/tui/styles"
1714 "github.com/charmbracelet/crush/internal/tui/util"
1815 "github.com/charmbracelet/lipgloss/v2"
@@ -27,6 +24,9 @@ const (
2724const (
2825 LargeModelType int = iota
2926 SmallModelType
27+
28+ largeModelInputPlaceholder = "Choose a model for large, complex tasks"
29+ smallModelInputPlaceholder = "Choose a model for small, simple tasks"
3030)
3131
3232// ModelSelectedMsg is sent when a model is selected
@@ -53,10 +53,9 @@ type modelDialogCmp struct {
5353 wWidth int
5454 wHeight int
5555
56− modelList list.ListModel
56+ modelList *ModelListComponent
5757 keyMap KeyMap
5858 help help.Model
59− modelType int
6059}
6160
6261func NewModelDialogCmp() ModelDialog {
@@ -75,12 +74,7 @@ func NewModelDialogCmp() ModelDialog {
7574
7675 t := styles.CurrentTheme()
7776 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
78− modelList := list.New(
79− list.WithFilterable(true),
80− list.WithKeyMap(listKeyMap),
81− list.WithInputStyle(inputStyle),
82− list.WithWrapNavigation(true),
83− )
77+ modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
8478 help := help.New()
8579 help.Styles = t.S().Help
8680
@@ -89,12 +83,10 @@ func NewModelDialogCmp() ModelDialog {
8983 width: defaultWidth,
9084 keyMap: DefaultKeyMap(),
9185 help: help,
92− modelType: LargeModelType,
9386 }
9487}
9588
9689func (m *modelDialogCmp) Init() tea.Cmd {
97− m.SetModelType(m.modelType)
9890 return m.modelList.Init()
9991}
10092
@@ -103,7 +95,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10395 case tea.WindowSizeMsg:
10496 m.wWidth = msg.Width
10597 m.wHeight = msg.Height
106− m.SetModelType(m.modelType)
10798 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
10899 case tea.KeyPressMsg:
109100 switch {
@@ -116,7 +107,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116107 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
117108
118109 var modelType config.SelectedModelType
119− if m.modelType == LargeModelType {
110+ if m.modelList.GetModelType() == LargeModelType {
120111 modelType = config.SelectedModelTypeLarge
121112 } else {
122113 modelType = config.SelectedModelTypeSmall
@@ -133,16 +124,18 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133124 }),
134125 )
135126 case key.Matches(msg, m.keyMap.Tab):
136− if m.modelType == LargeModelType {
137− return m, m.SetModelType(SmallModelType)
127+ if m.modelList.GetModelType() == LargeModelType {
128+ m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
129+ return m, m.modelList.SetModelType(SmallModelType)
138130 } else {
139− return m, m.SetModelType(LargeModelType)
131+ m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
132+ return m, m.modelList.SetModelType(LargeModelType)
140133 }
141134 case key.Matches(msg, m.keyMap.Close):
142135 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
143136 default:
144137 u, cmd := m.modelList.Update(msg)
145− m.modelList = u.(list.ListModel)
138+ m.modelList = u
146139 return m, cmd
147140 }
148141 }
@@ -164,12 +157,10 @@ func (m *modelDialogCmp) View() string {
164157}
165158
166159func (m *modelDialogCmp) Cursor() *tea.Cursor {
167− if cursor, ok := m.modelList.(util.Cursor); ok {
168− cursor := cursor.Cursor()
169− if cursor != nil {
170− cursor = m.moveCursor(cursor)
171− return cursor
172− }
160+ cursor := m.modelList.Cursor()
161+ if cursor != nil {
162+ cursor = m.moveCursor(cursor)
163+ return cursor
173164 }
174165 return nil
175166}
@@ -187,7 +178,8 @@ func (m *modelDialogCmp) listWidth() int {
187178}
188179
189180func (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
191183 return min(listHeigh, m.wHeight/2)
192184}
193185
@@ -215,115 +207,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
215207 choices := []string{"Large Task", "Small Task"}
216208 iconSelected := "◉"
217209 iconUnselected := "○"
218− if m.modelType == LargeModelType {
210+ if m.modelList.GetModelType() == LargeModelType {
219211 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
220212 }
221213 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
222214}
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 {
199199 }
200200
201201 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+ }
202209
203210 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
204211}
@@ -382,19 +389,10 @@ func (p *permissionDialogCmp) generateFetchContent() string {
382389 t := styles.CurrentTheme()
383390 baseStyle := t.S().Base.Background(t.BgSubtle)
384391 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−
394392 finalContent := baseStyle.
393+ Padding(1, 2).
395394 Width(p.contentViewPort.Width()).
396− Render(renderedContent)
397−
395+ Render(pr.URL)
398396 return finalContent
399397 }
400398 return ""
@@ -452,8 +450,8 @@ func (p *permissionDialogCmp) render() string {
452450 if p.supportsDiffView() {
453451 contentHelp = help.New().View(p.keyMap)
454452 }
455− // Calculate content height dynamically based on window size
456453
454+ // Calculate content height dynamically based on window size
457455 strs := []string{
458456 title,
459457 "",
@@ -491,7 +489,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
491489
492490 switch p.permission.ToolName {
493491 case tools.BashToolName:
494− p.width = int(float64(p.wWidth) * 0.4)
492+ p.width = int(float64(p.wWidth) * 0.8)
495493 p.height = int(float64(p.wHeight) * 0.3)
496494 case tools.EditToolName:
497495 p.width = int(float64(p.wWidth) * 0.8)
@@ -500,7 +498,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
500498 p.width = int(float64(p.wWidth) * 0.8)
501499 p.height = int(float64(p.wHeight) * 0.8)
502500 case tools.FetchToolName:
503− p.width = int(float64(p.wWidth) * 0.4)
501+ p.width = int(float64(p.wWidth) * 0.8)
504502 p.height = int(float64(p.wHeight) * 0.3)
505503 default:
506504 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 {
8585 }
8686
8787 // 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.
8989 const stepDownAt = 0
9090 rightField := new(strings.Builder)
9191 for i := range fieldHeight {
@@ -98,7 +98,16 @@ func Render(version string, compact bool, o Opts) string {
9898
9999 // Return the wide version.
100100 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
102111}
103112
104113// renderWord renders letterforms to fork a word.
internal/tui/keys.go+2−61
@@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap {
2020 key.WithHelp("ctrl+c", "quit"),
2121 ),
2222 Help: key.NewBinding(
23− key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
24− key.WithHelp("ctrl+?", "more"),
23+ key.WithKeys("ctrl+g"),
24+ key.WithHelp("ctrl+g", "more"),
2525 ),
2626 Commands: key.NewBinding(
2727 key.WithKeys("ctrl+p"),
@@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap {
3333 ),
3434 }
3535}
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
22
33import (
44 "context"
5− "strings"
5+ "runtime"
66 "time"
77
8+ "github.com/charmbracelet/bubbles/v2/help"
89 "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/spinner"
911 tea "github.com/charmbracelet/bubbletea/v2"
1012 "github.com/charmbracelet/crush/internal/app"
1113 "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/history"
1215 "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/pubsub"
1317 "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/crush/internal/tui/components/anim"
1419 "github.com/charmbracelet/crush/internal/tui/components/chat"
1520 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
1621 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
1722 "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"
1826 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1927 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
2029 "github.com/charmbracelet/crush/internal/tui/page"
2130 "github.com/charmbracelet/crush/internal/tui/styles"
2231 "github.com/charmbracelet/crush/internal/tui/util"
@@ -26,154 +35,223 @@ import (
2635
2736var ChatPageID page.PageID = "chat"
2837
29−const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
30−
3138type (
3239 OpenFilePickerMsg struct{}
3340 ChatFocusedMsg struct {
34− Focused bool // True if the chat input is focused, false otherwise
41+ Focused bool
3542 }
3643 CancelTimerExpiredMsg struct{}
3744)
3845
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+
3971type ChatPage interface {
4072 util.Model
4173 layout.Help
4274 IsChatFocused() bool
4375}
4476
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+
4584type chatPage struct {
46− wWidth, wHeight int // Window dimensions
47− app *app.App
85+ width, height int
86+ detailsWidth, detailsHeight int
87+ app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
4889
49− layout layout.SplitPaneLayout
90+ // Layout state
91+ compact bool
92+ forceCompact bool
93+ focusedPane PanelType
5094
95+ // Session
5196 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+}
52113
53− keyMap KeyMap
54−
55− chatFocused bool
56−
57− compactMode bool
58− forceCompactMode bool // Force compact mode regardless of window size
59− showDetails bool // Show details in the header
60− header header.Header
61− compactSidebar layout.Container
62−
63− cancelPending bool // True if ESC was pressed once and waiting for second press
114+func New(app *app.App) ChatPage {
115+ return &chatPage{
116+ app: app,
117+ keyMap: DefaultKeyMap(),
118+ header: header.New(app.LSPClients),
119+ sidebar: sidebar.New(app.History, app.LSPClients, false),
120+ chat: chat.New(app),
121+ editor: editor.New(app),
122+ splash: splash.New(),
123+ focusedPane: PanelTypeSplash,
124+ }
64125}
65126
66127func (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+
67151 return tea.Batch(
68− p.layout.Init(),
69− p.compactSidebar.Init(),
70− p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
152+ p.header.Init(),
153+ p.sidebar.Init(),
154+ p.chat.Init(),
155+ p.editor.Init(),
156+ p.splash.Init(),
71157 )
72158}
73159
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−
81160func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82161 var cmds []tea.Cmd
83162 switch msg := msg.(type) {
84163 case tea.KeyboardEnhancementsMsg:
85− m, cmd := p.layout.Update(msg)
86− p.layout = m.(layout.SplitPaneLayout)
87− return p, cmd
88− case CancelTimerExpiredMsg:
89− p.cancelPending = false
164+ p.keyboardEnhancements = msg
90165 return p, nil
91166 case tea.WindowSizeMsg:
92− h, cmd := p.header.Update(msg)
93− cmds = append(cmds, cmd)
94− p.header = h.(header.Header)
95− cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
96− // the mode is only relevant when there is a session
97− if p.session.ID != "" {
98− // Only auto-switch to compact mode if not forced
99− if !p.forceCompactMode {
100− if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
101− p.wWidth = msg.Width
102− p.wHeight = msg.Height
103− cmds = append(cmds, p.setCompactMode(true))
104− return p, tea.Batch(cmds...)
105− } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
106− p.wWidth = msg.Width
107− p.wHeight = msg.Height
108− return p, p.setCompactMode(false)
109− }
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
167+ return p, p.SetSize(msg.Width, msg.Height)
168+ case CancelTimerExpiredMsg:
169+ p.isCanceling = false
170+ return p, nil
171+ case chat.SendMsg:
172+ return p, p.sendMessage(msg.Text, msg.Attachments)
173+ case chat.SessionSelectedMsg:
174+ return p, p.setSession(msg)
175+ case commands.ToggleCompactModeMsg:
176+ p.forceCompact = !p.forceCompact
177+ var cmd tea.Cmd
178+ if p.forceCompact {
179+ p.setCompactMode(true)
180+ cmd = p.updateCompactConfig(true)
181+ } else if p.width >= CompactModeBreakpoint {
182+ p.setCompactMode(false)
183+ cmd = p.updateCompactConfig(false)
118184 }
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)
120224 cmds = append(cmds, cmd)
121225 return p, tea.Batch(cmds...)
122226
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− }
142227 case commands.CommandRunCustomMsg:
143− // Check if the agent is busy before executing custom commands
144228 if p.app.CoderAgent.IsBusy() {
145229 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
146230 }
147231
148− // Handle custom command execution
149232 cmd := p.sendMessage(msg.Content, nil)
150233 if cmd != nil {
151234 return p, cmd
152235 }
153− case chat.SessionSelectedMsg:
154− if p.session.ID == "" {
155− cmd := p.setMessages()
156− if cmd != nil {
157− cmds = append(cmds, cmd)
158− }
236+ case splash.OnboardingCompleteMsg:
237+ p.splashFullScreen = false
238+ if b, _ := config.ProjectNeedsInitialization(); b {
239+ p.splash.SetProjectInit(true)
240+ p.splashFullScreen = true
241+ return p, p.SetSize(p.width, p.height)
159242 }
160− needsModeChange := p.session.ID == ""
161− p.session = msg
162− p.header.SetSession(msg)
163− if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
164− cmds = append(cmds, p.setCompactMode(true))
243+ err := p.app.InitCoderAgent()
244+ if err != nil {
245+ return p, util.ReportError(err)
165246 }
247+ p.isOnboarding = false
248+ p.isProjectInit = false
249+ p.focusedPane = PanelTypeEditor
250+ return p, p.SetSize(p.width, p.height)
166251 case tea.KeyPressMsg:
167252 switch {
168253 case key.Matches(msg, p.keyMap.NewSession):
169− p.session = session.Session{}
170− return p, tea.Batch(
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− )
254+ return p, p.newSession()
177255 case key.Matches(msg, p.keyMap.AddAttachment):
178256 agentCfg := config.Get().Agents["coder"]
179257 model := config.Get().GetModelByType(agentCfg.Model)
@@ -184,171 +262,290 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184262 }
185263 case key.Matches(msg, p.keyMap.Tab):
186264 if p.session.ID == "" {
187− return p, nil
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}))
265+ u, cmd := p.splash.Update(msg)
266+ p.splash = u.(splash.Splash)
267+ return p, cmd
196268 }
197− return p, tea.Batch(cmds...)
269+ p.changeFocus()
270+ return p, nil
198271 case key.Matches(msg, p.keyMap.Cancel):
199− if p.session.ID != "" {
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()
209− }
272+ if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
273+ return p, p.cancel()
210274 }
211275 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
216− p.header.SetDetailsOpen(p.showDetails)
217− if p.showDetails {
218− return p, tea.Batch()
219− }
220−
276+ p.showDetails()
221277 return p, nil
222278 }
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+ }
223312 }
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)
233313 return p, tea.Batch(cmds...)
234314}
235315
236−func (p *chatPage) setMessages() tea.Cmd {
237− messagesContainer := layout.NewContainer(
238− chat.NewMessagesListCmp(p.app),
239− layout.WithPadding(1, 1, 0, 1),
240− )
241− return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
316+func (p *chatPage) Cursor() *tea.Cursor {
317+ switch p.focusedPane {
318+ case PanelTypeEditor:
319+ return p.editor.Cursor()
320+ case PanelTypeSplash:
321+ return p.splash.Cursor()
322+ default:
323+ return nil
324+ }
242325}
243326
244−func (p *chatPage) setSidebar() tea.Cmd {
245− sidebarContainer := sidebarCmp(p.app, false, p.session)
246− sidebarContainer.Init()
247− return p.layout.SetRightPanel(sidebarContainer)
327+func (p *chatPage) View() string {
328+ var chatView string
329+ t := styles.CurrentTheme()
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()
248394}
249395
250−func (p *chatPage) clearMessages() tea.Cmd {
251− return p.layout.ClearLeftPanel()
396+func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
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+ }
252407}
253408
254−func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
255− p.compactMode = compact
256− var cmds []tea.Cmd
409+func (p *chatPage) setCompactMode(compact bool) {
410+ if p.compact == compact {
411+ return
412+ }
413+ p.compact = compact
257414 if compact {
258− // add offset for the header
259− p.layout.SetOffset(0, 1)
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...)
415+ p.compact = true
416+ p.sidebar.SetCompactMode(true)
265417 } else {
266− // remove the offset for the header
267− p.layout.SetOffset(0, 0)
268− // restore the original size
269− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
270− // set the sidebar
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)
418+ p.compact = false
419+ p.showingDetails = false
420+ p.sidebar.SetCompactMode(false)
421+ }
422+}
275423
276− return tea.Batch(cmds...)
424+func (p *chatPage) handleCompactMode(newWidth int) {
425+ if p.forceCompact {
426+ return
427+ }
428+ if newWidth < CompactModeBreakpoint && !p.compact {
429+ p.setCompactMode(true)
430+ }
431+ if newWidth >= CompactModeBreakpoint && p.compact {
432+ p.setCompactMode(false)
277433 }
278434}
279435
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
281440 var cmds []tea.Cmd
441+
282442 if p.session.ID == "" {
283− session, err := p.app.Sessions.Create(context.Background(), "New Session")
284− if err != nil {
285− return util.ReportError(err)
443+ if p.splashFullScreen {
444+ cmds = append(cmds, p.splash.SetSize(width, height))
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))
286449 }
287−
288− p.session = session
289− cmd := p.setMessages()
290− if cmd != nil {
291− cmds = append(cmds, cmd)
450+ } else {
451+ if p.compact {
452+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
453+ p.detailsWidth = width - DetailsPositioning
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))
292461 }
293− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
294− }
295−
296− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
297− if err != nil {
298− return util.ReportError(err)
462+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
299463 }
300464 return tea.Batch(cmds...)
301465}
302466
303−func (p *chatPage) SetSize(width, height int) tea.Cmd {
304− return p.layout.SetSize(width, height)
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{}),
477+ p.SetSize(p.width, p.height),
478+ )
305479}
306480
307−func (p *chatPage) GetSize() (int, int) {
308− return p.layout.GetSize()
481+func (p *chatPage) setSession(session session.Session) tea.Cmd {
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...)
309496}
310497
311−func (p *chatPage) View() string {
312− if !p.compactMode || 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()
498+func (p *chatPage) changeFocus() {
499+ if p.session.ID == "" {
500+ return
315501 }
316− layoutView := p.layout.View()
317− chatView := strings.Join(
318− []string{
319− p.header.View(),
320− layoutView,
321− }, "\n",
322− )
323− layers := []*lipgloss.Layer{
324− lipgloss.NewLayer(chatView).X(0).Y(0),
502+ switch p.focusedPane {
503+ case PanelTypeChat:
504+ p.focusedPane = PanelTypeEditor
505+ p.editor.Focus()
506+ p.chat.Blur()
507+ case PanelTypeEditor:
508+ p.focusedPane = PanelTypeChat
509+ p.chat.Focus()
510+ p.editor.Blur()
325511 }
326− if p.showDetails {
327− t := styles.CurrentTheme()
328− style := t.S().Base.
329− Border(lipgloss.RoundedBorder()).
330− BorderForeground(t.BorderFocus)
331− version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
332− details := style.Render(
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))
512+}
513+
514+func (p *chatPage) cancel() tea.Cmd {
515+ if p.isCanceling {
516+ p.isCanceling = false
517+ p.app.CoderAgent.Cancel(p.session.ID)
518+ return nil
340519 }
341− canvas := lipgloss.NewCanvas(
342− layers...,
343− )
344− return canvas.Render()
520+
521+ p.isCanceling = true
522+ return cancelTimerCmd()
345523}
346524
347−func (p *chatPage) Cursor() *tea.Cursor {
348− if v, ok := p.layout.(util.Cursor); ok {
349− return v.Cursor()
525+func (p *chatPage) showDetails() {
526+ if p.session.ID == "" || !p.compact {
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)
350547 }
351− return nil
548+ return tea.Batch(cmds...)
352549}
353550
354551func (p *chatPage) Bindings() []key.Binding {
@@ -356,9 +553,9 @@ func (p *chatPage) Bindings() []key.Binding {
356553 p.keyMap.NewSession,
357554 p.keyMap.AddAttachment,
358555 }
359− if p.app.CoderAgent.IsBusy() {
556+ if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
360557 cancelBinding := p.keyMap.Cancel
361− if p.cancelPending {
558+ if p.isCanceling {
362559 cancelBinding = key.NewBinding(
363560 key.WithKeys("esc"),
364561 key.WithHelp("esc", "press again to cancel"),
@@ -367,61 +564,275 @@ func (p *chatPage) Bindings() []key.Binding {
367564 bindings = append([]key.Binding{cancelBinding}, bindings...)
368565 }
369566
370− if p.chatFocused {
567+ switch p.focusedPane {
568+ case PanelTypeChat:
371569 bindings = append([]key.Binding{
372570 key.NewBinding(
373571 key.WithKeys("tab"),
374572 key.WithHelp("tab", "focus editor"),
375573 ),
376574 }, bindings...)
377− } else {
575+ bindings = append(bindings, p.chat.Bindings()...)
576+ case PanelTypeEditor:
378577 bindings = append([]key.Binding{
379578 key.NewBinding(
380579 key.WithKeys("tab"),
381580 key.WithHelp("tab", "focus chat"),
382581 ),
383582 }, bindings...)
583+ bindings = append(bindings, p.editor.Bindings()...)
584+ case PanelTypeSplash:
585+ bindings = append(bindings, p.splash.Bindings()...)
384586 }
385587
386− bindings = append(bindings, p.layout.Bindings()...)
387588 return bindings
388589}
389590
390−func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
391− padding := layout.WithPadding(1, 1, 1, 1)
392− if compact {
393− padding = layout.WithPadding(0, 1, 1, 1)
394− }
395− sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
396− if session.ID != "" {
397− sidebar.SetSession(session)
591+func (a *chatPage) Help() help.KeyMap {
592+ var shortList []key.Binding
593+ var fullList [][]key.Binding
594+ switch {
595+ case a.isOnboarding && !a.splash.IsShowingAPIKey():
596+ shortList = append(shortList,
597+ // Choose model
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+ })
398831 }
399832
400− return layout.NewContainer(
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− }
833+ return core.NewSimpleHelp(shortList, fullList)
422834}
423835
424−// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
425836func (p *chatPage) IsChatFocused() bool {
426− return p.chatFocused
837+ return p.focusedPane == PanelTypeChat
427838}
internal/tui/tui.go+12−25
@@ -13,13 +13,13 @@ import (
1313 "github.com/charmbracelet/crush/internal/pubsub"
1414 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
1515 "github.com/charmbracelet/crush/internal/tui/components/completions"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
1617 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1718 "github.com/charmbracelet/crush/internal/tui/components/core/status"
1819 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1920 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
2021 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
2122 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22− initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
2323 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2424 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
2525 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -88,22 +88,7 @@ func (a appModel) Init() tea.Cmd {
8888 cmd = a.status.Init()
8989 cmds = append(cmds, cmd)
9090
91− // Check if we should show the init dialog
92− cmds = append(cmds, func() tea.Msg {
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− })
91+ cmds = append(cmds, tea.EnableMouseAllMotion)
10792
10893 return tea.Batch(cmds...)
10994}
@@ -124,6 +109,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
124109 }
125110 return a, tea.Batch(cmds...)
126111 case tea.WindowSizeMsg:
112+ a.completions.Update(msg)
127113 return a, a.handleWindowResize(msg.Width, msg.Height)
128114
129115 // Completions messages
@@ -134,9 +120,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134120
135121 // Dialog messages
136122 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
123+ u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
124+ a.completions = u.(completions.Completions)
137125 u, dialogCmd := a.dialog.Update(msg)
138126 a.dialog = u.(dialogs.DialogCmp)
139− return a, dialogCmd
127+ return a, tea.Batch(completionCmd, dialogCmd)
140128 case commands.ShowArgumentsDialogMsg:
141129 return a, util.CmdHandler(
142130 dialogs.OpenDialogMsg{
@@ -186,7 +174,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186174
187175 // Model Switch
188176 case models.ModelSelectedMsg:
189− config.UpdatePreferredModel(msg.ModelType, msg.Model)
177+ config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
190178
191179 // Update the agent with the new model/provider configuration
192180 if err := a.app.UpdateAgentModel(); err != nil {
@@ -273,7 +261,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
273261 var cmds []tea.Cmd
274262 a.wWidth, a.wHeight = width, height
275263 if a.showingFullHelp {
276− height -= 4
264+ height -= 5
277265 } else {
278266 height -= 2
279267 }
@@ -400,10 +388,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
400388// View renders the complete application interface including pages, dialogs, and overlays.
401389func (a *appModel) View() tea.View {
402390 page := a.pages[a.currentPage]
403− if withHelp, ok := page.(layout.Help); ok {
404− a.keyMap.pageBindings = withHelp.Bindings()
391+ if withHelp, ok := page.(core.KeyMapHelp); ok {
392+ a.status.SetKeyMap(withHelp.Help())
405393 }
406− a.status.SetKeyMap(a.keyMap)
407394 pageView := page.View()
408395 components := []string{
409396 pageView,
@@ -456,14 +443,14 @@ func (a *appModel) View() tea.View {
456443
457444// New creates and initializes a new TUI application model.
458445func New(app *app.App) tea.Model {
459− chatPage := chat.NewChatPage(app)
446+ chatPage := chat.New(app)
460447 keyMap := DefaultKeyMap()
461448 keyMap.pageBindings = chatPage.Bindings()
462449
463450 model := &appModel{
464451 currentPage: chat.ChatPageID,
465452 app: app,
466− status: status.NewStatusCmp(keyMap),
453+ status: status.NewStatusCmp(),
467454 loadedPages: make(map[page.PageID]bool),
468455 keyMap: keyMap,
469456
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) {
624624 switch msg.Mode {
625625 case ansi.AltScreenSaveCursorMode:
626626 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()
627631 case ansi.TextCursorEnableMode:
628632 p.renderer.showCursor()
629633 case ansi.GraphemeClusteringMode:
@@ -645,6 +649,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
645649 switch msg.Mode {
646650 case ansi.AltScreenSaveCursorMode:
647651 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()
648656 case ansi.TextCursorEnableMode:
649657 p.renderer.hideCursor()
650658 default:
@@ -1410,13 +1418,16 @@ func (p *Program) stopRenderer(kill bool) {
14101418// requestKeyboardEnhancements tries to enable keyboard enhancements and read
14111419// the active keyboard enhancements from the terminal.
14121420func (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.
14131424 if p.requestedEnhancements.modifyOtherKeys > 0 {
1414− p.execute(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd
1415− p.execute(ansi.QueryModifyOtherKeys)
1425+ _, _ = p.renderer.writeString(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd
1426+ _, _ = p.renderer.writeString(ansi.QueryModifyOtherKeys)
14161427 }
14171428 if p.requestedEnhancements.kittyFlags > 0 {
1418− p.execute(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags))
1419− p.execute(ansi.RequestKittyKeyboard)
1429+ _, _ = p.renderer.writeString(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags))
1430+ _, _ = p.renderer.writeString(ansi.RequestKittyKeyboard)
14201431 }
14211432}
14221433
vendor/github.com/charmbracelet/lipgloss/v2/get.go+33
@@ -135,6 +135,16 @@ func (s Style) GetPaddingLeft() int {
135135 return s.getAsInt(paddingLeftKey)
136136}
137137
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+
138148// GetHorizontalPadding returns the style's left and right padding. Unset
139149// values are measured as 0.
140150func (s Style) GetHorizontalPadding() int {
@@ -186,6 +196,16 @@ func (s Style) GetMarginLeft() int {
186196 return s.getAsInt(marginLeftKey)
187197}
188198
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+
189209// GetHorizontalMargins returns the style's left and right margins. Unset
190210// values are measured as 0.
191211func (s Style) GetHorizontalMargins() int {
@@ -432,6 +452,19 @@ func (s Style) isSet(k propKey) bool {
432452 return s.props.has(k)
433453}
434454
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+
435468func (s Style) getAsBool(k propKey, defaultVal bool) bool {
436469 if !s.isSet(k) {
437470 return defaultVal
vendor/github.com/charmbracelet/lipgloss/v2/set.go+27
@@ -29,6 +29,8 @@ func (s *Style) set(key propKey, value any) {
2929 s.paddingBottom = max(0, value.(int))
3030 case paddingLeftKey:
3131 s.paddingLeft = max(0, value.(int))
32+ case paddingCharKey:
33+ s.paddingChar = value.(rune)
3234 case marginTopKey:
3335 s.marginTop = max(0, value.(int))
3436 case marginRightKey:
@@ -39,6 +41,8 @@ func (s *Style) set(key propKey, value any) {
3941 s.marginLeft = max(0, value.(int))
4042 case marginBackgroundKey:
4143 s.marginBgColor = colorOrNil(value)
44+ case marginCharKey:
45+ s.marginChar = value.(rune)
4246 case borderStyleKey:
4347 s.borderStyle = value.(Border)
4448 case borderTopForegroundKey:
@@ -111,6 +115,8 @@ func (s *Style) setFrom(key propKey, i Style) {
111115 s.set(paddingBottomKey, i.paddingBottom)
112116 case paddingLeftKey:
113117 s.set(paddingLeftKey, i.paddingLeft)
118+ case paddingCharKey:
119+ s.set(paddingCharKey, i.paddingChar)
114120 case marginTopKey:
115121 s.set(marginTopKey, i.marginTop)
116122 case marginRightKey:
@@ -121,6 +127,8 @@ func (s *Style) setFrom(key propKey, i Style) {
121127 s.set(marginLeftKey, i.marginLeft)
122128 case marginBackgroundKey:
123129 s.set(marginBackgroundKey, i.marginBgColor)
130+ case marginCharKey:
131+ s.set(marginCharKey, i.marginChar)
124132 case borderStyleKey:
125133 s.set(borderStyleKey, i.borderStyle)
126134 case borderTopForegroundKey:
@@ -320,6 +328,18 @@ func (s Style) PaddingBottom(i int) Style {
320328 return s
321329}
322330
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+
323343// ColorWhitespace determines whether or not the background color should be
324344// applied to the padding. This is true by default as it's more than likely the
325345// desired and expected behavior, but it can be disabled for certain graphic
@@ -390,6 +410,13 @@ func (s Style) MarginBackground(c color.Color) Style {
390410 return s
391411}
392412
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+
393420// Border is shorthand for setting the border style and which sides should
394421// have a border at once. The variadic argument sides works as follows:
395422//
vendor/github.com/charmbracelet/lipgloss/v2/style.go+18−8
@@ -10,7 +10,8 @@ import (
1010)
1111
1212const (
13− nbsp = '\u00A0'
13+ // NBSP is the non-breaking space rune.
14+ NBSP = '\u00A0'
1415 tabWidthDefault = 4
1516)
1617
@@ -44,6 +45,7 @@ const (
4445 paddingRightKey
4546 paddingBottomKey
4647 paddingLeftKey
48+ paddingCharKey
4749
4850 // Margins.
4951 marginTopKey
@@ -51,6 +53,7 @@ const (
5153 marginBottomKey
5254 marginLeftKey
5355 marginBackgroundKey
56+ marginCharKey
5457
5558 // Border runes.
5659 borderStyleKey
@@ -128,12 +131,14 @@ type Style struct {
128131 paddingRight int
129132 paddingBottom int
130133 paddingLeft int
134+ paddingChar rune
131135
132136 marginTop int
133137 marginRight int
134138 marginBottom int
135139 marginLeft int
136140 marginBgColor color.Color
141+ marginChar rune
137142
138143 borderStyle Border
139144 borderTopFgColor color.Color
@@ -387,23 +392,24 @@ func (s Style) Render(strs ...string) string {
387392
388393 // Padding
389394 if !inline { //nolint:nestif
395+ padChar := s.paddingChar
396+ if padChar == 0 {
397+ padChar = ' '
398+ }
390399 if leftPadding > 0 {
391400 var st *ansi.Style
392401 if colorWhitespace || styleWhitespace {
393402 st = &teWhitespace
394403 }
395− str = padLeft(str, leftPadding, st, nbsp)
404+ str = padLeft(str, leftPadding, st, padChar)
396405 }
397406
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−
401407 if rightPadding > 0 {
402408 var st *ansi.Style
403409 if colorWhitespace || styleWhitespace {
404410 st = &teWhitespace
405411 }
406− str = padRight(str, rightPadding, st, nbsp)
412+ str = padRight(str, rightPadding, st, padChar)
407413 }
408414
409415 if topPadding > 0 {
@@ -494,8 +500,12 @@ func (s Style) applyMargins(str string, inline bool) string {
494500 }
495501
496502 // Add left and right margin
497− str = padLeft(str, leftMargin, &style, ' ')
498− str = padRight(str, rightMargin, &style, ' ')
503+ marginChar := s.marginChar
504+ if marginChar == 0 {
505+ marginChar = ' '
506+ }
507+ str = padLeft(str, leftMargin, &style, marginChar)
508+ str = padRight(str, rightMargin, &style, marginChar)
499509
500510 // Top/bottom margin
501511 if !inline {
vendor/github.com/charmbracelet/lipgloss/v2/unset.go+7
@@ -96,6 +96,13 @@ func (s Style) UnsetPadding() Style {
9696 s.unset(paddingRightKey)
9797 s.unset(paddingTopKey)
9898 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)
99106 return s
100107}
101108
vendor/modules.txt+5−5
@@ -254,8 +254,8 @@ github.com/charmbracelet/bubbles/v2/spinner
254254github.com/charmbracelet/bubbles/v2/textarea
255255github.com/charmbracelet/bubbles/v2/textinput
256256github.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
258−## explicit; go 1.24.3
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.0
259259github.com/charmbracelet/bubbletea/v2
260260# github.com/charmbracelet/colorprofile v0.3.1
261261## explicit; go 1.23.0
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
269269github.com/charmbracelet/glamour/v2/ansi
270270github.com/charmbracelet/glamour/v2/internal/autolink
271271github.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
273273## explicit; go 1.24.2
274274github.com/charmbracelet/lipgloss/v2
275275github.com/charmbracelet/lipgloss/v2/table
@@ -838,5 +838,5 @@ mvdan.cc/sh/v3/fileutil
838838mvdan.cc/sh/v3/interp
839839mvdan.cc/sh/v3/pattern
840840mvdan.cc/sh/v3/syntax
841−# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891
842−# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093
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-20250710185058-03664cb9cecb