Merge remote-tracking branch 'origin/main' into autoupdater

Carlos Alexandro Becker created

Change summary

.goreleaser.yml                                            |  1 
README.md                                                  |  2 
Taskfile.yaml                                              | 12 +
go.mod                                                     |  6 
go.sum                                                     | 12 
internal/cmd/logs.go                                       |  5 
internal/cmd/root.go                                       |  5 
internal/config/provider.go                                | 34 -----
internal/config/provider_test.go                           |  2 
internal/format/spinner.go                                 |  4 
internal/log/http.go                                       | 38 +++--
internal/tui/components/anim/anim.go                       |  3 
internal/tui/components/chat/chat.go                       |  2 
internal/tui/components/chat/editor/editor.go              |  4 
internal/tui/components/chat/header/header.go              |  2 
internal/tui/components/chat/messages/messages.go          |  6 
internal/tui/components/chat/messages/tool.go              |  6 
internal/tui/components/chat/sidebar/sidebar.go            |  2 
internal/tui/components/chat/splash/splash.go              |  2 
internal/tui/components/completions/completions.go         |  4 
internal/tui/components/core/status/status.go              |  2 
internal/tui/components/dialogs/commands/arguments.go      |  2 
internal/tui/components/dialogs/commands/commands.go       |  2 
internal/tui/components/dialogs/compact/compact.go         |  2 
internal/tui/components/dialogs/dialogs.go                 | 10 +
internal/tui/components/dialogs/filepicker/filepicker.go   |  2 
internal/tui/components/dialogs/models/apikey.go           |  3 
internal/tui/components/dialogs/models/models.go           |  2 
internal/tui/components/dialogs/permissions/permissions.go |  2 
internal/tui/components/dialogs/quit/quit.go               |  2 
internal/tui/components/dialogs/reasoning/reasoning.go     |  2 
internal/tui/components/dialogs/sessions/sessions.go       |  2 
internal/tui/exp/list/filterable.go                        | 50 ++++---
internal/tui/exp/list/filterable_group.go                  |  3 
internal/tui/exp/list/grouped.go                           |  2 
internal/tui/exp/list/items.go                             |  5 
internal/tui/exp/list/list.go                              |  4 
internal/tui/exp/list/list_test.go                         |  5 
internal/tui/page/chat/chat.go                             |  2 
internal/tui/tui.go                                        | 36 +----
internal/tui/util/util.go                                  |  5 
41 files changed, 144 insertions(+), 153 deletions(-)

Detailed changes

.goreleaser.yml 🔗

@@ -303,6 +303,7 @@ changelog:
       - "^docs: update$"
       - "^test:"
       - "^test\\("
+      - "^v\\d.*"
       - "merge conflict"
       - "merge conflict"
       - Merge branch

README.md 🔗

