diff --git a/cmd/root.go b/cmd/root.go index 8c407ace937510a192245033d2fff2b1f242641a..e27bc46adcf38ae4b36cfba8d0f518690091242f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "os" - "sync" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -16,7 +15,6 @@ import ( "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/llm/agent" "github.com/charmbracelet/crush/internal/log" - "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" @@ -124,76 +122,17 @@ to assist developers in writing, debugging, and understanding code directly from tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state ) - // Setup the subscriptions, this will send services events to the TUI - ch, cancelSubs := setupSubscriptions(app, ctx) + go app.Subscribe(program) - // Create a context for the TUI message handler - tuiCtx, tuiCancel := context.WithCancel(ctx) - var tuiWg sync.WaitGroup - tuiWg.Add(1) - - // Set up message handling for the TUI - go func() { - defer tuiWg.Done() - defer log.RecoverPanic("TUI-message-handler", func() { - attemptTUIRecovery(program) - }) - - for { - select { - case <-tuiCtx.Done(): - slog.Info("TUI message handler shutting down") - return - case msg, ok := <-ch: - if !ok { - slog.Info("TUI message channel closed") - return - } - program.Send(msg) - } - } - }() - - // Cleanup function for when the program exits - cleanup := func() { - // Shutdown the app - app.Shutdown() - - // Cancel subscriptions first - cancelSubs() - - // Then cancel TUI message handler - tuiCancel() - - // Wait for TUI message handler to finish - tuiWg.Wait() - - slog.Info("All goroutines cleaned up") - } - - // Run the TUI - result, err := program.Run() - cleanup() - - if err != nil { + if _, err := program.Run(); err != nil { slog.Error(fmt.Sprintf("TUI run error: %v", err)) return fmt.Errorf("TUI error: %v", err) } - - slog.Info(fmt.Sprintf("TUI exited with result: %v", result)) + app.Shutdown() return nil }, } -// attemptTUIRecovery tries to recover the TUI after a panic -func attemptTUIRecovery(program *tea.Program) { - slog.Info("Attempting to recover TUI after panic") - - // We could try to restart the TUI or gracefully exit - // For now, we'll just quit the program to avoid further issues - program.Quit() -} - func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) { go func() { defer log.RecoverPanic("MCP-goroutine", nil) @@ -208,81 +147,6 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) { }() } -func setupSubscriber[T any]( - ctx context.Context, - wg *sync.WaitGroup, - name string, - subscriber func(context.Context) <-chan pubsub.Event[T], - outputCh chan<- tea.Msg, -) { - wg.Add(1) - go func() { - defer wg.Done() - defer log.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) - - subCh := subscriber(ctx) - - for { - select { - case event, ok := <-subCh: - if !ok { - slog.Info("subscription channel closed", "name", name) - return - } - - var msg tea.Msg = event - - select { - case outputCh <- msg: - case <-time.After(2 * time.Second): - slog.Warn("message dropped due to slow consumer", "name", name) - case <-ctx.Done(): - slog.Info("subscription canceled", "name", name) - return - } - case <-ctx.Done(): - slog.Info("subscription canceled", "name", name) - return - } - } - }() -} - -func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { - ch := make(chan tea.Msg, 100) - - wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context - - setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) - setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) - setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) - setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch) - setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch) - - cleanupFunc := func() { - slog.Info("Cancelling all subscriptions") - cancel() // Signal all goroutines to stop - - waitCh := make(chan struct{}) - go func() { - defer log.RecoverPanic("subscription-cleanup", nil) - wg.Wait() - close(waitCh) - }() - - select { - case <-waitCh: - slog.Info("All subscription goroutines completed successfully") - close(ch) // Only close after all writers are confirmed done - case <-time.After(5 * time.Second): - slog.Warn("Timed out waiting for some subscription goroutines to complete") - close(ch) - } - } - return ch, cleanupFunc -} - func Execute() { if err := fang.Execute( context.Background(), diff --git a/go.mod b/go.mod index f77e83b72e9c6e596b56c2c3bd4f535cab1524be..35907121af5791acc5cfc5f3aa07f10df9eba763 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,9 @@ module github.com/charmbracelet/crush go 1.24.3 -replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 +replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 -replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 @@ -43,22 +43,12 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.10.0 + github.com/tidwall/sjson v1.2.5 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 gopkg.in/natefinch/lumberjack.v2 v2.2.1 mvdan.cc/sh/v3 v3.11.0 ) -require ( - github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect -) - -require ( - github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect -) - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect @@ -85,15 +75,18 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef github.com/charmbracelet/x/term v0.2.1 + github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect @@ -121,12 +114,12 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect @@ -148,5 +141,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a28be8055e4e55bdf77babc366ceefcea0efd705..50e30a46d4a47cb210add9c3fe61f0c9fb8e6c26 100644 --- a/go.sum +++ b/go.sum @@ -68,20 +68,18 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 h1:wh6N1dR4XkDh6XsiZh1/tImJAZvYB0yVLmaUKvJXvK0= -github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891/go.mod h1:SwBB+WoaQVMMOM9hknbN/7FNT86kgKG0LSHGTmLphX8= +github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 h1:wLMjzOqrwoM7Em9UR9sGbn4375G8WuxcwFB3kjZiqHo= +github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg= github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc= github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY= github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk= -github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 h1:c9vOmNJQUwy/lp/pNOB5ZDMhOuXJ3Y2LL9uZMYGgJxQ= -github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093/go.mod h1:XmxjFJcMEfYIHa4Mw4ra+uMjploDkTlkKIs7wLt9v4Q= +github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0= +github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb/go.mod h1:wEc/TRrTAIDJYjVCg3+y8WeKaN+F88gpYfGbUuP6W3A= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= github.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 github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= diff --git a/internal/app/app.go b/internal/app/app.go index aba2dd255d076566ec0b7412df654209d21350d5..36f0777d0547474c6b4fa8053436f3cf7b274406 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,11 +10,14 @@ import ( "sync" "time" + tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/log" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" @@ -36,9 +39,18 @@ type App struct { watcherCancelFuncs []context.CancelFunc cancelFuncsMutex sync.Mutex - watcherWG sync.WaitGroup + lspWatcherWG sync.WaitGroup config *config.Config + + serviceEventsWG *sync.WaitGroup + eventsCtx context.Context + events chan tea.Msg + tuiWG *sync.WaitGroup + + // global context and cleanup functions + globalCtx context.Context + cleanupFuncs []func() } func 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) { History: files, Permissions: permission.NewPermissionService(cfg.WorkingDir()), LSPClients: make(map[string]*lsp.Client), - config: cfg, + + globalCtx: ctx, + + config: cfg, + + events: make(chan tea.Msg, 100), + serviceEventsWG: &sync.WaitGroup{}, + tuiWG: &sync.WaitGroup{}, } + app.setupEvents() + // Initialize LSP clients in the background go app.initLSPClients(ctx) // TODO: remove the concept of agent config most likely - coderAgentCfg := cfg.Agents["coder"] - if coderAgentCfg.ID == "" { - return nil, fmt.Errorf("coder agent configuration is missing") - } - - var err error - app.CoderAgent, err = agent.NewAgent( - coderAgentCfg, - app.Permissions, - app.Sessions, - app.Messages, - app.History, - app.LSPClients, - ) - if err != nil { - slog.Error("Failed to create coder agent", "err", err) - return nil, err + if cfg.IsConfigured() { + if err := app.InitCoderAgent(); err != nil { + return nil, fmt.Errorf("failed to initialize coder agent: %w", err) + } + } else { + slog.Warn("No agent configuration found") } - return app, nil } @@ -147,32 +156,138 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat return nil } +func (app *App) UpdateAgentModel() error { + return app.CoderAgent.UpdateModel() +} + +func (app *App) setupEvents() { + ctx, cancel := context.WithCancel(app.globalCtx) + app.eventsCtx = ctx + setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) + cleanupFunc := func() { + cancel() + app.serviceEventsWG.Wait() + } + app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc) +} + +func setupSubscriber[T any]( + ctx context.Context, + wg *sync.WaitGroup, + name string, + subscriber func(context.Context) <-chan pubsub.Event[T], + outputCh chan<- tea.Msg, +) { + wg.Add(1) + go func() { + defer wg.Done() + subCh := subscriber(ctx) + for { + select { + case event, ok := <-subCh: + if !ok { + slog.Debug("subscription channel closed", "name", name) + return + } + var msg tea.Msg = event + select { + case outputCh <- msg: + case <-time.After(2 * time.Second): + slog.Warn("message dropped due to slow consumer", "name", name) + case <-ctx.Done(): + slog.Debug("subscription cancelled", "name", name) + return + } + case <-ctx.Done(): + slog.Debug("subscription cancelled", "name", name) + return + } + } + }() +} + +func (app *App) InitCoderAgent() error { + coderAgentCfg := app.config.Agents["coder"] + if coderAgentCfg.ID == "" { + return fmt.Errorf("coder agent configuration is missing") + } + var err error + app.CoderAgent, err = agent.NewAgent( + coderAgentCfg, + app.Permissions, + app.Sessions, + app.Messages, + app.History, + app.LSPClients, + ) + if err != nil { + slog.Error("Failed to create coder agent", "err", err) + return err + } + setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events) + return nil +} + +func (app *App) Subscribe(program *tea.Program) { + defer log.RecoverPanic("app.Subscribe", func() { + slog.Info("TUI subscription panic - attempting graceful shutdown") + program.Quit() + }) + + app.tuiWG.Add(1) + tuiCtx, tuiCancel := context.WithCancel(app.globalCtx) + app.cleanupFuncs = append(app.cleanupFuncs, func() { + slog.Debug("Cancelling TUI message handler") + tuiCancel() + app.tuiWG.Wait() + }) + defer app.tuiWG.Done() + for { + select { + case <-tuiCtx.Done(): + slog.Debug("TUI message handler shutting down") + return + case msg, ok := <-app.events: + if !ok { + slog.Debug("TUI message channel closed") + return + } + program.Send(msg) + } + } +} + // Shutdown performs a clean shutdown of the application func (app *App) Shutdown() { - // Cancel all watcher goroutines app.cancelFuncsMutex.Lock() for _, cancel := range app.watcherCancelFuncs { cancel() } app.cancelFuncsMutex.Unlock() - app.watcherWG.Wait() + app.lspWatcherWG.Wait() - // Perform additional cleanup for LSP clients app.clientsMutex.RLock() clients := make(map[string]*lsp.Client, len(app.LSPClients)) maps.Copy(clients, app.LSPClients) app.clientsMutex.RUnlock() for name, client := range clients { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) if err := client.Shutdown(shutdownCtx); err != nil { slog.Error("Failed to shutdown LSP client", "name", name, "error", err) } cancel() } - app.CoderAgent.CancelAll() -} + if app.CoderAgent != nil { + app.CoderAgent.CancelAll() + } -func (app *App) UpdateAgentModel() error { - return app.CoderAgent.UpdateModel() + for _, cleanup := range app.cleanupFuncs { + if cleanup != nil { + cleanup() + } + } } diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 1777a653a4153dc42cc87444f6122df01e82cedd..ba98d4b3a074c2e9abcef87eb3030a21be669eab 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman app.cancelFuncsMutex.Unlock() // Add the watcher to a WaitGroup to track active goroutines - app.watcherWG.Add(1) + app.lspWatcherWG.Add(1) // Add to map with mutex protection before starting goroutine app.clientsMutex.Lock() @@ -83,7 +83,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman // runWorkspaceWatcher executes the workspace watcher for an LSP client func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { - defer app.watcherWG.Done() + defer app.lspWatcherWG.Done() defer log.RecoverPanic("LSP-"+name, func() { // Try to restart the client app.restartLSPClient(ctx, name) diff --git a/internal/config/config.go b/internal/config/config.go index a32d04f9b62cf6e734f98e35df01c6148e06573a..5c978106bc49f7b5956ea1d1d6e4d994f53eae58 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,10 +2,12 @@ package config import ( "fmt" + "os" "slices" "strings" "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/tidwall/sjson" ) const ( @@ -58,6 +60,8 @@ type SelectedModel struct { type ProviderConfig struct { // The provider's id. ID string `json:"id,omitempty"` + // The provider's name, used for display purposes. + Name string `json:"name,omitempty"` // The provider's API endpoint. BaseURL string `json:"base_url,omitempty"` // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai. @@ -207,7 +211,9 @@ type Config struct { // TODO: most likely remove this concept when I come back to it Agents map[string]Agent `json:"-"` // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver + resolver VariableResolver + dataConfigDir string `json:"-"` + knownProviders []provider.Provider `json:"-"` } func (c *Config) WorkingDir() string { @@ -275,6 +281,14 @@ func (c *Config) SmallModel() *provider.Model { return c.GetModel(model.Provider, model.Model) } +func (c *Config) SetCompactMode(enabled bool) error { + if c.Options == nil { + c.Options = &Options{} + } + c.Options.TUI.CompactMode = enabled + return c.SetConfigField("options.tui.compact_mode", enabled) +} + func (c *Config) Resolve(key string) (string, error) { if c.resolver == nil { return "", fmt.Errorf("no variable resolver configured") @@ -282,9 +296,78 @@ func (c *Config) Resolve(key string) (string, error) { return c.resolver.ResolveValue(key) } -// TODO: maybe handle this better -func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { - cfg := Get() - cfg.Models[modelType] = model +func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { + c.Models[modelType] = model + if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil { + return fmt.Errorf("failed to update preferred model: %w", err) + } + return nil +} + +func (c *Config) SetConfigField(key string, value any) error { + // read the data + data, err := os.ReadFile(c.dataConfigDir) + if err != nil { + if os.IsNotExist(err) { + data = []byte("{}") + } else { + return fmt.Errorf("failed to read config file: %w", err) + } + } + + newValue, err := sjson.Set(string(data), key, value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %w", key, err) + } + if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +func (c *Config) SetProviderAPIKey(providerID, apiKey string) error { + // First save to the config file + err := c.SetConfigField("providers."+providerID+".api_key", apiKey) + if err != nil { + return fmt.Errorf("failed to save API key to config file: %w", err) + } + + if c.Providers == nil { + c.Providers = make(map[string]ProviderConfig) + } + + providerConfig, exists := c.Providers[providerID] + if exists { + providerConfig.APIKey = apiKey + c.Providers[providerID] = providerConfig + return nil + } + + var foundProvider *provider.Provider + for _, p := range c.knownProviders { + if string(p.ID) == providerID { + foundProvider = &p + break + } + } + + if foundProvider != nil { + // Create new provider config based on known provider + providerConfig = ProviderConfig{ + ID: providerID, + Name: foundProvider.Name, + BaseURL: foundProvider.APIEndpoint, + Type: foundProvider.Type, + APIKey: apiKey, + Disable: false, + ExtraHeaders: make(map[string]string), + ExtraParams: make(map[string]string), + Models: foundProvider.Models, + } + } else { + return fmt.Errorf("provider with ID %s not found in known providers", providerID) + } + // Store the updated provider config + c.Providers[providerID] = providerConfig return nil } diff --git a/internal/config/init.go b/internal/config/init.go index 200359bff2bea913940cb78588c97733efb7a142..12b30efd75f88d438e0734571cbb5c634ba231bc 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -103,3 +103,11 @@ func MarkProjectInitialized() error { return nil } + +func HasInitialDataConfig() bool { + cfgPath := GlobalConfigData() + if _, err := os.Stat(cfgPath); err != nil { + return false + } + return true +} diff --git a/internal/config/load.go b/internal/config/load.go index 84585d05d56dddbac36d2d147cc5c4cada781c7e..cc9191fcda5ebfb875fefbac899b21c3597ef0e2 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -37,7 +37,7 @@ func Load(workingDir string, debug bool) (*Config, error) { // uses default config paths configPaths := []string{ globalConfig(), - globalConfigData(), + GlobalConfigData(), filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)), filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)), } @@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) { return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err) } + cfg.dataConfigDir = GlobalConfigData() + cfg.setDefaults(workingDir) if debug { @@ -63,6 +65,7 @@ func Load(workingDir string, debug bool) (*Config, error) { if err != nil || len(providers) == 0 { return nil, fmt.Errorf("failed to load providers: %w", err) } + cfg.knownProviders = providers env := env.New() // Configure providers @@ -162,6 +165,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn } prepared := ProviderConfig{ ID: string(p.ID), + Name: p.Name, BaseURL: p.APIEndpoint, APIKey: p.APIKey, Type: p.Type, @@ -218,6 +222,9 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn // Make sure the provider ID is set providerConfig.ID = id + if providerConfig.Name == "" { + providerConfig.Name = id // Use ID as name if not set + } // default to OpenAI if not set if providerConfig.Type == "" { providerConfig.Type = provider.TypeOpenAI @@ -229,9 +236,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn continue } if providerConfig.APIKey == "" { - slog.Warn("Skipping custom provider due to missing API key", "provider", id) - delete(cfg.Providers, id) - continue + slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id) } if providerConfig.BaseURL == "" { 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 apiKey, err := resolver.ResolveValue(providerConfig.APIKey) if apiKey == "" || err != nil { - slog.Warn("Skipping custom provider due to missing API key", "provider", id, "error", err) - delete(cfg.Providers, id) - continue + slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id) } baseURL, err := resolver.ResolveValue(providerConfig.BaseURL) if baseURL == "" || err != nil { @@ -369,10 +372,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la } func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error { - large, small, err := cfg.defaultModelSelection(knownProviders) + defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders) if err != nil { return fmt.Errorf("failed to select default models: %w", err) } + large, small := defaultLarge, defaultSmall largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge] if largeModelConfigured { @@ -383,18 +387,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e large.Provider = largeModelSelected.Provider } model := cfg.GetModel(large.Provider, large.Model) + slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model) + slog.Info("MOdel configured", "model", model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if largeModelSelected.MaxTokens > 0 { - large.MaxTokens = largeModelSelected.MaxTokens + large = defaultLarge + // override the model type to large + err := cfg.UpdatePreferredModel(SelectedModelTypeLarge, large) + if err != nil { + return fmt.Errorf("failed to update preferred large model: %w", err) + } } else { - large.MaxTokens = model.DefaultMaxTokens - } - if largeModelSelected.ReasoningEffort != "" { - large.ReasoningEffort = largeModelSelected.ReasoningEffort + if largeModelSelected.MaxTokens > 0 { + large.MaxTokens = largeModelSelected.MaxTokens + } else { + large.MaxTokens = model.DefaultMaxTokens + } + if largeModelSelected.ReasoningEffort != "" { + large.ReasoningEffort = largeModelSelected.ReasoningEffort + } + large.Think = largeModelSelected.Think } - large.Think = largeModelSelected.Think } smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall] if smallModelConfigured { @@ -407,25 +419,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e model := cfg.GetModel(small.Provider, small.Model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if smallModelSelected.MaxTokens > 0 { - small.MaxTokens = smallModelSelected.MaxTokens + small = defaultSmall + // override the model type to small + err := cfg.UpdatePreferredModel(SelectedModelTypeSmall, small) + if err != nil { + return fmt.Errorf("failed to update preferred small model: %w", err) + } } else { - small.MaxTokens = model.DefaultMaxTokens + if smallModelSelected.MaxTokens > 0 { + small.MaxTokens = smallModelSelected.MaxTokens + } else { + small.MaxTokens = model.DefaultMaxTokens + } + small.ReasoningEffort = smallModelSelected.ReasoningEffort + small.Think = smallModelSelected.Think } - small.ReasoningEffort = smallModelSelected.ReasoningEffort - small.Think = smallModelSelected.Think - } - - // validate the selected models - largeModel := cfg.GetModel(large.Provider, large.Model) - if largeModel == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - smallModel := cfg.GetModel(small.Provider, small.Model) - if smallModel == nil { - return fmt.Errorf("small model %s not found for provider %s", small.Model, small.Provider) } cfg.Models[SelectedModelTypeLarge] = large cfg.Models[SelectedModelTypeSmall] = small @@ -512,9 +520,9 @@ func globalConfig() string { return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName)) } -// globalConfigData returns the path to the main data directory for the application. +// GlobalConfigData returns the path to the main data directory for the application. // this config is used when the app overrides configurations instead of updating the global config. -func globalConfigData() string { +func GlobalConfigData() string { xdgDataHome := os.Getenv("XDG_DATA_HOME") if xdgDataHome != "" { return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName)) @@ -533,3 +541,14 @@ func globalConfigData() string { return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } + +func HomeDir() string { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = os.Getenv("USERPROFILE") // For Windows compatibility + } + if homeDir == "" { + homeDir = os.Getenv("HOMEPATH") // Fallback for some environments + } + return homeDir +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index f397d704d638d05fb1de7a1f32475afc79f3f0c0..b96ca5e81cd265cbcd1bdf9d456603ad3f22c558 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -484,7 +484,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { } func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { - t.Run("custom provider with missing API key is removed", func(t *testing.T) { + t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) { cfg := &Config{ Providers: map[string]ProviderConfig{ "custom": { @@ -493,6 +493,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { ID: "test-model", }}, }, + "openai": { + APIKey: "$MISSING", + }, }, } cfg.setDefaults("/tmp") @@ -502,9 +505,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { err := cfg.configureProviders(env, resolver, []provider.Provider{}) assert.NoError(t, err) - assert.Len(t, cfg.Providers, 0) + assert.Len(t, cfg.Providers, 1) _, exists := cfg.Providers["custom"] - assert.False(t, exists) + assert.True(t, exists) }) t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) { diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index c3678041de4239cf66247ebbd9cb084cb8eb6b8a..462dcc6761f261a5be02658317884eb64fb07ebc 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -17,23 +17,14 @@ import ( ignore "github.com/sabhiram/go-gitignore" ) -var ( - rgPath string - fzfPath string -) +var rgPath string func init() { var err error rgPath, err = exec.LookPath("rg") if err != nil { if log.Initialized() { - slog.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") - } - } - fzfPath, err = exec.LookPath("fzf") - if err != nil { - if log.Initialized() { - slog.Warn("FZF not found in $PATH. Some features might be limited or slower.") + slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.") } } } diff --git a/internal/llm/prompt/initialize.go b/internal/llm/prompt/initialize.go new file mode 100644 index 0000000000000000000000000000000000000000..62a0f57c6122195490e2f989874cf5660f4a0da2 --- /dev/null +++ b/internal/llm/prompt/initialize.go @@ -0,0 +1,14 @@ +package prompt + +func Initialize() string { + return `Please analyze this codebase and create a **CRUSH.md** file containing: + +- Build/lint/test commands - especially for running a single test +- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +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. +If there's already a **CRUSH.md**, improve it. + +If there are Cursor rules` + " (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.\n" + + "Add the `.crush` directory to the `.gitignore` file if it's not already there." +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 058e3612d31f0e0d6be53fe28c4f8720fa49cb5b..0e6a95937476de9f33b1c5c0dd15e0489c645c43 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -14,8 +14,8 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/chat/messages" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" ) type SendMsg struct { @@ -37,6 +37,9 @@ type MessageListCmp interface { util.Model layout.Sizeable layout.Focusable + layout.Help + + SetSession(session.Session) tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -53,9 +56,9 @@ type messageListCmp struct { defaultListKeyMap list.KeyMap } -// NewMessagesListCmp creates a new message list component with custom keybindings +// New creates a new message list component with custom keybindings // and reverse ordering (newest messages at bottom). -func NewMessagesListCmp(app *app.App) MessageListCmp { +func New(app *app.App) MessageListCmp { defaultListKeyMap := list.DefaultKeyMap() listCmp := list.New( list.WithGapSize(1), @@ -70,7 +73,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp { } } -// Init initializes the component (no initialization needed). +// Init initializes the component. func (m *messageListCmp) Init() tea.Cmd { return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur()) } @@ -102,16 +105,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the message list or an initial screen if empty. func (m *messageListCmp) View() string { - return lipgloss.JoinVertical( - lipgloss.Left, - m.listCmp.View(), - ) + t := styles.CurrentTheme() + return t.S().Base. + Padding(1). + Width(m.width). + Height(m.height). + Render( + m.listCmp.View(), + ) } // handleChildSession handles messages from child sessions (agent tools). func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd { var cmds []tea.Cmd - if len(event.Payload.ToolCalls()) == 0 { + if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { return nil } items := m.listCmp.Items() @@ -151,6 +158,15 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) ) } } + for _, tr := range event.Payload.ToolResults() { + for nestedInx, nestedTC := range nestedToolCalls { + if nestedTC.GetToolCall().ID == tr.ToolCallID { + nestedToolCalls[nestedInx].SetToolResult(tr) + break + } + } + } + toolCall.SetNestedToolCalls(nestedToolCalls) m.listCmp.UpdateItem( toolCallInx, @@ -487,8 +503,8 @@ func (m *messageListCmp) GetSize() (int, int) { // SetSize updates the component dimensions and propagates to the list component. func (m *messageListCmp) SetSize(width int, height int) tea.Cmd { m.width = width - m.height = height - 1 - return m.listCmp.SetSize(width, height-1) + m.height = height + return m.listCmp.SetSize(width-2, height-2) // for padding } // Blur implements MessageListCmp. diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 5e1420e759a3e64222746b4374584d65b4749c2a..2185715c813dbdcb288bddde0fe70d63046cf731 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -26,6 +27,18 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) +type Editor interface { + util.Model + layout.Sizeable + layout.Focusable + layout.Help + layout.Positional + + SetSession(session session.Session) tea.Cmd + IsCompletionsOpen() bool + Cursor() *tea.Cursor +} + type FileCompletionItem struct { Path string // The file path } @@ -149,14 +162,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { - case tea.KeyboardEnhancementsMsg: - m.keyMap.keyboard = msg - return m, nil - case chat.SessionSelectedMsg: - if msg.ID != m.session.ID { - m.session = msg - } - return m, nil case filepicker.FilePickedMsg: if len(m.attachments) >= maxAttachments { 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) { value := m.textarea.Value() if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") - return m, nil + m.textarea.SetValue(value[:len(value)-1]) } else { // Otherwise, send the message return m, m.send() @@ -387,7 +391,18 @@ func (c *editorCmp) Bindings() []key.Binding { return c.keyMap.KeyBindings() } -func NewEditorCmp(app *app.App) util.Model { +// TODO: most likely we do not need to have the session here +// we need to move some functionality to the page level +func (c *editorCmp) SetSession(session session.Session) tea.Cmd { + c.session = session + return nil +} + +func (c *editorCmp) IsCompletionsOpen() bool { + return c.isCompletionsOpen +} + +func New(app *app.App) Editor { t := styles.CurrentTheme() ta := textarea.New() ta.SetStyles(t.S().TextArea) @@ -408,6 +423,7 @@ func NewEditorCmp(app *app.App) util.Model { ta.Focus() return &editorCmp{ + // TODO: remove the app instance from here app: app, textarea: ta, keyMap: DefaultEditorKeyMap(), diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index aa2ba1ee44ce7fe9928e7e812acea3898a7496e5..ef002436901ed0fbad3bcbd2da7cecc08ef255c1 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -2,7 +2,6 @@ package editor import ( "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" ) type EditorKeyMap struct { @@ -10,8 +9,6 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding - - keyboard tea.KeyboardEnhancementsMsg } func DefaultEditorKeyMap() EditorKeyMap { @@ -25,8 +22,8 @@ func DefaultEditorKeyMap() EditorKeyMap { key.WithHelp("enter", "send"), ), OpenEditor: key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "open editor"), + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "open editor"), ), Newline: key.NewBinding( key.WithKeys("shift+enter", "ctrl+j"), @@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap { // KeyBindings implements layout.KeyMapProvider func (k EditorKeyMap) KeyBindings() []key.Binding { - newline := k.Newline - if k.keyboard.SupportsKeyDisambiguation() { - newline.SetHelp("shift+enter", newline.Help().Desc) - } return []key.Binding{ k.AddFile, k.SendMessage, k.OpenEditor, - newline, + k.Newline, } } diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 0d01aa6fef1638cf85ad8ddf091c9bcbd2b789b0..5d27cc14fdf341ea3f201876f80f7edd7f1ce328 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -19,7 +18,8 @@ import ( type Header interface { util.Model - SetSession(session session.Session) + SetSession(session session.Session) tea.Cmd + SetWidth(width int) tea.Cmd SetDetailsOpen(open bool) } @@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd { func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.width = msg.Width - 2 - case chat.SessionSelectedMsg: - p.session = msg case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { if p.session.ID == msg.Payload.ID { @@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) { } // SetSession implements Header. -func (h *header) SetSession(session session.Session) { +func (h *header) SetSession(session session.Session) tea.Cmd { h.session = session + return nil +} + +// SetWidth implements Header. +func (h *header) SetWidth(width int) tea.Cmd { + h.width = width + return nil } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index bd10b1c78dc06d5569b943267018b95631892427..6e7d867c6bd1baf6f4f8998b5b01f054cafffff0 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() string { infoMsg := t.S().Subtle.Render(duration.String()) icon := t.S().Subtle.Render(styles.ModelIcon) model := config.Get().GetModel(m.message.Provider, m.message.Model) + if model == nil { + // This means the model is not configured anymore + model = &provider.Model{ + Model: "Unknown Model", + } + } modelFormatted := t.S().Muted.Render(model.Model) assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) return t.S().Base.PaddingLeft(2).Render( diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index c5cc2bc8f8bf4754c459ace83a8ec4c9cf4c94a6..b686c2a3267a1f69bcfb872e77be2734639bd091 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -112,10 +112,21 @@ func (br baseRenderer) unmarshalParams(input string, target any) error { } // makeHeader builds the tool call header with status icon and parameters for a nested tool call. -func (br baseRenderer) makeNestedHeader(_ *toolCallCmp, tool string, width int, params ...string) string { +func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string { t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) + if v.result.ToolCallID != "" { + if v.result.IsError { + icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) + } else { + icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) + } + } else if v.cancelled { + icon = t.S().Muted.Render(styles.ToolPending) + } tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " " - return tool + renderParamList(true, width-lipgloss.Width(tool), params...) + prefix := fmt.Sprintf("%s %s ", icon, tool) + return prefix + renderParamList(true, width-lipgloss.Width(tool), params...) } // makeHeader builds ": param (key=value)" and truncates as needed. @@ -542,7 +553,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string { if v.result.ToolCallID == "" { v.spinning = true - parts = append(parts, v.anim.View()) + parts = append(parts, "", v.anim.View()) } else { v.spinning = false } @@ -634,6 +645,9 @@ func earlyState(header string, v *toolCallCmp) (string, bool) { func joinHeaderBody(header, body string) string { t := styles.CurrentTheme() + if body == "" { + return header + } body = t.S().Base.PaddingLeft(2).Render(body) return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "") } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index fe61d44fb77f81d330447beecb9b1a7192a2a0c4..41c3d9656de59bcfae9cae3b5d9a8a07cfaf9afd 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -219,11 +219,11 @@ func (m *toolCallCmp) SetIsNested(isNested bool) { // renderPending displays the tool name with a loading animation for pending tool calls func (m *toolCallCmp) renderPending() string { t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) if m.isNested { tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s", tool, m.anim.View()) + return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) } - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name)) return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 3d8338c2b40b33febd9d78f2649cc3aa06337db6..3fa08ce021d0fcac1ce7dc9668d46198f6d08055 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -13,7 +13,6 @@ import ( "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" @@ -29,10 +28,6 @@ import ( "github.com/charmbracelet/x/ansi" ) -const ( - logoBreakpoint = 65 -) - type FileHistory struct { initialVersion history.File latestVersion history.File @@ -52,6 +47,7 @@ type Sidebar interface { util.Model layout.Sizeable SetSession(session session.Session) tea.Cmd + SetCompactMode(bool) } type sidebarCmp struct { @@ -66,7 +62,7 @@ type sidebarCmp struct { files sync.Map } -func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar { +func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar { return &sidebarCmp{ lspClients: lspClients, history: history, @@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c } func (m *sidebarCmp) Init() tea.Cmd { - m.logo = m.logoBlock(false) - m.cwd = cwd() return nil } func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case chat.SessionSelectedMsg: - return m, m.SetSession(msg) case SessionFilesMsg: m.files = sync.Map{} for _, file := range msg.Files { @@ -137,7 +129,16 @@ func (m *sidebarCmp) View() string { m.mcpBlock(), ) - return lipgloss.JoinVertical(lipgloss.Left, parts...) + style := t.S().Base. + Width(m.width). + Height(m.height). + Padding(1) + if m.compactMode { + style = style.PaddingTop(0) + } + return style.Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) } func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd { @@ -230,12 +231,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg { } func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { - if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) { - m.logo = m.logoBlock(true) - } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) { - m.logo = m.logoBlock(false) - } - + m.logo = m.logoBlock() + m.cwd = cwd() m.width = width m.height = height return nil @@ -245,23 +242,27 @@ func (m *sidebarCmp) GetSize() (int, int) { return m.width, m.height } -func (m *sidebarCmp) logoBlock(compact bool) string { +func (m *sidebarCmp) logoBlock() string { t := styles.CurrentTheme() - return logo.Render(version.Version, compact, logo.Opts{ + return logo.Render(version.Version, true, logo.Opts{ FieldColor: t.Primary, TitleColorA: t.Secondary, TitleColorB: t.Primary, CharmColor: t.Secondary, VersionColor: t.Primary, + Width: m.width - 2, }) } +func (m *sidebarCmp) getMaxWidth() int { + return min(m.width-2, 58) // -2 for padding +} + func (m *sidebarCmp) filesBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("Modified Files", maxWidth), + core.Section("Modified Files", m.getMaxWidth()), ) files := make([]SessionFile, 0) @@ -302,7 +303,7 @@ func (m *sidebarCmp) filesBlock() string { filePath := file.FilePath filePath = strings.TrimPrefix(filePath, cwd) filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…") + filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…") fileList = append(fileList, core.Status( core.StatusOpts{ @@ -311,7 +312,7 @@ func (m *sidebarCmp) filesBlock() string { Title: filePath, ExtraContent: extraContent, }, - m.width, + m.getMaxWidth(), ), ) } @@ -323,11 +324,10 @@ func (m *sidebarCmp) filesBlock() string { } func (m *sidebarCmp) lspBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("LSPs", maxWidth), + core.Section("LSPs", m.getMaxWidth()), ) lspList := []string{section, ""} @@ -385,7 +385,7 @@ func (m *sidebarCmp) lspBlock() string { Description: l.LSP.Command, ExtraContent: strings.Join(errs, " "), }, - m.width, + m.getMaxWidth(), ), ) } @@ -397,11 +397,10 @@ func (m *sidebarCmp) lspBlock() string { } func (m *sidebarCmp) mcpBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("MCPs", maxWidth), + core.Section("MCPs", m.getMaxWidth()), ) mcpList := []string{section, ""} @@ -428,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string { Title: l.Name, Description: l.MCP.Command, }, - m.width, + m.getMaxWidth(), ), ) } @@ -511,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd { return m.loadSessionFiles } +// SetCompactMode sets the compact mode for the sidebar. +func (m *sidebarCmp) SetCompactMode(compact bool) { + m.compactMode = compact +} + func cwd() string { cwd := config.Get().WorkingDir() t := styles.CurrentTheme() diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index df715c89e86971a0f788915737bf41a212c65b5a..9cf2e3124daa87b0fc62c2ea404fb1c6c86ec649 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -5,14 +5,49 @@ import ( ) type KeyMap struct { - Cancel key.Binding + Select, + Next, + Previous, + Yes, + No, + Tab, + LeftRight, + Back key.Binding } func DefaultKeyMap() KeyMap { return KeyMap{ - Cancel: key.NewBinding( + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + Yes: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + No: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "no"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch"), + ), + LeftRight: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch"), + ), + Back: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), + key.WithHelp("esc", "back"), ), } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 2bd8aaed34b636d92b488af961635973be9576ae..722aaea6f75c6ef0bef7e0a9ec2de319c6d71bfb 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -1,9 +1,22 @@ package splash import ( + "fmt" + "os" + "slices" + "strings" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/llm/prompt" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -15,23 +28,98 @@ type Splash interface { util.Model layout.Sizeable layout.Help + Cursor() *tea.Cursor + // SetOnboarding controls whether the splash shows model selection UI + SetOnboarding(bool) + // SetProjectInit controls whether the splash shows project initialization prompt + SetProjectInit(bool) + + // Showing API key input + IsShowingAPIKey() bool } +const ( + SplashScreenPaddingY = 1 // Padding Y for the splash screen + + LogoGap = 6 +) + +// OnboardingCompleteMsg is sent when onboarding is complete +type OnboardingCompleteMsg struct{} + type splashCmp struct { width, height int keyMap KeyMap logoRendered string + + // State + isOnboarding bool + needsProjectInit bool + needsAPIKey bool + selectedNo bool + + listHeight int + modelList *models.ModelListComponent + apiKeyInput *models.APIKeyInput + selectedModel *models.ModelOption } func New() Splash { + keyMap := DefaultKeyMap() + listKeyMap := list.DefaultKeyMap() + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + inputStyle := t.S().Base.Padding(0, 1, 0, 1) + modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") + apiKeyInput := models.NewAPIKeyInput() + return &splashCmp{ width: 0, height: 0, - keyMap: DefaultKeyMap(), + keyMap: keyMap, logoRendered: "", + modelList: modelList, + apiKeyInput: apiKeyInput, + selectedNo: false, } } +func (s *splashCmp) SetOnboarding(onboarding bool) { + s.isOnboarding = onboarding + if onboarding { + providers, err := config.Providers() + if err != nil { + return + } + filteredProviders := []provider.Provider{} + simpleProviders := []string{ + "anthropic", + "openai", + "gemini", + "xai", + "openrouter", + } + for _, p := range providers { + if slices.Contains(simpleProviders, string(p.ID)) { + filteredProviders = append(filteredProviders, p) + } + } + s.modelList.SetProviders(filteredProviders) + } +} + +func (s *splashCmp) SetProjectInit(needsInit bool) { + s.needsProjectInit = needsInit +} + // GetSize implements SplashPage. func (s *splashCmp) GetSize() (int, int) { return s.width, s.height @@ -39,15 +127,20 @@ func (s *splashCmp) GetSize() (int, int) { // Init implements SplashPage. func (s *splashCmp) Init() tea.Cmd { - return nil + return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init()) } // SetSize implements SplashPage. func (s *splashCmp) SetSize(width int, height int) tea.Cmd { - s.width = width s.height = height - s.logoRendered = s.logoBlock() - return nil + if width != s.width { + s.width = width + s.logoRendered = s.logoBlock() + } + // remove padding, logo height, gap, title space + s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2 + listWidth := min(60, width) + return s.modelList.SetSize(listWidth, s.listHeight) } // Update implements SplashPage. @@ -55,32 +148,484 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return s, s.SetSize(msg.Width, msg.Height) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Back): + if s.needsAPIKey { + // Go back to model selection + s.needsAPIKey = false + s.selectedModel = nil + return s, nil + } + case key.Matches(msg, s.keyMap.Select): + if s.isOnboarding && !s.needsAPIKey { + modelInx := s.modelList.SelectedIndex() + items := s.modelList.Items() + selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) + if s.isProviderConfigured(string(selectedItem.Provider.ID)) { + cmd := s.setPreferredModel(selectedItem) + s.isOnboarding = false + return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) + } else { + // Provider not configured, show API key input + s.needsAPIKey = true + s.selectedModel = &selectedItem + s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) + return s, nil + } + } else if s.needsAPIKey { + // Handle API key submission + apiKey := s.apiKeyInput.Value() + if apiKey != "" { + return s, s.saveAPIKeyAndContinue(apiKey) + } + } else if s.needsProjectInit { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): + if s.needsProjectInit { + s.selectedNo = !s.selectedNo + return s, nil + } + case key.Matches(msg, s.keyMap.Yes): + if s.needsProjectInit { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.No): + if s.needsProjectInit { + s.needsProjectInit = false + return s, util.CmdHandler(OnboardingCompleteMsg{}) + } + default: + if s.needsAPIKey { + u, cmd := s.apiKeyInput.Update(msg) + s.apiKeyInput = u.(*models.APIKeyInput) + return s, cmd + } else if s.isOnboarding { + u, cmd := s.modelList.Update(msg) + s.modelList = u + return s, cmd + } + } + case tea.PasteMsg: + if s.needsAPIKey { + u, cmd := s.apiKeyInput.Update(msg) + s.apiKeyInput = u.(*models.APIKeyInput) + return s, cmd + } else if s.isOnboarding { + var cmd tea.Cmd + s.modelList, cmd = s.modelList.Update(msg) + return s, cmd + } } return s, nil } -// View implements SplashPage. +func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd { + if s.selectedModel == nil { + return util.ReportError(fmt.Errorf("no model selected")) + } + + cfg := config.Get() + err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey) + if err != nil { + return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) + } + + // Reset API key state and continue with model selection + s.needsAPIKey = false + cmd := s.setPreferredModel(*s.selectedModel) + s.isOnboarding = false + s.selectedModel = nil + + return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) +} + +func (s *splashCmp) initializeProject() tea.Cmd { + s.needsProjectInit = false + + if err := config.MarkProjectInitialized(); err != nil { + return util.ReportError(err) + } + var cmds []tea.Cmd + + cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{})) + if !s.selectedNo { + cmds = append(cmds, + util.CmdHandler(chat.SessionClearedMsg{}), + util.CmdHandler(chat.SendMsg{ + Text: prompt.Initialize(), + }), + ) + } + return tea.Sequence(cmds...) +} + +func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd { + cfg := config.Get() + model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID) + if model == nil { + return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID)) + } + + selectedModel := config.SelectedModel{ + Model: selectedItem.Model.ID, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + + err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel) + if err != nil { + return util.ReportError(err) + } + + // Now lets automatically setup the small model + knownProvider, err := s.getProvider(selectedItem.Provider.ID) + if err != nil { + return util.ReportError(err) + } + if knownProvider == nil { + // for local provider we just use the same model + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + } else { + smallModel := knownProvider.DefaultSmallModelID + model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel) + // should never happen + if model == nil { + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + return nil + } + smallSelectedModel := config.SelectedModel{ + Model: smallModel, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel) + if err != nil { + return util.ReportError(err) + } + } + return nil +} + +func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) { + providers, err := config.Providers() + if err != nil { + return nil, err + } + for _, p := range providers { + if p.ID == providerID { + return &p, nil + } + } + return nil, nil +} + +func (s *splashCmp) isProviderConfigured(providerID string) bool { + cfg := config.Get() + if _, ok := cfg.Providers[providerID]; ok { + return true + } + return false +} + func (s *splashCmp) View() string { - content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered) - return content + t := styles.CurrentTheme() + var content string + if s.needsAPIKey { + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) + apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + apiKeyView, + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + apiKeySelector, + ) + } else if s.isOnboarding { + modelListView := s.modelList.View() + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"), + "", + modelListView, + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + modelSelector, + ) + } else if s.needsProjectInit { + titleStyle := t.S().Base.Foreground(t.FgBase) + bodyStyle := t.S().Base.Foreground(t.FgMuted) + shortcutStyle := t.S().Base.Foreground(t.Success) + + initText := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render("Would you like to initialize this project?"), + "", + bodyStyle.Render("When I initialize your codebase I examine the project and put the"), + bodyStyle.Render("result into a CRUSH.md file which serves as general context."), + "", + bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."), + "", + bodyStyle.Render("Would you like to initialize now?"), + ) + + yesButton := core.SelectableButton(core.ButtonOpts{ + Text: "Yep!", + UnderlineIndex: 0, + Selected: !s.selectedNo, + }) + + noButton := core.SelectableButton(core.ButtonOpts{ + Text: "Nope", + UnderlineIndex: 0, + Selected: s.selectedNo, + }) + + buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) + infoSection := s.infoSection() + + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) + + initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + initText, + "", + buttons, + ), + ) + + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + infoSection, + initContent, + ) + } else { + parts := []string{ + s.logoRendered, + s.infoSection(), + } + content = lipgloss.JoinVertical(lipgloss.Left, parts...) + } + + return t.S().Base. + Width(s.width). + Height(s.height). + PaddingTop(SplashScreenPaddingY). + PaddingBottom(SplashScreenPaddingY). + Render(content) +} + +func (s *splashCmp) Cursor() *tea.Cursor { + if s.needsAPIKey { + cursor := s.apiKeyInput.Cursor() + if cursor != nil { + return s.moveCursor(cursor) + } + } else if s.isOnboarding { + cursor := s.modelList.Cursor() + if cursor != nil { + return s.moveCursor(cursor) + } + } else { + return nil + } + return nil +} + +func (s *splashCmp) infoSection() string { + t := styles.CurrentTheme() + return t.S().Base.PaddingLeft(2).Render( + lipgloss.JoinVertical( + lipgloss.Left, + s.cwd(), + "", + lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), + "", + ), + ) } func (s *splashCmp) logoBlock() string { t := styles.CurrentTheme() - const padding = 2 - return logo.Render(version.Version, false, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: s.width - padding, - }) + return t.S().Base.Padding(0, 2).Width(s.width).Render( + logo.Render(version.Version, false, logo.Opts{ + FieldColor: t.Primary, + TitleColorA: t.Secondary, + TitleColorB: t.Primary, + CharmColor: t.Secondary, + VersionColor: t.Primary, + Width: s.width - 4, + }), + ) +} + +func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + if cursor == nil { + return nil + } + // Calculate the correct Y offset based on current state + logoHeight := lipgloss.Height(s.logoRendered) + if s.needsAPIKey { + infoSectionHeight := lipgloss.Height(s.infoSection()) + baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight + remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY + offset := baseOffset + remainingHeight + cursor.Y += offset + cursor.X = cursor.X + 1 + } else if s.isOnboarding { + offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3 + cursor.Y += offset + cursor.X = cursor.X + 1 + } + + return cursor +} + +func (s *splashCmp) logoGap() int { + if s.height > 35 { + return LogoGap + } + return 0 } // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { - return []key.Binding{ - s.keyMap.Cancel, + if s.needsAPIKey { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Back, + } + } else if s.isOnboarding { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Next, + s.keyMap.Previous, + } + } else if s.needsProjectInit { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Yes, + s.keyMap.No, + s.keyMap.Tab, + s.keyMap.LeftRight, + } } + return []key.Binding{} +} + +func (s *splashCmp) getMaxInfoWidth() int { + return min(s.width-2, 40) // 2 for left padding +} + +func (s *splashCmp) cwd() string { + cwd := config.Get().WorkingDir() + t := styles.CurrentTheme() + homeDir, err := os.UserHomeDir() + if err == nil && cwd != homeDir { + cwd = strings.ReplaceAll(cwd, homeDir, "~") + } + maxWidth := s.getMaxInfoWidth() + return t.S().Muted.Width(maxWidth).Render(cwd) +} + +func LSPList(maxWidth int) []string { + t := styles.CurrentTheme() + lspList := []string{} + lsp := config.Get().LSP.Sorted() + if len(lsp) == 0 { + return []string{t.S().Base.Foreground(t.Border).Render("None")} + } + for _, l := range lsp { + iconColor := t.Success + if l.LSP.Disabled { + iconColor = t.FgMuted + } + lspList = append(lspList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: l.LSP.Command, + }, + maxWidth, + ), + ) + } + return lspList +} + +func (s *splashCmp) lspBlock() string { + t := styles.CurrentTheme() + maxWidth := s.getMaxInfoWidth() / 2 + section := t.S().Subtle.Render("LSPs") + lspList := append([]string{section, ""}, LSPList(maxWidth-1)...) + return t.S().Base.Width(maxWidth).PaddingRight(1).Render( + lipgloss.JoinVertical( + lipgloss.Left, + lspList..., + ), + ) +} + +func MCPList(maxWidth int) []string { + t := styles.CurrentTheme() + mcpList := []string{} + mcps := config.Get().MCP.Sorted() + if len(mcps) == 0 { + return []string{t.S().Base.Foreground(t.Border).Render("None")} + } + for _, l := range mcps { + iconColor := t.Success + if l.MCP.Disabled { + iconColor = t.FgMuted + } + mcpList = append(mcpList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: l.MCP.Command, + }, + maxWidth, + ), + ) + } + return mcpList +} + +func (s *splashCmp) mcpBlock() string { + t := styles.CurrentTheme() + maxWidth := s.getMaxInfoWidth() / 2 + section := t.S().Subtle.Render("MCPs") + mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...) + return t.S().Base.Width(maxWidth).PaddingRight(1).Render( + lipgloss.JoinVertical( + lipgloss.Left, + mcpList..., + ), + ) +} + +func (s *splashCmp) IsShowingAPIKey() bool { + return s.needsAPIKey } diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 6153a76834ff697546e0c3ba38dece817bb97921..29ea86365e9f1532eab3aa1a61214ef74b7f4a05 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -70,8 +70,8 @@ func New() Completions { list.WithHideFilterInput(true), ) return &completionsCmp{ - width: 30, - height: 10, + width: 0, + height: 0, list: l, query: "", keyMap: completionsKeyMap, @@ -89,6 +89,10 @@ func (c *completionsCmp) Init() tea.Cmd { // Update implements Completions. func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.width = min(msg.Width-c.x, 80) + c.height = min(msg.Height-c.y, 15) + return c, nil case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Up): @@ -135,7 +139,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) items = append(items, item) } - c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height + c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height cmds := []tea.Cmd{ c.list.SetSize(c.width, c.height), c.list.SetItems(items), diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go index d1b18a75ba1591a52524713d228a7f8b24fa1c96..414ad94b9ffaae3792f80169feb4cdfff9a71d64 100644 --- a/internal/tui/components/completions/item.go +++ b/internal/tui/components/completions/item.go @@ -90,6 +90,7 @@ func (c *completionItemCmp) View() string { if c.bgColor != nil { titleStyle = titleStyle.Background(c.bgColor) titleMatchStyle = titleMatchStyle.Background(c.bgColor) + itemStyle = itemStyle.Background(c.bgColor) } if c.focus { diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go index fee3a0e574ab926cb4e80d02c0d9d19c0e614edd..530b429fe32ffd89d73c6cec1723c27de1ddd459 100644 --- a/internal/tui/components/completions/keys.go +++ b/internal/tui/components/completions/keys.go @@ -22,7 +22,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("up", "move up"), ), Select: key.NewBinding( - key.WithKeys("enter"), + key.WithKeys("enter", "tab", "ctrl+y"), key.WithHelp("enter", "select"), ), Cancel: key.NewBinding( diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/core.go similarity index 74% rename from internal/tui/components/core/helpers.go rename to internal/tui/components/core/core.go index 1c2c05a6229b98222d870694e726069bfc9c6e92..1db79e954350a11a4a843797b07a091736a1cae9 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/core.go @@ -5,12 +5,40 @@ import ( "strings" "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/crush/internal/tui/exp/diffview" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) +type KeyMapHelp interface { + Help() help.KeyMap +} + +type simpleHelp struct { + shortList []key.Binding + fullList [][]key.Binding +} + +func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap { + return &simpleHelp{ + shortList: shortList, + fullList: fullList, + } +} + +// FullHelp implements help.KeyMap. +func (s *simpleHelp) FullHelp() [][]key.Binding { + return s.fullList +} + +// ShortHelp implements help.KeyMap. +func (s *simpleHelp) ShortHelp() []key.Binding { + return s.shortList +} + func Section(text string, width int) string { t := styles.CurrentTheme() char := "─" @@ -23,6 +51,22 @@ func Section(text string, width int) string { return text } +func SectionWithInfo(text string, width int, info string) string { + t := styles.CurrentTheme() + char := "─" + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + + if info != "" { + remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info + } + lineStyle := t.S().Base.Foreground(t.Border) + if remainingWidth > 0 { + text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info + } + return text +} + func Title(title string, width int) string { t := styles.CurrentTheme() char := "╱" @@ -147,6 +191,21 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } +// SelectableButtonsVertical creates a vertical row of selectable buttons +func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string { + var parts []string + for i, button := range buttons { + parts = append(parts, SelectableButton(button)) + if i < len(buttons)-1 { + for j := 0; j < spacing; j++ { + parts = append(parts, "") + } + } + } + + return lipgloss.JoinVertical(lipgloss.Center, parts...) +} + func DiffFormatter() *diffview.DiffView { t := styles.CurrentTheme() formatDiff := diffview.New() diff --git a/internal/tui/components/core/layout/container.go b/internal/tui/components/core/layout/container.go deleted file mode 100644 index 9940a320e8c3a2733c8a543e09d5c25b68a103d1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/container.go +++ /dev/null @@ -1,263 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type Container interface { - util.Model - Sizeable - Help - Positional - Focusable -} -type container struct { - width int - height int - isFocused bool - - x, y int - - content util.Model - - // Style options - paddingTop int - paddingRight int - paddingBottom int - paddingLeft int - - borderTop bool - borderRight bool - borderBottom bool - borderLeft bool - borderStyle lipgloss.Border -} - -type ContainerOption func(*container) - -func NewContainer(content util.Model, options ...ContainerOption) Container { - c := &container{ - content: content, - borderStyle: lipgloss.NormalBorder(), - } - - for _, option := range options { - option(c) - } - - return c -} - -func (c *container) Init() tea.Cmd { - return c.content.Init() -} - -func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - if c.IsFocused() { - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } - return c, nil - default: - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } -} - -func (c *container) Cursor() *tea.Cursor { - if cursor, ok := c.content.(util.Cursor); ok { - return cursor.Cursor() - } - return nil -} - -func (c *container) View() string { - t := styles.CurrentTheme() - width := c.width - height := c.height - - style := t.S().Base - - // Apply border if any side is enabled - if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft { - // Adjust width and height for borders - if c.borderTop { - height-- - } - if c.borderBottom { - height-- - } - if c.borderLeft { - width-- - } - if c.borderRight { - width-- - } - style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft) - style = style.BorderBackground(t.BgBase).BorderForeground(t.Border) - } - style = style. - Width(width). - Height(height). - PaddingTop(c.paddingTop). - PaddingRight(c.paddingRight). - PaddingBottom(c.paddingBottom). - PaddingLeft(c.paddingLeft) - - contentView := c.content.View() - return style.Render(contentView) -} - -func (c *container) SetSize(width, height int) tea.Cmd { - c.width = width - c.height = height - - // If the content implements Sizeable, adjust its size to account for padding and borders - if sizeable, ok := c.content.(Sizeable); ok { - // Calculate horizontal space taken by padding and borders - horizontalSpace := c.paddingLeft + c.paddingRight - if c.borderLeft { - horizontalSpace++ - } - if c.borderRight { - horizontalSpace++ - } - - // Calculate vertical space taken by padding and borders - verticalSpace := c.paddingTop + c.paddingBottom - if c.borderTop { - verticalSpace++ - } - if c.borderBottom { - verticalSpace++ - } - - // Set content size with adjusted dimensions - contentWidth := max(0, width-horizontalSpace) - contentHeight := max(0, height-verticalSpace) - return sizeable.SetSize(contentWidth, contentHeight) - } - return nil -} - -func (c *container) GetSize() (int, int) { - return c.width, c.height -} - -func (c *container) SetPosition(x, y int) tea.Cmd { - c.x = x - c.y = y - if positionable, ok := c.content.(Positional); ok { - return positionable.SetPosition(x, y) - } - return nil -} - -func (c *container) Bindings() []key.Binding { - if b, ok := c.content.(Help); ok { - return b.Bindings() - } - return nil -} - -// Blur implements Container. -func (c *container) Blur() tea.Cmd { - c.isFocused = false - if focusable, ok := c.content.(Focusable); ok { - return focusable.Blur() - } - return nil -} - -// Focus implements Container. -func (c *container) Focus() tea.Cmd { - c.isFocused = true - if focusable, ok := c.content.(Focusable); ok { - return focusable.Focus() - } - return nil -} - -// IsFocused implements Container. -func (c *container) IsFocused() bool { - isFocused := c.isFocused - if focusable, ok := c.content.(Focusable); ok { - isFocused = isFocused || focusable.IsFocused() - } - return isFocused -} - -// Padding options -func WithPadding(top, right, bottom, left int) ContainerOption { - return func(c *container) { - c.paddingTop = top - c.paddingRight = right - c.paddingBottom = bottom - c.paddingLeft = left - } -} - -func WithPaddingAll(padding int) ContainerOption { - return WithPadding(padding, padding, padding, padding) -} - -func WithPaddingHorizontal(padding int) ContainerOption { - return func(c *container) { - c.paddingLeft = padding - c.paddingRight = padding - } -} - -func WithPaddingVertical(padding int) ContainerOption { - return func(c *container) { - c.paddingTop = padding - c.paddingBottom = padding - } -} - -func WithBorder(top, right, bottom, left bool) ContainerOption { - return func(c *container) { - c.borderTop = top - c.borderRight = right - c.borderBottom = bottom - c.borderLeft = left - } -} - -func WithBorderAll() ContainerOption { - return WithBorder(true, true, true, true) -} - -func WithBorderHorizontal() ContainerOption { - return WithBorder(true, false, true, false) -} - -func WithBorderVertical() ContainerOption { - return WithBorder(false, true, false, true) -} - -func WithBorderStyle(style lipgloss.Border) ContainerOption { - return func(c *container) { - c.borderStyle = style - } -} - -func WithRoundedBorder() ContainerOption { - return WithBorderStyle(lipgloss.RoundedBorder()) -} - -func WithThickBorder() ContainerOption { - return WithBorderStyle(lipgloss.ThickBorder()) -} - -func WithDoubleBorder() ContainerOption { - return WithBorderStyle(lipgloss.DoubleBorder()) -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go index f5f2361d72d0d41bcb898c81f00df174571cfa72..6ceb30adf45595f5d44d4b4b48d6ac0feb87a028 100644 --- a/internal/tui/components/core/layout/layout.go +++ b/internal/tui/components/core/layout/layout.go @@ -5,6 +5,8 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" ) +// TODO: move this to core + type Focusable interface { Focus() tea.Cmd Blur() tea.Cmd @@ -23,8 +25,3 @@ type Help interface { type Positional interface { SetPosition(x, y int) tea.Cmd } - -// KeyMapProvider defines an interface for types that can provide their key bindings as a slice -type KeyMapProvider interface { - KeyBindings() []key.Binding -} diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go deleted file mode 100644 index b36e655007aaccade96f6c760763184158b9bd32..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/split.go +++ /dev/null @@ -1,380 +0,0 @@ -package layout - -import ( - "log/slog" - - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type LayoutPanel string - -const ( - LeftPanel LayoutPanel = "left" - RightPanel LayoutPanel = "right" - BottomPanel LayoutPanel = "bottom" -) - -type SplitPaneLayout interface { - util.Model - Sizeable - Help - SetLeftPanel(panel Container) tea.Cmd - SetRightPanel(panel Container) tea.Cmd - SetBottomPanel(panel Container) tea.Cmd - - ClearLeftPanel() tea.Cmd - ClearRightPanel() tea.Cmd - ClearBottomPanel() tea.Cmd - - FocusPanel(panel LayoutPanel) tea.Cmd - SetOffset(x, y int) -} - -type splitPaneLayout struct { - width int - height int - xOffset int - yOffset int - - ratio float64 - verticalRatio float64 - - rightPanel Container - leftPanel Container - bottomPanel Container - - fixedBottomHeight int // Fixed height for the bottom panel, if any - fixedRightWidth int // Fixed width for the right panel, if any -} - -type SplitPaneOption func(*splitPaneLayout) - -func (s *splitPaneLayout) Init() tea.Cmd { - var cmds []tea.Cmd - - if s.leftPanel != nil { - cmds = append(cmds, s.leftPanel.Init()) - } - - if s.rightPanel != nil { - cmds = append(cmds, s.rightPanel.Init()) - } - - if s.bottomPanel != nil { - cmds = append(cmds, s.bottomPanel.Init()) - } - - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return s, s.SetSize(msg.Width, msg.Height) - } - - if s.rightPanel != nil { - u, cmd := s.rightPanel.Update(msg) - s.rightPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.leftPanel != nil { - u, cmd := s.leftPanel.Update(msg) - s.leftPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.bottomPanel != nil { - u, cmd := s.bottomPanel.Update(msg) - s.bottomPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return s, tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Cursor() *tea.Cursor { - if s.bottomPanel != nil { - if c, ok := s.bottomPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.rightPanel != nil { - if c, ok := s.rightPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.leftPanel != nil { - if c, ok := s.leftPanel.(util.Cursor); ok { - return c.Cursor() - } - } - return nil -} - -func (s *splitPaneLayout) View() string { - var topSection string - - if s.leftPanel != nil && s.rightPanel != nil { - leftView := s.leftPanel.View() - rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) - } else if s.leftPanel != nil { - topSection = s.leftPanel.View() - } else if s.rightPanel != nil { - topSection = s.rightPanel.View() - } else { - topSection = "" - } - - var finalView string - - if s.bottomPanel != nil && topSection != "" { - bottomView := s.bottomPanel.View() - finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView) - } else if s.bottomPanel != nil { - finalView = s.bottomPanel.View() - } else { - finalView = topSection - } - - t := styles.CurrentTheme() - - style := t.S().Base. - Width(s.width). - Height(s.height) - - return style.Render(finalView) -} - -func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { - s.width = width - s.height = height - slog.Info("Setting split pane size", "width", width, "height", height) - - var topHeight, bottomHeight int - var cmds []tea.Cmd - if s.bottomPanel != nil { - if s.fixedBottomHeight > 0 { - bottomHeight = s.fixedBottomHeight - topHeight = height - bottomHeight - } else { - topHeight = int(float64(height) * s.verticalRatio) - bottomHeight = height - topHeight - if bottomHeight <= 0 { - bottomHeight = 2 - topHeight = height - bottomHeight - } - } - } else { - topHeight = height - bottomHeight = 0 - } - - var leftWidth, rightWidth int - if s.leftPanel != nil && s.rightPanel != nil { - if s.fixedRightWidth > 0 { - rightWidth = s.fixedRightWidth - leftWidth = width - rightWidth - } else { - leftWidth = int(float64(width) * s.ratio) - rightWidth = width - leftWidth - if rightWidth <= 0 { - rightWidth = 2 - leftWidth = width - rightWidth - } - } - } else if s.leftPanel != nil { - leftWidth = width - rightWidth = 0 - } else if s.rightPanel != nil { - leftWidth = 0 - rightWidth = width - } - - if s.leftPanel != nil { - cmd := s.leftPanel.SetSize(leftWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.leftPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset)) - } - } - - if s.rightPanel != nil { - cmd := s.rightPanel.SetSize(rightWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.rightPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset)) - } - } - - if s.bottomPanel != nil { - cmd := s.bottomPanel.SetSize(width, bottomHeight) - cmds = append(cmds, cmd) - if positional, ok := s.bottomPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight)) - } - } - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) GetSize() (int, int) { - return s.width, s.height -} - -func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd { - s.leftPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd { - s.rightPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd { - s.bottomPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd { - s.leftPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearRightPanel() tea.Cmd { - s.rightPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd { - s.bottomPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) Bindings() []key.Binding { - if s.leftPanel != nil { - if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() { - return b.Bindings() - } - } - if s.rightPanel != nil { - if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() { - return b.Bindings() - } - } - if s.bottomPanel != nil { - if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() { - return b.Bindings() - } - } - return nil -} - -func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd { - panels := map[LayoutPanel]Container{ - LeftPanel: s.leftPanel, - RightPanel: s.rightPanel, - BottomPanel: s.bottomPanel, - } - var cmds []tea.Cmd - for p, container := range panels { - if container == nil { - continue - } - if p == panel { - cmds = append(cmds, container.Focus()) - } else { - cmds = append(cmds, container.Blur()) - } - } - return tea.Batch(cmds...) -} - -// SetOffset implements SplitPaneLayout. -func (s *splitPaneLayout) SetOffset(x int, y int) { - s.xOffset = x - s.yOffset = y -} - -func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout { - layout := &splitPaneLayout{ - ratio: 0.8, - verticalRatio: 0.92, // Default 90% for top section, 10% for bottom - } - for _, option := range options { - option(layout) - } - return layout -} - -func WithLeftPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.leftPanel = panel - } -} - -func WithRightPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.rightPanel = panel - } -} - -func WithRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.ratio = ratio - } -} - -func WithBottomPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.bottomPanel = panel - } -} - -func WithVerticalRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.verticalRatio = ratio - } -} - -func WithFixedBottomHeight(height int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedBottomHeight = height - } -} - -func WithFixedRightWidth(width int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedRightWidth = width - } -} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 3da14602ed2334f21e2af2e01574ccbcad0df8d5..fb0f461d810b74039ad466bfc5ade6e4be36d56f 100644 --- a/internal/tui/components/core/list/keys.go +++ b/internal/tui/components/core/list/keys.go @@ -9,6 +9,8 @@ type KeyMap struct { Up, DownOneItem, UpOneItem, + PageDown, + PageUp, HalfPageDown, HalfPageUp, Home, @@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("d"), key.WithHelp("d", "half page down"), ), - HalfPageUp: key.NewBinding( + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), HalfPageUp: key.NewBinding( key.WithKeys("u"), key.WithHelp("u", "half page up"), ), diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index cb74287aacbb54baebbe26656b0f7f4eff62beb4..3f99eda5d979e72f0497a120e056df10aca228c3 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -41,6 +41,8 @@ type ListModel interface { SelectedIndex() int // Get the index of the currently selected item SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it Filter(string) tea.Cmd // Filter items based on a search term + SetFilterPlaceholder(string) // Set the placeholder text for the filter input + Cursor() *tea.Cursor // Get the current cursor position in the filter input } // HasAnim interface identifies items that support animation. @@ -330,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.scrollDown(m.listHeight() / 2) case key.Matches(msg, m.keyMap.HalfPageUp): m.scrollUp(m.listHeight() / 2) + case key.Matches(msg, m.keyMap.PageDown): + m.scrollDown(m.listHeight()) + case key.Matches(msg, m.keyMap.PageUp): + m.scrollUp(m.listHeight()) case key.Matches(msg, m.keyMap.Home): return m, m.goToTop() case key.Matches(msg, m.keyMap.End): @@ -1359,3 +1365,7 @@ func (m *model) Focus() tea.Cmd { func (m *model) IsFocused() bool { return m.isFocused } + +func (m *model) SetFilterPlaceholder(placeholder string) { + m.input.Placeholder = placeholder +} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index bd87b013e86780d6a52a17f648d5f7479c7d350a..b7339705649f24129dc61c28471f23044ba7dafb 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -98,13 +98,12 @@ func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) { m.keyMap = keyMap } -func NewStatusCmp(keyMap help.KeyMap) StatusCmp { +func NewStatusCmp() StatusCmp { t := styles.CurrentTheme() help := help.New() help.Styles = t.S().Help return &statusCmp{ messageTTL: 5 * time.Second, help: help, - keyMap: keyMap, } } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 140996fdd59af21e27e6eb4017ca5cca847cb0d9..10cdbbd539f06836550b7da6a857d35db3becd74 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -239,16 +240,8 @@ func (c *commandDialogCmp) defaultCommands() []Command { Title: "Initialize Project", Description: "Create/Update the CRUSH.md memory file", Handler: func(cmd Command) tea.Cmd { - prompt := `Please analyze this codebase and create a CRUSH.md file containing: - 1. Build/lint/test commands - especially for running a single test - 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - - 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. - If there's already a CRUSH.md, improve it. - If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. - Add the .crush directory to the .gitignore file if it's not already there.` return util.CmdHandler(chat.SendMsg{ - Text: prompt, + Text: prompt.Initialize(), }) }, }, diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index 97668694e61c5c89e38b9034a249b6cd4a46f37e..990423958cdc41ab4a04afafed71762ab5e7f122 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -14,11 +14,12 @@ type ItemSection interface { util.Model layout.Sizeable list.SectionHeader + SetInfo(info string) } type itemSectionModel struct { - width int - title string - noPadding bool // No padding for the section header + width int + title string + info string } func NewItemSection(title string) ItemSection { @@ -40,7 +41,14 @@ func (m *itemSectionModel) View() string { title := ansi.Truncate(m.title, m.width-2, "…") style := t.S().Base.Padding(1, 1, 0, 1) title = t.S().Muted.Render(title) - return style.Render(core.Section(title, m.width-2)) + section := "" + if m.info != "" { + section = core.SectionWithInfo(title, m.width-2, m.info) + } else { + section = core.Section(title, m.width-2) + } + + return style.Render(section) } func (m *itemSectionModel) GetSize() (int, int) { @@ -55,3 +63,7 @@ func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { func (m *itemSectionModel) IsSectionHeader() bool { return true } + +func (m *itemSectionModel) SetInfo(info string) { + m.info = info +} diff --git a/internal/tui/components/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go deleted file mode 100644 index 66e26bd99f86e5b49fca11ca3daca2f74d5b8656..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/init.go +++ /dev/null @@ -1,214 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/config" - cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const InitDialogID dialogs.DialogID = "init" - -// InitDialogCmp is a component that asks the user if they want to initialize the project. -type InitDialogCmp interface { - dialogs.DialogModel -} - -type initDialogCmp struct { - wWidth, wHeight int - width, height int - selected int - keyMap KeyMap -} - -// NewInitDialogCmp creates a new InitDialogCmp. -func NewInitDialogCmp() InitDialogCmp { - return &initDialogCmp{ - selected: 0, - keyMap: DefaultKeyMap(), - } -} - -// Init implements tea.Model. -func (m *initDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - cmd := m.SetSize() - return m, cmd - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.keyMap.Close): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - case key.Matches(msg, m.keyMap.ChangeSelection): - m.selected = (m.selected + 1) % 2 - return m, nil - case key.Matches(msg, m.keyMap.Select): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(m.selected == 0), - ) - case key.Matches(msg, m.keyMap.Y): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(true), - ) - case key.Matches(msg, m.keyMap.N): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - } - } - return m, nil -} - -func (m *initDialogCmp) renderButtons() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - buttons := []core.ButtonOpts{ - { - Text: "Yes", - UnderlineIndex: 0, // "Y" - Selected: m.selected == 0, - }, - { - Text: "No", - UnderlineIndex: 0, // "N" - Selected: m.selected == 1, - }, - } - - content := core.SelectableButtons(buttons, " ") - - return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content) -} - -func (m *initDialogCmp) renderContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - explanation := t.S().Text. - Width(m.width - 4). - 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.") - - question := t.S().Text. - Width(m.width - 4). - Render("Would you like to initialize this project?") - - return baseStyle.Render(lipgloss.JoinVertical( - lipgloss.Left, - explanation, - "", - question, - )) -} - -func (m *initDialogCmp) render() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - title := core.Title("Initialize Project", m.width-4) - - content := m.renderContent() - buttons := m.renderButtons() - - dialogContent := lipgloss.JoinVertical( - lipgloss.Top, - title, - "", - content, - "", - buttons, - "", - ) - - return baseStyle. - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(m.width). - Render(dialogContent) -} - -// View implements tea.Model. -func (m *initDialogCmp) View() string { - return m.render() -} - -// SetSize sets the size of the component. -func (m *initDialogCmp) SetSize() tea.Cmd { - m.width = min(90, m.wWidth) - m.height = min(15, m.wHeight) - return nil -} - -// ID implements DialogModel. -func (m *initDialogCmp) ID() dialogs.DialogID { - return InitDialogID -} - -// Position implements DialogModel. -func (m *initDialogCmp) Position() (int, int) { - row := (m.wHeight / 2) - (m.height / 2) - col := (m.wWidth / 2) - (m.width / 2) - return row, col -} - -// handleInitialization handles the initialization logic when the dialog is closed. -func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd { - if initialize { - // Run the initialization command - prompt := `Please analyze this codebase and create a CRUSH.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -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. -If there's already a CRUSH.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. -Add the .crush directory to the .gitignore file if it's not already there.` - - // Mark the project as initialized - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - - return tea.Sequence( - util.CmdHandler(cmpChat.SessionClearedMsg{}), - util.CmdHandler(cmpChat.SendMsg{ - Text: prompt, - }), - ) - } else { - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - } - return nil -} - -// CloseInitDialogMsg is a message that is sent when the init dialog is closed. -type CloseInitDialogMsg struct { - Initialize bool -} - -// ShowInitDialogMsg is a message that is sent to show the init dialog. -type ShowInitDialogMsg struct { - Show bool -} diff --git a/internal/tui/components/dialogs/init/keys.go b/internal/tui/components/dialogs/init/keys.go deleted file mode 100644 index afd82d45ea8b47630c2d5ed1450419ae8d4b4c19..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/keys.go +++ /dev/null @@ -1,69 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" -) - -type KeyMap struct { - ChangeSelection, - Select, - Y, - N, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - ChangeSelection: key.NewBinding( - key.WithKeys("tab", "left", "right", "h", "l"), - key.WithHelp("tab/←/→", "toggle selection"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - Y: key.NewBinding( - key.WithKeys("y"), - key.WithHelp("y", "yes"), - ), - N: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "no"), - ), - Close: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Y, - k.N, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go new file mode 100644 index 0000000000000000000000000000000000000000..d5aa034d133d2e4d5cbe676aed0fb7e1edde487c --- /dev/null +++ b/internal/tui/components/dialogs/models/apikey.go @@ -0,0 +1,96 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" +) + +type APIKeyInput struct { + input textinput.Model + width int + height int + providerName string +} + +func NewAPIKeyInput() *APIKeyInput { + t := styles.CurrentTheme() + + ti := textinput.New() + ti.Placeholder = "Enter your API key..." + ti.SetWidth(50) + ti.SetVirtualCursor(false) + ti.Prompt = "> " + ti.SetStyles(t.S().TextInput) + ti.Focus() + + return &APIKeyInput{ + input: ti, + width: 60, + providerName: "Provider", + } +} + +func (a *APIKeyInput) SetProviderName(name string) { + a.providerName = name +} + +func (a *APIKeyInput) Init() tea.Cmd { + return textinput.Blink +} + +func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + } + + var cmd tea.Cmd + a.input, cmd = a.input.Update(msg) + return a, cmd +} + +func (a *APIKeyInput) View() string { + t := styles.CurrentTheme() + + title := t.S().Base. + Foreground(t.Primary). + Bold(true). + Render(fmt.Sprintf("Enter your %s API Key", a.providerName)) + + inputView := a.input.View() + + dataPath := config.GlobalConfigData() + dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1) + helpText := t.S().Muted. + Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath)) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + inputView, + "", + helpText, + ) + + return content +} + +func (a *APIKeyInput) Cursor() *tea.Cursor { + cursor := a.input.Cursor() + if cursor != nil { + cursor.Y += 2 // Adjust for title and spacing + } + return cursor +} + +func (a *APIKeyInput) Value() string { + return a.input.Value() +} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go new file mode 100644 index 0000000000000000000000000000000000000000..8425b8f2c04569749a33867fb7e14e4b628d019e --- /dev/null +++ b/internal/tui/components/dialogs/models/list.go @@ -0,0 +1,202 @@ +package models + +import ( + "fmt" + "slices" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type ModelListComponent struct { + list list.ListModel + modelType int + providers []provider.Provider +} + +func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { + modelList := list.New( + list.WithFilterable(true), + list.WithKeyMap(keyMap), + list.WithInputStyle(inputStyle), + list.WithFilterPlaceholder(inputPlaceholder), + list.WithWrapNavigation(true), + ) + + return &ModelListComponent{ + list: modelList, + modelType: LargeModelType, + } +} + +func (m *ModelListComponent) Init() tea.Cmd { + var cmds []tea.Cmd + if len(m.providers) == 0 { + providers, err := config.Providers() + m.providers = providers + if err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType)) + return tea.Batch(cmds...) +} + +func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { + u, cmd := m.list.Update(msg) + m.list = u.(list.ListModel) + return m, cmd +} + +func (m *ModelListComponent) View() string { + return m.list.View() +} + +func (m *ModelListComponent) Cursor() *tea.Cursor { + return m.list.Cursor() +} + +func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { + return m.list.SetSize(width, height) +} + +func (m *ModelListComponent) Items() []util.Model { + return m.list.Items() +} + +func (m *ModelListComponent) SelectedIndex() int { + return m.list.SelectedIndex() +} + +func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { + t := styles.CurrentTheme() + m.modelType = modelType + + modelItems := []util.Model{} + selectIndex := 0 + + cfg := config.Get() + var currentModel config.SelectedModel + if m.modelType == LargeModelType { + currentModel = cfg.Models[config.SelectedModelTypeLarge] + } else { + currentModel = cfg.Models[config.SelectedModelTypeSmall] + } + + configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon) + configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured")) + + // Create a map to track which providers we've already added + addedProviders := make(map[string]bool) + + // First, add any configured providers that are not in the known providers list + // These should appear at the top of the list + knownProviders := provider.KnownProviders() + for providerID, providerConfig := range cfg.Providers { + if providerConfig.Disable { + continue + } + + // Check if this provider is not in the known providers list + if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) { + // Convert config provider to provider.Provider format + configProvider := provider.Provider{ + Name: providerConfig.Name, + ID: provider.InferenceProvider(providerID), + Models: make([]provider.Model, len(providerConfig.Models)), + } + + // Convert models + for i, model := range providerConfig.Models { + configProvider.Models[i] = provider.Model{ + ID: model.ID, + Model: model.Model, + CostPer1MIn: model.CostPer1MIn, + CostPer1MOut: model.CostPer1MOut, + CostPer1MInCached: model.CostPer1MInCached, + CostPer1MOutCached: model.CostPer1MOutCached, + ContextWindow: model.ContextWindow, + DefaultMaxTokens: model.DefaultMaxTokens, + CanReason: model.CanReason, + HasReasoningEffort: model.HasReasoningEffort, + DefaultReasoningEffort: model.DefaultReasoningEffort, + SupportsImages: model.SupportsImages, + } + } + + // Add this unknown provider to the list + name := configProvider.Name + if name == "" { + name = string(configProvider.ID) + } + section := commands.NewItemSection(name) + section.SetInfo(configured) + modelItems = append(modelItems, section) + for _, model := range configProvider.Models { + modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{ + Provider: configProvider, + Model: model, + })) + if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { + selectIndex = len(modelItems) - 1 // Set the selected index to the current model + } + } + addedProviders[providerID] = true + } + } + + // Then add the known providers from the predefined list + for _, provider := range m.providers { + // Skip if we already added this provider as an unknown provider + if addedProviders[string(provider.ID)] { + continue + } + + // Check if this provider is configured and not disabled + if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable { + continue + } + + name := provider.Name + if name == "" { + name = string(provider.ID) + } + + section := commands.NewItemSection(name) + if _, ok := cfg.Providers[string(provider.ID)]; ok { + section.SetInfo(configured) + } + modelItems = append(modelItems, section) + for _, model := range provider.Models { + modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{ + Provider: provider, + Model: model, + })) + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectIndex = len(modelItems) - 1 // Set the selected index to the current model + } + } + } + + return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex)) +} + +// GetModelType returns the current model type +func (m *ModelListComponent) GetModelType() int { + return m.modelType +} + +func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { + m.list.SetFilterPlaceholder(placeholder) +} + +func (m *ModelListComponent) SetProviders(providers []provider.Provider) { + m.providers = providers +} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index b108a19af789d50ed6f3f63538bc8ce08a4fb21a..a4cb9bd47e81229b343d65660174f843a98503a8 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -1,8 +1,6 @@ package models import ( - "slices" - "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -12,7 +10,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -27,6 +24,9 @@ const ( const ( LargeModelType int = iota SmallModelType + + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" ) // ModelSelectedMsg is sent when a model is selected @@ -53,10 +53,9 @@ type modelDialogCmp struct { wWidth int wHeight int - modelList list.ListModel + modelList *ModelListComponent keyMap KeyMap help help.Model - modelType int } func NewModelDialogCmp() ModelDialog { @@ -75,12 +74,7 @@ func NewModelDialogCmp() ModelDialog { t := styles.CurrentTheme() inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := list.New( - list.WithFilterable(true), - list.WithKeyMap(listKeyMap), - list.WithInputStyle(inputStyle), - list.WithWrapNavigation(true), - ) + modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks") help := help.New() help.Styles = t.S().Help @@ -89,12 +83,10 @@ func NewModelDialogCmp() ModelDialog { width: defaultWidth, keyMap: DefaultKeyMap(), help: help, - modelType: LargeModelType, } } func (m *modelDialogCmp) Init() tea.Cmd { - m.SetModelType(m.modelType) return m.modelList.Init() } @@ -103,7 +95,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.wWidth = msg.Width m.wHeight = msg.Height - m.SetModelType(m.modelType) return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) case tea.KeyPressMsg: switch { @@ -116,7 +107,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption) var modelType config.SelectedModelType - if m.modelType == LargeModelType { + if m.modelList.GetModelType() == LargeModelType { modelType = config.SelectedModelTypeLarge } else { modelType = config.SelectedModelTypeSmall @@ -133,16 +124,18 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }), ) case key.Matches(msg, m.keyMap.Tab): - if m.modelType == LargeModelType { - return m, m.SetModelType(SmallModelType) + if m.modelList.GetModelType() == LargeModelType { + m.modelList.SetInputPlaceholder(smallModelInputPlaceholder) + return m, m.modelList.SetModelType(SmallModelType) } else { - return m, m.SetModelType(LargeModelType) + m.modelList.SetInputPlaceholder(largeModelInputPlaceholder) + return m, m.modelList.SetModelType(LargeModelType) } case key.Matches(msg, m.keyMap.Close): return m, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := m.modelList.Update(msg) - m.modelList = u.(list.ListModel) + m.modelList = u return m, cmd } } @@ -164,12 +157,10 @@ func (m *modelDialogCmp) View() string { } func (m *modelDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := m.modelList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = m.moveCursor(cursor) - return cursor - } + cursor := m.modelList.Cursor() + if cursor != nil { + cursor = m.moveCursor(cursor) + return cursor } return nil } @@ -187,7 +178,8 @@ func (m *modelDialogCmp) listWidth() int { } func (m *modelDialogCmp) listHeight() int { - listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + items := m.modelList.Items() + listHeigh := len(items) + 2 + 4 return min(listHeigh, m.wHeight/2) } @@ -215,115 +207,8 @@ func (m *modelDialogCmp) modelTypeRadio() string { choices := []string{"Large Task", "Small Task"} iconSelected := "◉" iconUnselected := "○" - if m.modelType == LargeModelType { + if m.modelList.GetModelType() == LargeModelType { return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) } return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) } - -func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd { - m.modelType = modelType - - providers, err := config.Providers() - if err != nil { - return util.ReportError(err) - } - - modelItems := []util.Model{} - selectIndex := 0 - - cfg := config.Get() - var currentModel config.SelectedModel - if m.modelType == LargeModelType { - currentModel = cfg.Models[config.SelectedModelTypeLarge] - } else { - currentModel = cfg.Models[config.SelectedModelTypeSmall] - } - - // Create a map to track which providers we've already added - addedProviders := make(map[string]bool) - - // First, add any configured providers that are not in the known providers list - // These should appear at the top of the list - knownProviders := provider.KnownProviders() - for providerID, providerConfig := range cfg.Providers { - if providerConfig.Disable { - continue - } - - // Check if this provider is not in the known providers list - if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) { - // Convert config provider to provider.Provider format - configProvider := provider.Provider{ - Name: string(providerID), // Use provider ID as name for unknown providers - ID: provider.InferenceProvider(providerID), - Models: make([]provider.Model, len(providerConfig.Models)), - } - - // Convert models - for i, model := range providerConfig.Models { - configProvider.Models[i] = provider.Model{ - ID: model.ID, - Model: model.Model, - CostPer1MIn: model.CostPer1MIn, - CostPer1MOut: model.CostPer1MOut, - CostPer1MInCached: model.CostPer1MInCached, - CostPer1MOutCached: model.CostPer1MOutCached, - ContextWindow: model.ContextWindow, - DefaultMaxTokens: model.DefaultMaxTokens, - CanReason: model.CanReason, - HasReasoningEffort: model.HasReasoningEffort, - DefaultReasoningEffort: model.DefaultReasoningEffort, - SupportsImages: model.SupportsImages, - } - } - - // Add this unknown provider to the list - name := configProvider.Name - if name == "" { - name = string(configProvider.ID) - } - modelItems = append(modelItems, commands.NewItemSection(name)) - for _, model := range configProvider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{ - Provider: configProvider, - Model: model, - })) - if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model - } - } - addedProviders[providerID] = true - } - } - - // Then add the known providers from the predefined list - for _, provider := range providers { - // Skip if we already added this provider as an unknown provider - if addedProviders[string(provider.ID)] { - continue - } - - // Check if this provider is configured and not disabled - if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable { - continue - } - - name := provider.Name - if name == "" { - name = string(provider.ID) - } - modelItems = append(modelItems, commands.NewItemSection(name)) - for _, model := range provider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{ - Provider: provider, - Model: model, - })) - if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model - } - } - } - - return tea.Sequence(m.modelList.SetItems(modelItems), m.modelList.SetSelected(selectIndex)) -} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index e7f6dd517b1504e2f938c9310e95b8a7086cbbf0..6bac6e58b37a99b376ad936bbf19f541b999eb4b 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -199,6 +199,13 @@ func (p *permissionDialogCmp) renderButtons() string { } content := core.SelectableButtons(buttons, " ") + if lipgloss.Width(content) > p.width-4 { + content = core.SelectableButtonsVertical(buttons, 1) + return baseStyle.AlignVertical(lipgloss.Center). + AlignHorizontal(lipgloss.Center). + Width(p.width - 4). + Render(content) + } return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content) } @@ -382,19 +389,10 @@ func (p *permissionDialogCmp) generateFetchContent() string { t := styles.CurrentTheme() baseStyle := t.S().Base.Background(t.BgSubtle) if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { - content := fmt.Sprintf("```bash\n%s\n```", pr.URL) - - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 4) - s, err := r.Render(content) - return s, err - }) - finalContent := baseStyle. + Padding(1, 2). Width(p.contentViewPort.Width()). - Render(renderedContent) - + Render(pr.URL) return finalContent } return "" @@ -452,8 +450,8 @@ func (p *permissionDialogCmp) render() string { if p.supportsDiffView() { contentHelp = help.New().View(p.keyMap) } - // Calculate content height dynamically based on window size + // Calculate content height dynamically based on window size strs := []string{ title, "", @@ -491,7 +489,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { switch p.permission.ToolName { case tools.BashToolName: - p.width = int(float64(p.wWidth) * 0.4) + p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.3) case tools.EditToolName: p.width = int(float64(p.wWidth) * 0.8) @@ -500,7 +498,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.8) case tools.FetchToolName: - p.width = int(float64(p.wWidth) * 0.4) + p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.3) default: p.width = int(float64(p.wWidth) * 0.7) diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go index 7a063f79b7c8a9fd34507c1762b1c98842be5ac4..dbd3229e9b6c49b9f59b1a477fac9a5dc1c84d6e 100644 --- a/internal/tui/components/logo/logo.go +++ b/internal/tui/components/logo/logo.go @@ -85,7 +85,7 @@ func Render(version string, compact bool, o Opts) string { } // Right field. - rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap. + rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap. const stepDownAt = 0 rightField := new(strings.Builder) for i := range fieldHeight { @@ -98,7 +98,16 @@ func Render(version string, compact bool, o Opts) string { // Return the wide version. const hGap = " " - return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + if o.Width > 0 { + // Truncate the logo to the specified width. + lines := strings.Split(logo, "\n") + for i, line := range lines { + lines[i] = ansi.Truncate(line, o.Width, "") + } + logo = strings.Join(lines, "\n") + } + return logo } // renderWord renders letterforms to fork a word. diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 22c029dd355845b3ac7e2066a8a93bfb335c1d53..d055870e5ab24816fa002d2ad4f5fc171876d56e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( - key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"), - key.WithHelp("ctrl+?", "more"), + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), ), Commands: key.NewBinding( key.WithKeys("ctrl+p"), @@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap { ), } } - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - slice = k.prependEscAndTab(slice) - slice = append(slice, k.pageBindings...) - // remove duplicates - seen := make(map[string]bool) - cleaned := []key.Binding{} - for _, b := range slice { - if !seen[b.Help().Key] { - seen[b.Help().Key] = true - cleaned = append(cleaned, b) - } - } - - for i := 0; i < len(cleaned); i += 3 { - end := min(i+3, len(cleaned)) - m = append(m, cleaned[i:end]) - } - return m -} - -func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding { - var cancel key.Binding - var tab key.Binding - for _, b := range k.pageBindings { - if b.Help().Key == "esc" { - cancel = b - } - if b.Help().Key == "tab" { - tab = b - } - } - if tab.Help().Key != "" { - bindings = append([]key.Binding{tab}, bindings...) - } - if cancel.Help().Key != "" { - bindings = append([]key.Binding{cancel}, bindings...) - } - return bindings -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - bindings := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - return k.prependEscAndTab(bindings) -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 5c3a769d23ae085b342d330e4089831179ad43bf..33267772e96662f14934a8417149259c7d22541a 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -2,21 +2,30 @@ package chat import ( "context" - "strings" + "runtime" "time" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/chat/editor" "github.com/charmbracelet/crush/internal/tui/components/chat/header" "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" + "github.com/charmbracelet/crush/internal/tui/components/chat/splash" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/page" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -26,154 +35,223 @@ import ( var ChatPageID page.PageID = "chat" -const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode - type ( OpenFilePickerMsg struct{} ChatFocusedMsg struct { - Focused bool // True if the chat input is focused, false otherwise + Focused bool } CancelTimerExpiredMsg struct{} ) +type PanelType string + +const ( + PanelTypeChat PanelType = "chat" + PanelTypeEditor PanelType = "editor" + PanelTypeSplash PanelType = "splash" +) + +const ( + CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode + EditorHeight = 5 // Height of the editor input area including padding + SideBarWidth = 31 // Width of the sidebar + SideBarDetailsPadding = 1 // Padding for the sidebar details section + HeaderHeight = 1 // Height of the header + + // Layout constants for borders and padding + BorderWidth = 1 // Width of component borders + LeftRightBorders = 2 // Left + right border width (1 + 1) + TopBottomBorders = 2 // Top + bottom border width (1 + 1) + DetailsPositioning = 2 // Positioning adjustment for details panel + + // Timing constants + CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires +) + type ChatPage interface { util.Model layout.Help IsChatFocused() bool } +// cancelTimerCmd creates a command that expires the cancel timer +func cancelTimerCmd() tea.Cmd { + return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg { + return CancelTimerExpiredMsg{} + }) +} + type chatPage struct { - wWidth, wHeight int // Window dimensions - app *app.App + width, height int + detailsWidth, detailsHeight int + app *app.App + keyboardEnhancements tea.KeyboardEnhancementsMsg - layout layout.SplitPaneLayout + // Layout state + compact bool + forceCompact bool + focusedPane PanelType + // Session session session.Session + keyMap KeyMap + + // Components + header header.Header + sidebar sidebar.Sidebar + chat chat.MessageListCmp + editor editor.Editor + splash splash.Splash + + // Simple state flags + showingDetails bool + isCanceling bool + splashFullScreen bool + isOnboarding bool + isProjectInit bool +} - keyMap KeyMap - - chatFocused bool - - compactMode bool - forceCompactMode bool // Force compact mode regardless of window size - showDetails bool // Show details in the header - header header.Header - compactSidebar layout.Container - - cancelPending bool // True if ESC was pressed once and waiting for second press +func New(app *app.App) ChatPage { + return &chatPage{ + app: app, + keyMap: DefaultKeyMap(), + header: header.New(app.LSPClients), + sidebar: sidebar.New(app.History, app.LSPClients, false), + chat: chat.New(app), + editor: editor.New(app), + splash: splash.New(), + focusedPane: PanelTypeSplash, + } } func (p *chatPage) Init() tea.Cmd { + cfg := config.Get() + compact := cfg.Options.TUI.CompactMode + p.compact = compact + p.forceCompact = compact + p.sidebar.SetCompactMode(p.compact) + + // Set splash state based on config + if !config.HasInitialDataConfig() { + // First-time setup: show model selection + p.splash.SetOnboarding(true) + p.isOnboarding = true + p.splashFullScreen = true + } else if b, _ := config.ProjectNeedsInitialization(); b { + // Project needs CRUSH.md initialization + p.splash.SetProjectInit(true) + p.isProjectInit = true + p.splashFullScreen = true + } else { + // Ready to chat: focus editor, splash in background + p.focusedPane = PanelTypeEditor + p.splashFullScreen = false + } + return tea.Batch( - p.layout.Init(), - p.compactSidebar.Init(), - p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor), + p.header.Init(), + p.sidebar.Init(), + p.chat.Init(), + p.editor.Init(), + p.splash.Init(), ) } -// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds -func (p *chatPage) cancelTimerCmd() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return CancelTimerExpiredMsg{} - }) -} - func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: - m, cmd := p.layout.Update(msg) - p.layout = m.(layout.SplitPaneLayout) - return p, cmd - case CancelTimerExpiredMsg: - p.cancelPending = false + p.keyboardEnhancements = msg return p, nil case tea.WindowSizeMsg: - h, cmd := p.header.Update(msg) - cmds = append(cmds, cmd) - p.header = h.(header.Header) - cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0)) - // the mode is only relevant when there is a session - if p.session.ID != "" { - // Only auto-switch to compact mode if not forced - if !p.forceCompactMode { - if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint { - p.wWidth = msg.Width - p.wHeight = msg.Height - cmds = append(cmds, p.setCompactMode(true)) - return p, tea.Batch(cmds...) - } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint { - p.wWidth = msg.Width - p.wHeight = msg.Height - return p, p.setCompactMode(false) - } - } - } - p.wWidth = msg.Width - p.wHeight = msg.Height - layoutHeight := msg.Height - if p.compactMode { - // make space for the header - layoutHeight -= 1 + return p, p.SetSize(msg.Width, msg.Height) + case CancelTimerExpiredMsg: + p.isCanceling = false + return p, nil + case chat.SendMsg: + return p, p.sendMessage(msg.Text, msg.Attachments) + case chat.SessionSelectedMsg: + return p, p.setSession(msg) + case commands.ToggleCompactModeMsg: + p.forceCompact = !p.forceCompact + var cmd tea.Cmd + if p.forceCompact { + p.setCompactMode(true) + cmd = p.updateCompactConfig(true) + } else if p.width >= CompactModeBreakpoint { + p.setCompactMode(false) + cmd = p.updateCompactConfig(false) } - cmd = p.layout.SetSize(msg.Width, layoutHeight) + return p, tea.Batch(p.SetSize(p.width, p.height), cmd) + case pubsub.Event[session.Session]: + u, cmd := p.header.Update(msg) + p.header = u.(header.Header) + cmds = append(cmds, cmd) + u, cmd = p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case chat.SessionClearedMsg: + u, cmd := p.header.Update(msg) + p.header = u.(header.Header) + cmds = append(cmds, cmd) + u, cmd = p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) + cmds = append(cmds, cmd) + u, cmd = p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case filepicker.FilePickedMsg, + completions.CompletionsClosedMsg, + completions.SelectCompletionMsg: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + + case pubsub.Event[message.Message], + anim.StepMsg, + spinner.TickMsg: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + + case pubsub.Event[history.File], sidebar.SessionFilesMsg: + u, cmd := p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) - case chat.SendMsg: - cmd := p.sendMessage(msg.Text, msg.Attachments) - if cmd != nil { - return p, cmd - } - case commands.ToggleCompactModeMsg: - // Only allow toggling if window width is larger than compact breakpoint - if p.wWidth > CompactModeBreakpoint { - p.forceCompactMode = !p.forceCompactMode - // If force compact mode is enabled, switch to compact mode - // If force compact mode is disabled, switch based on window size - if p.forceCompactMode { - return p, p.setCompactMode(true) - } else { - // Return to auto mode based on window size - shouldBeCompact := p.wWidth <= CompactModeBreakpoint - return p, p.setCompactMode(shouldBeCompact) - } - } case commands.CommandRunCustomMsg: - // Check if the agent is busy before executing custom commands if p.app.CoderAgent.IsBusy() { return p, util.ReportWarn("Agent is busy, please wait before executing a command...") } - // Handle custom command execution cmd := p.sendMessage(msg.Content, nil) if cmd != nil { return p, cmd } - case chat.SessionSelectedMsg: - if p.session.ID == "" { - cmd := p.setMessages() - if cmd != nil { - cmds = append(cmds, cmd) - } + case splash.OnboardingCompleteMsg: + p.splashFullScreen = false + if b, _ := config.ProjectNeedsInitialization(); b { + p.splash.SetProjectInit(true) + p.splashFullScreen = true + return p, p.SetSize(p.width, p.height) } - needsModeChange := p.session.ID == "" - p.session = msg - p.header.SetSession(msg) - if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) { - cmds = append(cmds, p.setCompactMode(true)) + err := p.app.InitCoderAgent() + if err != nil { + return p, util.ReportError(err) } + p.isOnboarding = false + p.isProjectInit = false + p.focusedPane = PanelTypeEditor + return p, p.SetSize(p.width, p.height) case tea.KeyPressMsg: switch { case key.Matches(msg, p.keyMap.NewSession): - p.session = session.Session{} - return p, tea.Batch( - p.clearMessages(), - util.CmdHandler(chat.SessionClearedMsg{}), - p.setCompactMode(false), - p.layout.FocusPanel(layout.BottomPanel), - util.CmdHandler(ChatFocusedMsg{Focused: false}), - ) + return p, p.newSession() case key.Matches(msg, p.keyMap.AddAttachment): agentCfg := config.Get().Agents["coder"] model := config.Get().GetModelByType(agentCfg.Model) @@ -184,171 +262,290 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, p.keyMap.Tab): if p.session.ID == "" { - return p, nil - } - p.chatFocused = !p.chatFocused - if p.chatFocused { - cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel)) - cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true})) - } else { - cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel)) - cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false})) + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + return p, cmd } - return p, tea.Batch(cmds...) + p.changeFocus() + return p, nil case key.Matches(msg, p.keyMap.Cancel): - if p.session.ID != "" { - if p.cancelPending { - // Second ESC press - actually cancel the session - p.cancelPending = false - p.app.CoderAgent.Cancel(p.session.ID) - return p, nil - } else { - // First ESC press - start the timer - p.cancelPending = true - return p, p.cancelTimerCmd() - } + if p.session.ID != "" && p.app.CoderAgent.IsBusy() { + return p, p.cancel() } case key.Matches(msg, p.keyMap.Details): - if p.session.ID == "" || !p.compactMode { - return p, nil // No session to show details for - } - p.showDetails = !p.showDetails - p.header.SetDetailsOpen(p.showDetails) - if p.showDetails { - return p, tea.Batch() - } - + p.showDetails() return p, nil } + + switch p.focusedPane { + case PanelTypeChat: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + case PanelTypeEditor: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + case PanelTypeSplash: + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + cmds = append(cmds, cmd) + } + case tea.PasteMsg: + switch p.focusedPane { + case PanelTypeEditor: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case PanelTypeChat: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case PanelTypeSplash: + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + } } - u, cmd := p.layout.Update(msg) - cmds = append(cmds, cmd) - p.layout = u.(layout.SplitPaneLayout) - h, cmd := p.header.Update(msg) - p.header = h.(header.Header) - cmds = append(cmds, cmd) - s, cmd := p.compactSidebar.Update(msg) - p.compactSidebar = s.(layout.Container) - cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } -func (p *chatPage) setMessages() tea.Cmd { - messagesContainer := layout.NewContainer( - chat.NewMessagesListCmp(p.app), - layout.WithPadding(1, 1, 0, 1), - ) - return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init()) +func (p *chatPage) Cursor() *tea.Cursor { + switch p.focusedPane { + case PanelTypeEditor: + return p.editor.Cursor() + case PanelTypeSplash: + return p.splash.Cursor() + default: + return nil + } } -func (p *chatPage) setSidebar() tea.Cmd { - sidebarContainer := sidebarCmp(p.app, false, p.session) - sidebarContainer.Init() - return p.layout.SetRightPanel(sidebarContainer) +func (p *chatPage) View() string { + var chatView string + t := styles.CurrentTheme() + + if p.session.ID == "" { + splashView := p.splash.View() + // Full screen during onboarding or project initialization + if p.splashFullScreen { + chatView = splashView + } else { + // Show splash + editor for new message state + editorView := p.editor.View() + chatView = lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Render(splashView), + editorView, + ) + } + } else { + messagesView := p.chat.View() + editorView := p.editor.View() + if p.compact { + headerView := p.header.View() + chatView = lipgloss.JoinVertical( + lipgloss.Left, + headerView, + messagesView, + editorView, + ) + } else { + sidebarView := p.sidebar.View() + messages := lipgloss.JoinHorizontal( + lipgloss.Left, + messagesView, + sidebarView, + ) + chatView = lipgloss.JoinVertical( + lipgloss.Left, + messages, + p.editor.View(), + ) + } + } + + layers := []*lipgloss.Layer{ + lipgloss.NewLayer(chatView).X(0).Y(0), + } + + if p.showingDetails { + style := t.S().Base. + Width(p.detailsWidth). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) + version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version) + details := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + p.sidebar.View(), + version, + ), + ) + layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) + } + canvas := lipgloss.NewCanvas( + layers..., + ) + return canvas.Render() } -func (p *chatPage) clearMessages() tea.Cmd { - return p.layout.ClearLeftPanel() +func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd { + return func() tea.Msg { + err := config.Get().SetCompactMode(compact) + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "Failed to update compact mode configuration: " + err.Error(), + } + } + return nil + } } -func (p *chatPage) setCompactMode(compact bool) tea.Cmd { - p.compactMode = compact - var cmds []tea.Cmd +func (p *chatPage) setCompactMode(compact bool) { + if p.compact == compact { + return + } + p.compact = compact if compact { - // add offset for the header - p.layout.SetOffset(0, 1) - // make space for the header - cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1)) - // remove the sidebar - cmds = append(cmds, p.layout.ClearRightPanel()) - return tea.Batch(cmds...) + p.compact = true + p.sidebar.SetCompactMode(true) } else { - // remove the offset for the header - p.layout.SetOffset(0, 0) - // restore the original size - cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight)) - // set the sidebar - cmds = append(cmds, p.setSidebar()) - l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session)) - p.layout = l.(layout.SplitPaneLayout) - cmds = append(cmds, cmd) + p.compact = false + p.showingDetails = false + p.sidebar.SetCompactMode(false) + } +} - return tea.Batch(cmds...) +func (p *chatPage) handleCompactMode(newWidth int) { + if p.forceCompact { + return + } + if newWidth < CompactModeBreakpoint && !p.compact { + p.setCompactMode(true) + } + if newWidth >= CompactModeBreakpoint && p.compact { + p.setCompactMode(false) } } -func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { +func (p *chatPage) SetSize(width, height int) tea.Cmd { + p.handleCompactMode(width) + p.width = width + p.height = height var cmds []tea.Cmd + if p.session.ID == "" { - session, err := p.app.Sessions.Create(context.Background(), "New Session") - if err != nil { - return util.ReportError(err) + if p.splashFullScreen { + cmds = append(cmds, p.splash.SetSize(width, height)) + } else { + cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) } - - p.session = session - cmd := p.setMessages() - if cmd != nil { - cmds = append(cmds, cmd) + } else { + if p.compact { + cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight)) + p.detailsWidth = width - DetailsPositioning + cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.header.SetWidth(width-BorderWidth)) + } else { + cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight)) } - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) - } - - _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...) - if err != nil { - return util.ReportError(err) + cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) } return tea.Batch(cmds...) } -func (p *chatPage) SetSize(width, height int) tea.Cmd { - return p.layout.SetSize(width, height) +func (p *chatPage) newSession() tea.Cmd { + if p.session.ID == "" { + return nil + } + + p.session = session.Session{} + p.focusedPane = PanelTypeEditor + p.isCanceling = false + return tea.Batch( + util.CmdHandler(chat.SessionClearedMsg{}), + p.SetSize(p.width, p.height), + ) } -func (p *chatPage) GetSize() (int, int) { - return p.layout.GetSize() +func (p *chatPage) setSession(session session.Session) tea.Cmd { + if p.session.ID == session.ID { + return nil + } + + var cmds []tea.Cmd + p.session = session + + cmds = append(cmds, p.SetSize(p.width, p.height)) + cmds = append(cmds, p.chat.SetSession(session)) + cmds = append(cmds, p.sidebar.SetSession(session)) + cmds = append(cmds, p.header.SetSession(session)) + cmds = append(cmds, p.editor.SetSession(session)) + + return tea.Sequence(cmds...) } -func (p *chatPage) View() string { - if !p.compactMode || p.session.ID == "" { - // If not in compact mode or there is no session, we don't show the header - return p.layout.View() +func (p *chatPage) changeFocus() { + if p.session.ID == "" { + return } - layoutView := p.layout.View() - chatView := strings.Join( - []string{ - p.header.View(), - layoutView, - }, "\n", - ) - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(chatView).X(0).Y(0), + switch p.focusedPane { + case PanelTypeChat: + p.focusedPane = PanelTypeEditor + p.editor.Focus() + p.chat.Blur() + case PanelTypeEditor: + p.focusedPane = PanelTypeChat + p.chat.Focus() + p.editor.Blur() } - if p.showDetails { - t := styles.CurrentTheme() - style := t.S().Base. - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version) - details := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - p.compactSidebar.View(), - version, - ), - ) - layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) +} + +func (p *chatPage) cancel() tea.Cmd { + if p.isCanceling { + p.isCanceling = false + p.app.CoderAgent.Cancel(p.session.ID) + return nil } - canvas := lipgloss.NewCanvas( - layers..., - ) - return canvas.Render() + + p.isCanceling = true + return cancelTimerCmd() } -func (p *chatPage) Cursor() *tea.Cursor { - if v, ok := p.layout.(util.Cursor); ok { - return v.Cursor() +func (p *chatPage) showDetails() { + if p.session.ID == "" || !p.compact { + return + } + p.showingDetails = !p.showingDetails + p.header.SetDetailsOpen(p.showingDetails) +} + +func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { + session := p.session + var cmds []tea.Cmd + if p.session.ID == "" { + newSession, err := p.app.Sessions.Create(context.Background(), "New Session") + if err != nil { + return util.ReportError(err) + } + session = newSession + cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) + } + _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...) + if err != nil { + return util.ReportError(err) } - return nil + return tea.Batch(cmds...) } func (p *chatPage) Bindings() []key.Binding { @@ -356,9 +553,9 @@ func (p *chatPage) Bindings() []key.Binding { p.keyMap.NewSession, p.keyMap.AddAttachment, } - if p.app.CoderAgent.IsBusy() { + if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() { cancelBinding := p.keyMap.Cancel - if p.cancelPending { + if p.isCanceling { cancelBinding = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "press again to cancel"), @@ -367,61 +564,275 @@ func (p *chatPage) Bindings() []key.Binding { bindings = append([]key.Binding{cancelBinding}, bindings...) } - if p.chatFocused { + switch p.focusedPane { + case PanelTypeChat: bindings = append([]key.Binding{ key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "focus editor"), ), }, bindings...) - } else { + bindings = append(bindings, p.chat.Bindings()...) + case PanelTypeEditor: bindings = append([]key.Binding{ key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "focus chat"), ), }, bindings...) + bindings = append(bindings, p.editor.Bindings()...) + case PanelTypeSplash: + bindings = append(bindings, p.splash.Bindings()...) } - bindings = append(bindings, p.layout.Bindings()...) return bindings } -func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container { - padding := layout.WithPadding(1, 1, 1, 1) - if compact { - padding = layout.WithPadding(0, 1, 1, 1) - } - sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact) - if session.ID != "" { - sidebar.SetSession(session) +func (a *chatPage) Help() help.KeyMap { + var shortList []key.Binding + var fullList [][]key.Binding + switch { + case a.isOnboarding && !a.splash.IsShowingAPIKey(): + shortList = append(shortList, + // Choose model + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ), + // Accept selection + key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "accept"), + ), + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isOnboarding && a.splash.IsShowingAPIKey(): + var pasteKey key.Binding + if runtime.GOOS != "darwin" { + pasteKey = key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste API key"), + ) + } else { + pasteKey = key.NewBinding( + key.WithKeys("cmd+v"), + key.WithHelp("cmd+v", "paste API key"), + ) + } + shortList = append(shortList, + // Go back + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + // Paste + pasteKey, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isProjectInit: + shortList = append(shortList, + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + default: + if a.editor.IsCompletionsOpen() { + shortList = append(shortList, + key.NewBinding( + key.WithKeys("tab", "enter"), + key.WithHelp("tab/enter", "complete"), + ), + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ), + ) + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + return core.NewSimpleHelp(shortList, fullList) + } + if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { + cancelBinding := key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ) + if a.isCanceling { + cancelBinding = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "press again to cancel"), + ) + } + shortList = append(shortList, cancelBinding) + fullList = append(fullList, + []key.Binding{ + cancelBinding, + }, + ) + } + globalBindings := []key.Binding{} + // we are in a session + if a.session.ID != "" { + tabKey := key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus chat"), + ) + if a.focusedPane == PanelTypeChat { + tabKey = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus editor"), + ) + } + shortList = append(shortList, tabKey) + globalBindings = append(globalBindings, tabKey) + } + commandsBinding := key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ) + helpBinding := key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), + ) + globalBindings = append(globalBindings, commandsBinding) + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + ) + if a.session.ID != "" { + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new sessions"), + )) + } + shortList = append(shortList, + // Commands + commandsBinding, + ) + fullList = append(fullList, globalBindings) + + if a.focusedPane == PanelTypeChat { + shortList = append(shortList, + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + ) + fullList = append(fullList, + []key.Binding{ + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "next/prev item"), + ), + key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + }, + []key.Binding{ + key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "hone"), + ), + key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + }, + ) + } else if a.focusedPane == PanelTypeEditor { + newLineBinding := key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help text + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + if a.keyboardEnhancements.SupportsKeyDisambiguation() { + newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc) + } + shortList = append(shortList, newLineBinding) + fullList = append(fullList, + []key.Binding{ + newLineBinding, + key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ), + key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ), + key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "open editor"), + ), + }) + } + shortList = append(shortList, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + // Help + helpBinding, + ) + fullList = append(fullList, []key.Binding{ + key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "less"), + ), + }) } - return layout.NewContainer( - sidebar, - padding, - ) -} - -func NewChatPage(app *app.App) ChatPage { - editorContainer := layout.NewContainer( - editor.NewEditorCmp(app), - ) - return &chatPage{ - app: app, - layout: layout.NewSplitPane( - layout.WithRightPanel(sidebarCmp(app, false, session.Session{})), - layout.WithBottomPanel(editorContainer), - layout.WithFixedBottomHeight(5), - layout.WithFixedRightWidth(31), - ), - compactSidebar: sidebarCmp(app, true, session.Session{}), - keyMap: DefaultKeyMap(), - header: header.New(app.LSPClients), - } + return core.NewSimpleHelp(shortList, fullList) } -// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false) func (p *chatPage) IsChatFocused() bool { - return p.chatFocused + return p.focusedPane == PanelTypeChat } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9efecb2ac9aab48bd24881176b1f1b530c1db658..633766a1d80bf8b0056e8d856b71df04613e1101 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,13 +13,13 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/status" "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -88,22 +88,7 @@ func (a appModel) Init() tea.Cmd { cmd = a.status.Init() cmds = append(cmds, cmd) - // Check if we should show the init dialog - cmds = append(cmds, func() tea.Msg { - shouldShow, err := config.ProjectNeedsInitialization() - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to check init status: " + err.Error(), - } - } - if shouldShow { - return dialogs.OpenDialogMsg{ - Model: initDialog.NewInitDialogCmp(), - } - } - return nil - }) + cmds = append(cmds, tea.EnableMouseAllMotion) return tea.Batch(cmds...) } @@ -124,6 +109,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) case tea.WindowSizeMsg: + a.completions.Update(msg) return a, a.handleWindowResize(msg.Width, msg.Height) // Completions messages @@ -134,9 +120,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Dialog messages case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: + u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{}) + a.completions = u.(completions.Completions) u, dialogCmd := a.dialog.Update(msg) a.dialog = u.(dialogs.DialogCmp) - return a, dialogCmd + return a, tea.Batch(completionCmd, dialogCmd) case commands.ShowArgumentsDialogMsg: return a, util.CmdHandler( dialogs.OpenDialogMsg{ @@ -186,7 +174,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Model Switch case models.ModelSelectedMsg: - config.UpdatePreferredModel(msg.ModelType, msg.Model) + config.Get().UpdatePreferredModel(msg.ModelType, msg.Model) // Update the agent with the new model/provider configuration if err := a.app.UpdateAgentModel(); err != nil { @@ -273,7 +261,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { var cmds []tea.Cmd a.wWidth, a.wHeight = width, height if a.showingFullHelp { - height -= 4 + height -= 5 } else { height -= 2 } @@ -400,10 +388,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { // View renders the complete application interface including pages, dialogs, and overlays. func (a *appModel) View() tea.View { page := a.pages[a.currentPage] - if withHelp, ok := page.(layout.Help); ok { - a.keyMap.pageBindings = withHelp.Bindings() + if withHelp, ok := page.(core.KeyMapHelp); ok { + a.status.SetKeyMap(withHelp.Help()) } - a.status.SetKeyMap(a.keyMap) pageView := page.View() components := []string{ pageView, @@ -456,14 +443,14 @@ func (a *appModel) View() tea.View { // New creates and initializes a new TUI application model. func New(app *app.App) tea.Model { - chatPage := chat.NewChatPage(app) + chatPage := chat.New(app) keyMap := DefaultKeyMap() keyMap.pageBindings = chatPage.Bindings() model := &appModel{ currentPage: chat.ChatPageID, app: app, - status: status.NewStatusCmp(keyMap), + status: status.NewStatusCmp(), loadedPages: make(map[page.PageID]bool), keyMap: keyMap, diff --git a/vendor/github.com/charmbracelet/bubbletea/v2/tea.go b/vendor/github.com/charmbracelet/bubbletea/v2/tea.go index 5320e4463a3b673a2a980009ed2fb25bc2853c71..1a6be630366d40aaac4706f9ee1e775306091c4e 100644 --- a/vendor/github.com/charmbracelet/bubbletea/v2/tea.go +++ b/vendor/github.com/charmbracelet/bubbletea/v2/tea.go @@ -624,6 +624,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { switch msg.Mode { case ansi.AltScreenSaveCursorMode: p.renderer.enterAltScreen() + // Main and alternate screen have their own Kitty keyboard + // stack. We need to request keyboard enhancements again + // when entering/exiting the alternate screen. + p.requestKeyboardEnhancements() case ansi.TextCursorEnableMode: p.renderer.showCursor() case ansi.GraphemeClusteringMode: @@ -645,6 +649,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { switch msg.Mode { case ansi.AltScreenSaveCursorMode: p.renderer.exitAltScreen() + // Main and alternate screen have their own Kitty keyboard + // stack. We need to request keyboard enhancements again + // when entering/exiting the alternate screen. + p.requestKeyboardEnhancements() case ansi.TextCursorEnableMode: p.renderer.hideCursor() default: @@ -1410,13 +1418,16 @@ func (p *Program) stopRenderer(kill bool) { // requestKeyboardEnhancements tries to enable keyboard enhancements and read // the active keyboard enhancements from the terminal. func (p *Program) requestKeyboardEnhancements() { + // XXX: We write to the renderer directly so that we synchronize with the + // alt-screen state of the renderer. This is because the main screen and + // alternate screen have their own Kitty keyboard state stack. if p.requestedEnhancements.modifyOtherKeys > 0 { - p.execute(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd - p.execute(ansi.QueryModifyOtherKeys) + _, _ = p.renderer.writeString(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd + _, _ = p.renderer.writeString(ansi.QueryModifyOtherKeys) } if p.requestedEnhancements.kittyFlags > 0 { - p.execute(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags)) - p.execute(ansi.RequestKittyKeyboard) + _, _ = p.renderer.writeString(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags)) + _, _ = p.renderer.writeString(ansi.RequestKittyKeyboard) } } diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/get.go b/vendor/github.com/charmbracelet/lipgloss/v2/get.go index c54cf647de0e60e0250880d7a3ac662e857dc3b6..350bbc79edd06d313129032a6f95d6b112c8ba93 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/get.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/get.go @@ -135,6 +135,16 @@ func (s Style) GetPaddingLeft() int { return s.getAsInt(paddingLeftKey) } +// GetPaddingChar returns the style's padding character. If no value is set a +// space (`\u0020`) is returned. +func (s Style) GetPaddingChar() rune { + char := s.getAsRune(paddingCharKey) + if char == 0 { + return ' ' + } + return char +} + // GetHorizontalPadding returns the style's left and right padding. Unset // values are measured as 0. func (s Style) GetHorizontalPadding() int { @@ -186,6 +196,16 @@ func (s Style) GetMarginLeft() int { return s.getAsInt(marginLeftKey) } +// GetMarginChar returns the style's padding character. If no value is set a +// space (`\u0020`) is returned. +func (s Style) GetMarginChar() rune { + char := s.getAsRune(marginCharKey) + if char == 0 { + return ' ' + } + return char +} + // GetHorizontalMargins returns the style's left and right margins. Unset // values are measured as 0. func (s Style) GetHorizontalMargins() int { @@ -432,6 +452,19 @@ func (s Style) isSet(k propKey) bool { return s.props.has(k) } +func (s Style) getAsRune(k propKey) rune { + if !s.isSet(k) { + return 0 + } + switch k { //nolint:exhaustive + case paddingCharKey: + return s.paddingChar + case marginCharKey: + return s.marginChar + } + return 0 +} + func (s Style) getAsBool(k propKey, defaultVal bool) bool { if !s.isSet(k) { return defaultVal diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/set.go b/vendor/github.com/charmbracelet/lipgloss/v2/set.go index 0934c9c205ba4879a844c9b42eaa994d1ca9d106..32893668b6ade932583f9151b2563eece25cef1d 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/set.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/set.go @@ -29,6 +29,8 @@ func (s *Style) set(key propKey, value any) { s.paddingBottom = max(0, value.(int)) case paddingLeftKey: s.paddingLeft = max(0, value.(int)) + case paddingCharKey: + s.paddingChar = value.(rune) case marginTopKey: s.marginTop = max(0, value.(int)) case marginRightKey: @@ -39,6 +41,8 @@ func (s *Style) set(key propKey, value any) { s.marginLeft = max(0, value.(int)) case marginBackgroundKey: s.marginBgColor = colorOrNil(value) + case marginCharKey: + s.marginChar = value.(rune) case borderStyleKey: s.borderStyle = value.(Border) case borderTopForegroundKey: @@ -111,6 +115,8 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(paddingBottomKey, i.paddingBottom) case paddingLeftKey: s.set(paddingLeftKey, i.paddingLeft) + case paddingCharKey: + s.set(paddingCharKey, i.paddingChar) case marginTopKey: s.set(marginTopKey, i.marginTop) case marginRightKey: @@ -121,6 +127,8 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(marginLeftKey, i.marginLeft) case marginBackgroundKey: s.set(marginBackgroundKey, i.marginBgColor) + case marginCharKey: + s.set(marginCharKey, i.marginChar) case borderStyleKey: s.set(borderStyleKey, i.borderStyle) case borderTopForegroundKey: @@ -320,6 +328,18 @@ func (s Style) PaddingBottom(i int) Style { return s } +// PaddingChar sets the character used for padding. This is useful for +// rendering blocks with a specific character, such as a space or a dot. +// Example of using [NBSP] as padding to prevent line breaks: +// +// ```go +// s := lipgloss.NewStyle().PaddingChar(lipgloss.NBSP) +// ``` +func (s Style) PaddingChar(r rune) Style { + s.set(paddingCharKey, r) + return s +} + // ColorWhitespace determines whether or not the background color should be // applied to the padding. This is true by default as it's more than likely the // desired and expected behavior, but it can be disabled for certain graphic @@ -390,6 +410,13 @@ func (s Style) MarginBackground(c color.Color) Style { return s } +// MarginChar sets the character used for the margin. This is useful for +// rendering blocks with a specific character, such as a space or a dot. +func (s Style) MarginChar(r rune) Style { + s.set(marginCharKey, r) + return s +} + // Border is shorthand for setting the border style and which sides should // have a border at once. The variadic argument sides works as follows: // diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/style.go b/vendor/github.com/charmbracelet/lipgloss/v2/style.go index 5a40a94a367c2070f73cd8815aa349302dae01aa..ef13f6a4476cd1c3c0e4619795dd62d92804d7e1 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/style.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/style.go @@ -10,7 +10,8 @@ import ( ) const ( - nbsp = '\u00A0' + // NBSP is the non-breaking space rune. + NBSP = '\u00A0' tabWidthDefault = 4 ) @@ -44,6 +45,7 @@ const ( paddingRightKey paddingBottomKey paddingLeftKey + paddingCharKey // Margins. marginTopKey @@ -51,6 +53,7 @@ const ( marginBottomKey marginLeftKey marginBackgroundKey + marginCharKey // Border runes. borderStyleKey @@ -128,12 +131,14 @@ type Style struct { paddingRight int paddingBottom int paddingLeft int + paddingChar rune marginTop int marginRight int marginBottom int marginLeft int marginBgColor color.Color + marginChar rune borderStyle Border borderTopFgColor color.Color @@ -387,23 +392,24 @@ func (s Style) Render(strs ...string) string { // Padding if !inline { //nolint:nestif + padChar := s.paddingChar + if padChar == 0 { + padChar = ' ' + } if leftPadding > 0 { var st *ansi.Style if colorWhitespace || styleWhitespace { st = &teWhitespace } - str = padLeft(str, leftPadding, st, nbsp) + str = padLeft(str, leftPadding, st, padChar) } - // XXX: We use a non-breaking space to pad so that the padding is - // preserved when the string is copied and pasted. - if rightPadding > 0 { var st *ansi.Style if colorWhitespace || styleWhitespace { st = &teWhitespace } - str = padRight(str, rightPadding, st, nbsp) + str = padRight(str, rightPadding, st, padChar) } if topPadding > 0 { @@ -494,8 +500,12 @@ func (s Style) applyMargins(str string, inline bool) string { } // Add left and right margin - str = padLeft(str, leftMargin, &style, ' ') - str = padRight(str, rightMargin, &style, ' ') + marginChar := s.marginChar + if marginChar == 0 { + marginChar = ' ' + } + str = padLeft(str, leftMargin, &style, marginChar) + str = padRight(str, rightMargin, &style, marginChar) // Top/bottom margin if !inline { diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/unset.go b/vendor/github.com/charmbracelet/lipgloss/v2/unset.go index 1086e722686bfa48a5910c5aac110057977a501d..b6f96607c0e0c8b31f23540ef492f4d25e22c346 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/unset.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/unset.go @@ -96,6 +96,13 @@ func (s Style) UnsetPadding() Style { s.unset(paddingRightKey) s.unset(paddingTopKey) s.unset(paddingBottomKey) + s.unset(paddingCharKey) + return s +} + +// UnsetPaddingChar removes the padding character style rule, if set. +func (s Style) UnsetPaddingChar() Style { + s.unset(paddingCharKey) return s } diff --git a/vendor/modules.txt b/vendor/modules.txt index c71fa56d7160a1c9658c08d4a2a1189db7e85713..33d95285eebb41a1038aa2d95233bbcc96a87151 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -254,8 +254,8 @@ github.com/charmbracelet/bubbles/v2/spinner github.com/charmbracelet/bubbles/v2/textarea github.com/charmbracelet/bubbles/v2/textinput github.com/charmbracelet/bubbles/v2/viewport -# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 -## explicit; go 1.24.3 +# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 +## explicit; go 1.24.0 github.com/charmbracelet/bubbletea/v2 # github.com/charmbracelet/colorprofile v0.3.1 ## explicit; go 1.23.0 @@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2 github.com/charmbracelet/glamour/v2/ansi github.com/charmbracelet/glamour/v2/internal/autolink github.com/charmbracelet/glamour/v2/styles -# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb ## explicit; go 1.24.2 github.com/charmbracelet/lipgloss/v2 github.com/charmbracelet/lipgloss/v2/table @@ -838,5 +838,5 @@ mvdan.cc/sh/v3/fileutil mvdan.cc/sh/v3/interp mvdan.cc/sh/v3/pattern mvdan.cc/sh/v3/syntax -# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 -# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 +# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb