Merge pull request #95 from charmbracelet/onboarding

Kujtim Hoxha created

Rewrite Chat and Onboarding

Change summary

cmd/root.go                                                | 142 -
go.mod                                                     |  22 
go.sum                                                     |  12 
internal/app/app.go                                        | 171 +
internal/app/lsp.go                                        |   4 
internal/config/config.go                                  |  93 
internal/config/init.go                                    |   8 
internal/config/load.go                                    |  91 
internal/config/load_test.go                               |   9 
internal/fsext/fileutil.go                                 |  13 
internal/llm/prompt/initialize.go                          |  14 
internal/tui/components/chat/chat.go                       |  38 
internal/tui/components/chat/editor/editor.go              |  38 
internal/tui/components/chat/editor/keys.go                |  13 
internal/tui/components/chat/header/header.go              |  17 
internal/tui/components/chat/messages/messages.go          |   7 
internal/tui/components/chat/messages/renderer.go          |  20 
internal/tui/components/chat/messages/tool.go              |   4 
internal/tui/components/chat/sidebar/sidebar.go            |  62 
internal/tui/components/chat/splash/keys.go                |  41 
internal/tui/components/chat/splash/splash.go              | 583 ++++
internal/tui/components/completions/completions.go         |  10 
internal/tui/components/completions/item.go                |   1 
internal/tui/components/completions/keys.go                |   2 
internal/tui/components/core/core.go                       |  59 
internal/tui/components/core/layout/container.go           | 263 --
internal/tui/components/core/layout/layout.go              |   7 
internal/tui/components/core/layout/split.go               | 380 ---
internal/tui/components/core/list/keys.go                  |  11 
internal/tui/components/core/list/list.go                  |  10 
internal/tui/components/core/status/status.go              |   3 
internal/tui/components/dialogs/commands/commands.go       |  11 
internal/tui/components/dialogs/commands/item.go           |  20 
internal/tui/components/dialogs/init/init.go               | 214 -
internal/tui/components/dialogs/init/keys.go               |  69 
internal/tui/components/dialogs/models/apikey.go           |  96 
internal/tui/components/dialogs/models/list.go             | 202 +
internal/tui/components/dialogs/models/models.go           | 153 -
internal/tui/components/dialogs/permissions/permissions.go |  26 
internal/tui/components/logo/logo.go                       |  13 
internal/tui/keys.go                                       |  63 
internal/tui/page/chat/chat.go                             | 935 +++++--
internal/tui/tui.go                                        |  37 
vendor/github.com/charmbracelet/bubbletea/v2/tea.go        |  19 
vendor/github.com/charmbracelet/lipgloss/v2/get.go         |  33 
vendor/github.com/charmbracelet/lipgloss/v2/set.go         |  27 
vendor/github.com/charmbracelet/lipgloss/v2/style.go       |  26 
vendor/github.com/charmbracelet/lipgloss/v2/unset.go       |   7 
vendor/modules.txt                                         |  10 
49 files changed, 2,302 insertions(+), 1,807 deletions(-)

Detailed changes

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(),

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
 )

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=

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()
+		}
+	}
 }

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)

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
 }

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
+}

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
+}

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) {

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.")
 		}
 	}
 }

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."
+}

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.

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(),

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,
 	}
 }
 

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
 }

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(

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 "<Tool>: 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, "")
 }

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())
 }

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()

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"),
 		),
 	}
 }

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
 }

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),

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 {

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(

internal/tui/components/core/helpers.go → 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()

internal/tui/components/core/layout/container.go 🔗

@@ -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())
-}

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
-}

internal/tui/components/core/layout/split.go 🔗

@@ -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
-	}
-}

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"),
 		),

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
+}

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,
 	}
 }

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(),
 				})
 			},
 		},

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
+}

internal/tui/components/dialogs/init/init.go 🔗

@@ -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
-}

internal/tui/components/dialogs/init/keys.go 🔗

@@ -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,
-	}
-}

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()
+}

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
+}

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))
-}

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)

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.

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)
-}

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
 }

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,
 

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)
 	}
 }
 

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

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:
 //

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 {

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
 }
 

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