@@ -650,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
 We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
 
 - [Twitter](https://twitter.com/charmcli)
-- [Discord][discord]
 - [Slack](https://charm.land/slack)
+- [Discord][discord]
 - [The Fediverse](https://mastodon.social/@charmcli)
 - [Bluesky](https://bsky.app/profile/charm.land)
 

Taskfile.yaml 🔗

@@ -2,6 +2,10 @@
 
 version: "3"
 
+vars:
+  VERSION:
+    sh: git describe --long 2>/dev/null || echo ""
+
 env:
   CGO_ENABLED: 0
   GOEXPERIMENT: greenteagc
@@ -30,8 +34,10 @@ tasks:
 
   build:
     desc: Run build
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go build .
+      - go build {{.LDFLAGS}} .
     generates:
       - crush
 
@@ -59,8 +65,10 @@ tasks:
 
   install:
     desc: Install the application
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go install -v .
+      - go install {{.LDFLAGS}} -v .
 
   profile:cpu:
     desc: 10s CPU profile

go.mod 🔗

@@ -13,7 +13,7 @@ require (
 	github.com/charlievieth/fastwalk v1.0.14
 	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5
 	github.com/charmbracelet/catwalk v0.7.0
 	github.com/charmbracelet/fang v0.4.3
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
@@ -74,7 +74,7 @@ require (
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
 	github.com/charmbracelet/colorprofile v0.3.2
-	github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
+	github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
 	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
@@ -148,7 +148,7 @@ require (
 	golang.org/x/net v0.43.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.36.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
 	golang.org/x/term v0.35.0 // indirect
 	golang.org/x/text v0.30.0
 	golang.org/x/time v0.8.0 // indirect

go.sum 🔗

@@ -78,8 +78,8 @@ github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
 github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio=
 github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
 github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
@@ -92,8 +92,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
 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-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
-github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
+github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 h1:Lr+igmzKpLPdb8yUZBP9noYWwCZP042z2nWPrJZTc+8=
+github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731/go.mod h1:KfWwUa0Oe//D72YlhbOq/g40L7UiGtATrvsGI3cciG8=
 github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
 github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI=
@@ -388,8 +388,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

internal/cmd/logs.go 🔗

@@ -10,8 +10,10 @@ import (
 	"slices"
 	"time"
 
+	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/x/term"
 	"github.com/nxadm/tail"
 	"github.com/spf13/cobra"
 )
@@ -45,6 +47,9 @@ var logsCmd = &cobra.Command{
 
 		log.SetLevel(log.DebugLevel)
 		log.SetOutput(os.Stdout)
+		if !term.IsTerminal(os.Stdout.Fd()) {
+			log.SetColorProfile(colorprofile.NoTTY)
+		}
 
 		cfg, err := config.Load(cwd, dataDir, false)
 		if err != nil {

internal/cmd/root.go 🔗

@@ -83,11 +83,8 @@ crush -y
 		// Set up the TUI.
 		program := tea.NewProgram(
 			tui.New(app),
-			tea.WithAltScreen(),
 			tea.WithContext(cmd.Context()),
-			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
-			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
-		)
+			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 
 		go app.Subscribe(program)
 

internal/config/provider.go 🔗

@@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
 }
 
 func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
-	cacheIsStale, cacheExists := isCacheStale(path)
+	_, cacheExists := isCacheStale(path)
 
 	catwalkGetAndSave := func() ([]catwalk.Provider, error) {
 		providers, err := client.GetProviders()
@@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		return providers, nil
 	}
 
-	backgroundCacheUpdate := func() {
-		go func() {
-			slog.Info("Updating providers cache in background", "path", path)
-
-			providers, err := client.GetProviders()
-			if err != nil {
-				slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
-				return
-			}
-			if len(providers) == 0 {
-				slog.Error("Empty providers list from Catwalk")
-				return
-			}
-			if err := saveProvidersInCache(path, providers); err != nil {
-				slog.Error("Failed to update providers.json in background", "error", err)
-			}
-		}()
-	}
-
 	switch {
 	case autoUpdateDisabled:
 		slog.Warn("Providers auto-update is disabled")
@@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		}
 		return providers, nil
 
-	case cacheExists && !cacheIsStale:
-		slog.Info("Recent providers cache is available.", "path", path)
-
-		providers, err := loadProvidersFromCache(path)
-		if err != nil {
-			return nil, err
-		}
-		if len(providers) == 0 {
-			return catwalkGetAndSave()
-		}
-		backgroundCacheUpdate()
-		return providers, nil
-
 	default:
 		slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
 

internal/config/provider_test.go 🔗

@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
 	if err != nil {
 		t.Fatalf("Failed to write old providers to file: %v", err)
 	}
-	providers, err := loadProviders(false, client, tmpPath)
+	providers, err := loadProviders(true, client, tmpPath)
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Len(t, providers, 1)

internal/format/spinner.go 🔗

@@ -23,8 +23,8 @@ type model struct {
 	anim   *anim.Anim
 }
 
-func (m model) Init() tea.Cmd { return m.anim.Init() }
-func (m model) View() string  { return m.anim.View() }
+func (m model) Init() tea.Cmd  { return m.anim.Init() }
+func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
 
 // Update implements tea.Model.
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

internal/log/http.go 🔗

@@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 		return nil, err
 	}
 
-	slog.Debug(
-		"HTTP Request",
-		"method", req.Method,
-		"url", req.URL,
-		"body", bodyToString(save),
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Request",
+			"method", req.Method,
+			"url", req.URL,
+			"body", bodyToString(save),
+		)
+	}
 
 	start := time.Now()
 	resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 	}
 
 	save, resp.Body, err = drainBody(resp.Body)
-	slog.Debug(
-		"HTTP Response",
-		"status_code", resp.StatusCode,
-		"status", resp.Status,
-		"headers", formatHeaders(resp.Header),
-		"body", bodyToString(save),
-		"content_length", resp.ContentLength,
-		"duration_ms", duration.Milliseconds(),
-		"error", err,
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Response",
+			"status_code", resp.StatusCode,
+			"status", resp.Status,
+			"headers", formatHeaders(resp.Header),
+			"body", bodyToString(save),
+			"content_length", resp.ContentLength,
+			"duration_ms", duration.Milliseconds(),
+			"error", err,
+		)
+	}
 	return resp, err
 }
 
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
 		return ""
 	}
 	var b bytes.Buffer
-	if json.Compact(&b, bytes.TrimSpace(src)) != nil {
+	if json.Indent(&b, bytes.TrimSpace(src), "", "  ") != nil {
 		// not json probably
 		return string(src)
 	}

internal/tui/components/anim/anim.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/lucasb-eyer/go-colorful"
 
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
 const (
@@ -318,7 +319,7 @@ func (a *Anim) Init() tea.Cmd {
 }
 
 // Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case StepMsg:
 		if msg.id != a.id {

internal/tui/components/chat/chat.go 🔗

@@ -101,7 +101,7 @@ func (m *messageListCmp) Init() tea.Cmd {
 }
 
 // Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	if m.session.ID != "" && m.app.CoderAgent != nil {
 		queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)

internal/tui/components/chat/editor/editor.go 🔗

@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 
 const (
 	maxAttachments = 5
+	maxFileResults = 25
 )
 
 type OpenEditorMsg struct {
@@ -171,7 +172,7 @@ func (m *editorCmp) repositionCompletions() tea.Msg {
 	return completions.RepositionCompletionsMsg{X: x, Y: y}
 }
 
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
@@ -500,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
 		Completions: completionItems,
 		X:           x,
 		Y:           y,
+		MaxResults:  maxFileResults,
 	}
 }
 

internal/tui/components/chat/header/header.go 🔗

@@ -44,7 +44,7 @@ func (h *header) Init() tea.Cmd {
 	return nil
 }
 
-func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {

internal/tui/components/chat/messages/messages.go 🔗

@@ -35,7 +35,7 @@ var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithH
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
-	util.Model                      // Basic Bubble Tea model interface
+	util.Model                      // Basic Bubble util.Model interface
 	layout.Sizeable                 // Width/height management
 	layout.Focusable                // Focus state management
 	GetMessage() message.Message    // Access to underlying message data
@@ -94,7 +94,7 @@ func (m *messageCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		m.spinning = m.shouldSpin()
@@ -384,7 +384,7 @@ func (m *assistantSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 

internal/tui/components/chat/messages/tool.go 🔗

@@ -27,7 +27,7 @@ import (
 // ToolCallCmp defines the interface for tool call components in the chat interface.
 // It manages the display of tool execution including pending states, results, and errors.
 type ToolCallCmp interface {
-	util.Model                         // Basic Bubble Tea model interface
+	util.Model                         // Basic Bubble util.Model interface
 	layout.Sizeable                    // Width/height management
 	layout.Focusable                   // Focus state management
 	GetToolCall() message.ToolCall     // Access to tool call data
@@ -147,7 +147,7 @@ func (m *toolCallCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		var cmds []tea.Cmd
@@ -160,7 +160,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)
-			m.anim = u.(util.Model)
+			m.anim = u
 			cmds = append(cmds, cmd)
 		}
 		return m, tea.Batch(cmds...)

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -88,7 +88,7 @@ func (m *sidebarCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case SessionFilesMsg:
 		m.files = csync.NewMap[string, SessionFile]()

internal/tui/components/chat/splash/splash.go 🔗

@@ -135,7 +135,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
 }
 
 // Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		return s, s.SetSize(msg.Width, msg.Height)

internal/tui/components/completions/completions.go 🔗

@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
 	Completions []Completion
 	X           int // X position for the completions popup
 	Y           int // Y position for the completions popup
+	MaxResults  int // Maximum number of results to render, 0 for no limit
 }
 
 type FilterCompletionsMsg struct {
@@ -111,7 +112,7 @@ func (c *completionsCmp) Init() tea.Cmd {
 }
 
 // Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth, c.wHeight = msg.Width, msg.Height
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		c.width = width
 		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
+		c.list.SetResultsSize(msg.MaxResults)
 		return c, tea.Batch(
 			c.list.SetItems(items),
 			c.list.SetSize(c.width, c.height),

internal/tui/components/core/status/status.go 🔗

@@ -36,7 +36,7 @@ func (m *statusCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -92,7 +92,7 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
 }
 
 // Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width

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

@@ -116,7 +116,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 	return c.SetCommandType(c.commandType)
 }
 
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width

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

@@ -61,7 +61,7 @@ func (c *compactDialogCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *compactDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width

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

@@ -32,7 +32,7 @@ type CloseDialogMsg struct{}
 
 // DialogCmp manages a stack of dialogs with keyboard navigation.
 type DialogCmp interface {
-	tea.Model
+	util.Model
 
 	Dialogs() []DialogModel
 	HasDialogs() bool
@@ -62,7 +62,7 @@ func (d dialogCmp) Init() tea.Cmd {
 }
 
 // Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd
@@ -98,7 +98,11 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return d, nil
 }
 
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) View() string {
+	return ""
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
 	if d.HasDialogs() {
 		dialog := d.dialogs[len(d.dialogs)-1]
 		if dialog.ID() == msg.Model.ID() {

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

@@ -88,7 +88,7 @@ func (m *model) Init() tea.Cmd {
 	return m.filePicker.Init()
 }
 
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width

internal/tui/components/dialogs/models/apikey.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
@@ -75,7 +76,7 @@ func (a *APIKeyInput) Init() tea.Cmd {
 	return a.spinner.Tick
 }
 
-func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case spinner.TickMsg:
 		if a.state == APIKeyInputStateVerifying {

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

@@ -98,7 +98,7 @@ func (m *modelDialogCmp) Init() tea.Cmd {
 	return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
 }
 
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width

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

@@ -95,7 +95,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool {
 	return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
 }
 
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {

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

@@ -40,7 +40,7 @@ func (q *quitDialogCmp) Init() tea.Cmd {
 }
 
 // Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		q.wWidth = msg.Width

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

@@ -172,7 +172,7 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 	return nil
 }
 
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		r.wWidth = msg.Width

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

@@ -81,7 +81,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd {
 	return tea.Sequence(cmds...)
 }
 
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd

internal/tui/exp/list/filterable.go 🔗

@@ -3,14 +3,13 @@ package list
 import (
 	"regexp"
 	"slices"
-	"sort"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/textinput"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sahilm/fuzzy"
 )
@@ -28,7 +27,9 @@ type FilterableList[T FilterableItem] interface {
 	Cursor() *tea.Cursor
 	SetInputWidth(int)
 	SetInputPlaceholder(string)
+	SetResultsSize(int)
 	Filter(q string) tea.Cmd
+	fuzzy.Source
 }
 
 type HasMatchIndexes interface {
@@ -47,10 +48,11 @@ type filterableList[T FilterableItem] struct {
 	*filterableOptions
 	width, height int
 	// stores all available items
-	items      []T
-	input      textinput.Model
-	inputWidth int
-	query      string
+	items       []T
+	resultsSize int
+	input       textinput.Model
+	inputWidth  int
+	query       string
 }
 
 type filterableListOption func(*filterableOptions)
@@ -115,7 +117,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	return f
 }
 
-func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -246,22 +248,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 		return f.list.SetItems(f.items)
 	}
 
-	words := make([]string, len(f.items))
-	for i, item := range f.items {
-		words[i] = strings.ToLower(item.FilterValue())
-	}
-
-	matches := fuzzy.Find(query, words)
-
-	sort.SliceStable(matches, func(i, j int) bool {
-		return matches[i].Score > matches[j].Score
-	})
+	matches := fuzzy.FindFrom(query, f)
 
 	var matchedItems []T
-	for _, match := range matches {
+	resultSize := len(matches)
+	if f.resultsSize > 0 && resultSize > f.resultsSize {
+		resultSize = f.resultsSize
+	}
+	for i := range resultSize {
+		match := matches[i]
 		item := f.items[match.Index]
-		if i, ok := any(item).(HasMatchIndexes); ok {
-			i.MatchIndexes(match.MatchedIndexes)
+		if it, ok := any(item).(HasMatchIndexes); ok {
+			it.MatchIndexes(match.MatchedIndexes)
 		}
 		matchedItems = append(matchedItems, item)
 	}
@@ -307,3 +305,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
 func (f *filterableList[T]) SetInputPlaceholder(ph string) {
 	f.placeholder = ph
 }
+
+func (f *filterableList[T]) SetResultsSize(size int) {
+	f.resultsSize = size
+}
+
+func (f *filterableList[T]) String(i int) string {
+	return f.items[i].FilterValue()
+}
+
+func (f *filterableList[T]) Len() int {
+	return len(f.items)
+}

internal/tui/exp/list/filterable_group.go 🔗

@@ -11,6 +11,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sahilm/fuzzy"
 )
@@ -65,7 +66,7 @@ func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filter
 	return f
 }
 
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {

internal/tui/exp/list/grouped.go 🔗

@@ -58,7 +58,7 @@ func (g *groupedList[T]) Init() tea.Cmd {
 	return g.render()
 }
 
-func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	u, cmd := l.list.Update(msg)
 	l.list = u.(*list[Item])
 	return l, cmd

internal/tui/exp/list/items.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/google/uuid"
@@ -97,7 +98,7 @@ func (c *completionItemCmp[T]) Init() tea.Cmd {
 }
 
 // Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return c, nil
 }
 
@@ -348,7 +349,7 @@ func (m *itemSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 

internal/tui/exp/list/list.go 🔗

@@ -217,7 +217,7 @@ func (l *list[T]) Init() tea.Cmd {
 }
 
 // Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.MouseWheelMsg:
 		if l.enableMouse {
@@ -277,7 +277,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return l, nil
 }
 
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	switch msg.Button {
 	case tea.MouseWheelDown:

internal/tui/exp/list/list_test.go 🔗

@@ -7,6 +7,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/golden"
 	"github.com/google/uuid"
@@ -602,7 +603,7 @@ func (s *simpleItem) Init() tea.Cmd {
 	return nil
 }
 
-func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	return s, nil
 }
 
@@ -644,7 +645,7 @@ func (s *selectableItem) IsFocused() bool {
 	return s.focused
 }
 
-func execCmd(m tea.Model, cmd tea.Cmd) {
+func execCmd(m util.Model, cmd tea.Cmd) {
 	for cmd != nil {
 		msg := cmd()
 		m, cmd = m.Update(msg)

internal/tui/page/chat/chat.go 🔗

@@ -163,7 +163,7 @@ func (p *chatPage) Init() tea.Cmd {
 	)
 }
 
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:

internal/tui/tui.go 🔗

@@ -91,8 +91,6 @@ func (a appModel) Init() tea.Cmd {
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
 
-	cmds = append(cmds, tea.EnableMouseAllMotion)
-
 	return tea.Batch(cmds...)
 }
 
@@ -106,9 +104,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyboardEnhancementsMsg:
 		for id, page := range a.pages {
 			m, pageCmd := page.Update(msg)
-			if model, ok := m.(util.Model); ok {
-				a.pages[id] = model
-			}
+			a.pages[id] = m
 
 			if pageCmd != nil {
 				cmds = append(cmds, pageCmd)
@@ -232,9 +228,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// Forward to view.
 		updated, itemCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		return a, itemCmd
 	case pubsub.Event[permission.PermissionRequest]:
@@ -303,9 +297,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		a.isConfigured = config.HasInitialDataConfig()
 		updated, pageCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		cmds = append(cmds, pageCmd)
 		return a, tea.Batch(cmds...)
@@ -325,9 +317,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -347,9 +337,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -364,9 +352,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	updated, cmd := item.Update(msg)
-	if model, ok := updated.(util.Model); ok {
-		a.pages[a.currentPage] = model
-	}
+	a.pages[a.currentPage] = updated
 
 	if a.dialog.HasDialogs() {
 		u, dialogCmd := a.dialog.Update(msg)
@@ -402,9 +388,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	// Update the current view.
 	for p, page := range a.pages {
 		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
-		if model, ok := updated.(util.Model); ok {
-			a.pages[p] = model
-		}
+		a.pages[p] = updated
 
 		cmds = append(cmds, pageCmd)
 	}
@@ -507,9 +491,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		}
 
 		updated, cmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 		return cmd
 	}
 }
@@ -613,6 +595,8 @@ func (a *appModel) View() tea.View {
 
 	view.Layer = canvas
 	view.Cursor = cursor
+	view.MouseMode = tea.MouseModeCellMotion
+	view.AltScreen = true
 	if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.

internal/tui/util/util.go 🔗

@@ -12,8 +12,9 @@ type Cursor interface {
 }
 
 type Model interface {
-	tea.Model
-	tea.ViewModel
+	Init() tea.Cmd
+	Update(tea.Msg) (Model, tea.Cmd)
+	View() string
 }
 
 func CmdHandler(msg tea.Msg) tea.Cmd {