refactor(ui): reimplement UI components (#1652)

Ayman Bagabas , Kujtim Hoxha , Andrey Nering , Carlos Alexandro Becker , Copilot , and Christian Rocha created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

.gitignore                             |    3 
AGENTS.md                              |    3 
go.mod                                 |    6 
go.sum                                 |    9 
internal/cmd/root.go                   |   18 
internal/commands/commands.go          |  237 ++
internal/config/config.go              |    5 
internal/message/attachment.go         |    2 
internal/ui/AGENTS.md                  |   61 
internal/ui/anim/anim.go               |  445 ++++
internal/ui/attachments/attachments.go |  135 +
internal/ui/chat/agent.go              |  302 ++
internal/ui/chat/assistant.go          |  257 ++
internal/ui/chat/bash.go               |  248 ++
internal/ui/chat/diagnostics.go        |   68 
internal/ui/chat/fetch.go              |  192 +
internal/ui/chat/file.go               |  340 +++
internal/ui/chat/mcp.go                |  121 +
internal/ui/chat/messages.go           |  312 +++
internal/ui/chat/search.go             |  256 ++
internal/ui/chat/todos.go              |  192 +
internal/ui/chat/tools.go              |  805 +++++++
internal/ui/chat/user.go               |   94 
internal/ui/common/button.go           |   69 
internal/ui/common/common.go           |   65 
internal/ui/common/diff.go             |   16 
internal/ui/common/elements.go         |  190 +
internal/ui/common/highlight.go        |   57 
internal/ui/common/interface.go        |   11 
internal/ui/common/markdown.go         |   26 
internal/ui/common/scrollbar.go        |   46 
internal/ui/completions/completions.go |  267 ++
internal/ui/completions/item.go        |  185 +
internal/ui/completions/keys.go        |   74 
internal/ui/dialog/actions.go          |  165 +
internal/ui/dialog/api_key_input.go    |  302 ++
internal/ui/dialog/arguments.go        |  399 +++
internal/ui/dialog/commands.go         |  477 ++++
internal/ui/dialog/commands_item.go    |   70 
internal/ui/dialog/common.go           |  130 +
internal/ui/dialog/dialog.go           |  197 +
internal/ui/dialog/filepicker.go       |  304 ++
internal/ui/dialog/models.go           |  478 ++++
internal/ui/dialog/models_item.go      |  124 +
internal/ui/dialog/models_list.go      |  273 ++
internal/ui/dialog/oauth.go            |  369 +++
internal/ui/dialog/oauth_copilot.go    |   72 
internal/ui/dialog/oauth_hyper.go      |   90 
internal/ui/dialog/permissions.go      |  760 +++++++
internal/ui/dialog/quit.go             |  133 +
internal/ui/dialog/reasoning.go        |  297 ++
internal/ui/dialog/sessions.go         |  194 +
internal/ui/dialog/sessions_item.go    |  187 +
internal/ui/image/image.go             |  299 ++
internal/ui/list/filterable.go         |  125 +
internal/ui/list/focus.go              |   13 
internal/ui/list/highlight.go          |  208 ++
internal/ui/list/item.go               |   61 
internal/ui/list/list.go               |  634 ++++++
internal/ui/logo/logo.go               |  346 +++
internal/ui/logo/rand.go               |   24 
internal/ui/model/chat.go              |  600 +++++
internal/ui/model/header.go            |  112 +
internal/ui/model/keys.go              |  246 ++
internal/ui/model/landing.go           |   50 
internal/ui/model/lsp.go               |  118 +
internal/ui/model/mcp.go               |   98 
internal/ui/model/onboarding.go        |  101 
internal/ui/model/pills.go             |  283 ++
internal/ui/model/session.go           |  244 ++
internal/ui/model/sidebar.go           |  163 +
internal/ui/model/status.go            |  106 +
internal/ui/model/ui.go                | 2895 ++++++++++++++++++++++++++++
internal/ui/styles/grad.go             |  117 +
internal/ui/styles/styles.go           | 1344 ++++++++++++
internal/uicmd/uicmd.go                |    1 
internal/uiutil/uiutil.go              |   38 
77 files changed, 18,344 insertions(+), 20 deletions(-)

Detailed changes

.gitignore πŸ”—

@@ -48,6 +48,5 @@ Thumbs.db
 /tmp/
 
 manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
 .prettierignore

CRUSH.md β†’ AGENTS.md πŸ”—

@@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) {
 - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
 - Try to keep commits to one line, not including your attribution. Only use
   multi-line commits when additional context is truly necessary.
+
+## Working on the TUI (UI)
+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file

go.mod πŸ”—

@@ -31,10 +31,13 @@ require (
 	github.com/charmbracelet/x/exp/ordered v0.1.0
 	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
 	github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59
+	github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
 	github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+	github.com/disintegration/imaging v1.6.2
+	github.com/dustin/go-humanize v1.0.1
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
@@ -109,8 +112,7 @@ require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
-	github.com/dustin/go-humanize v1.0.1 // indirect
-	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect
+	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect

go.sum πŸ”—

@@ -122,6 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvP
 github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -150,12 +152,14 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -397,6 +401,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

internal/cmd/root.go πŸ”—

@@ -22,6 +22,8 @@ import (
 	"github.com/charmbracelet/crush/internal/projects"
 	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/tui"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	ui "github.com/charmbracelet/crush/internal/ui/model"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -86,11 +88,21 @@ crush -y
 
 		// Set up the TUI.
 		var env uv.Environ = os.Environ()
-		ui := tui.New(app)
-		ui.QueryVersion = shouldQueryTerminalVersion(env)
 
+		var model tea.Model
+		if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
+			slog.Info("New UI in control!")
+			com := common.DefaultCommon(app)
+			ui := ui.New(com)
+			ui.QueryVersion = shouldQueryTerminalVersion(env)
+			model = ui
+		} else {
+			ui := tui.New(app)
+			ui.QueryVersion = shouldQueryTerminalVersion(env)
+			model = ui
+		}
 		program := tea.NewProgram(
-			ui,
+			model,
 			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
 			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state

internal/commands/commands.go πŸ”—

@@ -0,0 +1,237 @@
+package commands
+
+import (
+	"context"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+const (
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
+)
+
+// Argument represents a command argument with its metadata.
+type Argument struct {
+	ID          string
+	Title       string
+	Description string
+	Required    bool
+}
+
+// MCPPrompt represents a custom command loaded from an MCP server.
+type MCPPrompt struct {
+	ID          string
+	Title       string
+	Description string
+	PromptID    string
+	ClientID    string
+	Arguments   []Argument
+}
+
+// CustomCommand represents a user-defined custom command loaded from markdown files.
+type CustomCommand struct {
+	ID        string
+	Name      string
+	Content   string
+	Arguments []Argument
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+// LoadCustomCommands loads custom commands from multiple sources including
+// XDG config directory, home directory, and project directory.
+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
+	return loadAll(buildCommandSources(cfg))
+}
+
+// LoadMCPPrompts loads custom commands from available MCP servers.
+func LoadMCPPrompts() ([]MCPPrompt, error) {
+	var commands []MCPPrompt
+	for mcpName, prompts := range mcp.Prompts() {
+		for _, prompt := range prompts {
+			key := mcpName + ":" + prompt.Name
+			var args []Argument
+			for _, arg := range prompt.Arguments {
+				title := arg.Title
+				if title == "" {
+					title = arg.Name
+				}
+				args = append(args, Argument{
+					ID:          arg.Name,
+					Title:       title,
+					Description: arg.Description,
+					Required:    arg.Required,
+				})
+			}
+			commands = append(commands, MCPPrompt{
+				ID:          key,
+				Title:       prompt.Title,
+				Description: prompt.Description,
+				PromptID:    prompt.Name,
+				ClientID:    mcpName,
+				Arguments:   args,
+			})
+		}
+	}
+	return commands, nil
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home := home.Dir(); home != "" {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".crush", "commands"),
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
+		prefix: projectCommandPrefix,
+	})
+
+	return sources
+}
+
+func loadAll(sources []commandSource) ([]CustomCommand, error) {
+	var commands []CustomCommand
+
+	for _, source := range sources {
+		if cmds, err := loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func loadFromSource(source commandSource) ([]CustomCommand, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []CustomCommand
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return CustomCommand{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return CustomCommand{
+		ID:        id,
+		Name:      id,
+		Content:   string(content),
+		Arguments: extractArgNames(string(content)),
+	}, nil
+}
+
+func extractArgNames(content string) []Argument {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []Argument
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			// for normal custom commands, all args are required
+			args = append(args, Argument{ID: arg, Title: arg, Required: true})
+		}
+	}
+
+	return args
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home := home.Dir(); home != "" {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "crush", "commands")
+	}
+	return ""
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0o755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}
+
+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+	// TODO: we should pass the context down
+	result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
+	if err != nil {
+		return "", err
+	}
+	return strings.Join(result, " "), nil
+}

internal/config/config.go πŸ”—

@@ -53,6 +53,11 @@ var defaultContextPaths = []string{
 
 type SelectedModelType string
 
+// String returns the string representation of the [SelectedModelType].
+func (s SelectedModelType) String() string {
+	return string(s)
+}
+
 const (
 	SelectedModelTypeLarge SelectedModelType = "large"
 	SelectedModelTypeSmall SelectedModelType = "small"

internal/message/attachment.go πŸ”—

@@ -15,7 +15,7 @@ type Attachment struct {
 func (a Attachment) IsText() bool  { return strings.HasPrefix(a.MimeType, "text/") }
 func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
 
-// ContainsTextAttachment returns true if any of the attachments is a text attachments.
+// ContainsTextAttachment returns true if any of the attachments is a text attachment.
 func ContainsTextAttachment(attachments []Attachment) bool {
 	return slices.ContainsFunc(attachments, func(a Attachment) bool {
 		return a.IsText()

internal/ui/AGENTS.md πŸ”—

@@ -0,0 +1,61 @@
+# UI Development Instructions
+
+## General Guidelines
+- Never use commands to send messages when you can directly mutate children or state.
+- Keep things simple; do not overcomplicate.
+- Create files if needed to separate logic; do not nest models.
+- Always do IO in commands
+- Never change the model state inside of a command use messages and than update the state in the main loop
+
+## Architecture
+
+### Main Model (`model/ui.go`)
+Keep most of the logic and state in the main model. This is where:
+- Message routing happens
+- Focus and UI state is managed
+- Layout calculations are performed
+- Dialogs are orchestrated
+
+### Components Should Be Dumb
+Components should not handle bubbletea messages directly. Instead:
+- Expose methods for state changes
+- Return `tea.Cmd` from methods when side effects are needed
+- Handle their own rendering via `Render(width int) string`
+
+### Chat Logic (`model/chat.go`)
+Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
+
+## Key Patterns
+
+### Composition Over Inheritance
+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
+
+### Interfaces
+- List item interfaces are in `list/item.go`
+- Chat message interfaces are in `chat/messages.go`
+- Dialog interface is in `dialog/dialog.go`
+
+### Styling
+- All styles are defined in `styles/styles.go`
+- Access styles via `*common.Common` passed to components
+- Use semantic color fields rather than hardcoded colors
+
+### Dialogs
+- Implement the dialog interface in `dialog/dialog.go`
+- Return message types from `Update()` to signal actions to the main model
+- Use the overlay system for managing dialog lifecycle
+
+## File Organization
+- `model/` - Main UI model and major components (chat, sidebar, etc.)
+- `chat/` - Chat message item types and renderers
+- `dialog/` - Dialog implementations
+- `list/` - Generic list component with lazy rendering
+- `common/` - Shared utilities and the Common struct
+- `styles/` - All style definitions
+- `anim/` - Animation system
+- `logo/` - Logo rendering
+
+## Common Gotchas
+- Always account for padding/borders in width calculations
+- Use `tea.Batch()` when returning multiple commands
+- Pass `*common.Common` to components that need styles or app access

internal/ui/anim/anim.go πŸ”—

@@ -0,0 +1,445 @@
+// Package anim provides an animated spinner.
+package anim
+
+import (
+	"fmt"
+	"image/color"
+	"math/rand/v2"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/zeebo/xxh3"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+
+	"github.com/charmbracelet/crush/internal/csync"
+)
+
+const (
+	fps           = 20
+	initialChar   = '.'
+	labelGap      = " "
+	labelGapWidth = 1
+
+	// Periods of ellipsis animation speed in steps.
+	//
+	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
+	// change every 8 frames (400 milliseconds).
+	ellipsisAnimSpeed = 8
+
+	// The maximum amount of time that can pass before a character appears.
+	// This is used to create a staggered entrance effect.
+	maxBirthOffset = time.Second
+
+	// Number of frames to prerender for the animation. After this number
+	// of frames, the animation will loop. This only applies when color
+	// cycling is disabled.
+	prerenderedFrames = 10
+
+	// Default number of cycling chars.
+	defaultNumCyclingChars = 10
+)
+
+// Default colors for gradient.
+var (
+	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
+	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
+	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
+)
+
+var (
+	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+	ellipsisFrames = []string{".", "..", "...", ""}
+)
+
+// Internal ID management. Used during animating to ensure that frame messages
+// are received only by spinner components that sent them.
+var lastID int64
+
+func nextID() int {
+	return int(atomic.AddInt64(&lastID, 1))
+}
+
+// Cache for expensive animation calculations
+type animCache struct {
+	initialFrames  [][]string
+	cyclingFrames  [][]string
+	width          int
+	labelWidth     int
+	label          []string
+	ellipsisFrames []string
+}
+
+var animCacheMap = csync.NewMap[string, *animCache]()
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+	h := xxh3.New()
+	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// StepMsg is a message type used to trigger the next step in the animation.
+type StepMsg struct{ ID string }
+
+// Settings defines settings for the animation.
+type Settings struct {
+	ID          string
+	Size        int
+	Label       string
+	LabelColor  color.Color
+	GradColorA  color.Color
+	GradColorB  color.Color
+	CycleColors bool
+}
+
+// Default settings.
+const ()
+
+// Anim is a Bubble for an animated spinner.
+type Anim struct {
+	width            int
+	cyclingCharWidth int
+	label            *csync.Slice[string]
+	labelWidth       int
+	labelColor       color.Color
+	startTime        time.Time
+	birthOffsets     []time.Duration
+	initialFrames    [][]string // frames for the initial characters
+	initialized      atomic.Bool
+	cyclingFrames    [][]string           // frames for the cycling characters
+	step             atomic.Int64         // current main frame step
+	ellipsisStep     atomic.Int64         // current ellipsis frame step
+	ellipsisFrames   *csync.Slice[string] // ellipsis animation frames
+	id               string
+}
+
+// New creates a new Anim instance with the specified width and label.
+func New(opts Settings) *Anim {
+	a := &Anim{}
+	// Validate settings.
+	if opts.Size < 1 {
+		opts.Size = defaultNumCyclingChars
+	}
+	if colorIsUnset(opts.GradColorA) {
+		opts.GradColorA = defaultGradColorA
+	}
+	if colorIsUnset(opts.GradColorB) {
+		opts.GradColorB = defaultGradColorB
+	}
+	if colorIsUnset(opts.LabelColor) {
+		opts.LabelColor = defaultLabelColor
+	}
+
+	if opts.ID != "" {
+		a.id = opts.ID
+	} else {
+		a.id = fmt.Sprintf("%d", nextID())
+	}
+	a.startTime = time.Now()
+	a.cyclingCharWidth = opts.Size
+	a.labelColor = opts.LabelColor
+
+	// Check cache first
+	cacheKey := settingsHash(opts)
+	cached, exists := animCacheMap.Get(cacheKey)
+
+	if exists {
+		// Use cached values
+		a.width = cached.width
+		a.labelWidth = cached.labelWidth
+		a.label = csync.NewSliceFrom(cached.label)
+		a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
+		a.initialFrames = cached.initialFrames
+		a.cyclingFrames = cached.cyclingFrames
+	} else {
+		// Generate new values and cache them
+		a.labelWidth = lipgloss.Width(opts.Label)
+
+		// Total width of anim, in cells.
+		a.width = opts.Size
+		if opts.Label != "" {
+			a.width += labelGapWidth + lipgloss.Width(opts.Label)
+		}
+
+		// Render the label
+		a.renderLabel(opts.Label)
+
+		// Pre-generate gradient.
+		var ramp []color.Color
+		numFrames := prerenderedFrames
+		if opts.CycleColors {
+			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+			numFrames = a.width * 2
+		} else {
+			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
+		}
+
+		// Pre-render initial characters.
+		a.initialFrames = make([][]string, numFrames)
+		offset := 0
+		for i := range a.initialFrames {
+			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+			for j := range a.initialFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				var c color.Color
+				if j <= a.cyclingCharWidth {
+					c = ramp[j+offset]
+				} else {
+					c = opts.LabelColor
+				}
+
+				// Also prerender the initial character with Lip Gloss to avoid
+				// processing in the render loop.
+				a.initialFrames[i][j] = lipgloss.NewStyle().
+					Foreground(c).
+					Render(string(initialChar))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Prerender scrambled rune frames for the animation.
+		a.cyclingFrames = make([][]string, numFrames)
+		offset = 0
+		for i := range a.cyclingFrames {
+			a.cyclingFrames[i] = make([]string, a.width)
+			for j := range a.cyclingFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				// Also prerender the color with Lip Gloss here to avoid processing
+				// in the render loop.
+				r := availableRunes[rand.IntN(len(availableRunes))]
+				a.cyclingFrames[i][j] = lipgloss.NewStyle().
+					Foreground(ramp[j+offset]).
+					Render(string(r))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Cache the results
+		labelSlice := make([]string, a.label.Len())
+		for i, v := range a.label.Seq2() {
+			labelSlice[i] = v
+		}
+		ellipsisSlice := make([]string, a.ellipsisFrames.Len())
+		for i, v := range a.ellipsisFrames.Seq2() {
+			ellipsisSlice[i] = v
+		}
+		cached = &animCache{
+			initialFrames:  a.initialFrames,
+			cyclingFrames:  a.cyclingFrames,
+			width:          a.width,
+			labelWidth:     a.labelWidth,
+			label:          labelSlice,
+			ellipsisFrames: ellipsisSlice,
+		}
+		animCacheMap.Set(cacheKey, cached)
+	}
+
+	// Random assign a birth to each character for a stagged entrance effect.
+	a.birthOffsets = make([]time.Duration, a.width)
+	for i := range a.birthOffsets {
+		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
+	}
+
+	return a
+}
+
+// SetLabel updates the label text and re-renders it.
+func (a *Anim) SetLabel(newLabel string) {
+	a.labelWidth = lipgloss.Width(newLabel)
+
+	// Update total width
+	a.width = a.cyclingCharWidth
+	if newLabel != "" {
+		a.width += labelGapWidth + a.labelWidth
+	}
+
+	// Re-render the label
+	a.renderLabel(newLabel)
+}
+
+// renderLabel renders the label with the current label color.
+func (a *Anim) renderLabel(label string) {
+	if a.labelWidth > 0 {
+		// Pre-render the label.
+		labelRunes := []rune(label)
+		a.label = csync.NewSlice[string]()
+		for i := range labelRunes {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(string(labelRunes[i]))
+			a.label.Append(rendered)
+		}
+
+		// Pre-render the ellipsis frames which come after the label.
+		a.ellipsisFrames = csync.NewSlice[string]()
+		for _, frame := range ellipsisFrames {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(frame)
+			a.ellipsisFrames.Append(rendered)
+		}
+	} else {
+		a.label = csync.NewSlice[string]()
+		a.ellipsisFrames = csync.NewSlice[string]()
+	}
+}
+
+// Width returns the total width of the animation.
+func (a *Anim) Width() (w int) {
+	w = a.width
+	if a.labelWidth > 0 {
+		w += labelGapWidth + a.labelWidth
+
+		var widestEllipsisFrame int
+		for _, f := range ellipsisFrames {
+			fw := lipgloss.Width(f)
+			if fw > widestEllipsisFrame {
+				widestEllipsisFrame = fw
+			}
+		}
+		w += widestEllipsisFrame
+	}
+	return w
+}
+
+// Start starts the animation.
+func (a *Anim) Start() tea.Cmd {
+	return a.Step()
+}
+
+// Animate advances the animation to the next step.
+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
+	if msg.ID != a.id {
+		return nil
+	}
+
+	step := a.step.Add(1)
+	if int(step) >= len(a.cyclingFrames) {
+		a.step.Store(0)
+	}
+
+	if a.initialized.Load() && a.labelWidth > 0 {
+		// Manage the ellipsis animation.
+		ellipsisStep := a.ellipsisStep.Add(1)
+		if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
+			a.ellipsisStep.Store(0)
+		}
+	} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
+		a.initialized.Store(true)
+	}
+	return a.Step()
+}
+
+// Render renders the current state of the animation.
+func (a *Anim) Render() string {
+	var b strings.Builder
+	step := int(a.step.Load())
+	for i := range a.width {
+		switch {
+		case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
+			// Birth offset not reached: render initial character.
+			b.WriteString(a.initialFrames[step][i])
+		case i < a.cyclingCharWidth:
+			// Render a cycling character.
+			b.WriteString(a.cyclingFrames[step][i])
+		case i == a.cyclingCharWidth:
+			// Render label gap.
+			b.WriteString(labelGap)
+		case i > a.cyclingCharWidth:
+			// Label.
+			if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
+				b.WriteString(labelChar)
+			}
+		}
+	}
+	// Render animated ellipsis at the end of the label if all characters
+	// have been initialized.
+	if a.initialized.Load() && a.labelWidth > 0 {
+		ellipsisStep := int(a.ellipsisStep.Load())
+		if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
+			b.WriteString(ellipsisFrame)
+		}
+	}
+
+	return b.String()
+}
+
+// Step is a command that triggers the next step in the animation.
+func (a *Anim) Step() tea.Cmd {
+	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
+		return StepMsg{ID: a.id}
+	})
+}
+
+// makeGradientRamp() returns a slice of colors blended between the given keys.
+// Blending is done as Hcl to stay in gamut.
+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	points := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		points[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stops) - 1
+	if numSegments == 0 {
+		return nil
+	}
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := points[i]
+		c2 := points[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			if segmentSize == 0 {
+				continue
+			}
+			t := float64(j) / float64(segmentSize)
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}
+
+func colorIsUnset(c color.Color) bool {
+	if c == nil {
+		return true
+	}
+	_, _, _, a := c.RGBA()
+	return a == 0
+}

internal/ui/attachments/attachments.go πŸ”—

@@ -0,0 +1,135 @@
+package attachments
+
+import (
+	"fmt"
+	"math"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const maxFilename = 15
+
+type Keymap struct {
+	DeleteMode,
+	DeleteAll,
+	Escape key.Binding
+}
+
+func New(renderer *Renderer, keyMap Keymap) *Attachments {
+	return &Attachments{
+		keyMap:   keyMap,
+		renderer: renderer,
+	}
+}
+
+type Attachments struct {
+	renderer *Renderer
+	keyMap   Keymap
+	list     []message.Attachment
+	deleting bool
+}
+
+func (m *Attachments) List() []message.Attachment { return m.list }
+func (m *Attachments) Reset()                     { m.list = nil }
+
+func (m *Attachments) Update(msg tea.Msg) bool {
+	switch msg := msg.(type) {
+	case message.Attachment:
+		m.list = append(m.list, msg)
+		return true
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.DeleteMode):
+			if len(m.list) > 0 {
+				m.deleting = true
+			}
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.Escape):
+			m.deleting = false
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
+			m.deleting = false
+			m.list = nil
+			return true
+		case m.deleting:
+			// Handle digit keys for individual attachment deletion.
+			r := msg.Code
+			if r >= '0' && r <= '9' {
+				num := int(r - '0')
+				if num < len(m.list) {
+					m.list = slices.Delete(m.list, num, num+1)
+				}
+				m.deleting = false
+			}
+			return true
+		}
+	}
+	return false
+}
+
+func (m *Attachments) Render(width int) string {
+	return m.renderer.Render(m.list, m.deleting, width)
+}
+
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+	return &Renderer{
+		normalStyle:   normalStyle,
+		textStyle:     textStyle,
+		imageStyle:    imageStyle,
+		deletingStyle: deletingStyle,
+	}
+}
+
+type Renderer struct {
+	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+}
+
+func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
+	var chips []string
+
+	maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
+	fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
+
+	for i, att := range attachments {
+		filename := filepath.Base(att.FileName)
+		// Truncate if needed.
+		if ansi.StringWidth(filename) > maxFilename {
+			filename = ansi.Truncate(filename, maxFilename, "…")
+		}
+
+		if deleting {
+			chips = append(
+				chips,
+				r.deletingStyle.Render(fmt.Sprintf("%d", i)),
+				r.normalStyle.Render(filename),
+			)
+		} else {
+			chips = append(
+				chips,
+				r.icon(att).String(),
+				r.normalStyle.Render(filename),
+			)
+		}
+
+		if i == fits && len(attachments) > i {
+			chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
+			break
+		}
+	}
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
+}
+
+func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
+	if a.IsImage() {
+		return r.imageStyle
+	}
+	return r.textStyle
+}

internal/ui/chat/agent.go πŸ”—

@@ -0,0 +1,302 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Agent Tool
+// -----------------------------------------------------------------------------
+
+// NestedToolContainer is an interface for tool items that can contain nested tool calls.
+type NestedToolContainer interface {
+	NestedTools() []ToolMessageItem
+	SetNestedTools(tools []ToolMessageItem)
+	AddNestedTool(tool ToolMessageItem)
+}
+
+// AgentToolMessageItem is a message item that represents an agent tool call.
+type AgentToolMessageItem struct {
+	*baseToolMessageItem
+
+	nestedTools []ToolMessageItem
+}
+
+var (
+	_ ToolMessageItem     = (*AgentToolMessageItem)(nil)
+	_ NestedToolContainer = (*AgentToolMessageItem)(nil)
+)
+
+// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
+func NewAgentToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) *AgentToolMessageItem {
+	t := &AgentToolMessageItem{}
+	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
+	// For the agent tool we keep spinning until the tool call is finished.
+	t.spinningFunc = func(state SpinningState) bool {
+		return !state.HasResult() && !state.IsCanceled()
+	}
+	return t
+}
+
+// Animate progresses the message animation if it should be spinning.
+func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if a.result != nil || a.Status() == ToolStatusCanceled {
+		return nil
+	}
+	if msg.ID == a.ID() {
+		return a.anim.Animate(msg)
+	}
+	for _, nestedTool := range a.nestedTools {
+		if msg.ID != nestedTool.ID() {
+			continue
+		}
+		if s, ok := nestedTool.(Animatable); ok {
+			return s.Animate(msg)
+		}
+	}
+	return nil
+}
+
+// NestedTools returns the nested tools.
+func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
+	return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+	a.nestedTools = tools
+	a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+	// Mark nested tools as simple (compact) rendering.
+	if s, ok := tool.(Compactable); ok {
+		s.SetCompact(true)
+	}
+	a.nestedTools = append(a.nestedTools, tool)
+	a.clearCache()
+}
+
+// AgentToolRenderContext renders agent tool messages.
+type AgentToolRenderContext struct {
+	agent *AgentToolMessageItem
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
+		return pendingTool(sty, "Agent", opts.Anim)
+	}
+
+	var params agent.AgentParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
+	if opts.Compact {
+		return header
+	}
+
+	// Build the task tag and prompt.
+	taskTag := sty.Tool.AgentTaskTag.Render("Task")
+	taskTagWidth := lipgloss.Width(taskTag)
+
+	// Calculate remaining width for prompt.
+	remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
+
+	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			taskTag,
+			" ",
+			promptText,
+		),
+	)
+
+	// Build tree with nested tool calls.
+	childTools := tree.Root(header)
+
+	for _, nestedTool := range r.agent.nestedTools {
+		childView := nestedTool.Render(remainingWidth)
+		childTools.Child(childView)
+	}
+
+	// Build parts.
+	var parts []string
+	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
+
+	// Show animation if still running.
+	if !opts.HasResult() && !opts.IsCanceled() {
+		parts = append(parts, "", opts.Anim.Render())
+	}
+
+	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	// Add body content when completed.
+	if opts.HasResult() && opts.Result.Content != "" {
+		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+		return joinToolParts(result, body)
+	}
+
+	return result
+}
+
+// -----------------------------------------------------------------------------
+// Agentic Fetch Tool
+// -----------------------------------------------------------------------------
+
+// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
+type AgenticFetchToolMessageItem struct {
+	*baseToolMessageItem
+
+	nestedTools []ToolMessageItem
+}
+
+var (
+	_ ToolMessageItem     = (*AgenticFetchToolMessageItem)(nil)
+	_ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
+)
+
+// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
+func NewAgenticFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) *AgenticFetchToolMessageItem {
+	t := &AgenticFetchToolMessageItem{}
+	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
+	// For the agentic fetch tool we keep spinning until the tool call is finished.
+	t.spinningFunc = func(state SpinningState) bool {
+		return !state.HasResult() && !state.IsCanceled()
+	}
+	return t
+}
+
+// NestedTools returns the nested tools.
+func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
+	return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+	a.nestedTools = tools
+	a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+	// Mark nested tools as simple (compact) rendering.
+	if s, ok := tool.(Compactable); ok {
+		s.SetCompact(true)
+	}
+	a.nestedTools = append(a.nestedTools, tool)
+	a.clearCache()
+}
+
+// AgenticFetchToolRenderContext renders agentic fetch tool messages.
+type AgenticFetchToolRenderContext struct {
+	fetch *AgenticFetchToolMessageItem
+}
+
+// agenticFetchParams matches tools.AgenticFetchParams.
+type agenticFetchParams struct {
+	URL    string `json:"url,omitempty"`
+	Prompt string `json:"prompt"`
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
+		return pendingTool(sty, "Agentic Fetch", opts.Anim)
+	}
+
+	var params agenticFetchParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	// Build header with optional URL param.
+	toolParams := []string{}
+	if params.URL != "" {
+		toolParams = append(toolParams, params.URL)
+	}
+
+	header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	// Build the prompt tag.
+	promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
+	promptTagWidth := lipgloss.Width(promptTag)
+
+	// Calculate remaining width for prompt text.
+	remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
+
+	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			promptTag,
+			" ",
+			promptText,
+		),
+	)
+
+	// Build tree with nested tool calls.
+	childTools := tree.Root(header)
+
+	for _, nestedTool := range r.fetch.nestedTools {
+		childView := nestedTool.Render(remainingWidth)
+		childTools.Child(childView)
+	}
+
+	// Build parts.
+	var parts []string
+	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
+
+	// Show animation if still running.
+	if !opts.HasResult() && !opts.IsCanceled() {
+		parts = append(parts, "", opts.Anim.Render())
+	}
+
+	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	// Add body content when completed.
+	if opts.HasResult() && opts.Result.Content != "" {
+		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+		return joinToolParts(result, body)
+	}
+
+	return result
+}

internal/ui/chat/assistant.go πŸ”—

@@ -0,0 +1,257 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// assistantMessageTruncateFormat is the text shown when an assistant message is
+// truncated.
+const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
+
+// maxCollapsedThinkingHeight defines the maximum height of the thinking
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message in the chat UI.
+//
+// This item includes thinking, and the content but does not include the tool calls.
+type AssistantMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	message           *message.Message
+	sty               *styles.Styles
+	anim              *anim.Anim
+	thinkingExpanded  bool
+	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessageItem creates a new AssistantMessageItem.
+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+	a := &AssistantMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		message:                  message,
+		sty:                      sty,
+	}
+
+	a.anim = anim.New(anim.Settings{
+		ID:          a.ID(),
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+	return a
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Animate(msg)
+}
+
+// ID implements MessageItem.
+func (a *AssistantMessageItem) ID() string {
+	return a.message.ID
+}
+
+// RawRender implements [MessageItem].
+func (a *AssistantMessageItem) RawRender(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	var spinner string
+	if a.isSpinning() {
+		spinner = a.renderSpinning()
+	}
+
+	content, height, ok := a.getCachedRender(cappedWidth)
+	if !ok {
+		content = a.renderMessageContent(cappedWidth)
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		a.setCachedRender(content, cappedWidth, height)
+	}
+
+	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
+	if spinner != "" {
+		if highlightedContent != "" {
+			highlightedContent += "\n\n"
+		}
+		return highlightedContent + spinner
+	}
+
+	return highlightedContent
+}
+
+// Render implements MessageItem.
+func (a *AssistantMessageItem) Render(width int) string {
+	style := a.sty.Chat.Message.AssistantBlurred
+	if a.focused {
+		style = a.sty.Chat.Message.AssistantFocused
+	}
+	return style.Render(a.RawRender(width))
+}
+
+// renderMessageContent renders the message content including thinking, main content, and finish reason.
+func (a *AssistantMessageItem) renderMessageContent(width int) string {
+	var messageParts []string
+	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
+	content := strings.TrimSpace(a.message.Content().Text)
+	// if the massage has reasoning content add that first
+	if thinking != "" {
+		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
+	}
+
+	// then add the main content
+	if content != "" {
+		// add a spacer between thinking and content
+		if thinking != "" {
+			messageParts = append(messageParts, "")
+		}
+		messageParts = append(messageParts, a.renderMarkdown(content, width))
+	}
+
+	// finally add any finish reason info
+	if a.message.IsFinished() {
+		switch a.message.FinishReason() {
+		case message.FinishReasonCanceled:
+			messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
+		case message.FinishReasonError:
+			messageParts = append(messageParts, a.renderError(width))
+		}
+	}
+
+	return strings.Join(messageParts, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content with footer.
+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
+	renderer := common.PlainMarkdownRenderer(a.sty, width)
+	rendered, err := renderer.Render(thinking)
+	if err != nil {
+		rendered = thinking
+	}
+	rendered = strings.TrimSpace(rendered)
+
+	lines := strings.Split(rendered, "\n")
+	totalLines := len(lines)
+
+	isTruncated := totalLines > maxCollapsedThinkingHeight
+	if !a.thinkingExpanded && isTruncated {
+		lines = lines[totalLines-maxCollapsedThinkingHeight:]
+		hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
+			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
+		)
+		lines = append([]string{hint, ""}, lines...)
+	}
+
+	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
+	result := thinkingStyle.Render(strings.Join(lines, "\n"))
+	a.thinkingBoxHeight = lipgloss.Height(result)
+
+	var footer string
+	// if thinking is done add the thought for footer
+	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
+		duration := a.message.ThinkingDuration()
+		if duration.String() != "0s" {
+			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
+				a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
+		}
+	}
+
+	if footer != "" {
+		result += "\n\n" + footer
+	}
+
+	return result
+}
+
+// renderMarkdown renders content as markdown.
+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
+	renderer := common.MarkdownRenderer(a.sty, width)
+	result, err := renderer.Render(content)
+	if err != nil {
+		return content
+	}
+	return strings.TrimSuffix(result, "\n")
+}
+
+func (a *AssistantMessageItem) renderSpinning() string {
+	if a.message.IsThinking() {
+		a.anim.SetLabel("Thinking")
+	} else if a.message.IsSummaryMessage {
+		a.anim.SetLabel("Summarizing")
+	}
+	return a.anim.Render()
+}
+
+// renderError renders an error message.
+func (a *AssistantMessageItem) renderError(width int) string {
+	finishPart := a.message.FinishPart()
+	errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
+	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
+	title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
+	details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
+	return fmt.Sprintf("%s\n\n%s", title, details)
+}
+
+// isSpinning returns true if the assistant message is still generating.
+func (a *AssistantMessageItem) isSpinning() bool {
+	isThinking := a.message.IsThinking()
+	isFinished := a.message.IsFinished()
+	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
+	hasToolCalls := len(a.message.ToolCalls()) > 0
+	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
+}
+
+// SetMessage is used to update the underlying message.
+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
+	wasSpinning := a.isSpinning()
+	a.message = message
+	a.clearCache()
+	if !wasSpinning && a.isSpinning() {
+		return a.StartAnimation()
+	}
+	return nil
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (a *AssistantMessageItem) ToggleExpanded() {
+	a.thinkingExpanded = !a.thinkingExpanded
+	a.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	// check if the click is within the thinking box
+	if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
+		a.ToggleExpanded()
+		return true
+	}
+	return false
+}

internal/ui/chat/bash.go πŸ”—

@@ -0,0 +1,248 @@
+package chat
+
+import (
+	"cmp"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// -----------------------------------------------------------------------------
+// Bash Tool
+// -----------------------------------------------------------------------------
+
+// BashToolMessageItem is a message item that represents a bash tool call.
+type BashToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
+
+// NewBashToolMessageItem creates a new [BashToolMessageItem].
+func NewBashToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
+}
+
+// BashToolRenderContext renders bash tool messages.
+type BashToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Bash", opts.Anim)
+	}
+
+	var params tools.BashParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		params.Command = "failed to parse command"
+	}
+
+	// Check if this is a background job.
+	var meta tools.BashResponseMetadata
+	if opts.HasResult() {
+		_ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
+	}
+
+	if meta.Background {
+		description := cmp.Or(meta.Description, params.Command)
+		content := "Command: " + params.Command + "\n" + opts.Result.Content
+		return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
+	}
+
+	// Regular bash command.
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	toolParams := []string{cmd}
+	if params.RunInBackground {
+		toolParams = append(toolParams, "background", "true")
+	}
+
+	header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	output := meta.Output
+	if output == "" && opts.Result.Content != tools.BashNoOutput {
+		output = opts.Result.Content
+	}
+	if output == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Job Output Tool
+// -----------------------------------------------------------------------------
+
+// JobOutputToolMessageItem is a message item for job_output tool calls.
+type JobOutputToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
+
+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
+func NewJobOutputToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
+}
+
+// JobOutputToolRenderContext renders job_output tool messages.
+type JobOutputToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobOutputParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.HasResult() && opts.Result.Metadata != "" {
+		var meta tools.JobOutputResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.HasResult() {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
+}
+
+// -----------------------------------------------------------------------------
+// Job Kill Tool
+// -----------------------------------------------------------------------------
+
+// JobKillToolMessageItem is a message item for job_kill tool calls.
+type JobKillToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
+
+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
+func NewJobKillToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
+}
+
+// JobKillToolRenderContext renders job_kill tool messages.
+type JobKillToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobKillParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.HasResult() && opts.Result.Metadata != "" {
+		var meta tools.JobKillResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.HasResult() {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
+}
+
+// renderJobTool renders a job-related tool with the common pattern:
+// header β†’ nested check β†’ early state β†’ body.
+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
+	header := jobHeader(sty, opts.Status, action, shellID, description, width)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if content == "" {
+		return header
+	}
+
+	bodyWidth := width - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// jobHeader builds a header for job-related tools.
+// Format: "● Job (Action) PID shellID description..."
+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
+	icon := toolIcon(sty, status)
+	jobPart := sty.Tool.JobToolName.Render("Job")
+	actionPart := sty.Tool.JobAction.Render("(" + action + ")")
+	pidPart := sty.Tool.JobPID.Render("PID " + shellID)
+
+	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
+
+	if description == "" {
+		return prefix
+	}
+
+	prefixWidth := lipgloss.Width(prefix)
+	availableWidth := width - prefixWidth - 1
+	if availableWidth < 10 {
+		return prefix
+	}
+
+	truncatedDesc := ansi.Truncate(description, availableWidth, "…")
+	return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
+}
+
+// joinToolParts joins header and body with a blank line separator.
+func joinToolParts(header, body string) string {
+	return strings.Join([]string{header, "", body}, "\n")
+}

internal/ui/chat/diagnostics.go πŸ”—

@@ -0,0 +1,68 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Diagnostics Tool
+// -----------------------------------------------------------------------------
+
+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
+type DiagnosticsToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
+
+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
+func NewDiagnosticsToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
+}
+
+// DiagnosticsToolRenderContext renders diagnostics tool messages.
+type DiagnosticsToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Diagnostics", opts.Anim)
+	}
+
+	var params tools.DiagnosticsParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	// Show "project" if no file path, otherwise show the file path.
+	mainParam := "project"
+	if params.FilePath != "" {
+		mainParam = fsext.PrettyPath(params.FilePath)
+	}
+
+	header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

internal/ui/chat/fetch.go πŸ”—

@@ -0,0 +1,192 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Fetch Tool
+// -----------------------------------------------------------------------------
+
+// FetchToolMessageItem is a message item that represents a fetch tool call.
+type FetchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
+
+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
+func NewFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
+}
+
+// FetchToolRenderContext renders fetch tool messages.
+type FetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Fetch", opts.Anim)
+	}
+
+	var params tools.FetchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	if params.Format != "" {
+		toolParams = append(toolParams, "format", params.Format)
+	}
+	if params.Timeout != 0 {
+		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+	}
+
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	// Determine file extension for syntax highlighting based on format.
+	file := getFileExtensionForFormat(params.Format)
+	body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
+func getFileExtensionForFormat(format string) string {
+	switch format {
+	case "text":
+		return "fetch.txt"
+	case "html":
+		return "fetch.html"
+	default:
+		return "fetch.md"
+	}
+}
+
+// -----------------------------------------------------------------------------
+// WebFetch Tool
+// -----------------------------------------------------------------------------
+
+// WebFetchToolMessageItem is a message item that represents a web_fetch tool call.
+type WebFetchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil)
+
+// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem].
+func NewWebFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
+}
+
+// WebFetchToolRenderContext renders web_fetch tool messages.
+type WebFetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Fetch", opts.Anim)
+	}
+
+	var params tools.WebFetchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// WebSearch Tool
+// -----------------------------------------------------------------------------
+
+// WebSearchToolMessageItem is a message item that represents a web_search tool call.
+type WebSearchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil)
+
+// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem].
+func NewWebSearchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
+}
+
+// WebSearchToolRenderContext renders web_search tool messages.
+type WebSearchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Search", opts.Anim)
+	}
+
+	var params tools.WebSearchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Query}
+	header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}

internal/ui/chat/file.go πŸ”—

@@ -0,0 +1,340 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// View Tool
+// -----------------------------------------------------------------------------
+
+// ViewToolMessageItem is a message item that represents a view tool call.
+type ViewToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*ViewToolMessageItem)(nil)
+
+// NewViewToolMessageItem creates a new [ViewToolMessageItem].
+func NewViewToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
+}
+
+// ViewToolRenderContext renders view tool messages.
+type ViewToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "View", opts.Anim)
+	}
+
+	var params tools.ViewParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	toolParams := []string{file}
+	if params.Limit != 0 {
+		toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+	}
+	if params.Offset != 0 {
+		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+	}
+
+	header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Handle image content.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)
+		return joinToolParts(header, body)
+	}
+
+	// Try to get content from metadata first (contains actual file content).
+	var meta tools.ViewResponseMetadata
+	content := opts.Result.Content
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" {
+		content = meta.Content
+	}
+
+	if content == "" {
+		return header
+	}
+
+	// Render code content with syntax highlighting.
+	body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Write Tool
+// -----------------------------------------------------------------------------
+
+// WriteToolMessageItem is a message item that represents a write tool call.
+type WriteToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WriteToolMessageItem)(nil)
+
+// NewWriteToolMessageItem creates a new [WriteToolMessageItem].
+func NewWriteToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
+}
+
+// WriteToolRenderContext renders write tool messages.
+type WriteToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Write", opts.Anim)
+	}
+
+	var params tools.WriteParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if params.Content == "" {
+		return header
+	}
+
+	// Render code content with syntax highlighting.
+	body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Edit Tool
+// -----------------------------------------------------------------------------
+
+// EditToolMessageItem is a message item that represents an edit tool call.
+type EditToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*EditToolMessageItem)(nil)
+
+// NewEditToolMessageItem creates a new [EditToolMessageItem].
+func NewEditToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
+}
+
+// EditToolRenderContext renders edit tool messages.
+type EditToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	// Edit tool uses full width for diffs.
+	if opts.IsPending() {
+		return pendingTool(sty, "Edit", opts.Anim)
+	}
+
+	var params tools.EditParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Get diff content from metadata.
+	var meta tools.EditResponseMetadata
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
+		bodyWidth := width - toolBodyLeftPaddingTotal
+		body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		return joinToolParts(header, body)
+	}
+
+	// Render diff.
+	body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// MultiEdit Tool
+// -----------------------------------------------------------------------------
+
+// MultiEditToolMessageItem is a message item that represents a multi-edit tool call.
+type MultiEditToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil)
+
+// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem].
+func NewMultiEditToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
+}
+
+// MultiEditToolRenderContext renders multi-edit tool messages.
+type MultiEditToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	// MultiEdit tool uses full width for diffs.
+	if opts.IsPending() {
+		return pendingTool(sty, "Multi-Edit", opts.Anim)
+	}
+
+	var params tools.MultiEditParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	toolParams := []string{file}
+	if len(params.Edits) > 0 {
+		toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
+	}
+
+	header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Get diff content from metadata.
+	var meta tools.MultiEditResponseMetadata
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
+		bodyWidth := width - toolBodyLeftPaddingTotal
+		body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		return joinToolParts(header, body)
+	}
+
+	// Render diff with optional failed edits note.
+	body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Download Tool
+// -----------------------------------------------------------------------------
+
+// DownloadToolMessageItem is a message item that represents a download tool call.
+type DownloadToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
+
+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
+func NewDownloadToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
+}
+
+// DownloadToolRenderContext renders download tool messages.
+type DownloadToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Download", opts.Anim)
+	}
+
+	var params tools.DownloadParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	if params.FilePath != "" {
+		toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
+	}
+	if params.Timeout != 0 {
+		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+	}
+
+	header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

internal/ui/chat/mcp.go πŸ”—

@@ -0,0 +1,121 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// MCPToolMessageItem is a message item that represents a bash tool call.
+type MCPToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*MCPToolMessageItem)(nil)
+
+// NewMCPToolMessageItem creates a new [MCPToolMessageItem].
+func NewMCPToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
+}
+
+// MCPToolRenderContext renders bash tool messages.
+type MCPToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
+	if len(toolNameParts) != 3 {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
+	}
+	mcpName := prettyName(toolNameParts[1])
+	toolName := prettyName(toolNameParts[2])
+
+	mcpName = sty.Tool.MCPName.Render(mcpName)
+	toolName = sty.Tool.MCPToolName.Render(toolName)
+
+	name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName)
+
+	if opts.IsPending() {
+		return pendingTool(sty, name, opts.Anim)
+	}
+
+	var params map[string]any
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var toolParams []string
+	if len(params) > 0 {
+		parsed, _ := json.Marshal(params)
+		toolParams = append(toolParams, string(parsed))
+	}
+
+	header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	// see if the result is json
+	var result json.RawMessage
+	var body string
+	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+		prettyResult, err := json.MarshalIndent(result, "", "  ")
+		if err == nil {
+			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+		} else {
+			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		}
+	} else if looksLikeMarkdown(opts.Result.Content) {
+		body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+	} else {
+		body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	}
+	return joinToolParts(header, body)
+}
+
+func prettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}
+
+// looksLikeMarkdown checks if content appears to be markdown by looking for
+// common markdown patterns.
+func looksLikeMarkdown(content string) bool {
+	patterns := []string{
+		"# ",  // headers
+		"## ", // headers
+		"**",  // bold
+		"```", // code fence
+		"- ",  // unordered list
+		"1. ", // ordered list
+		"> ",  // blockquote
+		"---", // horizontal rule
+		"***", // horizontal rule
+	}
+	for _, p := range patterns {
+		if strings.Contains(content, p) {
+			return true
+		}
+	}
+	return false
+}

internal/ui/chat/messages.go πŸ”—

@@ -0,0 +1,312 @@
+package chat
+
+import (
+	"fmt"
+	"image"
+	"strings"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// this is the total width that is taken up by the border + padding
+// we also cap the width so text is readable to the maxTextWidth(120)
+const messageLeftPaddingTotal = 2
+
+// maxTextWidth is the maximum width text messages can be
+const maxTextWidth = 120
+
+// Identifiable is an interface for items that can provide a unique identifier.
+type Identifiable interface {
+	ID() string
+}
+
+// Animatable is an interface for items that support animation.
+type Animatable interface {
+	StartAnimation() tea.Cmd
+	Animate(msg anim.StepMsg) tea.Cmd
+}
+
+// Expandable is an interface for items that can be expanded or collapsed.
+type Expandable interface {
+	ToggleExpanded()
+}
+
+// MessageItem represents a [message.Message] item that can be displayed in the
+// UI and be part of a [list.List] identifiable by a unique ID.
+type MessageItem interface {
+	list.Item
+	list.RawRenderable
+	Identifiable
+}
+
+// HighlightableMessageItem is a message item that supports highlighting.
+type HighlightableMessageItem interface {
+	MessageItem
+	list.Highlightable
+}
+
+// FocusableMessageItem is a message item that supports focus.
+type FocusableMessageItem interface {
+	MessageItem
+	list.Focusable
+}
+
+// SendMsg represents a message to send a chat message.
+type SendMsg struct {
+	Text        string
+	Attachments []message.Attachment
+}
+
+type highlightableMessageItem struct {
+	startLine   int
+	startCol    int
+	endLine     int
+	endCol      int
+	highlighter list.Highlighter
+}
+
+var _ list.Highlightable = (*highlightableMessageItem)(nil)
+
+// isHighlighted returns true if the item has a highlight range set.
+func (h *highlightableMessageItem) isHighlighted() bool {
+	return h.startLine != -1 || h.endLine != -1
+}
+
+// renderHighlighted highlights the content if necessary.
+func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
+	if !h.isHighlighted() {
+		return content
+	}
+	area := image.Rect(0, 0, width, height)
+	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
+}
+
+// SetHighlight implements list.Highlightable.
+func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
+	// Adjust columns for the style's left inset (border + padding) since we
+	// highlight the content only.
+	offset := messageLeftPaddingTotal
+	h.startLine = startLine
+	h.startCol = max(0, startCol-offset)
+	h.endLine = endLine
+	if endCol >= 0 {
+		h.endCol = max(0, endCol-offset)
+	} else {
+		h.endCol = endCol
+	}
+}
+
+// Highlight implements list.Highlightable.
+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
+	return h.startLine, h.startCol, h.endLine, h.endCol
+}
+
+func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
+	return &highlightableMessageItem{
+		startLine:   -1,
+		startCol:    -1,
+		endLine:     -1,
+		endCol:      -1,
+		highlighter: list.ToHighlighter(sty.TextSelection),
+	}
+}
+
+// cachedMessageItem caches rendered message content to avoid re-rendering.
+//
+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
+//
+// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
+// the issue with that could be memory usage
+type cachedMessageItem struct {
+	// rendered is the cached rendered string
+	rendered string
+	// width and height are the dimensions of the cached render
+	width  int
+	height int
+}
+
+// getCachedRender returns the cached render if it exists for the given width.
+func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
+	if c.width == width && c.rendered != "" {
+		return c.rendered, c.height, true
+	}
+	return "", 0, false
+}
+
+// setCachedRender sets the cached render.
+func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
+	c.rendered = rendered
+	c.width = width
+	c.height = height
+}
+
+// clearCache clears the cached render.
+func (c *cachedMessageItem) clearCache() {
+	c.rendered = ""
+	c.width = 0
+	c.height = 0
+}
+
+// focusableMessageItem is a base struct for message items that can be focused.
+type focusableMessageItem struct {
+	focused bool
+}
+
+// SetFocused implements MessageItem.
+func (f *focusableMessageItem) SetFocused(focused bool) {
+	f.focused = focused
+}
+
+// AssistantInfoID returns a stable ID for assistant info items.
+func AssistantInfoID(messageID string) string {
+	return fmt.Sprintf("%s:assistant-info", messageID)
+}
+
+// AssistantInfoItem renders model info and response time after assistant completes.
+type AssistantInfoItem struct {
+	*cachedMessageItem
+
+	id                  string
+	message             *message.Message
+	sty                 *styles.Styles
+	lastUserMessageTime time.Time
+}
+
+// NewAssistantInfoItem creates a new AssistantInfoItem.
+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
+	return &AssistantInfoItem{
+		cachedMessageItem:   &cachedMessageItem{},
+		id:                  AssistantInfoID(message.ID),
+		message:             message,
+		sty:                 sty,
+		lastUserMessageTime: lastUserMessageTime,
+	}
+}
+
+// ID implements MessageItem.
+func (a *AssistantInfoItem) ID() string {
+	return a.id
+}
+
+// RawRender implements MessageItem.
+func (a *AssistantInfoItem) RawRender(width int) string {
+	innerWidth := max(0, width-messageLeftPaddingTotal)
+	content, _, ok := a.getCachedRender(innerWidth)
+	if !ok {
+		content = a.renderContent(innerWidth)
+		height := lipgloss.Height(content)
+		a.setCachedRender(content, innerWidth, height)
+	}
+	return content
+}
+
+// Render implements MessageItem.
+func (a *AssistantInfoItem) Render(width int) string {
+	return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
+}
+
+func (a *AssistantInfoItem) renderContent(width int) string {
+	finishData := a.message.FinishPart()
+	if finishData == nil {
+		return ""
+	}
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(a.lastUserMessageTime)
+	infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
+	icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
+	model := config.Get().GetModel(a.message.Provider, a.message.Model)
+	if model == nil {
+		model = &catwalk.Model{Name: "Unknown Model"}
+	}
+	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
+	providerName := a.message.Provider
+	if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
+		providerName = providerConfig.Name
+	}
+	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
+	assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
+	return common.Section(a.sty, assistant, width)
+}
+
+// cappedMessageWidth returns the maximum width for message content for readability.
+func cappedMessageWidth(availableWidth int) int {
+	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+}
+
+// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
+// returns all parts of the message as [MessageItem]s.
+//
+// For assistant messages with tool calls, pass a toolResults map to link results.
+// Use BuildToolResultMap to create this map from all messages in a session.
+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
+	switch msg.Role {
+	case message.User:
+		r := attachments.NewRenderer(
+			sty.Attachments.Normal,
+			sty.Attachments.Deleting,
+			sty.Attachments.Image,
+			sty.Attachments.Text,
+		)
+		return []MessageItem{NewUserMessageItem(sty, msg, r)}
+	case message.Assistant:
+		var items []MessageItem
+		if ShouldRenderAssistantMessage(msg) {
+			items = append(items, NewAssistantMessageItem(sty, msg))
+		}
+		for _, tc := range msg.ToolCalls() {
+			var result *message.ToolResult
+			if tr, ok := toolResults[tc.ID]; ok {
+				result = &tr
+			}
+			items = append(items, NewToolMessageItem(
+				sty,
+				msg.ID,
+				tc,
+				result,
+				msg.FinishReason() == message.FinishReasonCanceled,
+			))
+		}
+		return items
+	}
+	return []MessageItem{}
+}
+
+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
+//
+// In some cases the assistant message only has tools so we do not want to render an
+// empty message.
+func ShouldRenderAssistantMessage(msg *message.Message) bool {
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	isError := msg.FinishReason() == message.FinishReasonError
+	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
+}
+
+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
+// Tool result messages (role == message.Tool) contain the results that should be linked
+// to tool calls in assistant messages.
+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
+	resultMap := make(map[string]message.ToolResult)
+	for _, msg := range messages {
+		if msg.Role == message.Tool {
+			for _, result := range msg.ToolResults() {
+				if result.ToolCallID != "" {
+					resultMap[result.ToolCallID] = result
+				}
+			}
+		}
+	}
+	return resultMap
+}

internal/ui/chat/search.go πŸ”—

@@ -0,0 +1,256 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Glob Tool
+// -----------------------------------------------------------------------------
+
+// GlobToolMessageItem is a message item that represents a glob tool call.
+type GlobToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GlobToolMessageItem)(nil)
+
+// NewGlobToolMessageItem creates a new [GlobToolMessageItem].
+func NewGlobToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled)
+}
+
+// GlobToolRenderContext renders glob tool messages.
+type GlobToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Glob", opts.Anim)
+	}
+
+	var params tools.GlobParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Pattern}
+	if params.Path != "" {
+		toolParams = append(toolParams, "path", params.Path)
+	}
+
+	header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Grep Tool
+// -----------------------------------------------------------------------------
+
+// GrepToolMessageItem is a message item that represents a grep tool call.
+type GrepToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GrepToolMessageItem)(nil)
+
+// NewGrepToolMessageItem creates a new [GrepToolMessageItem].
+func NewGrepToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled)
+}
+
+// GrepToolRenderContext renders grep tool messages.
+type GrepToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Grep", opts.Anim)
+	}
+
+	var params tools.GrepParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Pattern}
+	if params.Path != "" {
+		toolParams = append(toolParams, "path", params.Path)
+	}
+	if params.Include != "" {
+		toolParams = append(toolParams, "include", params.Include)
+	}
+	if params.LiteralText {
+		toolParams = append(toolParams, "literal", "true")
+	}
+
+	header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// LS Tool
+// -----------------------------------------------------------------------------
+
+// LSToolMessageItem is a message item that represents an ls tool call.
+type LSToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*LSToolMessageItem)(nil)
+
+// NewLSToolMessageItem creates a new [LSToolMessageItem].
+func NewLSToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled)
+}
+
+// LSToolRenderContext renders ls tool messages.
+type LSToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "List", opts.Anim)
+	}
+
+	var params tools.LSParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	path := params.Path
+	if path == "" {
+		path = "."
+	}
+	path = fsext.PrettyPath(path)
+
+	header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Sourcegraph Tool
+// -----------------------------------------------------------------------------
+
+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
+type SourcegraphToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
+
+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
+func NewSourcegraphToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
+}
+
+// SourcegraphToolRenderContext renders sourcegraph tool messages.
+type SourcegraphToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Sourcegraph", opts.Anim)
+	}
+
+	var params tools.SourcegraphParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Query}
+	if params.Count != 0 {
+		toolParams = append(toolParams, "count", formatNonZero(params.Count))
+	}
+	if params.ContextWindow != 0 {
+		toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
+	}
+
+	header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

internal/ui/chat/todos.go πŸ”—

@@ -0,0 +1,192 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// -----------------------------------------------------------------------------
+// Todos Tool
+// -----------------------------------------------------------------------------
+
+// TodosToolMessageItem is a message item that represents a todos tool call.
+type TodosToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
+
+// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
+func NewTodosToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
+}
+
+// TodosToolRenderContext renders todos tool messages.
+type TodosToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "To-Do", opts.Anim)
+	}
+
+	var params tools.TodosParams
+	var meta tools.TodosResponseMetadata
+	var headerText string
+	var body string
+
+	// Parse params for pending state (before result is available).
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
+		completedCount := 0
+		inProgressTask := ""
+		for _, todo := range params.Todos {
+			if todo.Status == "completed" {
+				completedCount++
+			}
+			if todo.Status == "in_progress" {
+				if todo.ActiveForm != "" {
+					inProgressTask = todo.ActiveForm
+				} else {
+					inProgressTask = todo.Content
+				}
+			}
+		}
+
+		// Default display from params (used when pending or no metadata).
+		ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+		headerText = ratio
+		if inProgressTask != "" {
+			headerText = fmt.Sprintf("%s Β· %s", ratio, inProgressTask)
+		}
+
+		// If we have metadata, use it for richer display.
+		if opts.HasResult() && opts.Result.Metadata != "" {
+			if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+				if meta.IsNew {
+					if meta.JustStarted != "" {
+						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+					} else {
+						headerText = fmt.Sprintf("created %d todos", meta.Total)
+					}
+					body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+				} else {
+					// Build header based on what changed.
+					hasCompleted := len(meta.JustCompleted) > 0
+					hasStarted := meta.JustStarted != ""
+					allCompleted := meta.Completed == meta.Total
+
+					ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+					if hasCompleted && hasStarted {
+						text := sty.Subtle.Render(fmt.Sprintf(" Β· completed %d, starting next", len(meta.JustCompleted)))
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasCompleted {
+						text := sty.Subtle.Render(fmt.Sprintf(" Β· completed %d", len(meta.JustCompleted)))
+						if allCompleted {
+							text = sty.Subtle.Render(" Β· completed all")
+						}
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasStarted {
+						headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" Β· starting task"))
+					} else {
+						headerText = ratio
+					}
+
+					// Build body with details.
+					if allCompleted {
+						// Show all todos when all are completed, like when created.
+						body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+					} else if meta.JustStarted != "" {
+						body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
+							sty.Base.Render(meta.JustStarted)
+					}
+				}
+			}
+		}
+	}
+
+	toolParams := []string{headerText}
+	header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if body == "" {
+		return header
+	}
+
+	return joinToolParts(header, sty.Tool.Body.Render(body))
+}
+
+// FormatTodosList formats a list of todos for display.
+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
+	if len(todos) == 0 {
+		return ""
+	}
+
+	sorted := make([]session.Todo, len(todos))
+	copy(sorted, todos)
+	sortTodos(sorted)
+
+	var lines []string
+	for _, todo := range sorted {
+		var prefix string
+		textStyle := sty.Base
+
+		switch todo.Status {
+		case session.TodoStatusCompleted:
+			prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
+		case session.TodoStatusInProgress:
+			prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
+		default:
+			prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
+		}
+
+		text := todo.Content
+		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+			text = todo.ActiveForm
+		}
+		line := prefix + textStyle.Render(text)
+		line = ansi.Truncate(line, width, "…")
+
+		lines = append(lines, line)
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// sortTodos sorts todos by status: completed, in_progress, pending.
+func sortTodos(todos []session.Todo) {
+	slices.SortStableFunc(todos, func(a, b session.Todo) int {
+		return statusOrder(a.Status) - statusOrder(b.Status)
+	})
+}
+
+// statusOrder returns the sort order for a todo status.
+func statusOrder(s session.TodoStatus) int {
+	switch s {
+	case session.TodoStatusCompleted:
+		return 0
+	case session.TodoStatusInProgress:
+		return 1
+	default:
+		return 2
+	}
+}

internal/ui/chat/tools.go πŸ”—

@@ -0,0 +1,805 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
+const toolBodyLeftPaddingTotal = 2
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+	ToolStatusAwaitingPermission ToolStatus = iota
+	ToolStatusRunning
+	ToolStatusSuccess
+	ToolStatusError
+	ToolStatusCanceled
+)
+
+// ToolMessageItem represents a tool call message in the chat UI.
+type ToolMessageItem interface {
+	MessageItem
+
+	ToolCall() message.ToolCall
+	SetToolCall(tc message.ToolCall)
+	SetResult(res *message.ToolResult)
+	MessageID() string
+	SetMessageID(id string)
+	SetStatus(status ToolStatus)
+	Status() ToolStatus
+}
+
+// Compactable is an interface for tool items that can render in a compacted mode.
+// When compact mode is enabled, tools render as a compact single-line header.
+type Compactable interface {
+	SetCompact(compact bool)
+}
+
+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
+type SpinningState struct {
+	ToolCall message.ToolCall
+	Result   *message.ToolResult
+	Status   ToolStatus
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (s *SpinningState) IsCanceled() bool {
+	return s.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (s *SpinningState) HasResult() bool {
+	return s.Result != nil
+}
+
+// SpinningFunc is a function type for custom spinning logic.
+// Returns true if the tool should show the spinning animation.
+type SpinningFunc func(state SpinningState) bool
+
+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
+type DefaultToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
+}
+
+// ToolRenderOpts contains the data needed to render a tool call.
+type ToolRenderOpts struct {
+	ToolCall        message.ToolCall
+	Result          *message.ToolResult
+	Anim            *anim.Anim
+	ExpandedContent bool
+	Compact         bool
+	IsSpinning      bool
+	Status          ToolStatus
+}
+
+// IsPending returns true if the tool call is still pending (not finished and
+// not canceled).
+func (o *ToolRenderOpts) IsPending() bool {
+	return !o.ToolCall.Finished && !o.IsCanceled()
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (o *ToolRenderOpts) IsCanceled() bool {
+	return o.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (o *ToolRenderOpts) HasResult() bool {
+	return o.Result != nil
+}
+
+// HasEmptyResult returns true if the result is nil or has empty content.
+func (o *ToolRenderOpts) HasEmptyResult() bool {
+	return o.Result == nil || o.Result.Content == ""
+}
+
+// ToolRenderer represents an interface for rendering tool calls.
+type ToolRenderer interface {
+	RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+}
+
+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+
+// RenderTool implements the ToolRenderer interface.
+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return f(sty, width, opts)
+}
+
+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
+type baseToolMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	toolRenderer ToolRenderer
+	toolCall     message.ToolCall
+	result       *message.ToolResult
+	messageID    string
+	status       ToolStatus
+	// we use this so we can efficiently cache
+	// tools that have a capped width (e.x bash.. and others)
+	hasCappedWidth bool
+	// isCompact indicates this tool should render in compact mode.
+	isCompact bool
+	// spinningFunc allows tools to override the default spinning logic.
+	// If nil, uses the default: !toolCall.Finished && !canceled.
+	spinningFunc SpinningFunc
+
+	sty             *styles.Styles
+	anim            *anim.Anim
+	expandedContent bool
+}
+
+// newBaseToolMessageItem is the internal constructor for base tool message items.
+func newBaseToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	toolRenderer ToolRenderer,
+	canceled bool,
+) *baseToolMessageItem {
+	// we only do full width for diffs (as far as I know)
+	hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
+
+	status := ToolStatusRunning
+	if canceled {
+		status = ToolStatusCanceled
+	}
+
+	t := &baseToolMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		sty:                      sty,
+		toolRenderer:             toolRenderer,
+		toolCall:                 toolCall,
+		result:                   result,
+		status:                   status,
+		hasCappedWidth:           hasCappedWidth,
+	}
+	t.anim = anim.New(anim.Settings{
+		ID:          toolCall.ID,
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+
+	return t
+}
+
+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
+//
+// It returns a specific tool message item type if implemented, otherwise it
+// returns a generic tool message item. The messageID is the ID of the assistant
+// message containing this tool call.
+func NewToolMessageItem(
+	sty *styles.Styles,
+	messageID string,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	var item ToolMessageItem
+	switch toolCall.Name {
+	case tools.BashToolName:
+		item = NewBashToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobOutputToolName:
+		item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobKillToolName:
+		item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
+	case tools.ViewToolName:
+		item = NewViewToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WriteToolName:
+		item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
+	case tools.EditToolName:
+		item = NewEditToolMessageItem(sty, toolCall, result, canceled)
+	case tools.MultiEditToolName:
+		item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
+	case tools.GlobToolName:
+		item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
+	case tools.GrepToolName:
+		item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
+	case tools.LSToolName:
+		item = NewLSToolMessageItem(sty, toolCall, result, canceled)
+	case tools.DownloadToolName:
+		item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
+	case tools.FetchToolName:
+		item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.SourcegraphToolName:
+		item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
+	case tools.DiagnosticsToolName:
+		item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
+	case agent.AgentToolName:
+		item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
+	case tools.AgenticFetchToolName:
+		item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WebFetchToolName:
+		item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WebSearchToolName:
+		item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.TodosToolName:
+		item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
+	default:
+		if strings.HasPrefix(toolCall.Name, "mcp_") {
+			item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
+		} else {
+			// TODO: Implement other tool items
+			item = newBaseToolMessageItem(
+				sty,
+				toolCall,
+				result,
+				&DefaultToolRenderContext{},
+				canceled,
+			)
+		}
+	}
+	item.SetMessageID(messageID)
+	return item
+}
+
+// SetCompact implements the Compactable interface.
+func (t *baseToolMessageItem) SetCompact(compact bool) {
+	t.isCompact = compact
+	t.clearCache()
+}
+
+// ID returns the unique identifier for this tool message item.
+func (t *baseToolMessageItem) ID() string {
+	return t.toolCall.ID
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Animate(msg)
+}
+
+// RawRender implements [MessageItem].
+func (t *baseToolMessageItem) RawRender(width int) string {
+	toolItemWidth := width - messageLeftPaddingTotal
+	if t.hasCappedWidth {
+		toolItemWidth = cappedMessageWidth(width)
+	}
+
+	content, height, ok := t.getCachedRender(toolItemWidth)
+	// if we are spinning or there is no cache rerender
+	if !ok || t.isSpinning() {
+		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
+			ToolCall:        t.toolCall,
+			Result:          t.result,
+			Anim:            t.anim,
+			ExpandedContent: t.expandedContent,
+			Compact:         t.isCompact,
+			IsSpinning:      t.isSpinning(),
+			Status:          t.computeStatus(),
+		})
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		t.setCachedRender(content, toolItemWidth, height)
+	}
+
+	return t.renderHighlighted(content, toolItemWidth, height)
+}
+
+// Render renders the tool message item at the given width.
+func (t *baseToolMessageItem) Render(width int) string {
+	style := t.sty.Chat.Message.ToolCallBlurred
+	if t.focused {
+		style = t.sty.Chat.Message.ToolCallFocused
+	}
+
+	if t.isCompact {
+		style = t.sty.Chat.Message.ToolCallCompact
+	}
+
+	return style.Render(t.RawRender(width))
+}
+
+// ToolCall returns the tool call associated with this message item.
+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
+	return t.toolCall
+}
+
+// SetToolCall sets the tool call associated with this message item.
+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
+	t.toolCall = tc
+	t.clearCache()
+}
+
+// SetResult sets the tool result associated with this message item.
+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
+	t.result = res
+	t.clearCache()
+}
+
+// MessageID returns the ID of the message containing this tool call.
+func (t *baseToolMessageItem) MessageID() string {
+	return t.messageID
+}
+
+// SetMessageID sets the ID of the message containing this tool call.
+func (t *baseToolMessageItem) SetMessageID(id string) {
+	t.messageID = id
+}
+
+// SetStatus sets the tool status.
+func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
+	t.status = status
+	t.clearCache()
+}
+
+// Status returns the current tool status.
+func (t *baseToolMessageItem) Status() ToolStatus {
+	return t.status
+}
+
+// computeStatus computes the effective status considering the result.
+func (t *baseToolMessageItem) computeStatus() ToolStatus {
+	if t.result != nil {
+		if t.result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	return t.status
+}
+
+// isSpinning returns true if the tool should show animation.
+func (t *baseToolMessageItem) isSpinning() bool {
+	if t.spinningFunc != nil {
+		return t.spinningFunc(SpinningState{
+			ToolCall: t.toolCall,
+			Result:   t.result,
+			Status:   t.status,
+		})
+	}
+	return !t.toolCall.Finished && t.status != ToolStatusCanceled
+}
+
+// SetSpinningFunc sets a custom function to determine if the tool should spin.
+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
+	t.spinningFunc = fn
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (t *baseToolMessageItem) ToggleExpanded() {
+	t.expandedContent = !t.expandedContent
+	t.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	t.ToggleExpanded()
+	return true
+}
+
+// pendingTool renders a tool that is still in progress with an animation.
+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
+	icon := sty.Tool.IconPending.Render()
+	toolName := sty.Tool.NameNormal.Render(name)
+
+	var animView string
+	if anim != nil {
+		animView = anim.Render()
+	}
+
+	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
+}
+
+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
+	var msg string
+	switch opts.Status {
+	case ToolStatusError:
+		msg = toolErrorContent(sty, opts.Result, width)
+	case ToolStatusCanceled:
+		msg = sty.Tool.StateCancelled.Render("Canceled.")
+	case ToolStatusAwaitingPermission:
+		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+	case ToolStatusRunning:
+		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+	default:
+		return "", false
+	}
+	return msg, true
+}
+
+// toolErrorContent formats an error message with ERROR tag.
+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
+	if result == nil {
+		return ""
+	}
+	errContent := strings.ReplaceAll(result.Content, "\n", " ")
+	errTag := sty.Tool.ErrorTag.Render("ERROR")
+	tagWidth := lipgloss.Width(errTag)
+	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// toolIcon returns the status icon for a tool call.
+// toolIcon returns the status icon for a tool call based on its status.
+func toolIcon(sty *styles.Styles, status ToolStatus) string {
+	switch status {
+	case ToolStatusSuccess:
+		return sty.Tool.IconSuccess.String()
+	case ToolStatusError:
+		return sty.Tool.IconError.String()
+	case ToolStatusCanceled:
+		return sty.Tool.IconCancelled.String()
+	default:
+		return sty.Tool.IconPending.String()
+	}
+}
+
+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
+func toolParamList(sty *styles.Styles, params []string, width int) string {
+	// minSpaceForMainParam is the min space required for the main param
+	// if this is less that the value set we will only show the main param nothing else
+	const minSpaceForMainParam = 30
+	if len(params) == 0 {
+		return ""
+	}
+
+	mainParam := params[0]
+
+	// Build key=value pairs from remaining params (consecutive key, value pairs).
+	var kvPairs []string
+	for i := 1; i+1 < len(params); i += 2 {
+		if params[i+1] != "" {
+			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
+		}
+	}
+
+	// Try to include key=value pairs if there's enough space.
+	output := mainParam
+	if len(kvPairs) > 0 {
+		partsStr := strings.Join(kvPairs, ", ")
+		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
+			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
+		}
+	}
+
+	if width >= 0 {
+		output = ansi.Truncate(output, width, "…")
+	}
+	return sty.Tool.ParamMain.Render(output)
+}
+
+// toolHeader builds the tool header line: "● ToolName params..."
+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
+	icon := toolIcon(sty, status)
+	nameStyle := sty.Tool.NameNormal
+	if nested {
+		nameStyle = sty.Tool.NameNested
+	}
+	toolName := nameStyle.Render(name)
+	prefix := fmt.Sprintf("%s %s ", icon, toolName)
+	prefixWidth := lipgloss.Width(prefix)
+	remainingWidth := width - prefixWidth
+	paramsStr := toolParamList(sty, params, remainingWidth)
+	return prefix + paramsStr
+}
+
+// toolOutputPlainContent renders plain text with optional expansion support.
+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines) // Show all
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		ln = " " + ln
+		if lipgloss.Width(ln) > width {
+			ln = ansi.Truncate(ln, width, "…")
+		}
+		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}
+
+// toolOutputCodeContent renders code with syntax highlighting and line numbers.
+func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+
+	lines := strings.Split(content, "\n")
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	// Truncate if needed.
+	displayLines := lines
+	if len(lines) > maxLines {
+		displayLines = lines[:maxLines]
+	}
+
+	bg := sty.Tool.ContentCodeBg
+	highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
+	highlightedLines := strings.Split(highlighted, "\n")
+
+	// Calculate line number width.
+	maxLineNumber := len(displayLines) + offset
+	maxDigits := getDigits(maxLineNumber)
+	numFmt := fmt.Sprintf("%%%dd", maxDigits)
+
+	bodyWidth := width - toolBodyLeftPaddingTotal
+	codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
+
+	var out []string
+	for i, ln := range highlightedLines {
+		lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
+
+		if lipgloss.Width(ln) > codeWidth {
+			ln = ansi.Truncate(ln, codeWidth, "…")
+		}
+
+		codeLine := sty.Tool.ContentCodeLine.
+			Width(codeWidth).
+			PaddingLeft(2).
+			Render(ln)
+
+		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
+	}
+
+	// Add truncation message if needed.
+	if len(lines) > maxLines && !expanded {
+		out = append(out, sty.Tool.ContentCodeTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+		)
+	}
+
+	return sty.Tool.Body.Render(strings.Join(out, "\n"))
+}
+
+// toolOutputImageContent renders image data with size info.
+func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
+	dataSize := len(data) * 3 / 4
+	sizeStr := formatSize(dataSize)
+
+	loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
+	arrow := sty.Base.Foreground(sty.GreenDark).Render("β†’")
+	typeStyled := sty.Base.Render(mediaType)
+	sizeStyled := sty.Subtle.Render(sizeStr)
+
+	return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
+}
+
+// getDigits returns the number of digits in a number.
+func getDigits(n int) int {
+	if n == 0 {
+		return 1
+	}
+	if n < 0 {
+		n = -n
+	}
+	digits := 0
+	for n > 0 {
+		n /= 10
+		digits++
+	}
+	return digits
+}
+
+// formatSize formats byte size into human readable format.
+func formatSize(bytes int) string {
+	const (
+		kb = 1024
+		mb = kb * 1024
+	)
+	switch {
+	case bytes >= mb:
+		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
+	case bytes >= kb:
+		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
+	default:
+		return fmt.Sprintf("%d B", bytes)
+	}
+}
+
+// toolOutputDiffContent renders a diff between old and new content.
+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
+	bodyWidth := width - toolBodyLeftPaddingTotal
+
+	formatter := common.DiffFormatter(sty).
+		Before(file, oldContent).
+		After(file, newContent).
+		Width(bodyWidth)
+
+	// Use split view for wide terminals.
+	if width > maxTextWidth {
+		formatter = formatter.Split()
+	}
+
+	formatted := formatter.String()
+	lines := strings.Split(formatted, "\n")
+
+	// Truncate if needed.
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		truncMsg := sty.Tool.DiffTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
+		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+	}
+
+	return sty.Tool.Body.Render(formatted)
+}
+
+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
+// Returns empty string if timeout is 0.
+func formatTimeout(timeout int) string {
+	if timeout == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%ds", timeout)
+}
+
+// formatNonZero returns string representation of non-zero integers, empty string for zero.
+func formatNonZero(value int) string {
+	if value == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d", value)
+}
+
+// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
+	bodyWidth := width - toolBodyLeftPaddingTotal
+
+	formatter := common.DiffFormatter(sty).
+		Before(file, meta.OldContent).
+		After(file, meta.NewContent).
+		Width(bodyWidth)
+
+	// Use split view for wide terminals.
+	if width > maxTextWidth {
+		formatter = formatter.Split()
+	}
+
+	formatted := formatter.String()
+	lines := strings.Split(formatted, "\n")
+
+	// Truncate if needed.
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		truncMsg := sty.Tool.DiffTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
+		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+	}
+
+	// Add failed edits note if any exist.
+	if len(meta.EditsFailed) > 0 {
+		noteTag := sty.Tool.NoteTag.Render("Note")
+		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
+		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
+		formatted = formatted + "\n\n" + note
+	}
+
+	return sty.Tool.Body.Render(formatted)
+}
+
+// roundedEnumerator creates a tree enumerator with rounded corners.
+func roundedEnumerator(lPadding, width int) tree.Enumerator {
+	if width == 0 {
+		width = 2
+	}
+	if lPadding == 0 {
+		lPadding = 1
+	}
+	return func(children tree.Children, index int) string {
+		line := strings.Repeat("─", width)
+		padding := strings.Repeat(" ", lPadding)
+		if children.Length()-1 == index {
+			return padding + "β•°" + line
+		}
+		return padding + "β”œ" + line
+	}
+}
+
+// toolOutputMarkdownContent renders markdown content with optional truncation.
+func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+
+	// Cap width for readability.
+	if width > maxTextWidth {
+		width = maxTextWidth
+	}
+
+	renderer := common.PlainMarkdownRenderer(sty, width)
+	rendered, err := renderer.Render(content)
+	if err != nil {
+		return toolOutputPlainContent(sty, content, width, expanded)
+	}
+
+	lines := strings.Split(rendered, "\n")
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		out = append(out, ln)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+		)
+	}
+
+	return sty.Tool.Body.Render(strings.Join(out, "\n"))
+}

internal/ui/chat/user.go πŸ”—

@@ -0,0 +1,94 @@
+package chat
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// UserMessageItem represents a user message in the chat UI.
+type UserMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	attachments *attachments.Renderer
+	message     *message.Message
+	sty         *styles.Styles
+}
+
+// NewUserMessageItem creates a new UserMessageItem.
+func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
+	return &UserMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		attachments:              attachments,
+		message:                  message,
+		sty:                      sty,
+	}
+}
+
+// RawRender implements [MessageItem].
+func (m *UserMessageItem) RawRender(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	content, height, ok := m.getCachedRender(cappedWidth)
+	// cache hit
+	if ok {
+		return m.renderHighlighted(content, cappedWidth, height)
+	}
+
+	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+
+	msgContent := strings.TrimSpace(m.message.Content().Text)
+	result, err := renderer.Render(msgContent)
+	if err != nil {
+		content = msgContent
+	} else {
+		content = strings.TrimSuffix(result, "\n")
+	}
+
+	if len(m.message.BinaryContent()) > 0 {
+		attachmentsStr := m.renderAttachments(cappedWidth)
+		if content == "" {
+			content = attachmentsStr
+		} else {
+			content = strings.Join([]string{content, "", attachmentsStr}, "\n")
+		}
+	}
+
+	height = lipgloss.Height(content)
+	m.setCachedRender(content, cappedWidth, height)
+	return m.renderHighlighted(content, cappedWidth, height)
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+	style := m.sty.Chat.Message.UserBlurred
+	if m.focused {
+		style = m.sty.Chat.Message.UserFocused
+	}
+	return style.Render(m.RawRender(width))
+}
+
+// ID implements MessageItem.
+func (m *UserMessageItem) ID() string {
+	return m.message.ID
+}
+
+// renderAttachments renders attachments.
+func (m *UserMessageItem) renderAttachments(width int) string {
+	var attachments []message.Attachment
+	for _, at := range m.message.BinaryContent() {
+		attachments = append(attachments, message.Attachment{
+			FileName: at.Path,
+			MimeType: at.MIMEType,
+		})
+	}
+	return m.attachments.Render(attachments, false, width)
+}

internal/ui/common/button.go πŸ”—

@@ -0,0 +1,69 @@
+package common
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// ButtonOpts defines the configuration for a single button
+type ButtonOpts struct {
+	// Text is the button label
+	Text string
+	// UnderlineIndex is the 0-based index of the character to underline (-1 for none)
+	UnderlineIndex int
+	// Selected indicates whether this button is currently selected
+	Selected bool
+	// Padding inner horizontal padding defaults to 2 if this is 0
+	Padding int
+}
+
+// Button creates a button with an underlined character and selection state
+func Button(t *styles.Styles, opts ButtonOpts) string {
+	// Select style based on selection state
+	style := t.ButtonBlur
+	if opts.Selected {
+		style = t.ButtonFocus
+	}
+
+	text := opts.Text
+	if opts.Padding == 0 {
+		opts.Padding = 2
+	}
+
+	// the index is out of bound
+	if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 {
+		opts.UnderlineIndex = -1
+	}
+
+	text = style.Padding(0, opts.Padding).Render(text)
+
+	if opts.UnderlineIndex != -1 {
+		text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true)))
+	}
+
+	return text
+}
+
+// ButtonGroup creates a row of selectable buttons
+// Spacing is the separator between buttons
+// Use "  " or similar for horizontal layout
+// Use "\n"  for vertical layout
+// Defaults to "  " (horizontal)
+func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string {
+	if len(buttons) == 0 {
+		return ""
+	}
+
+	if spacing == "" {
+		spacing = "  "
+	}
+
+	parts := make([]string, len(buttons))
+	for i, button := range buttons {
+		parts[i] = Button(t, button)
+	}
+
+	return strings.Join(parts, spacing)
+}

internal/ui/common/common.go πŸ”—

@@ -0,0 +1,65 @@
+package common
+
+import (
+	"fmt"
+	"image"
+	"os"
+
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
+const MaxAttachmentSize = int64(5 * 1024 * 1024)
+
+// AllowedImageTypes defines the permitted image file types.
+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
+
+// Common defines common UI options and configurations.
+type Common struct {
+	App    *app.App
+	Styles *styles.Styles
+}
+
+// Config returns the configuration associated with this [Common] instance.
+func (c *Common) Config() *config.Config {
+	return c.App.Config()
+}
+
+// DefaultCommon returns the default common UI configurations.
+func DefaultCommon(app *app.App) *Common {
+	s := styles.DefaultStyles()
+	return &Common{
+		App:    app,
+		Styles: &s,
+	}
+}
+
+// CenterRect returns a new [Rectangle] centered within the given area with the
+// specified width and height.
+func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
+	centerX := area.Min.X + area.Dx()/2
+	centerY := area.Min.Y + area.Dy()/2
+	minX := centerX - width/2
+	minY := centerY - height/2
+	maxX := minX + width
+	maxY := minY + height
+	return image.Rect(minX, minY, maxX, maxY)
+}
+
+// IsFileTooBig checks if the file at the given path exceeds the specified size
+// limit.
+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return false, fmt.Errorf("error getting file info: %w", err)
+	}
+
+	if fileInfo.Size() > sizeLimit {
+		return true, nil
+	}
+
+	return false, nil
+}

internal/ui/common/diff.go πŸ”—

@@ -0,0 +1,16 @@
+package common
+
+import (
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DiffFormatter returns a diff formatter with the given styles that can be
+// used to format diff outputs.
+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
+	formatDiff := diffview.New()
+	style := chroma.MustNewStyle("crush", s.ChromaTheme())
+	diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
+	return diff
+}

internal/ui/common/elements.go πŸ”—

@@ -0,0 +1,190 @@
+package common
+
+import (
+	"cmp"
+	"fmt"
+	"image/color"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// PrettyPath formats a file path with home directory shortening and applies
+// muted styling.
+func PrettyPath(t *styles.Styles, path string, width int) string {
+	formatted := home.Short(path)
+	return t.Muted.Width(width).Render(formatted)
+}
+
+// ModelContextInfo contains token usage and cost information for a model.
+type ModelContextInfo struct {
+	ContextUsed  int64
+	ModelContext int64
+	Cost         float64
+}
+
+// ModelInfo renders model information including name, provider, reasoning
+// settings, and optional context usage/cost.
+func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string {
+	modelIcon := t.Subtle.Render(styles.ModelIcon)
+	modelName = t.Base.Render(modelName)
+
+	// Build first line with model name and optionally provider on the same line
+	var firstLine string
+	if providerName != "" {
+		providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName))
+		modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo)
+
+		// Check if it fits on one line
+		if lipgloss.Width(modelWithProvider) <= width {
+			firstLine = modelWithProvider
+		} else {
+			// If it doesn't fit, put provider on next line
+			firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+		}
+	} else {
+		firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+	}
+
+	parts := []string{firstLine}
+
+	// If provider didn't fit on first line, add it as second line
+	if providerName != "" && !strings.Contains(firstLine, "via") {
+		providerInfo := fmt.Sprintf("via %s", providerName)
+		parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo))
+	}
+
+	if reasoningInfo != "" {
+		parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo))
+	}
+
+	if context != nil {
+		formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
+		parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(
+		lipgloss.JoinVertical(lipgloss.Left, parts...),
+	)
+}
+
+// formatTokensAndCost formats token usage and cost with appropriate units
+// (K/M) and percentage of context window.
+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
+	var formattedTokens string
+	switch {
+	case tokens >= 1_000_000:
+		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
+	case tokens >= 1_000:
+		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
+	default:
+		formattedTokens = fmt.Sprintf("%d", tokens)
+	}
+
+	if strings.HasSuffix(formattedTokens, ".0K") {
+		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
+	}
+	if strings.HasSuffix(formattedTokens, ".0M") {
+		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
+	}
+
+	percentage := (float64(tokens) / float64(contextWindow)) * 100
+
+	formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost))
+
+	formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens))
+	formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
+	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
+	if percentage > 80 {
+		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
+	}
+
+	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
+}
+
+// StatusOpts defines options for rendering a status line with icon, title,
+// description, and optional extra content.
+type StatusOpts struct {
+	Icon             string // if empty no icon will be shown
+	Title            string
+	TitleColor       color.Color
+	Description      string
+	DescriptionColor color.Color
+	ExtraContent     string // additional content to append after the description
+}
+
+// Status renders a status line with icon, title, description, and extra
+// content. The description is truncated if it exceeds the available width.
+func Status(t *styles.Styles, opts StatusOpts, width int) string {
+	icon := opts.Icon
+	title := opts.Title
+	description := opts.Description
+
+	titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground())
+	descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground())
+
+	title = t.Base.Foreground(titleColor).Render(title)
+
+	if description != "" {
+		extraContentWidth := lipgloss.Width(opts.ExtraContent)
+		if extraContentWidth > 0 {
+			extraContentWidth += 1
+		}
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
+		description = t.Base.Foreground(descriptionColor).Render(description)
+	}
+
+	content := []string{}
+	if icon != "" {
+		content = append(content, icon)
+	}
+	content = append(content, title)
+	if description != "" {
+		content = append(content, description)
+	}
+	if opts.ExtraContent != "" {
+		content = append(content, opts.ExtraContent)
+	}
+
+	return strings.Join(content, " ")
+}
+
+// Section renders a section header with a title and a horizontal line filling
+// the remaining width.
+func Section(t *styles.Styles, text string, width int, info ...string) string {
+	char := styles.SectionSeparator
+	length := lipgloss.Width(text) + 1
+	remainingWidth := width - length
+
+	var infoText string
+	if len(info) > 0 {
+		infoText = strings.Join(info, " ")
+		if len(infoText) > 0 {
+			infoText = " " + infoText
+			remainingWidth -= lipgloss.Width(infoText)
+		}
+	}
+
+	text = t.Section.Title.Render(text)
+	if remainingWidth > 0 {
+		text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
+	}
+	return text
+}
+
+// DialogTitle renders a dialog title with a decorative line filling the
+// remaining width.
+func DialogTitle(t *styles.Styles, title string, width int) string {
+	char := "β•±"
+	length := lipgloss.Width(title) + 1
+	remainingWidth := width - length
+	if remainingWidth > 0 {
+		lines := strings.Repeat(char, remainingWidth)
+		lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
+		title = title + " " + lines
+	}
+	return title
+}

internal/ui/common/highlight.go πŸ”—

@@ -0,0 +1,57 @@
+package common
+
+import (
+	"bytes"
+	"image/color"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/formatters"
+	"github.com/alecthomas/chroma/v2/lexers"
+	chromastyles "github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// SyntaxHighlight applies syntax highlighting to the given source code based
+// on the file name and background color. It returns the highlighted code as a
+// string.
+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
+	// Determine the language lexer to use
+	l := lexers.Match(fileName)
+	if l == nil {
+		l = lexers.Analyse(source)
+	}
+	if l == nil {
+		l = lexers.Fallback
+	}
+	l = chroma.Coalesce(l)
+
+	// Get the formatter
+	f := formatters.Get("terminal16m")
+	if f == nil {
+		f = formatters.Fallback
+	}
+
+	style := chroma.MustNewStyle("crush", st.ChromaTheme())
+
+	// Modify the style to use the provided background
+	s, err := style.Builder().Transform(
+		func(t chroma.StyleEntry) chroma.StyleEntry {
+			r, g, b, _ := bg.RGBA()
+			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+			return t
+		},
+	).Build()
+	if err != nil {
+		s = chromastyles.Fallback
+	}
+
+	// Tokenize and format
+	it, err := l.Tokenise(nil, source)
+	if err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	err = f.Format(&buf, s, it)
+	return buf.String(), err
+}

internal/ui/common/interface.go πŸ”—

@@ -0,0 +1,11 @@
+package common
+
+import (
+	tea "charm.land/bubbletea/v2"
+)
+
+// Model represents a common interface for UI components.
+type Model[T any] interface {
+	Update(msg tea.Msg) (T, tea.Cmd)
+	View() string
+}

internal/ui/common/markdown.go πŸ”—

@@ -0,0 +1,26 @@
+package common
+
+import (
+	"charm.land/glamour/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
+// the given styles and width.
+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(sty.Markdown),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}
+
+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
+// (plain text with structure) and the given width.
+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(sty.PlainMarkdown),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}

internal/ui/common/scrollbar.go πŸ”—

@@ -0,0 +1,46 @@
+package common
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// Scrollbar renders a vertical scrollbar based on content and viewport size.
+// Returns an empty string if content fits within viewport (no scrolling needed).
+func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string {
+	if height <= 0 || contentSize <= viewportSize {
+		return ""
+	}
+
+	// Calculate thumb size (minimum 1 character).
+	thumbSize := max(1, height*viewportSize/contentSize)
+
+	// Calculate thumb position.
+	maxOffset := contentSize - viewportSize
+	if maxOffset <= 0 {
+		return ""
+	}
+
+	// Calculate where the thumb starts.
+	trackSpace := height - thumbSize
+	thumbPos := 0
+	if trackSpace > 0 && maxOffset > 0 {
+		thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
+	}
+
+	// Build the scrollbar.
+	var sb strings.Builder
+	for i := range height {
+		if i > 0 {
+			sb.WriteString("\n")
+		}
+		if i >= thumbPos && i < thumbPos+thumbSize {
+			sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
+		} else {
+			sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
+		}
+	}
+
+	return sb.String()
+}

internal/ui/completions/completions.go πŸ”—

@@ -0,0 +1,267 @@
+package completions
+
+import (
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/ordered"
+)
+
+const (
+	minHeight = 1
+	maxHeight = 10
+	minWidth  = 10
+	maxWidth  = 100
+)
+
+// SelectionMsg is sent when a completion is selected.
+type SelectionMsg struct {
+	Value  any
+	Insert bool // If true, insert without closing.
+}
+
+// ClosedMsg is sent when the completions are closed.
+type ClosedMsg struct{}
+
+// FilesLoadedMsg is sent when files have been loaded for completions.
+type FilesLoadedMsg struct {
+	Files []string
+}
+
+// Completions represents the completions popup component.
+type Completions struct {
+	// Popup dimensions
+	width  int
+	height int
+
+	// State
+	open  bool
+	query string
+
+	// Key bindings
+	keyMap KeyMap
+
+	// List component
+	list *list.FilterableList
+
+	// Styling
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// New creates a new completions component.
+func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
+	l := list.NewFilterableList()
+	l.SetGap(0)
+	l.SetReverse(true)
+
+	return &Completions{
+		keyMap:       DefaultKeyMap(),
+		list:         l,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// IsOpen returns whether the completions popup is open.
+func (c *Completions) IsOpen() bool {
+	return c.open
+}
+
+// Query returns the current filter query.
+func (c *Completions) Query() string {
+	return c.query
+}
+
+// Size returns the visible size of the popup.
+func (c *Completions) Size() (width, height int) {
+	visible := len(c.list.VisibleItems())
+	return c.width, min(visible, c.height)
+}
+
+// KeyMap returns the key bindings.
+func (c *Completions) KeyMap() KeyMap {
+	return c.keyMap
+}
+
+// OpenWithFiles opens the completions with file items from the filesystem.
+func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
+	return func() tea.Msg {
+		files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
+		slices.Sort(files)
+		return FilesLoadedMsg{Files: files}
+	}
+}
+
+// SetFiles sets the file items on the completions popup.
+func (c *Completions) SetFiles(files []string) {
+	items := make([]list.FilterableItem, 0, len(files))
+	width := 0
+	for _, file := range files {
+		file = strings.TrimPrefix(file, "./")
+		item := NewCompletionItem(
+			file,
+			FileCompletionValue{Path: file},
+			c.normalStyle,
+			c.focusedStyle,
+			c.matchStyle,
+		)
+
+		width = max(width, ansi.StringWidth(file))
+		items = append(items, item)
+	}
+
+	c.open = true
+	c.query = ""
+	c.list.SetItems(items...)
+	c.list.SetFilter("") // Clear any previous filter.
+	c.list.Focus()
+
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+}
+
+// Close closes the completions popup.
+func (c *Completions) Close() {
+	c.open = false
+}
+
+// Filter filters the completions with the given query.
+func (c *Completions) Filter(query string) {
+	if !c.open {
+		return
+	}
+
+	if query == c.query {
+		return
+	}
+
+	c.query = query
+	c.list.SetFilter(query)
+
+	items := c.list.VisibleItems()
+	width := 0
+	for _, item := range items {
+		width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
+	}
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+}
+
+// HasItems returns whether there are visible items.
+func (c *Completions) HasItems() bool {
+	return len(c.list.VisibleItems()) > 0
+}
+
+// Update handles key events for the completions.
+func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
+	if !c.open {
+		return nil, false
+	}
+
+	switch {
+	case key.Matches(msg, c.keyMap.Up):
+		c.selectPrev()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.Down):
+		c.selectNext()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.UpInsert):
+		c.selectPrev()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.DownInsert):
+		c.selectNext()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.Select):
+		return c.selectCurrent(false), true
+
+	case key.Matches(msg, c.keyMap.Cancel):
+		c.Close()
+		return ClosedMsg{}, true
+	}
+
+	return nil, false
+}
+
+// selectPrev selects the previous item with circular navigation.
+func (c *Completions) selectPrev() {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectPrev() {
+		c.list.WrapToEnd()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectNext selects the next item with circular navigation.
+func (c *Completions) selectNext() {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectNext() {
+		c.list.WrapToStart()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectCurrent returns a command with the currently selected item.
+func (c *Completions) selectCurrent(insert bool) tea.Msg {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return nil
+	}
+
+	selected := c.list.Selected()
+	if selected < 0 || selected >= len(items) {
+		return nil
+	}
+
+	item, ok := items[selected].(*CompletionItem)
+	if !ok {
+		return nil
+	}
+
+	if !insert {
+		c.open = false
+	}
+
+	return SelectionMsg{
+		Value:  item.Value(),
+		Insert: insert,
+	}
+}
+
+// Render renders the completions popup.
+func (c *Completions) Render() string {
+	if !c.open {
+		return ""
+	}
+
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return ""
+	}
+
+	return c.list.Render()
+}

internal/ui/completions/item.go πŸ”—

@@ -0,0 +1,185 @@
+package completions
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/rivo/uniseg"
+	"github.com/sahilm/fuzzy"
+)
+
+// FileCompletionValue represents a file path completion value.
+type FileCompletionValue struct {
+	Path string
+}
+
+// CompletionItem represents an item in the completions list.
+type CompletionItem struct {
+	text    string
+	value   any
+	match   fuzzy.Match
+	focused bool
+	cache   map[int]string
+
+	// Styles
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// NewCompletionItem creates a new completion item.
+func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
+	return &CompletionItem{
+		text:         text,
+		value:        value,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// Text returns the display text of the item.
+func (c *CompletionItem) Text() string {
+	return c.text
+}
+
+// Value returns the value of the item.
+func (c *CompletionItem) Value() any {
+	return c.value
+}
+
+// Filter implements [list.FilterableItem].
+func (c *CompletionItem) Filter() string {
+	return c.text
+}
+
+// SetMatch implements [list.MatchSettable].
+func (c *CompletionItem) SetMatch(m fuzzy.Match) {
+	c.cache = nil
+	c.match = m
+}
+
+// SetFocused implements [list.Focusable].
+func (c *CompletionItem) SetFocused(focused bool) {
+	if c.focused != focused {
+		c.cache = nil
+	}
+	c.focused = focused
+}
+
+// Render implements [list.Item].
+func (c *CompletionItem) Render(width int) string {
+	return renderItem(
+		c.normalStyle,
+		c.focusedStyle,
+		c.matchStyle,
+		c.text,
+		c.focused,
+		width,
+		c.cache,
+		&c.match,
+	)
+}
+
+func renderItem(
+	normalStyle, focusedStyle, matchStyle lipgloss.Style,
+	text string,
+	focused bool,
+	width int,
+	cache map[int]string,
+	match *fuzzy.Match,
+) string {
+	if cache == nil {
+		cache = make(map[int]string)
+	}
+
+	cached, ok := cache[width]
+	if ok {
+		return cached
+	}
+
+	innerWidth := width - 2 // Account for padding
+	// Truncate if needed.
+	if ansi.StringWidth(text) > innerWidth {
+		text = ansi.Truncate(text, innerWidth, "…")
+	}
+
+	// Select base style.
+	style := normalStyle
+	matchStyle = matchStyle.Background(style.GetBackground())
+	if focused {
+		style = focusedStyle
+		matchStyle = matchStyle.Background(style.GetBackground())
+	}
+
+	// Render full-width text with background.
+	content := style.Padding(0, 1).Width(width).Render(text)
+
+	// Apply match highlighting using StyleRanges.
+	if len(match.MatchedIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(match.MatchedIndexes) {
+			start, stop := bytePosToVisibleCharPos(text, rng)
+			// Offset by 1 for the padding space.
+			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
+		}
+		content = lipgloss.StyleRanges(content, ranges...)
+	}
+
+	cache[width] = content
+	return content
+}
+
+// matchedRanges converts a list of match indexes into contiguous ranges.
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+// bytePosToVisibleCharPos converts byte positions to visible character positions.
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}
+
+// Ensure CompletionItem implements the required interfaces.
+var (
+	_ list.Item           = (*CompletionItem)(nil)
+	_ list.FilterableItem = (*CompletionItem)(nil)
+	_ list.MatchSettable  = (*CompletionItem)(nil)
+	_ list.Focusable      = (*CompletionItem)(nil)
+)

internal/ui/completions/keys.go πŸ”—

@@ -0,0 +1,74 @@
+package completions
+
+import (
+	"charm.land/bubbles/v2/key"
+)
+
+// KeyMap defines the key bindings for the completions component.
+type KeyMap struct {
+	Down,
+	Up,
+	Select,
+	Cancel key.Binding
+	DownInsert,
+	UpInsert key.Binding
+}
+
+// DefaultKeyMap returns the default key bindings for completions.
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down"),
+			key.WithHelp("down", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up"),
+			key.WithHelp("up", "move up"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "select"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		DownInsert: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "insert next"),
+		),
+		UpInsert: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "insert previous"),
+		),
+	}
+}
+
+// KeyBindings returns all key bindings as a slice.
+func (k KeyMap) KeyBindings() []key.Binding {
+	return []key.Binding{
+		k.Down,
+		k.Up,
+		k.Select,
+		k.Cancel,
+	}
+}
+
+// FullHelp returns the full help for the key bindings.
+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 returns the short help for the key bindings.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

internal/ui/dialog/actions.go πŸ”—

@@ -0,0 +1,165 @@
+package dialog
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+)
+
+// ActionClose is a message to close the current dialog.
+type ActionClose struct{}
+
+// ActionQuit is a message to quit the application.
+type ActionQuit = tea.QuitMsg
+
+// ActionOpenDialog is a message to open a dialog.
+type ActionOpenDialog struct {
+	DialogID string
+}
+
+// ActionSelectSession is a message indicating a session has been selected.
+type ActionSelectSession struct {
+	Session session.Session
+}
+
+// ActionSelectModel is a message indicating a model has been selected.
+type ActionSelectModel struct {
+	Provider  catwalk.Provider
+	Model     config.SelectedModel
+	ModelType config.SelectedModelType
+}
+
+// Messages for commands
+type (
+	ActionNewSession        struct{}
+	ActionToggleHelp        struct{}
+	ActionToggleCompactMode struct{}
+	ActionToggleThinking    struct{}
+	ActionExternalEditor    struct{}
+	ActionToggleYoloMode    struct{}
+	// ActionInitializeProject is a message to initialize a project.
+	ActionInitializeProject struct{}
+	ActionSummarize         struct {
+		SessionID string
+	}
+	// ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
+	ActionSelectReasoningEffort struct {
+		Effort string
+	}
+	ActionPermissionResponse struct {
+		Permission permission.PermissionRequest
+		Action     PermissionAction
+	}
+	// ActionRunCustomCommand is a message to run a custom command.
+	ActionRunCustomCommand struct {
+		Content   string
+		Arguments []commands.Argument
+		Args      map[string]string // Actual argument values
+	}
+	// ActionRunMCPPrompt is a message to run a custom command.
+	ActionRunMCPPrompt struct {
+		Title       string
+		Description string
+		PromptID    string
+		ClientID    string
+		Arguments   []commands.Argument
+		Args        map[string]string // Actual argument values
+	}
+)
+
+// Messages for API key input dialog.
+type (
+	ActionChangeAPIKeyState struct {
+		State APIKeyInputState
+	}
+)
+
+// Messages for OAuth2 device flow dialog.
+type (
+	// ActionInitiateOAuth is sent when the device auth is initiated
+	// successfully.
+	ActionInitiateOAuth struct {
+		DeviceCode      string
+		UserCode        string
+		ExpiresIn       int
+		VerificationURL string
+		Interval        int
+	}
+
+	// ActionCompleteOAuth is sent when the device flow completes successfully.
+	ActionCompleteOAuth struct {
+		Token *oauth.Token
+	}
+
+	// ActionOAuthErrored is sent when the device flow encounters an error.
+	ActionOAuthErrored struct {
+		Error error
+	}
+)
+
+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
+// Bubble Tea program loop.
+type ActionCmd struct {
+	Cmd tea.Cmd
+}
+
+// ActionFilePickerSelected is a message indicating a file has been selected in
+// the file picker dialog.
+type ActionFilePickerSelected struct {
+	Path string
+}
+
+// Cmd returns a command that reads the file at path and sends a
+// [message.Attachement] to the program.
+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
+	path := a.Path
+	if path == "" {
+		return nil
+	}
+	return func() tea.Msg {
+		isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+		if isFileLarge {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "file too large, max 5MB",
+			}
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
+	}
+}

internal/ui/dialog/api_key_input.go πŸ”—

@@ -0,0 +1,302 @@
+package dialog
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+type APIKeyInputState int
+
+const (
+	APIKeyInputStateInitial APIKeyInputState = iota
+	APIKeyInputStateVerifying
+	APIKeyInputStateVerified
+	APIKeyInputStateError
+)
+
+// APIKeyInputID is the identifier for the model selection dialog.
+const APIKeyInputID = "api_key_input"
+
+// APIKeyInput represents a model selection dialog.
+type APIKeyInput struct {
+	com *common.Common
+
+	provider  catwalk.Provider
+	model     config.SelectedModel
+	modelType config.SelectedModelType
+
+	width int
+	state APIKeyInputState
+
+	keyMap struct {
+		Submit key.Binding
+		Close  key.Binding
+	}
+	input   textinput.Model
+	spinner spinner.Model
+	help    help.Model
+}
+
+var _ Dialog = (*APIKeyInput)(nil)
+
+// NewAPIKeyInput creates a new Models dialog.
+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
+	t := com.Styles
+
+	m := APIKeyInput{}
+	m.com = com
+	m.provider = provider
+	m.model = model
+	m.modelType = modelType
+	m.width = 60
+
+	innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2
+
+	m.input = textinput.New()
+	m.input.SetVirtualCursor(false)
+	m.input.Placeholder = "Enter you API key..."
+	m.input.SetStyles(com.Styles.TextInput)
+	m.input.Focus()
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+
+	m.spinner = spinner.New(
+		spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(t.Base.Foreground(t.Green)),
+	)
+
+	m.help = help.New()
+	m.help.Styles = t.DialogHelpStyles()
+
+	m.keyMap.Submit = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "submit"),
+	)
+	m.keyMap.Close = CloseKey
+
+	return &m, nil
+}
+
+// ID implements Dialog.
+func (m *APIKeyInput) ID() string {
+	return APIKeyInputID
+}
+
+// HandleMsg implements [Dialog].
+func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case ActionChangeAPIKeyState:
+		m.state = msg.State
+		switch m.state {
+		case APIKeyInputStateVerifying:
+			cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
+			return ActionCmd{cmd}
+		}
+	case spinner.TickMsg:
+		switch m.state {
+		case APIKeyInputStateVerifying:
+			var cmd tea.Cmd
+			m.spinner, cmd = m.spinner.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case m.state == APIKeyInputStateVerifying:
+			// do nothing
+		case key.Matches(msg, m.keyMap.Close):
+			switch m.state {
+			case APIKeyInputStateVerified:
+				return m.saveKeyAndContinue()
+			default:
+				return ActionClose{}
+			}
+		case key.Matches(msg, m.keyMap.Submit):
+			switch m.state {
+			case APIKeyInputStateInitial, APIKeyInputStateError:
+				return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying}
+			case APIKeyInputStateVerified:
+				return m.saveKeyAndContinue()
+			}
+		default:
+			var cmd tea.Cmd
+			m.input, cmd = m.input.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+	case tea.PasteMsg:
+		var cmd tea.Cmd
+		m.input, cmd = m.input.Update(msg)
+		if cmd != nil {
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Draw implements [Dialog].
+func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := m.com.Styles
+
+	textStyle := t.Dialog.SecondaryText
+	helpStyle := t.Dialog.HelpView
+	dialogStyle := t.Dialog.View.Width(m.width)
+	inputStyle := t.Dialog.InputPrompt
+	helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize())
+
+	m.input.Prompt = m.spinner.View()
+
+	content := strings.Join([]string{
+		m.headerView(),
+		inputStyle.Render(m.inputView()),
+		textStyle.Render("This will be written in your global configuration:"),
+		textStyle.Render(config.GlobalConfigData()),
+		"",
+		helpStyle.Render(m.help.View(m)),
+	}, "\n")
+
+	view := dialogStyle.Render(content)
+
+	cur := m.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+func (m *APIKeyInput) headerView() string {
+	t := m.com.Styles
+	titleStyle := t.Dialog.Title
+	dialogStyle := t.Dialog.View.Width(m.width)
+
+	headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+	return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
+}
+
+func (m *APIKeyInput) dialogTitle() string {
+	t := m.com.Styles
+	textStyle := t.Dialog.TitleText
+	errorStyle := t.Dialog.TitleError
+	accentStyle := t.Dialog.TitleAccent
+
+	switch m.state {
+	case APIKeyInputStateInitial:
+		return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".")
+	case APIKeyInputStateVerifying:
+		return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...")
+	case APIKeyInputStateVerified:
+		return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.")
+	case APIKeyInputStateError:
+		return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?")
+	}
+	return ""
+}
+
+func (m *APIKeyInput) inputView() string {
+	t := m.com.Styles
+
+	switch m.state {
+	case APIKeyInputStateInitial:
+		m.input.Prompt = "> "
+		m.input.SetStyles(t.TextInput)
+		m.input.Focus()
+	case APIKeyInputStateVerifying:
+		ts := t.TextInput
+		ts.Blurred.Prompt = ts.Focused.Prompt
+
+		m.input.Prompt = m.spinner.View()
+		m.input.SetStyles(ts)
+		m.input.Blur()
+	case APIKeyInputStateVerified:
+		ts := t.TextInput
+		ts.Blurred.Prompt = ts.Focused.Prompt
+
+		m.input.Prompt = styles.CheckIcon + " "
+		m.input.SetStyles(ts)
+		m.input.Blur()
+	case APIKeyInputStateError:
+		ts := t.TextInput
+		ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry)
+
+		m.input.Prompt = styles.ErrorIcon + " "
+		m.input.SetStyles(ts)
+		m.input.Focus()
+	}
+	return m.input.View()
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (m *APIKeyInput) Cursor() *tea.Cursor {
+	return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+// FullHelp returns the full help view.
+func (m *APIKeyInput) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			m.keyMap.Submit,
+			m.keyMap.Close,
+		},
+	}
+}
+
+// ShortHelp returns the full help view.
+func (m *APIKeyInput) ShortHelp() []key.Binding {
+	return []key.Binding{
+		m.keyMap.Submit,
+		m.keyMap.Close,
+	}
+}
+
+func (m *APIKeyInput) verifyAPIKey() tea.Msg {
+	start := time.Now()
+
+	providerConfig := config.ProviderConfig{
+		ID:      string(m.provider.ID),
+		Name:    m.provider.Name,
+		APIKey:  m.input.Value(),
+		Type:    m.provider.Type,
+		BaseURL: m.provider.APIEndpoint,
+	}
+	err := providerConfig.TestConnection(config.Get().Resolver())
+
+	// intentionally wait for at least 750ms to make sure the user sees the spinner
+	elapsed := time.Since(start)
+	minimum := 750 * time.Millisecond
+	if elapsed < minimum {
+		time.Sleep(minimum - elapsed)
+	}
+
+	if err == nil {
+		return ActionChangeAPIKeyState{APIKeyInputStateVerified}
+	}
+	return ActionChangeAPIKeyState{APIKeyInputStateError}
+}
+
+func (m *APIKeyInput) saveKeyAndContinue() Action {
+	cfg := m.com.Config()
+
+	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
+	if err != nil {
+		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+	}
+
+	return ActionSelectModel{
+		Provider:  m.provider,
+		Model:     m.model,
+		ModelType: m.modelType,
+	}
+}

internal/ui/dialog/arguments.go πŸ”—

@@ -0,0 +1,399 @@
+package dialog
+
+import (
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textinput"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// ArgumentsID is the identifier for the arguments dialog.
+const ArgumentsID = "arguments"
+
+// Dialog sizing for arguments.
+const (
+	maxInputWidth        = 120
+	minInputWidth        = 30
+	maxViewportHeight    = 20
+	argumentsFieldHeight = 3 // label + input + spacing per field
+)
+
+// Arguments represents a dialog for collecting command arguments.
+type Arguments struct {
+	com       *common.Common
+	title     string
+	arguments []commands.Argument
+	inputs    []textinput.Model
+	focused   int
+	spinner   spinner.Model
+	loading   bool
+
+	description  string
+	resultAction Action
+
+	help   help.Model
+	keyMap struct {
+		Confirm,
+		Next,
+		Previous,
+		ScrollUp,
+		ScrollDown,
+		Close key.Binding
+	}
+
+	viewport viewport.Model
+}
+
+var _ Dialog = (*Arguments)(nil)
+
+// NewArguments creates a new arguments dialog.
+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
+	a := &Arguments{
+		com:          com,
+		title:        title,
+		description:  description,
+		arguments:    arguments,
+		resultAction: resultAction,
+	}
+
+	a.help = help.New()
+	a.help.Styles = com.Styles.DialogHelpStyles()
+
+	a.keyMap.Confirm = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "confirm"),
+	)
+	a.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "tab"),
+		key.WithHelp("↓/tab", "next"),
+	)
+	a.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "shift+tab"),
+		key.WithHelp("↑/shift+tab", "previous"),
+	)
+	a.keyMap.Close = CloseKey
+
+	// Create input fields for each argument.
+	a.inputs = make([]textinput.Model, len(arguments))
+	for i, arg := range arguments {
+		input := textinput.New()
+		input.SetVirtualCursor(false)
+		input.SetStyles(com.Styles.TextInput)
+		input.Prompt = "> "
+		// Use description as placeholder if available, otherwise title
+		if arg.Description != "" {
+			input.Placeholder = arg.Description
+		} else {
+			input.Placeholder = arg.Title
+		}
+
+		if i == 0 {
+			input.Focus()
+		} else {
+			input.Blur()
+		}
+
+		a.inputs[i] = input
+	}
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = com.Styles.Dialog.Spinner
+	a.spinner = s
+
+	return a
+}
+
+// ID implements Dialog.
+func (a *Arguments) ID() string {
+	return ArgumentsID
+}
+
+// focusInput changes focus to a new input by index with wrap-around.
+func (a *Arguments) focusInput(newIndex int) {
+	a.inputs[a.focused].Blur()
+
+	// Wrap around: Go's modulo can return negative, so add len first.
+	n := len(a.inputs)
+	a.focused = ((newIndex % n) + n) % n
+
+	a.inputs[a.focused].Focus()
+
+	// Ensure the newly focused field is visible in the viewport
+	a.ensureFieldVisible(a.focused)
+}
+
+// isFieldVisible checks if a field at the given index is visible in the viewport.
+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
+	fieldStart := fieldIndex * argumentsFieldHeight
+	fieldEnd := fieldStart + argumentsFieldHeight - 1
+	viewportTop := a.viewport.YOffset()
+	viewportBottom := viewportTop + a.viewport.Height() - 1
+
+	return fieldStart >= viewportTop && fieldEnd <= viewportBottom
+}
+
+// ensureFieldVisible scrolls the viewport to make the field visible.
+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
+	if a.isFieldVisible(fieldIndex) {
+		return
+	}
+
+	fieldStart := fieldIndex * argumentsFieldHeight
+	fieldEnd := fieldStart + argumentsFieldHeight - 1
+	viewportTop := a.viewport.YOffset()
+	viewportHeight := a.viewport.Height()
+
+	// If field is above viewport, scroll up to show it at top
+	if fieldStart < viewportTop {
+		a.viewport.SetYOffset(fieldStart)
+		return
+	}
+
+	// If field is below viewport, scroll down to show it at bottom
+	if fieldEnd > viewportTop+viewportHeight-1 {
+		a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
+	}
+}
+
+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
+	offset := a.viewport.YOffset()
+	if !fromTop {
+		offset += a.viewport.Height() - 1
+	}
+
+	fieldIndex := offset / argumentsFieldHeight
+	if fieldIndex >= len(a.inputs) {
+		return len(a.inputs) - 1
+	}
+	return fieldIndex
+}
+
+// HandleMsg implements Dialog.
+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		if a.loading {
+			var cmd tea.Cmd
+			a.spinner, cmd = a.spinner.Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, a.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, a.keyMap.Confirm):
+			// If we're on the last input or there's only one input, submit.
+			if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
+				args := make(map[string]string)
+				var warning tea.Cmd
+				for i, arg := range a.arguments {
+					args[arg.ID] = a.inputs[i].Value()
+					if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
+						warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
+						break
+					}
+				}
+				if warning != nil {
+					return ActionCmd{Cmd: warning}
+				}
+
+				switch action := a.resultAction.(type) {
+				case ActionRunCustomCommand:
+					action.Args = args
+					return action
+				case ActionRunMCPPrompt:
+					action.Args = args
+					return action
+				}
+			}
+			a.focusInput(a.focused + 1)
+		case key.Matches(msg, a.keyMap.Next):
+			a.focusInput(a.focused + 1)
+		case key.Matches(msg, a.keyMap.Previous):
+			a.focusInput(a.focused - 1)
+		default:
+			var cmd tea.Cmd
+			a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.MouseWheelMsg:
+		a.viewport, _ = a.viewport.Update(msg)
+		// If focused field scrolled out of view, focus the visible field
+		if !a.isFieldVisible(a.focused) {
+			a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
+		}
+	case tea.PasteMsg:
+		var cmd tea.Cmd
+		a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+		return ActionCmd{Cmd: cmd}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+// we pass the description height to offset the cursor correctly.
+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
+	cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
+	if cursor == nil {
+		return nil
+	}
+	cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
+	return cursor
+}
+
+// Draw implements Dialog.
+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	s := a.com.Styles
+
+	dialogContentStyle := s.Dialog.Arguments.Content
+	possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
+	// Build fields with label and input.
+	caser := cases.Title(language.English)
+
+	var fields []string
+	for i, arg := range a.arguments {
+		isFocused := i == a.focused
+
+		// Try to pretty up the title for the label.
+		title := strings.ReplaceAll(arg.Title, "_", " ")
+		title = strings.ReplaceAll(title, "-", " ")
+		titleParts := strings.Fields(title)
+		for i, part := range titleParts {
+			titleParts[i] = caser.String(strings.ToLower(part))
+		}
+		labelText := strings.Join(titleParts, " ")
+
+		markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
+
+		labelStyle := s.Dialog.Arguments.InputLabelBlurred
+		if isFocused {
+			labelStyle = s.Dialog.Arguments.InputLabelFocused
+			markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
+		}
+		if arg.Required {
+			labelText += markRequiredStyle.String()
+		}
+		label := labelStyle.Render(labelText)
+
+		labelWidth := lipgloss.Width(labelText)
+		placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
+
+		inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
+		inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
+		a.inputs[i].SetWidth(inputWidth)
+
+		inputLine := a.inputs[i].View()
+
+		field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
+		fields = append(fields, field)
+	}
+
+	renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
+
+	// Anchor width to the longest field, capped at maxInputWidth.
+	const scrollbarWidth = 1
+	width := lipgloss.Width(renderedFields)
+	height := lipgloss.Height(renderedFields)
+
+	// Use standard header
+	titleStyle := s.Dialog.Title
+
+	titleText := a.title
+	if titleText == "" {
+		titleText = "Arguments"
+	}
+
+	header := common.DialogTitle(s, titleText, width)
+
+	// Add description if available.
+	var description string
+	if a.description != "" {
+		descStyle := s.Dialog.Arguments.Description.Width(width)
+		description = descStyle.Render(a.description)
+	}
+
+	helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
+	if a.loading {
+		helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
+	}
+
+	availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
+	viewportHeight := min(height, maxViewportHeight, availableHeight)
+
+	a.viewport.SetWidth(width) // -1 for scrollbar
+	a.viewport.SetHeight(viewportHeight)
+	a.viewport.SetContent(renderedFields)
+
+	scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
+	content := a.viewport.View()
+	if scrollbar != "" {
+		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+	}
+	contentParts := []string{}
+	if description != "" {
+		contentParts = append(contentParts, description)
+	}
+	contentParts = append(contentParts, content)
+
+	view := lipgloss.JoinVertical(
+		lipgloss.Left,
+		titleStyle.Render(header),
+		dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
+		helpView,
+	)
+
+	dialog := s.Dialog.View.Render(view)
+
+	descriptionHeight := 0
+	if a.description != "" {
+		descriptionHeight = lipgloss.Height(description)
+	}
+	cur := a.Cursor(descriptionHeight)
+
+	DrawCenterCursor(scr, area, dialog, cur)
+	return cur
+}
+
+// StartLoading implements [LoadingDialog].
+func (a *Arguments) StartLoading() tea.Cmd {
+	if a.loading {
+		return nil
+	}
+	a.loading = true
+	return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Arguments) StopLoading() {
+	a.loading = false
+}
+
+// ShortHelp implements help.KeyMap.
+func (a *Arguments) ShortHelp() []key.Binding {
+	return []key.Binding{
+		a.keyMap.Confirm,
+		a.keyMap.Next,
+		a.keyMap.Close,
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (a *Arguments) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
+		{a.keyMap.Close},
+	}
+}

internal/ui/dialog/commands.go πŸ”—

@@ -0,0 +1,477 @@
+package dialog
+
+import (
+	"os"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/hyper"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// CommandsID is the identifier for the commands dialog.
+const CommandsID = "commands"
+
+// CommandType represents the type of commands being displayed.
+type CommandType uint
+
+// String returns the string representation of the CommandType.
+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
+
+const sidebarCompactModeBreakpoint = 120
+
+const (
+	SystemCommands CommandType = iota
+	UserCommands
+	MCPPrompts
+)
+
+// Commands represents a dialog that shows available commands.
+type Commands struct {
+	com    *common.Common
+	keyMap struct {
+		Select,
+		UpDown,
+		Next,
+		Previous,
+		Tab,
+		ShiftTab,
+		Close key.Binding
+	}
+
+	sessionID string // can be empty for non-session-specific commands
+	selected  CommandType
+
+	spinner spinner.Model
+	loading bool
+
+	help  help.Model
+	input textinput.Model
+	list  *list.FilterableList
+
+	windowWidth int
+
+	customCommands []commands.CustomCommand
+	mcpPrompts     []commands.MCPPrompt
+}
+
+var _ Dialog = (*Commands)(nil)
+
+// NewCommands creates a new commands dialog.
+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
+	c := &Commands{
+		com:            com,
+		selected:       SystemCommands,
+		sessionID:      sessionID,
+		customCommands: customCommands,
+		mcpPrompts:     mcpPrompts,
+	}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	c.help = help
+
+	c.list = list.NewFilterableList()
+	c.list.Focus()
+	c.list.SetSelected(0)
+
+	c.input = textinput.New()
+	c.input.SetVirtualCursor(false)
+	c.input.Placeholder = "Type to filter"
+	c.input.SetStyles(com.Styles.TextInput)
+	c.input.Focus()
+
+	c.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	c.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	c.keyMap.Next = key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("↓", "next item"),
+	)
+	c.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	c.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "switch selection"),
+	)
+	c.keyMap.ShiftTab = key.NewBinding(
+		key.WithKeys("shift+tab"),
+		key.WithHelp("shift+tab", "switch selection prev"),
+	)
+	closeKey := CloseKey
+	closeKey.SetHelp("esc", "cancel")
+	c.keyMap.Close = closeKey
+
+	// Set initial commands
+	c.setCommandItems(c.selected)
+
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = com.Styles.Dialog.Spinner
+	c.spinner = s
+
+	return c, nil
+}
+
+// ID implements Dialog.
+func (c *Commands) ID() string {
+	return CommandsID
+}
+
+// HandleMsg implements [Dialog].
+func (c *Commands) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		if c.loading {
+			var cmd tea.Cmd
+			c.spinner, cmd = c.spinner.Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, c.keyMap.Previous):
+			c.list.Focus()
+			if c.list.IsSelectedFirst() {
+				c.list.SelectLast()
+				c.list.ScrollToBottom()
+				break
+			}
+			c.list.SelectPrev()
+			c.list.ScrollToSelected()
+		case key.Matches(msg, c.keyMap.Next):
+			c.list.Focus()
+			if c.list.IsSelectedLast() {
+				c.list.SelectFirst()
+				c.list.ScrollToTop()
+				break
+			}
+			c.list.SelectNext()
+			c.list.ScrollToSelected()
+		case key.Matches(msg, c.keyMap.Select):
+			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
+				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
+					return item.Action()
+				}
+			}
+		case key.Matches(msg, c.keyMap.Tab):
+			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
+				c.selected = c.nextCommandType()
+				c.setCommandItems(c.selected)
+			}
+		case key.Matches(msg, c.keyMap.ShiftTab):
+			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
+				c.selected = c.previousCommandType()
+				c.setCommandItems(c.selected)
+			}
+		default:
+			var cmd tea.Cmd
+			for _, item := range c.list.VisibleItems() {
+				if item, ok := item.(*CommandItem); ok && item != nil {
+					if msg.String() == item.Shortcut() {
+						return item.Action()
+					}
+				}
+			}
+			c.input, cmd = c.input.Update(msg)
+			value := c.input.Value()
+			c.list.SetFilter(value)
+			c.list.ScrollToTop()
+			c.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (c *Commands) Cursor() *tea.Cursor {
+	return InputCursor(c.com.Styles, c.input.Cursor())
+}
+
+// commandsRadioView generates the command type selector radio buttons.
+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+	if !hasUserCmds && !hasMCPPrompts {
+		return ""
+	}
+
+	selectedFn := func(t CommandType) string {
+		if t == selected {
+			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
+		}
+		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
+	}
+
+	parts := []string{
+		selectedFn(SystemCommands),
+	}
+
+	if hasUserCmds {
+		parts = append(parts, selectedFn(UserCommands))
+	}
+	if hasMCPPrompts {
+		parts = append(parts, selectedFn(MCPPrompts))
+	}
+
+	return strings.Join(parts, " ")
+}
+
+// Draw implements [Dialog].
+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := c.com.Styles
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
+	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
+		c.windowWidth = area.Dx()
+		// since some items in the list depend on width (e.g. toggle sidebar command),
+		// we need to reset the command items when width changes
+		c.setCommandItems(c.selected)
+	}
+
+	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	c.list.SetSize(innerWidth, height-heightOffset)
+	c.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Commands"
+	rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
+	inputView := t.Dialog.InputPrompt.Render(c.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
+	rc.AddPart(listView)
+	rc.Help = c.help.View(c)
+
+	if c.loading {
+		rc.Help = c.spinner.View() + " Generating Prompt..."
+	}
+
+	view := rc.Render()
+
+	cur := c.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (c *Commands) ShortHelp() []key.Binding {
+	return []key.Binding{
+		c.keyMap.Tab,
+		c.keyMap.UpDown,
+		c.keyMap.Select,
+		c.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (c *Commands) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
+		{c.keyMap.Close},
+	}
+}
+
+// nextCommandType returns the next command type in the cycle.
+func (c *Commands) nextCommandType() CommandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case UserCommands:
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case MCPPrompts:
+		return SystemCommands
+	default:
+		return SystemCommands
+	}
+}
+
+// previousCommandType returns the previous command type in the cycle.
+func (c *Commands) previousCommandType() CommandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	case UserCommands:
+		return SystemCommands
+	case MCPPrompts:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	default:
+		return SystemCommands
+	}
+}
+
+// setCommandItems sets the command items based on the specified command type.
+func (c *Commands) setCommandItems(commandType CommandType) {
+	c.selected = commandType
+
+	commandItems := []list.FilterableItem{}
+	switch c.selected {
+	case SystemCommands:
+		for _, cmd := range c.defaultCommands() {
+			commandItems = append(commandItems, cmd)
+		}
+	case UserCommands:
+		for _, cmd := range c.customCommands {
+			action := ActionRunCustomCommand{
+				Content:   cmd.Content,
+				Arguments: cmd.Arguments,
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+		}
+	case MCPPrompts:
+		for _, cmd := range c.mcpPrompts {
+			action := ActionRunMCPPrompt{
+				Title:       cmd.Title,
+				Description: cmd.Description,
+				PromptID:    cmd.PromptID,
+				ClientID:    cmd.ClientID,
+				Arguments:   cmd.Arguments,
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
+		}
+	}
+
+	c.list.SetItems(commandItems...)
+	c.list.SetFilter("")
+	c.list.ScrollToTop()
+	c.list.SetSelected(0)
+	c.input.SetValue("")
+}
+
+// defaultCommands returns the list of default system commands.
+func (c *Commands) defaultCommands() []*CommandItem {
+	commands := []*CommandItem{
+		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
+		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
+		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
+	}
+
+	// Only show compact command if there's an active session
+	if c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
+	}
+
+	// Add reasoning toggle for models that support it
+	cfg := c.com.Config()
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
+		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
+		model := cfg.GetModelByType(agentCfg.Model)
+		if providerCfg != nil && model != nil && model.CanReason {
+			selectedModel := cfg.Models[agentCfg.Model]
+
+			// Anthropic models: thinking toggle
+			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+				status := "Enable"
+				if selectedModel.Think {
+					status = "Disable"
+				}
+				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
+			}
+
+			// OpenAI models: reasoning effort dialog
+			if len(model.ReasoningLevels) > 0 {
+				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
+					DialogID: ReasoningID,
+				}))
+			}
+		}
+	}
+	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
+	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
+	}
+	if c.sessionID != "" {
+		cfg := c.com.Config()
+		agentCfg := cfg.Agents[config.AgentCoder]
+		model := cfg.GetModelByType(agentCfg.Model)
+		if model != nil && model.SupportsImages {
+			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
+				// TODO: Pass in the file picker dialog id
+			}))
+		}
+	}
+
+	// Add external editor command if $EDITOR is available
+	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
+	if os.Getenv("EDITOR") != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
+	}
+
+	return append(commands,
+		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
+		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
+		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
+	)
+}
+
+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
+	c.customCommands = customCommands
+	if c.selected == UserCommands {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
+	c.mcpPrompts = mcpPrompts
+	if c.selected == MCPPrompts {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// StartLoading implements [LoadingDialog].
+func (a *Commands) StartLoading() tea.Cmd {
+	if a.loading {
+		return nil
+	}
+	a.loading = true
+	return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Commands) StopLoading() {
+	a.loading = false
+}

internal/ui/dialog/commands_item.go πŸ”—

@@ -0,0 +1,70 @@
+package dialog
+
+import (
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/sahilm/fuzzy"
+)
+
+// CommandItem wraps a uicmd.Command to implement the ListItem interface.
+type CommandItem struct {
+	id       string
+	title    string
+	shortcut string
+	action   Action
+	t        *styles.Styles
+	m        fuzzy.Match
+	cache    map[int]string
+	focused  bool
+}
+
+var _ ListItem = &CommandItem{}
+
+// NewCommandItem creates a new CommandItem.
+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
+	return &CommandItem{
+		id:       id,
+		t:        t,
+		title:    title,
+		shortcut: shortcut,
+		action:   action,
+	}
+}
+
+// Filter implements ListItem.
+func (c *CommandItem) Filter() string {
+	return c.title
+}
+
+// ID implements ListItem.
+func (c *CommandItem) ID() string {
+	return c.id
+}
+
+// SetFocused implements ListItem.
+func (c *CommandItem) SetFocused(focused bool) {
+	if c.focused != focused {
+		c.cache = nil
+	}
+	c.focused = focused
+}
+
+// SetMatch implements ListItem.
+func (c *CommandItem) SetMatch(m fuzzy.Match) {
+	c.cache = nil
+	c.m = m
+}
+
+// Action returns the action associated with the command item.
+func (c *CommandItem) Action() Action {
+	return c.action
+}
+
+// Shortcut returns the shortcut associated with the command item.
+func (c *CommandItem) Shortcut() string {
+	return c.shortcut
+}
+
+// Render implements ListItem.
+func (c *CommandItem) Render(width int) string {
+	return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+}

internal/ui/dialog/common.go πŸ”—

@@ -0,0 +1,130 @@
+package dialog
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// InputCursor adjusts the cursor position for an input field within a dialog.
+func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
+	if cur != nil {
+		titleStyle := t.Dialog.Title
+		dialogStyle := t.Dialog.View
+		inputStyle := t.Dialog.InputPrompt
+		// Adjust cursor position to account for dialog layout
+		cur.X += inputStyle.GetBorderLeftSize() +
+			inputStyle.GetMarginLeft() +
+			inputStyle.GetPaddingLeft() +
+			dialogStyle.GetBorderLeftSize() +
+			dialogStyle.GetPaddingLeft() +
+			dialogStyle.GetMarginLeft()
+		cur.Y += titleStyle.GetVerticalFrameSize() +
+			inputStyle.GetBorderTopSize() +
+			inputStyle.GetMarginTop() +
+			inputStyle.GetPaddingTop() +
+			inputStyle.GetBorderBottomSize() +
+			inputStyle.GetMarginBottom() +
+			inputStyle.GetPaddingBottom() +
+			dialogStyle.GetPaddingTop() +
+			dialogStyle.GetMarginTop() +
+			dialogStyle.GetBorderTopSize()
+	}
+	return cur
+}
+
+// RenderContext is a dialog rendering context that can be used to render
+// common dialog layouts.
+type RenderContext struct {
+	// Styles is the styles to use for rendering.
+	Styles *styles.Styles
+	// Width is the total width of the dialog including any margins, borders,
+	// and paddings.
+	Width int
+	// Gap is the gap between content parts. Zero means no gap.
+	Gap int
+	// Title is the title of the dialog. This will be styled using the default
+	// dialog title style and prepended to the content parts slice.
+	Title string
+	// TitleInfo is additional information to display next to the title. This
+	// part is displayed as is, any styling must be applied before setting this
+	// field.
+	TitleInfo string
+	// Parts are the rendered parts of the dialog.
+	Parts []string
+	// Help is the help view content. This will be appended to the content parts
+	// slice using the default dialog help style.
+	Help string
+}
+
+// NewRenderContext creates a new RenderContext with the provided styles and width.
+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
+	return &RenderContext{
+		Styles: t,
+		Width:  width,
+		Parts:  []string{},
+	}
+}
+
+// AddPart adds a rendered part to the dialog.
+func (rc *RenderContext) AddPart(part string) {
+	if len(part) > 0 {
+		rc.Parts = append(rc.Parts, part)
+	}
+}
+
+// Render renders the dialog using the provided context.
+func (rc *RenderContext) Render() string {
+	titleStyle := rc.Styles.Dialog.Title
+	dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
+
+	parts := []string{}
+	if len(rc.Title) > 0 {
+		var titleInfoWidth int
+		if len(rc.TitleInfo) > 0 {
+			titleInfoWidth = lipgloss.Width(rc.TitleInfo)
+		}
+		title := common.DialogTitle(rc.Styles, rc.Title,
+			max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
+				titleStyle.GetHorizontalFrameSize()-
+				titleInfoWidth))
+		if len(rc.TitleInfo) > 0 {
+			title += rc.TitleInfo
+		}
+		parts = append(parts, titleStyle.Render(title))
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+	}
+
+	if rc.Gap <= 0 {
+		parts = append(parts, rc.Parts...)
+	} else {
+		for i, p := range rc.Parts {
+			if len(p) > 0 {
+				parts = append(parts, p)
+			}
+			if i < len(rc.Parts)-1 {
+				parts = append(parts, make([]string, rc.Gap)...)
+			}
+		}
+	}
+
+	if len(rc.Help) > 0 {
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+		helpStyle := rc.Styles.Dialog.HelpView
+		helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
+		helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
+		parts = append(parts, helpView)
+	}
+
+	content := strings.Join(parts, "\n")
+
+	return dialogStyle.Render(content)
+}

internal/ui/dialog/dialog.go πŸ”—

@@ -0,0 +1,197 @@
+package dialog
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// Dialog sizing constants.
+const (
+	// defaultDialogMaxWidth is the maximum width for standard dialogs.
+	defaultDialogMaxWidth = 120
+	// defaultDialogHeight is the default height for standard dialogs.
+	defaultDialogHeight = 30
+	// titleContentHeight is the height of the title content line.
+	titleContentHeight = 1
+	// inputContentHeight is the height of the input content line.
+	inputContentHeight = 1
+)
+
+// CloseKey is the default key binding to close dialogs.
+var CloseKey = key.NewBinding(
+	key.WithKeys("esc", "alt+esc"),
+	key.WithHelp("esc", "exit"),
+)
+
+// Action represents an action taken in a dialog after handling a message.
+type Action any
+
+// Dialog is a component that can be displayed on top of the UI.
+type Dialog interface {
+	// ID returns the unique identifier of the dialog.
+	ID() string
+	// HandleMsg processes a message and returns an action. An [Action] can be
+	// anything and the caller is responsible for handling it appropriately.
+	HandleMsg(msg tea.Msg) Action
+	// Draw draws the dialog onto the provided screen within the specified area
+	// and returns the desired cursor position on the screen.
+	Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
+}
+
+// LoadingDialog is a dialog that can show a loading state.
+type LoadingDialog interface {
+	StartLoading() tea.Cmd
+	StopLoading()
+}
+
+// Overlay manages multiple dialogs as an overlay.
+type Overlay struct {
+	dialogs []Dialog
+}
+
+// NewOverlay creates a new [Overlay] instance.
+func NewOverlay(dialogs ...Dialog) *Overlay {
+	return &Overlay{
+		dialogs: dialogs,
+	}
+}
+
+// HasDialogs checks if there are any active dialogs.
+func (d *Overlay) HasDialogs() bool {
+	return len(d.dialogs) > 0
+}
+
+// ContainsDialog checks if a dialog with the specified ID exists.
+func (d *Overlay) ContainsDialog(dialogID string) bool {
+	for _, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			return true
+		}
+	}
+	return false
+}
+
+// OpenDialog opens a new dialog to the stack.
+func (d *Overlay) OpenDialog(dialog Dialog) {
+	d.dialogs = append(d.dialogs, dialog)
+}
+
+// CloseDialog closes the dialog with the specified ID from the stack.
+func (d *Overlay) CloseDialog(dialogID string) {
+	for i, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			d.removeDialog(i)
+			return
+		}
+	}
+}
+
+// CloseFrontDialog closes the front dialog in the stack.
+func (d *Overlay) CloseFrontDialog() {
+	if len(d.dialogs) == 0 {
+		return
+	}
+	d.removeDialog(len(d.dialogs) - 1)
+}
+
+// Dialog returns the dialog with the specified ID, or nil if not found.
+func (d *Overlay) Dialog(dialogID string) Dialog {
+	for _, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			return dialog
+		}
+	}
+	return nil
+}
+
+// DialogLast returns the front dialog, or nil if there are no dialogs.
+func (d *Overlay) DialogLast() Dialog {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+	return d.dialogs[len(d.dialogs)-1]
+}
+
+// BringToFront brings the dialog with the specified ID to the front.
+func (d *Overlay) BringToFront(dialogID string) {
+	for i, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			// Move the dialog to the end of the slice
+			d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
+			d.dialogs = append(d.dialogs, dialog)
+			return
+		}
+	}
+}
+
+// Update handles dialog updates.
+func (d *Overlay) Update(msg tea.Msg) tea.Msg {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+
+	idx := len(d.dialogs) - 1 // active dialog is the last one
+	dialog := d.dialogs[idx]
+	if dialog == nil {
+		return nil
+	}
+
+	return dialog.HandleMsg(msg)
+}
+
+// StartLoading starts the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StartLoading() tea.Cmd {
+	dialog := d.DialogLast()
+	if ld, ok := dialog.(LoadingDialog); ok {
+		return ld.StartLoading()
+	}
+	return nil
+}
+
+// StopLoading stops the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StopLoading() {
+	dialog := d.DialogLast()
+	if ld, ok := dialog.(LoadingDialog); ok {
+		ld.StopLoading()
+	}
+}
+
+// DrawCenterCursor draws the given string view centered in the screen area and
+// adjusts the cursor position accordingly.
+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
+	width, height := lipgloss.Size(view)
+	center := common.CenterRect(area, width, height)
+	if cur != nil {
+		cur.X += center.Min.X
+		cur.Y += center.Min.Y
+	}
+
+	uv.NewStyledString(view).Draw(scr, center)
+}
+
+// DrawCenter draws the given string view centered in the screen area.
+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
+	DrawCenterCursor(scr, area, view, nil)
+}
+
+// Draw renders the overlay and its dialogs.
+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var cur *tea.Cursor
+	for _, dialog := range d.dialogs {
+		cur = dialog.Draw(scr, area)
+	}
+	return cur
+}
+
+// removeDialog removes a dialog from the stack.
+func (d *Overlay) removeDialog(idx int) {
+	if idx < 0 || idx >= len(d.dialogs) {
+		return
+	}
+	d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
+}

internal/ui/dialog/filepicker.go πŸ”—

@@ -0,0 +1,304 @@
+package dialog
+
+import (
+	"fmt"
+	"image"
+	_ "image/jpeg" // register JPEG format
+	_ "image/png"  // register PNG format
+	"os"
+	"strings"
+	"sync"
+
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	fimage "github.com/charmbracelet/crush/internal/ui/image"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// FilePickerID is the identifier for the FilePicker dialog.
+const FilePickerID = "filepicker"
+
+// FilePicker is a dialog that allows users to select files or directories.
+type FilePicker struct {
+	com *common.Common
+
+	imgEnc                      fimage.Encoding
+	imgPrevWidth, imgPrevHeight int
+	cellSize                    fimage.CellSize
+
+	fp              filepicker.Model
+	help            help.Model
+	previewingImage bool // indicates if an image is being previewed
+	isTmux          bool
+
+	km struct {
+		Select,
+		Down,
+		Up,
+		Forward,
+		Backward,
+		Navigate,
+		Close key.Binding
+	}
+}
+
+var _ Dialog = (*FilePicker)(nil)
+
+// NewFilePicker creates a new [FilePicker] dialog.
+func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
+	f := new(FilePicker)
+	f.com = com
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	f.help = help
+
+	f.km.Select = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "accept"),
+	)
+	f.km.Down = key.NewBinding(
+		key.WithKeys("down", "j"),
+		key.WithHelp("down/j", "move down"),
+	)
+	f.km.Up = key.NewBinding(
+		key.WithKeys("up", "k"),
+		key.WithHelp("up/k", "move up"),
+	)
+	f.km.Forward = key.NewBinding(
+		key.WithKeys("right", "l"),
+		key.WithHelp("right/l", "move forward"),
+	)
+	f.km.Backward = key.NewBinding(
+		key.WithKeys("left", "h"),
+		key.WithHelp("left/h", "move backward"),
+	)
+	f.km.Navigate = key.NewBinding(
+		key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
+		key.WithHelp("↑↓←→", "navigate"),
+	)
+	f.km.Close = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "close/exit"),
+	)
+
+	fp := filepicker.New()
+	fp.AllowedTypes = common.AllowedImageTypes
+	fp.ShowPermissions = false
+	fp.ShowSize = false
+	fp.AutoHeight = false
+	fp.Styles = com.Styles.FilePicker
+	fp.Cursor = ""
+	fp.CurrentDirectory = f.WorkingDir()
+
+	f.fp = fp
+
+	return f, f.fp.Init()
+}
+
+// SetImageCapabilities sets the image capabilities for the [FilePicker].
+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+	if caps != nil {
+		if caps.SupportsKittyGraphics {
+			f.imgEnc = fimage.EncodingKitty
+		}
+		f.cellSize = caps.CellSize()
+		_, f.isTmux = caps.Env.LookupEnv("TMUX")
+	}
+}
+
+// WorkingDir returns the current working directory of the [FilePicker].
+func (f *FilePicker) WorkingDir() string {
+	wd := f.com.Config().WorkingDir()
+	if len(wd) > 0 {
+		return wd
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		return home.Dir()
+	}
+
+	return cwd
+}
+
+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) ShortHelp() []key.Binding {
+	return []key.Binding{
+		f.km.Navigate,
+		f.km.Select,
+		f.km.Close,
+	}
+}
+
+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			f.km.Select,
+			f.km.Down,
+			f.km.Up,
+			f.km.Forward,
+		},
+		{
+			f.km.Backward,
+			f.km.Close,
+		},
+	}
+}
+
+// ID returns the identifier of the [FilePicker] dialog.
+func (f *FilePicker) ID() string {
+	return FilePickerID
+}
+
+// HandleMsg updates the [FilePicker] dialog based on the given message.
+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, f.km.Close):
+			return ActionClose{}
+		}
+	}
+
+	var cmd tea.Cmd
+	f.fp, cmd = f.fp.Update(msg)
+	if selFile := f.fp.HighlightedPath(); selFile != "" {
+		var allowed bool
+		for _, allowedExt := range f.fp.AllowedTypes {
+			if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
+				allowed = true
+				break
+			}
+		}
+
+		f.previewingImage = allowed
+		if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
+			f.previewingImage = false
+			img, err := loadImage(selFile)
+			if err == nil {
+				cmds = append(cmds, tea.Sequence(
+					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
+					func() tea.Msg {
+						f.previewingImage = true
+						return nil
+					},
+				))
+			}
+		}
+	}
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
+		return ActionFilePickerSelected{Path: path}
+	}
+
+	return ActionCmd{tea.Batch(cmds...)}
+}
+
+const (
+	filePickerMinWidth  = 70
+	filePickerMinHeight = 10
+)
+
+// Draw renders the [FilePicker] dialog as a string.
+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	width := max(0, min(filePickerMinWidth, area.Dx()))
+	height := max(0, min(10, area.Dy()))
+	innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
+	imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
+	f.imgPrevWidth = imgPrevWidth
+	f.imgPrevHeight = imgPrevHeight
+	f.fp.SetHeight(height)
+
+	styles := f.com.Styles.FilePicker
+	styles.File = styles.File.Width(innerWidth)
+	styles.Directory = styles.Directory.Width(innerWidth)
+	styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
+	styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
+	f.fp.Styles = styles
+
+	t := f.com.Styles
+	rc := NewRenderContext(t, width)
+	rc.Gap = 1
+	rc.Title = "Add Image"
+	rc.Help = f.help.View(f)
+
+	imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
+	rc.AddPart(imgPreview)
+
+	files := strings.TrimSpace(f.fp.View())
+	rc.AddPart(files)
+
+	view := rc.Render()
+
+	DrawCenter(scr, area, view)
+	return nil
+}
+
+var (
+	imagePreviewCache = map[string]string{}
+	imagePreviewMutex sync.RWMutex
+)
+
+// imagePreview returns the image preview section of the [FilePicker] dialog.
+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
+	if !f.previewingImage {
+		key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
+		imagePreviewMutex.RLock()
+		cached, ok := imagePreviewCache[key]
+		imagePreviewMutex.RUnlock()
+		if ok {
+			return cached
+		}
+
+		var sb strings.Builder
+		for y := range imgPrevHeight {
+			for range imgPrevWidth {
+				sb.WriteRune('β–ˆ')
+			}
+			if y < imgPrevHeight-1 {
+				sb.WriteRune('\n')
+			}
+		}
+
+		imagePreviewMutex.Lock()
+		imagePreviewCache[key] = sb.String()
+		imagePreviewMutex.Unlock()
+
+		return sb.String()
+	}
+
+	if id := f.fp.HighlightedPath(); id != "" {
+		r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
+		return r
+	}
+
+	return ""
+}
+
+func loadImage(path string) (img image.Image, err error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	img, _, err = image.Decode(file)
+	if err != nil {
+		return nil, err
+	}
+
+	return img, nil
+}

internal/ui/dialog/models.go πŸ”—

@@ -0,0 +1,478 @@
+package dialog
+
+import (
+	"cmp"
+	"fmt"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// ModelType represents the type of model to select.
+type ModelType int
+
+const (
+	ModelTypeLarge ModelType = iota
+	ModelTypeSmall
+)
+
+// String returns the string representation of the [ModelType].
+func (mt ModelType) String() string {
+	switch mt {
+	case ModelTypeLarge:
+		return "Large Task"
+	case ModelTypeSmall:
+		return "Small Task"
+	default:
+		return "Unknown"
+	}
+}
+
+// Config returns the corresponding config model type.
+func (mt ModelType) Config() config.SelectedModelType {
+	switch mt {
+	case ModelTypeLarge:
+		return config.SelectedModelTypeLarge
+	case ModelTypeSmall:
+		return config.SelectedModelTypeSmall
+	default:
+		return ""
+	}
+}
+
+// Placeholder returns the input placeholder for the model type.
+func (mt ModelType) Placeholder() string {
+	switch mt {
+	case ModelTypeLarge:
+		return largeModelInputPlaceholder
+	case ModelTypeSmall:
+		return smallModelInputPlaceholder
+	default:
+		return ""
+	}
+}
+
+const (
+	largeModelInputPlaceholder = "Choose a model for large, complex tasks"
+	smallModelInputPlaceholder = "Choose a model for small, simple tasks"
+)
+
+// ModelsID is the identifier for the model selection dialog.
+const ModelsID = "models"
+
+// Models represents a model selection dialog.
+type Models struct {
+	com *common.Common
+
+	modelType ModelType
+	providers []catwalk.Provider
+
+	keyMap struct {
+		Tab      key.Binding
+		UpDown   key.Binding
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		Close    key.Binding
+	}
+	list  *ModelsList
+	input textinput.Model
+	help  help.Model
+}
+
+var _ Dialog = (*Models)(nil)
+
+// NewModels creates a new Models dialog.
+func NewModels(com *common.Common) (*Models, error) {
+	t := com.Styles
+	m := &Models{}
+	m.com = com
+	help := help.New()
+	help.Styles = t.DialogHelpStyles()
+
+	m.help = help
+	m.list = NewModelsList(t)
+	m.list.Focus()
+	m.list.SetSelected(0)
+
+	m.input = textinput.New()
+	m.input.SetVirtualCursor(false)
+	m.input.Placeholder = largeModelInputPlaceholder
+	m.input.SetStyles(com.Styles.TextInput)
+	m.input.Focus()
+
+	m.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab", "shift+tab"),
+		key.WithHelp("tab", "toggle type"),
+	)
+	m.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	m.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	m.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	m.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	m.keyMap.Close = CloseKey
+
+	providers, err := getFilteredProviders(com.Config())
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	m.providers = providers
+	if err := m.setProviderItems(); err != nil {
+		return nil, fmt.Errorf("failed to set provider items: %w", err)
+	}
+
+	return m, nil
+}
+
+// ID implements Dialog.
+func (m *Models) ID() string {
+	return ModelsID
+}
+
+// HandleMsg implements Dialog.
+func (m *Models) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, m.keyMap.Previous):
+			m.list.Focus()
+			if m.list.IsSelectedFirst() {
+				m.list.SelectLast()
+				m.list.ScrollToBottom()
+				break
+			}
+			m.list.SelectPrev()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Next):
+			m.list.Focus()
+			if m.list.IsSelectedLast() {
+				m.list.SelectFirst()
+				m.list.ScrollToTop()
+				break
+			}
+			m.list.SelectNext()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Select):
+			selectedItem := m.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+
+			modelItem, ok := selectedItem.(*ModelItem)
+			if !ok {
+				break
+			}
+
+			return ActionSelectModel{
+				Provider:  modelItem.prov,
+				Model:     modelItem.SelectedModel(),
+				ModelType: modelItem.SelectedModelType(),
+			}
+		case key.Matches(msg, m.keyMap.Tab):
+			if m.modelType == ModelTypeLarge {
+				m.modelType = ModelTypeSmall
+			} else {
+				m.modelType = ModelTypeLarge
+			}
+			if err := m.setProviderItems(); err != nil {
+				return uiutil.ReportError(err)
+			}
+		default:
+			var cmd tea.Cmd
+			m.input, cmd = m.input.Update(msg)
+			value := m.input.Value()
+			m.list.SetFilter(value)
+			m.list.ScrollToSelected()
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor for the dialog.
+func (m *Models) Cursor() *tea.Cursor {
+	return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+// modelTypeRadioView returns the radio view for model type selection.
+func (m *Models) modelTypeRadioView() string {
+	t := m.com.Styles
+	textStyle := t.HalfMuted
+	largeRadioStyle := t.RadioOff
+	smallRadioStyle := t.RadioOff
+	if m.modelType == ModelTypeLarge {
+		largeRadioStyle = t.RadioOn
+	} else {
+		smallRadioStyle = t.RadioOn
+	}
+
+	largeRadio := largeRadioStyle.Padding(0, 1).Render()
+	smallRadio := smallRadioStyle.Padding(0, 1).Render()
+
+	return fmt.Sprintf("%s%s  %s%s",
+		largeRadio, textStyle.Render(ModelTypeLarge.String()),
+		smallRadio, textStyle.Render(ModelTypeSmall.String()))
+}
+
+// Draw implements [Dialog].
+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := m.com.Styles
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	m.list.SetSize(innerWidth, height-heightOffset)
+	m.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Model"
+	rc.TitleInfo = m.modelTypeRadioView()
+	inputView := t.Dialog.InputPrompt.Render(m.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+	rc.AddPart(listView)
+	rc.Help = m.help.View(m)
+
+	view := rc.Render()
+
+	cur := m.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp returns the short help view.
+func (m *Models) ShortHelp() []key.Binding {
+	return []key.Binding{
+		m.keyMap.UpDown,
+		m.keyMap.Tab,
+		m.keyMap.Select,
+		m.keyMap.Close,
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *Models) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			m.keyMap.Select,
+			m.keyMap.Next,
+			m.keyMap.Previous,
+			m.keyMap.Tab,
+		},
+		{
+			m.keyMap.Close,
+		},
+	}
+}
+
+// setProviderItems sets the provider items in the list.
+func (m *Models) setProviderItems() error {
+	t := m.com.Styles
+	cfg := m.com.Config()
+
+	var selectedItemID string
+	selectedType := m.modelType.Config()
+	currentModel := cfg.Models[selectedType]
+	recentItems := cfg.RecentModels[selectedType]
+
+	// Track providers already added to avoid duplicates
+	addedProviders := make(map[string]bool)
+
+	// Get a list of known providers to compare against
+	knownProviders, err := config.Providers(cfg)
+	if err != nil {
+		return fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	containsProviderFunc := func(id string) func(p catwalk.Provider) bool {
+		return func(p catwalk.Provider) bool {
+			return p.ID == catwalk.InferenceProvider(id)
+		}
+	}
+
+	// itemsMap contains the keys of added model items.
+	itemsMap := make(map[string]*ModelItem)
+	groups := []ModelGroup{}
+	for id, p := range cfg.Providers.Seq2() {
+		if p.Disable {
+			continue
+		}
+
+		// Check if this provider is not in the known providers list
+		if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) ||
+			!slices.ContainsFunc(m.providers, containsProviderFunc(id)) {
+			provider := p.ToProvider()
+
+			// Add this unknown provider to the list
+			name := cmp.Or(p.Name, id)
+
+			addedProviders[id] = true
+
+			group := NewModelGroup(t, name, true)
+			for _, model := range p.Models {
+				item := NewModelItem(t, provider, model, m.modelType, false)
+				group.AppendItems(item)
+				itemsMap[item.ID()] = item
+				if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+					selectedItemID = item.ID()
+				}
+			}
+			if len(group.Items) > 0 {
+				groups = append(groups, group)
+			}
+		}
+	}
+
+	// Now add known providers from the predefined list
+	for _, provider := range m.providers {
+		providerID := string(provider.ID)
+		if addedProviders[providerID] {
+			continue
+		}
+
+		providerConfig, providerConfigured := cfg.Providers.Get(providerID)
+		if providerConfigured && providerConfig.Disable {
+			continue
+		}
+
+		displayProvider := provider
+		if providerConfigured {
+			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
+			modelIndex := make(map[string]int, len(displayProvider.Models))
+			for i, model := range displayProvider.Models {
+				modelIndex[model.ID] = i
+			}
+			for _, model := range providerConfig.Models {
+				if model.ID == "" {
+					continue
+				}
+				if idx, ok := modelIndex[model.ID]; ok {
+					if model.Name != "" {
+						displayProvider.Models[idx].Name = model.Name
+					}
+					continue
+				}
+				if model.Name == "" {
+					model.Name = model.ID
+				}
+				displayProvider.Models = append(displayProvider.Models, model)
+				modelIndex[model.ID] = len(displayProvider.Models) - 1
+			}
+		}
+
+		name := displayProvider.Name
+		if name == "" {
+			name = providerID
+		}
+
+		group := NewModelGroup(t, name, providerConfigured)
+		for _, model := range displayProvider.Models {
+			item := NewModelItem(t, provider, model, m.modelType, false)
+			group.AppendItems(item)
+			itemsMap[item.ID()] = item
+			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		groups = append(groups, group)
+	}
+
+	if len(recentItems) > 0 {
+		recentGroup := NewModelGroup(t, "Recently used", false)
+
+		var validRecentItems []config.SelectedModel
+		for _, recent := range recentItems {
+			key := modelKey(recent.Provider, recent.Model)
+			item, ok := itemsMap[key]
+			if !ok {
+				continue
+			}
+
+			// Show provider for recent items
+			item = NewModelItem(t, item.prov, item.model, m.modelType, true)
+			item.showProvider = true
+
+			validRecentItems = append(validRecentItems, recent)
+			recentGroup.AppendItems(item)
+			if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		if len(validRecentItems) != len(recentItems) {
+			// FIXME: Does this need to be here? Is it mutating the config during a read?
+			if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
+				return fmt.Errorf("failed to update recent models: %w", err)
+			}
+		}
+
+		if len(recentGroup.Items) > 0 {
+			groups = append([]ModelGroup{recentGroup}, groups...)
+		}
+	}
+
+	// Set model groups in the list.
+	m.list.SetGroups(groups...)
+	m.list.SetSelectedItem(selectedItemID)
+
+	// Update placeholder based on model type
+	m.input.Placeholder = m.modelType.Placeholder()
+
+	return nil
+}
+
+func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
+	providers, err := config.Providers(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+	var filteredProviders []catwalk.Provider
+	for _, p := range providers {
+		var (
+			isAzure         = p.ID == catwalk.InferenceProviderAzure
+			isCopilot       = p.ID == catwalk.InferenceProviderCopilot
+			isHyper         = string(p.ID) == "hyper"
+			hasAPIKeyEnv    = strings.HasPrefix(p.APIKey, "$")
+			_, isConfigured = cfg.Providers.Get(string(p.ID))
+		)
+		if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
+			filteredProviders = append(filteredProviders, p)
+		}
+	}
+	return filteredProviders, nil
+}
+
+func modelKey(providerID, modelID string) string {
+	if providerID == "" || modelID == "" {
+		return ""
+	}
+	return providerID + ":" + modelID
+}

internal/ui/dialog/models_item.go πŸ”—

@@ -0,0 +1,124 @@
+package dialog
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelGroup represents a group of model items.
+type ModelGroup struct {
+	Title      string
+	Items      []*ModelItem
+	configured bool
+	t          *styles.Styles
+}
+
+// NewModelGroup creates a new ModelGroup.
+func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
+	return ModelGroup{
+		Title:      title,
+		Items:      items,
+		configured: configured,
+		t:          t,
+	}
+}
+
+// AppendItems appends [ModelItem]s to the group.
+func (m *ModelGroup) AppendItems(items ...*ModelItem) {
+	m.Items = append(m.Items, items...)
+}
+
+// Render implements [list.Item].
+func (m *ModelGroup) Render(width int) string {
+	var configured string
+	if m.configured {
+		configuredIcon := m.t.ToolCallSuccess.Render()
+		configuredText := m.t.Subtle.Render("Configured")
+		configured = configuredIcon + " " + configuredText
+	}
+
+	title := " " + m.Title + " "
+	title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…")
+
+	return common.Section(m.t, title, width, configured)
+}
+
+// ModelItem represents a list item for a model type.
+type ModelItem struct {
+	prov      catwalk.Provider
+	model     catwalk.Model
+	modelType ModelType
+
+	cache        map[int]string
+	t            *styles.Styles
+	m            fuzzy.Match
+	focused      bool
+	showProvider bool
+}
+
+// SelectedModel returns this model item as a [config.SelectedModel] instance.
+func (m *ModelItem) SelectedModel() config.SelectedModel {
+	return config.SelectedModel{
+		Model:           m.model.ID,
+		Provider:        string(m.prov.ID),
+		ReasoningEffort: m.model.DefaultReasoningEffort,
+		MaxTokens:       m.model.DefaultMaxTokens,
+	}
+}
+
+// SelectedModelType returns the type of model represented by this item.
+func (m *ModelItem) SelectedModelType() config.SelectedModelType {
+	return m.modelType.Config()
+}
+
+var _ ListItem = &ModelItem{}
+
+// NewModelItem creates a new ModelItem.
+func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
+	return &ModelItem{
+		prov:         prov,
+		model:        model,
+		modelType:    typ,
+		t:            t,
+		cache:        make(map[int]string),
+		showProvider: showProvider,
+	}
+}
+
+// Filter implements ListItem.
+func (m *ModelItem) Filter() string {
+	return m.model.Name
+}
+
+// ID implements ListItem.
+func (m *ModelItem) ID() string {
+	return modelKey(string(m.prov.ID), m.model.ID)
+}
+
+// Render implements ListItem.
+func (m *ModelItem) Render(width int) string {
+	var providerInfo string
+	if m.showProvider {
+		providerInfo = string(m.prov.Name)
+	}
+	return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
+}
+
+// SetFocused implements ListItem.
+func (m *ModelItem) SetFocused(focused bool) {
+	if m.focused != focused {
+		m.cache = nil
+	}
+	m.focused = focused
+}
+
+// SetMatch implements ListItem.
+func (m *ModelItem) SetMatch(fm fuzzy.Match) {
+	m.cache = nil
+	m.m = fm
+}

internal/ui/dialog/models_list.go πŸ”—

@@ -0,0 +1,273 @@
+package dialog
+
+import (
+	"fmt"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelsList is a list specifically for model items and groups.
+type ModelsList struct {
+	*list.List
+	groups []ModelGroup
+	query  string
+	t      *styles.Styles
+}
+
+// NewModelsList creates a new list suitable for model items and groups.
+func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
+	f := &ModelsList{
+		List:   list.NewList(),
+		groups: groups,
+		t:      sty,
+	}
+	f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
+	return f
+}
+
+// Len returns the number of model items across all groups.
+func (f *ModelsList) Len() int {
+	n := 0
+	for _, g := range f.groups {
+		n += len(g.Items)
+	}
+	return n
+}
+
+// SetGroups sets the model groups and updates the list items.
+func (f *ModelsList) SetGroups(groups ...ModelGroup) {
+	f.groups = groups
+	items := []list.Item{}
+	for _, g := range f.groups {
+		items = append(items, &g)
+		for _, item := range g.Items {
+			items = append(items, item)
+		}
+		// Add a space separator after each provider section
+		items = append(items, list.NewSpacerItem(1))
+	}
+	f.SetItems(items...)
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *ModelsList) SetFilter(q string) {
+	f.query = q
+}
+
+// SetSelected sets the selected item index. It overrides the base method to
+// skip non-model items.
+func (f *ModelsList) SetSelected(index int) {
+	if index < 0 || index >= f.Len() {
+		f.List.SetSelected(index)
+		return
+	}
+
+	f.List.SetSelected(index)
+	for {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return
+		}
+		f.List.SetSelected(index + 1)
+		index++
+		if index >= f.Len() {
+			return
+		}
+	}
+}
+
+// SetSelectedItem sets the selected item in the list by item ID.
+func (f *ModelsList) SetSelectedItem(itemID string) {
+	if itemID == "" {
+		f.SetSelected(0)
+		return
+	}
+
+	count := 0
+	for _, g := range f.groups {
+		for _, item := range g.Items {
+			if item.ID() == itemID {
+				f.SetSelected(count)
+				return
+			}
+			count++
+		}
+	}
+}
+
+// SelectNext selects the next model item, skipping any non-focusable items
+// like group headers and spacers.
+func (f *ModelsList) SelectNext() (v bool) {
+	for {
+		v = f.List.SelectNext()
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+	}
+}
+
+// SelectPrev selects the previous model item, skipping any non-focusable items
+// like group headers and spacers.
+func (f *ModelsList) SelectPrev() (v bool) {
+	for {
+		v = f.List.SelectPrev()
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+	}
+}
+
+// SelectFirst selects the first model item in the list.
+func (f *ModelsList) SelectFirst() (v bool) {
+	v = f.List.SelectFirst()
+	for {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+		v = f.List.SelectNext()
+	}
+}
+
+// SelectLast selects the last model item in the list.
+func (f *ModelsList) SelectLast() (v bool) {
+	v = f.List.SelectLast()
+	for {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+		v = f.List.SelectPrev()
+	}
+}
+
+// IsSelectedFirst checks if the selected item is the first model item.
+func (f *ModelsList) IsSelectedFirst() bool {
+	originalIndex := f.Selected()
+	f.SelectFirst()
+	isFirst := f.Selected() == originalIndex
+	f.List.SetSelected(originalIndex)
+	return isFirst
+}
+
+// IsSelectedLast checks if the selected item is the last model item.
+func (f *ModelsList) IsSelectedLast() bool {
+	originalIndex := f.Selected()
+	f.SelectLast()
+	isLast := f.Selected() == originalIndex
+	f.List.SetSelected(originalIndex)
+	return isLast
+}
+
+// VisibleItems returns the visible items after filtering.
+func (f *ModelsList) VisibleItems() []list.Item {
+	query := strings.ToLower(strings.ReplaceAll(f.query, " ", ""))
+
+	if query == "" {
+		// No filter, return all items with group headers
+		items := []list.Item{}
+		for _, g := range f.groups {
+			items = append(items, &g)
+			for _, item := range g.Items {
+				item.SetMatch(fuzzy.Match{})
+				items = append(items, item)
+			}
+			// Add a space separator after each provider section
+			items = append(items, list.NewSpacerItem(1))
+		}
+		return items
+	}
+
+	filterableItems := make([]list.FilterableItem, 0, f.Len())
+	for _, g := range f.groups {
+		for _, item := range g.Items {
+			filterableItems = append(filterableItems, item)
+		}
+	}
+
+	items := []list.Item{}
+	visitedGroups := map[int]bool{}
+
+	// Reconstruct groups with matched items
+	// Find which group this item belongs to
+	for gi, g := range f.groups {
+		addedCount := 0
+		name := strings.ToLower(g.Title) + " "
+
+		names := make([]string, len(filterableItems))
+		for i, item := range filterableItems {
+			ms := item.(*ModelItem)
+			names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
+		}
+
+		matches := fuzzy.Find(query, names)
+		sort.SliceStable(matches, func(i, j int) bool {
+			return matches[i].Score > matches[j].Score
+		})
+
+		for _, match := range matches {
+			item := filterableItems[match.Index].(*ModelItem)
+			idxs := []int{}
+			for _, idx := range match.MatchedIndexes {
+				// Adjusts removing provider name highlights
+				if idx < len(name) {
+					continue
+				}
+				idxs = append(idxs, idx-len(name))
+			}
+
+			match.MatchedIndexes = idxs
+			if slices.Contains(g.Items, item) {
+				if !visitedGroups[gi] {
+					// Add section header
+					items = append(items, &g)
+					visitedGroups[gi] = true
+				}
+				// Add the matched item
+				item.SetMatch(match)
+				items = append(items, item)
+				addedCount++
+			}
+		}
+		if addedCount > 0 {
+			// Add a space separator after each provider section
+			items = append(items, list.NewSpacerItem(1))
+		}
+	}
+
+	return items
+}
+
+// Render renders the filterable list.
+func (f *ModelsList) Render() string {
+	f.SetItems(f.VisibleItems()...)
+	return f.List.Render()
+}
+
+type modelGroups []ModelGroup
+
+func (m modelGroups) Len() int {
+	n := 0
+	for _, g := range m {
+		n += len(g.Items)
+	}
+	return n
+}
+
+func (m modelGroups) String(i int) string {
+	count := 0
+	for _, g := range m {
+		if i < count+len(g.Items) {
+			return g.Items[i-count].Filter()
+		}
+		count += len(g.Items)
+	}
+	return ""
+}

internal/ui/dialog/oauth.go πŸ”—

@@ -0,0 +1,369 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/pkg/browser"
+)
+
+type OAuthProvider interface {
+	name() string
+	initiateAuth() tea.Msg
+	startPolling(deviceCode string, expiresIn int) tea.Cmd
+	stopPolling() tea.Msg
+}
+
+// OAuthState represents the current state of the device flow.
+type OAuthState int
+
+const (
+	OAuthStateInitializing OAuthState = iota
+	OAuthStateDisplay
+	OAuthStateSuccess
+	OAuthStateError
+)
+
+// OAuthID is the identifier for the model selection dialog.
+const OAuthID = "oauth"
+
+// OAuth handles the OAuth flow authentication.
+type OAuth struct {
+	com *common.Common
+
+	provider      catwalk.Provider
+	model         config.SelectedModel
+	modelType     config.SelectedModelType
+	oAuthProvider OAuthProvider
+
+	State OAuthState
+
+	spinner spinner.Model
+	help    help.Model
+	keyMap  struct {
+		Copy   key.Binding
+		Submit key.Binding
+		Close  key.Binding
+	}
+
+	width           int
+	deviceCode      string
+	userCode        string
+	verificationURL string
+	expiresIn       int
+	interval        int
+	token           *oauth.Token
+	cancelFunc      context.CancelFunc
+}
+
+var _ Dialog = (*OAuth)(nil)
+
+// newOAuth creates a new device flow component.
+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
+	t := com.Styles
+
+	m := OAuth{}
+	m.com = com
+	m.provider = provider
+	m.model = model
+	m.modelType = modelType
+	m.oAuthProvider = oAuthProvider
+	m.width = 60
+	m.State = OAuthStateInitializing
+
+	m.spinner = spinner.New(
+		spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
+	)
+
+	m.help = help.New()
+	m.help.Styles = t.DialogHelpStyles()
+
+	m.keyMap.Copy = key.NewBinding(
+		key.WithKeys("c"),
+		key.WithHelp("c", "copy code"),
+	)
+	m.keyMap.Submit = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "copy & open"),
+	)
+	m.keyMap.Close = CloseKey
+
+	return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
+}
+
+// ID implements Dialog.
+func (m *OAuth) ID() string {
+	return OAuthID
+}
+
+// HandleMsg handles messages and state transitions.
+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		switch m.State {
+		case OAuthStateInitializing, OAuthStateDisplay:
+			var cmd tea.Cmd
+			m.spinner, cmd = m.spinner.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Copy):
+			cmd := m.copyCode()
+			return ActionCmd{cmd}
+
+		case key.Matches(msg, m.keyMap.Submit):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				cmd := m.copyCodeAndOpenURL()
+				return ActionCmd{cmd}
+			}
+
+		case key.Matches(msg, m.keyMap.Close):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				return ActionClose{}
+			}
+		}
+
+	case ActionInitiateOAuth:
+		m.deviceCode = msg.DeviceCode
+		m.userCode = msg.UserCode
+		m.expiresIn = msg.ExpiresIn
+		m.verificationURL = msg.VerificationURL
+		m.interval = msg.Interval
+		m.State = OAuthStateDisplay
+		return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
+
+	case ActionCompleteOAuth:
+		m.State = OAuthStateSuccess
+		m.token = msg.Token
+		return ActionCmd{m.oAuthProvider.stopPolling}
+
+	case ActionOAuthErrored:
+		m.State = OAuthStateError
+		cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
+		return ActionCmd{cmd}
+	}
+	return nil
+}
+
+// View renders the device flow dialog.
+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var (
+		t           = m.com.Styles
+		dialogStyle = t.Dialog.View.Width(m.width)
+		view        = dialogStyle.Render(m.dialogContent())
+	)
+	DrawCenterCursor(scr, area, view, nil)
+	return nil
+}
+
+func (m *OAuth) dialogContent() string {
+	var (
+		t         = m.com.Styles
+		helpStyle = t.Dialog.HelpView
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return m.innerDialogContent()
+
+	default:
+		elements := []string{
+			m.headerContent(),
+			m.innerDialogContent(),
+			helpStyle.Render(m.help.View(m)),
+		}
+		return strings.Join(elements, "\n")
+	}
+}
+
+func (m *OAuth) headerContent() string {
+	var (
+		t            = m.com.Styles
+		titleStyle   = t.Dialog.Title
+		dialogStyle  = t.Dialog.View.Width(m.width)
+		headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+	)
+	return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
+}
+
+func (m *OAuth) innerDialogContent() string {
+	var (
+		t            = m.com.Styles
+		whiteStyle   = lipgloss.NewStyle().Foreground(t.White)
+		primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
+		greenStyle   = lipgloss.NewStyle().Foreground(t.GreenLight)
+		linkStyle    = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
+		errorStyle   = lipgloss.NewStyle().Foreground(t.Error)
+		mutedStyle   = lipgloss.NewStyle().Foreground(t.FgMuted)
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return lipgloss.NewStyle().
+			Margin(1, 1).
+			Width(m.width - 2).
+			Align(lipgloss.Center).
+			Render(
+				greenStyle.Render(m.spinner.View()) +
+					mutedStyle.Render("Initializing..."),
+			)
+
+	case OAuthStateDisplay:
+		instructions := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				whiteStyle.Render("Press ") +
+					primaryStyle.Render("enter") +
+					whiteStyle.Render(" to copy the code below and open the browser."),
+			)
+
+		codeBox := lipgloss.NewStyle().
+			Width(m.width-2).
+			Height(7).
+			Align(lipgloss.Center, lipgloss.Center).
+			Background(t.BgBaseLighter).
+			Margin(0, 1).
+			Render(
+				lipgloss.NewStyle().
+					Bold(true).
+					Foreground(t.White).
+					Render(m.userCode),
+			)
+
+		link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
+		url := mutedStyle.
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render("Browser not opening? Refer to\n" + link)
+
+		waiting := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
+			)
+
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			"",
+			instructions,
+			"",
+			codeBox,
+			"",
+			url,
+			"",
+			waiting,
+			"",
+		)
+
+	case OAuthStateSuccess:
+		return greenStyle.
+			Margin(1).
+			Width(m.width - 2).
+			Render("Authentication successful!")
+
+	case OAuthStateError:
+		return lipgloss.NewStyle().
+			Margin(1).
+			Width(m.width - 2).
+			Render(errorStyle.Render("Authentication failed."))
+
+	default:
+		return ""
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *OAuth) FullHelp() [][]key.Binding {
+	return [][]key.Binding{m.ShortHelp()}
+}
+
+// ShortHelp returns the full help view.
+func (m *OAuth) ShortHelp() []key.Binding {
+	switch m.State {
+	case OAuthStateError:
+		return []key.Binding{m.keyMap.Close}
+
+	case OAuthStateSuccess:
+		return []key.Binding{
+			key.NewBinding(
+				key.WithKeys("finish", "ctrl+y", "esc"),
+				key.WithHelp("enter", "finish"),
+			),
+		}
+
+	default:
+		return []key.Binding{
+			m.keyMap.Copy,
+			m.keyMap.Submit,
+			m.keyMap.Close,
+		}
+	}
+}
+
+func (d *OAuth) copyCode() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		uiutil.ReportInfo("Code copied to clipboard"),
+	)
+}
+
+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		func() tea.Msg {
+			if err := browser.OpenURL(d.verificationURL); err != nil {
+				return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
+			}
+			return nil
+		},
+		uiutil.ReportInfo("Code copied and URL opened"),
+	)
+}
+
+func (m *OAuth) saveKeyAndContinue() Action {
+	cfg := m.com.Config()
+
+	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
+	if err != nil {
+		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+	}
+
+	return ActionSelectModel{
+		Provider:  m.provider,
+		Model:     m.model,
+		ModelType: m.modelType,
+	}
+}

internal/ui/dialog/oauth_copilot.go πŸ”—

@@ -0,0 +1,72 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
+}
+
+type OAuthCopilot struct {
+	deviceCode *copilot.DeviceCode
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthCopilot)(nil)
+
+func (m *OAuthCopilot) name() string {
+	return "GitHub Copilot"
+}
+
+func (m *OAuthCopilot) initiateAuth() tea.Msg {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	deviceCode, err := copilot.RequestDeviceCode(ctx)
+	if err != nil {
+		return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	m.deviceCode = deviceCode
+
+	return ActionInitiateOAuth{
+		DeviceCode:      deviceCode.DeviceCode,
+		UserCode:        deviceCode.UserCode,
+		VerificationURL: deviceCode.VerificationURI,
+		ExpiresIn:       deviceCode.ExpiresIn,
+		Interval:        deviceCode.Interval,
+	}
+}
+
+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		token, err := copilot.PollForToken(ctx, m.deviceCode)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil // cancelled, don't report error.
+			}
+			return ActionOAuthErrored{Error: err}
+		}
+
+		return ActionCompleteOAuth{Token: token}
+	}
+}
+
+func (m *OAuthCopilot) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

internal/ui/dialog/oauth_hyper.go πŸ”—

@@ -0,0 +1,90 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/hyper"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthHyper{})
+}
+
+type OAuthHyper struct {
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthHyper)(nil)
+
+func (m *OAuthHyper) name() string {
+	return "Hyper"
+}
+
+func (m *OAuthHyper) initiateAuth() tea.Msg {
+	minimumWait := 750 * time.Millisecond
+	startTime := time.Now()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	authResp, err := hyper.InitiateDeviceAuth(ctx)
+
+	ellapsed := time.Since(startTime)
+	if ellapsed < minimumWait {
+		time.Sleep(minimumWait - ellapsed)
+	}
+
+	if err != nil {
+		return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	return ActionInitiateOAuth{
+		DeviceCode:      authResp.DeviceCode,
+		UserCode:        authResp.UserCode,
+		ExpiresIn:       authResp.ExpiresIn,
+		VerificationURL: authResp.VerificationURL,
+	}
+}
+
+func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil
+			}
+			return ActionOAuthErrored{err}
+		}
+
+		token, err := hyper.ExchangeToken(ctx, refreshToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
+		}
+
+		introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
+		}
+		if !introspect.Active {
+			return ActionOAuthErrored{fmt.Errorf("access token is not active")}
+		}
+
+		return ActionCompleteOAuth{token}
+	}
+}
+
+func (m *OAuthHyper) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

internal/ui/dialog/permissions.go πŸ”—

@@ -0,0 +1,760 @@
+package dialog
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// PermissionsID is the identifier for the permissions dialog.
+const PermissionsID = "permissions"
+
+// PermissionAction represents the user's response to a permission request.
+type PermissionAction string
+
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+)
+
+// Permissions dialog sizing constants.
+const (
+	// diffMaxWidth is the maximum width for diff views.
+	diffMaxWidth = 180
+	// diffSizeRatio is the size ratio for diff views relative to window.
+	diffSizeRatio = 0.8
+	// simpleMaxWidth is the maximum width for simple content dialogs.
+	simpleMaxWidth = 100
+	// simpleSizeRatio is the size ratio for simple content dialogs.
+	simpleSizeRatio = 0.6
+	// simpleHeightRatio is the height ratio for simple content dialogs.
+	simpleHeightRatio = 0.5
+	// splitModeMinWidth is the minimum width to enable split diff mode.
+	splitModeMinWidth = 140
+	// layoutSpacingLines is the number of empty lines used for layout spacing.
+	layoutSpacingLines = 4
+	// minWindowWidth is the minimum window width before forcing fullscreen.
+	minWindowWidth = 60
+	// minWindowHeight is the minimum window height before forcing fullscreen.
+	minWindowHeight = 20
+)
+
+// Permissions represents a dialog for permission requests.
+type Permissions struct {
+	com          *common.Common
+	windowWidth  int // Terminal window dimensions.
+	windowHeight int
+	fullscreen   bool // true when dialog is fullscreen
+
+	permission     permission.PermissionRequest
+	selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
+
+	viewport      viewport.Model
+	viewportDirty bool // true when viewport content needs to be re-rendered
+	viewportWidth int
+
+	// Diff view state.
+	diffSplitMode        *bool // nil means use default based on width
+	defaultDiffSplitMode bool  // default split mode based on width
+	unifiedDiffContent   string
+	splitDiffContent     string
+
+	help   help.Model
+	keyMap permissionsKeyMap
+}
+
+type permissionsKeyMap struct {
+	Left             key.Binding
+	Right            key.Binding
+	Tab              key.Binding
+	Select           key.Binding
+	Allow            key.Binding
+	AllowSession     key.Binding
+	Deny             key.Binding
+	Close            key.Binding
+	ToggleDiffMode   key.Binding
+	ToggleFullscreen key.Binding
+	ScrollUp         key.Binding
+	ScrollDown       key.Binding
+	ScrollLeft       key.Binding
+	ScrollRight      key.Binding
+	Choose           key.Binding
+	Scroll           key.Binding
+}
+
+func defaultPermissionsKeyMap() permissionsKeyMap {
+	return permissionsKeyMap{
+		Left: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("←", "previous"),
+		),
+		Right: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("β†’", "next"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "next option"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Allow: key.NewBinding(
+			key.WithKeys("a", "A", "ctrl+a"),
+			key.WithHelp("a", "allow"),
+		),
+		AllowSession: key.NewBinding(
+			key.WithKeys("s", "S", "ctrl+s"),
+			key.WithHelp("s", "allow session"),
+		),
+		Deny: key.NewBinding(
+			key.WithKeys("d", "D"),
+			key.WithHelp("d", "deny"),
+		),
+		Close: CloseKey,
+		ToggleDiffMode: key.NewBinding(
+			key.WithKeys("t"),
+			key.WithHelp("t", "toggle diff view"),
+		),
+		ToggleFullscreen: key.NewBinding(
+			key.WithKeys("f"),
+			key.WithHelp("f", "toggle fullscreen"),
+		),
+		ScrollUp: key.NewBinding(
+			key.WithKeys("shift+up", "K"),
+			key.WithHelp("shift+↑", "scroll up"),
+		),
+		ScrollDown: key.NewBinding(
+			key.WithKeys("shift+down", "J"),
+			key.WithHelp("shift+↓", "scroll down"),
+		),
+		ScrollLeft: key.NewBinding(
+			key.WithKeys("shift+left", "H"),
+			key.WithHelp("shift+←", "scroll left"),
+		),
+		ScrollRight: key.NewBinding(
+			key.WithKeys("shift+right", "L"),
+			key.WithHelp("shift+β†’", "scroll right"),
+		),
+		Choose: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/β†’", "choose"),
+		),
+		Scroll: key.NewBinding(
+			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
+			key.WithHelp("shift+←↓↑→", "scroll"),
+		),
+	}
+}
+
+var _ Dialog = (*Permissions)(nil)
+
+// PermissionsOption configures the permissions dialog.
+type PermissionsOption func(*Permissions)
+
+// WithDiffMode sets the initial diff mode (split or unified).
+func WithDiffMode(split bool) PermissionsOption {
+	return func(p *Permissions) {
+		p.diffSplitMode = &split
+	}
+}
+
+// NewPermissions creates a new permissions dialog.
+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
+	h := help.New()
+	h.Styles = com.Styles.DialogHelpStyles()
+
+	km := defaultPermissionsKeyMap()
+
+	// Configure viewport with matching keybindings.
+	vp := viewport.New()
+	vp.KeyMap = viewport.KeyMap{
+		Up:    km.ScrollUp,
+		Down:  km.ScrollDown,
+		Left:  km.ScrollLeft,
+		Right: km.ScrollRight,
+		// Disable other viewport keys to avoid conflicts with dialog shortcuts.
+		PageUp:       key.NewBinding(key.WithDisabled()),
+		PageDown:     key.NewBinding(key.WithDisabled()),
+		HalfPageUp:   key.NewBinding(key.WithDisabled()),
+		HalfPageDown: key.NewBinding(key.WithDisabled()),
+	}
+
+	p := &Permissions{
+		com:            com,
+		permission:     perm,
+		selectedOption: 0,
+		viewport:       vp,
+		help:           h,
+		keyMap:         km,
+	}
+
+	for _, opt := range opts {
+		opt(p)
+	}
+
+	return p
+}
+
+// Calculate usable content width (dialog border + horizontal padding).
+func (p *Permissions) calculateContentWidth(width int) int {
+	t := p.com.Styles
+	const dialogHorizontalPadding = 2
+	return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
+}
+
+// ID implements [Dialog].
+func (*Permissions) ID() string {
+	return PermissionsID
+}
+
+// HandleMsg implements [Dialog].
+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Close):
+			// Escape denies the permission request.
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
+			p.selectedOption = (p.selectedOption + 1) % 3
+		case key.Matches(msg, p.keyMap.Left):
+			// Add 2 instead of subtracting 1 to avoid negative modulo.
+			p.selectedOption = (p.selectedOption + 2) % 3
+		case key.Matches(msg, p.keyMap.Select):
+			return p.selectCurrentOption()
+		case key.Matches(msg, p.keyMap.Allow):
+			return p.respond(PermissionAllow)
+		case key.Matches(msg, p.keyMap.AllowSession):
+			return p.respond(PermissionAllowForSession)
+		case key.Matches(msg, p.keyMap.Deny):
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.ToggleDiffMode):
+			if p.hasDiffView() {
+				newMode := !p.isSplitMode()
+				p.diffSplitMode = &newMode
+				p.viewportDirty = true
+			}
+		case key.Matches(msg, p.keyMap.ToggleFullscreen):
+			if p.hasDiffView() {
+				p.fullscreen = !p.fullscreen
+			}
+		case key.Matches(msg, p.keyMap.ScrollDown):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollUp):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollLeft):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollRight):
+			p.viewport, _ = p.viewport.Update(msg)
+		}
+	case tea.MouseWheelMsg:
+		p.viewport, _ = p.viewport.Update(msg)
+	default:
+		// Pass unhandled keys to viewport for non-diff content scrolling.
+		if !p.hasDiffView() {
+			p.viewport, _ = p.viewport.Update(msg)
+			p.viewportDirty = true
+		}
+	}
+
+	return nil
+}
+
+func (p *Permissions) selectCurrentOption() tea.Msg {
+	switch p.selectedOption {
+	case 0:
+		return p.respond(PermissionAllow)
+	case 1:
+		return p.respond(PermissionAllowForSession)
+	default:
+		return p.respond(PermissionDeny)
+	}
+}
+
+func (p *Permissions) respond(action PermissionAction) tea.Msg {
+	return ActionPermissionResponse{
+		Permission: p.permission,
+		Action:     action,
+	}
+}
+
+func (p *Permissions) hasDiffView() bool {
+	switch p.permission.ToolName {
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
+		return true
+	}
+	return false
+}
+
+func (p *Permissions) isSplitMode() bool {
+	if p.diffSplitMode != nil {
+		return *p.diffSplitMode
+	}
+	return p.defaultDiffSplitMode
+}
+
+// Draw implements [Dialog].
+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := p.com.Styles
+	// Force fullscreen when window is too small.
+	forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
+
+	// Calculate dialog dimensions based on fullscreen state and content type.
+	var width, maxHeight int
+	if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
+		// Use nearly full window for fullscreen.
+		width = area.Dx()
+		maxHeight = area.Dy()
+	} else if p.hasDiffView() {
+		// Wide for side-by-side diffs, capped for readability.
+		width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
+		maxHeight = int(float64(area.Dy()) * diffSizeRatio)
+	} else {
+		// Narrower for simple content like commands/URLs.
+		width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
+		maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
+	}
+
+	dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
+
+	contentWidth := p.calculateContentWidth(width)
+	header := p.renderHeader(contentWidth)
+	buttons := p.renderButtons(contentWidth)
+	helpView := p.help.View(p)
+
+	// Calculate available height for content.
+	headerHeight := lipgloss.Height(header)
+	buttonsHeight := lipgloss.Height(buttons)
+	helpHeight := lipgloss.Height(helpView)
+	frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
+
+	p.defaultDiffSplitMode = width >= splitModeMinWidth
+
+	// Pre-render content to measure its actual height.
+	renderedContent := p.renderContent(contentWidth)
+	contentHeight := lipgloss.Height(renderedContent)
+
+	// For non-diff views, shrink dialog to fit content if it's smaller than max.
+	var availableHeight int
+	if !p.hasDiffView() && !forceFullscreen {
+		fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
+		neededHeight := fixedHeight + contentHeight
+		if neededHeight < maxHeight {
+			availableHeight = contentHeight
+		} else {
+			availableHeight = maxHeight - fixedHeight
+		}
+	} else {
+		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
+	}
+
+	// Determine if scrollbar is needed.
+	needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
+	viewportWidth := contentWidth
+	if needsScrollbar {
+		viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
+	}
+
+	if p.viewport.Width() != viewportWidth {
+		// Mark content as dirty if width has changed.
+		p.viewportDirty = true
+		renderedContent = p.renderContent(viewportWidth)
+	}
+
+	var content string
+	var scrollbar string
+	p.viewport.SetWidth(viewportWidth)
+	p.viewport.SetHeight(availableHeight)
+	if p.viewportDirty {
+		p.viewport.SetContent(renderedContent)
+		p.viewportWidth = p.viewport.Width()
+		p.viewportDirty = false
+	}
+	content = p.viewport.View()
+	if needsScrollbar {
+		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
+	}
+
+	// Join content with scrollbar if present.
+	if scrollbar != "" {
+		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+	}
+
+	parts := []string{header}
+	if content != "" {
+		parts = append(parts, "", content)
+	}
+	parts = append(parts, "", buttons, "", helpView)
+
+	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
+	return nil
+}
+
+func (p *Permissions) renderHeader(contentWidth int) string {
+	t := p.com.Styles
+
+	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
+	title = t.Dialog.Title.Render(title)
+
+	// Tool info.
+	toolLine := p.renderToolName(contentWidth)
+	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
+
+	lines := []string{title, "", toolLine, pathLine}
+
+	// Add tool-specific header info.
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
+		}
+	case tools.DownloadToolName:
+		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
+		}
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
+		var filePath string
+		switch params := p.permission.Params.(type) {
+		case tools.EditPermissionsParams:
+			filePath = params.FilePath
+		case tools.WritePermissionsParams:
+			filePath = params.FilePath
+		case tools.MultiEditPermissionsParams:
+			filePath = params.FilePath
+		case tools.ViewPermissionsParams:
+			filePath = params.FilePath
+		}
+		if filePath != "" {
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
+		}
+	case tools.LSToolName:
+		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
+		}
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (p *Permissions) renderKeyValue(key, value string, width int) string {
+	t := p.com.Styles
+	keyStyle := t.Muted
+	valueStyle := t.Base
+
+	keyStr := keyStyle.Render(key)
+	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
+}
+
+func (p *Permissions) renderToolName(width int) string {
+	toolName := p.permission.ToolName
+
+	// Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
+	if strings.HasPrefix(toolName, "mcp_") {
+		parts := strings.SplitN(toolName, "_", 3)
+		if len(parts) == 3 {
+			mcpName := prettyName(parts[1])
+			toolPart := prettyName(parts[2])
+			toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
+		}
+	}
+
+	return p.renderKeyValue("Tool", toolName, width)
+}
+
+// prettyName converts snake_case or kebab-case to Title Case.
+func prettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}
+
+func (p *Permissions) renderContent(width int) string {
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		return p.renderBashContent(width)
+	case tools.EditToolName:
+		return p.renderEditContent(width)
+	case tools.WriteToolName:
+		return p.renderWriteContent(width)
+	case tools.MultiEditToolName:
+		return p.renderMultiEditContent(width)
+	case tools.DownloadToolName:
+		return p.renderDownloadContent(width)
+	case tools.FetchToolName:
+		return p.renderFetchContent(width)
+	case tools.AgenticFetchToolName:
+		return p.renderAgenticFetchContent(width)
+	case tools.ViewToolName:
+		return p.renderViewContent(width)
+	case tools.LSToolName:
+		return p.renderLSContent(width)
+	default:
+		return p.renderDefaultContent(width)
+	}
+}
+
+func (p *Permissions) renderBashContent(width int) string {
+	params, ok := p.permission.Params.(tools.BashPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.renderContentPanel(params.Command, width)
+}
+
+func (p *Permissions) renderEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.EditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderWriteContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.WritePermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
+	if !p.viewportDirty {
+		if p.isSplitMode() {
+			return p.splitDiffContent
+		}
+		return p.unifiedDiffContent
+	}
+
+	isSplitMode := p.isSplitMode()
+	formatter := common.DiffFormatter(p.com.Styles).
+		Before(fsext.PrettyPath(filePath), oldContent).
+		After(fsext.PrettyPath(filePath), newContent).
+		// TODO: Allow horizontal scrolling instead of cropping. However, the
+		// diffview currently would only background color the width of the
+		// content. If the viewport is wider than the content, the rest of the
+		// line would not be colored properly.
+		Width(contentWidth)
+
+	var result string
+	if isSplitMode {
+		formatter = formatter.Split()
+		p.splitDiffContent = formatter.String()
+		result = p.splitDiffContent
+	} else {
+		formatter = formatter.Unified()
+		p.unifiedDiffContent = formatter.String()
+		result = p.unifiedDiffContent
+	}
+
+	return result
+}
+
+func (p *Permissions) renderDownloadContent(width int) string {
+	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
+	if params.Timeout > 0 {
+		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderFetchContent(width int) string {
+	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.renderContentPanel(params.URL, width)
+}
+
+func (p *Permissions) renderAgenticFetchContent(width int) string {
+	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	var content string
+	if params.URL != "" {
+		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
+	} else {
+		content = fmt.Sprintf("Prompt: %s", params.Prompt)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderViewContent(width int) string {
+	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
+	if params.Offset > 0 {
+		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
+	}
+	if params.Limit > 0 && params.Limit != 2000 {
+		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderLSContent(width int) string {
+	params, ok := p.permission.Params.(tools.LSPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
+	if len(params.Ignore) > 0 {
+		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderDefaultContent(width int) string {
+	t := p.com.Styles
+	var content string
+	// do not add the description for mcp tools
+	if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
+		content = p.permission.Description
+	}
+
+	// Pretty-print JSON params if available.
+	if p.permission.Params != nil {
+		var paramStr string
+		if str, ok := p.permission.Params.(string); ok {
+			paramStr = str
+		} else {
+			paramStr = fmt.Sprintf("%v", p.permission.Params)
+		}
+
+		var parsed any
+		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
+			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+				jsonContent := string(b)
+				highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
+				if err == nil {
+					jsonContent = highlighted
+				}
+				if content != "" {
+					content += "\n\n"
+				}
+				content += jsonContent
+			}
+		} else if paramStr != "" {
+			if content != "" {
+				content += "\n\n"
+			}
+			content += paramStr
+		}
+	}
+
+	if content == "" {
+		return ""
+	}
+
+	return p.renderContentPanel(strings.TrimSpace(content), width)
+}
+
+// renderContentPanel renders content in a panel with the full width.
+func (p *Permissions) renderContentPanel(content string, width int) string {
+	panelStyle := p.com.Styles.Dialog.ContentPanel
+	return panelStyle.Width(width).Render(content)
+}
+
+func (p *Permissions) renderButtons(contentWidth int) string {
+	buttons := []common.ButtonOpts{
+		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
+		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
+		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
+	}
+
+	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
+
+	// If buttons are too wide, stack them vertically.
+	if lipgloss.Width(content) > contentWidth {
+		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
+		return lipgloss.NewStyle().
+			Width(contentWidth).
+			Align(lipgloss.Center).
+			Render(content)
+	}
+
+	return lipgloss.NewStyle().
+		Width(contentWidth).
+		Align(lipgloss.Right).
+		Render(content)
+}
+
+func (p *Permissions) canScroll() bool {
+	if p.hasDiffView() {
+		// Diff views can always scroll.
+		return true
+	}
+	// For non-diff content, check if viewport has scrollable content.
+	return !p.viewport.AtTop() || !p.viewport.AtBottom()
+}
+
+// ShortHelp implements [help.KeyMap].
+func (p *Permissions) ShortHelp() []key.Binding {
+	bindings := []key.Binding{
+		p.keyMap.Choose,
+		p.keyMap.Select,
+		p.keyMap.Close,
+	}
+
+	if p.canScroll() {
+		bindings = append(bindings, p.keyMap.Scroll)
+	}
+
+	if p.hasDiffView() {
+		bindings = append(bindings,
+			p.keyMap.ToggleDiffMode,
+			p.keyMap.ToggleFullscreen,
+		)
+	}
+
+	return bindings
+}
+
+// FullHelp implements [help.KeyMap].
+func (p *Permissions) FullHelp() [][]key.Binding {
+	return [][]key.Binding{p.ShortHelp()}
+}

internal/ui/dialog/quit.go πŸ”—

@@ -0,0 +1,133 @@
+package dialog
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// QuitID is the identifier for the quit dialog.
+const QuitID = "quit"
+
+// Quit represents a confirmation dialog for quitting the application.
+type Quit struct {
+	com        *common.Common
+	selectedNo bool // true if "No" button is selected
+	keyMap     struct {
+		LeftRight,
+		EnterSpace,
+		Yes,
+		No,
+		Tab,
+		Close,
+		Quit key.Binding
+	}
+}
+
+var _ Dialog = (*Quit)(nil)
+
+// NewQuit creates a new quit confirmation dialog.
+func NewQuit(com *common.Common) *Quit {
+	q := &Quit{
+		com:        com,
+		selectedNo: true,
+	}
+	q.keyMap.LeftRight = key.NewBinding(
+		key.WithKeys("left", "right"),
+		key.WithHelp("←/β†’", "switch options"),
+	)
+	q.keyMap.EnterSpace = key.NewBinding(
+		key.WithKeys("enter", " "),
+		key.WithHelp("enter/space", "confirm"),
+	)
+	q.keyMap.Yes = key.NewBinding(
+		key.WithKeys("y", "Y", "ctrl+c"),
+		key.WithHelp("y/Y/ctrl+c", "yes"),
+	)
+	q.keyMap.No = key.NewBinding(
+		key.WithKeys("n", "N"),
+		key.WithHelp("n/N", "no"),
+	)
+	q.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "switch options"),
+	)
+	q.keyMap.Close = CloseKey
+	q.keyMap.Quit = key.NewBinding(
+		key.WithKeys("ctrl+c"),
+		key.WithHelp("ctrl+c", "quit"),
+	)
+	return q
+}
+
+// ID implements [Model].
+func (*Quit) ID() string {
+	return QuitID
+}
+
+// HandleMsg implements [Model].
+func (q *Quit) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, q.keyMap.Quit):
+			return ActionQuit{}
+		case key.Matches(msg, q.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
+			q.selectedNo = !q.selectedNo
+		case key.Matches(msg, q.keyMap.EnterSpace):
+			if !q.selectedNo {
+				return ActionQuit{}
+			}
+			return ActionClose{}
+		case key.Matches(msg, q.keyMap.Yes):
+			return ActionQuit{}
+		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
+			return ActionClose{}
+		}
+	}
+
+	return nil
+}
+
+// Draw implements [Dialog].
+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	const question = "Are you sure you want to quit?"
+	baseStyle := q.com.Styles.Base
+	buttonOpts := []common.ButtonOpts{
+		{Text: "Yep!", Selected: !q.selectedNo, Padding: 3},
+		{Text: "Nope", Selected: q.selectedNo, Padding: 3},
+	}
+	buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ")
+	content := baseStyle.Render(
+		lipgloss.JoinVertical(
+			lipgloss.Center,
+			question,
+			"",
+			buttons,
+		),
+	)
+
+	view := q.com.Styles.BorderFocus.Render(content)
+	DrawCenter(scr, area, view)
+	return nil
+}
+
+// ShortHelp implements [help.KeyMap].
+func (q *Quit) ShortHelp() []key.Binding {
+	return []key.Binding{
+		q.keyMap.LeftRight,
+		q.keyMap.EnterSpace,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (q *Quit) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
+		{q.keyMap.Tab, q.keyMap.Close},
+	}
+}

internal/ui/dialog/reasoning.go πŸ”—

@@ -0,0 +1,297 @@
+package dialog
+
+import (
+	"errors"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/sahilm/fuzzy"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+const (
+	// ReasoningID is the identifier for the reasoning effort dialog.
+	ReasoningID              = "reasoning"
+	reasoningDialogMaxWidth  = 80
+	reasoningDialogMaxHeight = 12
+)
+
+// Reasoning represents a dialog for selecting reasoning effort.
+type Reasoning struct {
+	com   *common.Common
+	help  help.Model
+	list  *list.FilterableList
+	input textinput.Model
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+// ReasoningItem represents a reasoning effort list item.
+type ReasoningItem struct {
+	effort    string
+	title     string
+	isCurrent bool
+	t         *styles.Styles
+	m         fuzzy.Match
+	cache     map[int]string
+	focused   bool
+}
+
+var (
+	_ Dialog   = (*Reasoning)(nil)
+	_ ListItem = (*ReasoningItem)(nil)
+)
+
+// NewReasoning creates a new reasoning effort dialog.
+func NewReasoning(com *common.Common) (*Reasoning, error) {
+	r := &Reasoning{com: com}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+	r.help = help
+
+	r.list = list.NewFilterableList()
+	r.list.Focus()
+
+	r.input = textinput.New()
+	r.input.SetVirtualCursor(false)
+	r.input.Placeholder = "Type to filter"
+	r.input.SetStyles(com.Styles.TextInput)
+	r.input.Focus()
+
+	r.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	r.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	r.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	r.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	r.keyMap.Close = CloseKey
+
+	if err := r.setReasoningItems(); err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
+// ID implements Dialog.
+func (r *Reasoning) ID() string {
+	return ReasoningID
+}
+
+// HandleMsg implements [Dialog].
+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, r.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, r.keyMap.Previous):
+			r.list.Focus()
+			if r.list.IsSelectedFirst() {
+				r.list.SelectLast()
+				r.list.ScrollToBottom()
+				break
+			}
+			r.list.SelectPrev()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Next):
+			r.list.Focus()
+			if r.list.IsSelectedLast() {
+				r.list.SelectFirst()
+				r.list.ScrollToTop()
+				break
+			}
+			r.list.SelectNext()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Select):
+			selectedItem := r.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+			reasoningItem, ok := selectedItem.(*ReasoningItem)
+			if !ok {
+				break
+			}
+			return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
+		default:
+			var cmd tea.Cmd
+			r.input, cmd = r.input.Update(msg)
+			value := r.input.Value()
+			r.list.SetFilter(value)
+			r.list.ScrollToTop()
+			r.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (r *Reasoning) Cursor() *tea.Cursor {
+	return InputCursor(r.com.Styles, r.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := r.com.Styles
+	width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
+	height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+	r.list.SetSize(innerWidth, height-heightOffset)
+	r.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Select Reasoning Effort"
+	inputView := t.Dialog.InputPrompt.Render(r.input.View())
+	rc.AddPart(inputView)
+
+	visibleCount := len(r.list.VisibleItems())
+	if r.list.Height() >= visibleCount {
+		r.list.ScrollToTop()
+	} else {
+		r.list.ScrollToSelected()
+	}
+
+	listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
+	rc.AddPart(listView)
+	rc.Help = r.help.View(r)
+
+	view := rc.Render()
+
+	cur := r.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (r *Reasoning) ShortHelp() []key.Binding {
+	return []key.Binding{
+		r.keyMap.UpDown,
+		r.keyMap.Select,
+		r.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (r *Reasoning) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		r.keyMap.Select,
+		r.keyMap.Next,
+		r.keyMap.Previous,
+		r.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+func (r *Reasoning) setReasoningItems() error {
+	cfg := r.com.Config()
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("agent configuration not found")
+	}
+
+	selectedModel := cfg.Models[agentCfg.Model]
+	model := cfg.GetModelByType(agentCfg.Model)
+	if model == nil {
+		return errors.New("model configuration not found")
+	}
+
+	if len(model.ReasoningLevels) == 0 {
+		return errors.New("no reasoning levels available")
+	}
+
+	currentEffort := selectedModel.ReasoningEffort
+	if currentEffort == "" {
+		currentEffort = model.DefaultReasoningEffort
+	}
+
+	caser := cases.Title(language.English)
+	items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
+	selectedIndex := 0
+	for i, effort := range model.ReasoningLevels {
+		item := &ReasoningItem{
+			effort:    effort,
+			title:     caser.String(effort),
+			isCurrent: effort == currentEffort,
+			t:         r.com.Styles,
+		}
+		items = append(items, item)
+		if effort == currentEffort {
+			selectedIndex = i
+		}
+	}
+
+	r.list.SetItems(items...)
+	r.list.SetSelected(selectedIndex)
+	r.list.ScrollToSelected()
+	return nil
+}
+
+// Filter returns the filter value for the reasoning item.
+func (r *ReasoningItem) Filter() string {
+	return r.title
+}
+
+// ID returns the unique identifier for the reasoning effort.
+func (r *ReasoningItem) ID() string {
+	return r.effort
+}
+
+// SetFocused sets the focus state of the reasoning item.
+func (r *ReasoningItem) SetFocused(focused bool) {
+	if r.focused != focused {
+		r.cache = nil
+	}
+	r.focused = focused
+}
+
+// SetMatch sets the fuzzy match for the reasoning item.
+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
+	r.cache = nil
+	r.m = m
+}
+
+// Render returns the string representation of the reasoning item.
+func (r *ReasoningItem) Render(width int) string {
+	info := ""
+	if r.isCurrent {
+		info = "current"
+	}
+	return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
+}

internal/ui/dialog/sessions.go πŸ”—

@@ -0,0 +1,194 @@
+package dialog
+
+import (
+	"context"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// SessionsID is the identifier for the session selector dialog.
+const SessionsID = "session"
+
+// Session is a session selector dialog.
+type Session struct {
+	com                *common.Common
+	help               help.Model
+	list               *list.FilterableList
+	input              textinput.Model
+	selectedSessionInx int
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+var _ Dialog = (*Session)(nil)
+
+// NewSessions creates a new Session dialog.
+func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
+	s := new(Session)
+	s.com = com
+	sessions, err := com.App.Sessions.List(context.TODO())
+	if err != nil {
+		return nil, err
+	}
+
+	for i, sess := range sessions {
+		if sess.ID == selectedSessionID {
+			s.selectedSessionInx = i
+			break
+		}
+	}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	s.help = help
+	s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
+	s.list.Focus()
+	s.list.SetSelected(s.selectedSessionInx)
+	s.list.ScrollToSelected()
+
+	s.input = textinput.New()
+	s.input.SetVirtualCursor(false)
+	s.input.Placeholder = "Enter session name"
+	s.input.SetStyles(com.Styles.TextInput)
+	s.input.Focus()
+
+	s.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "tab", "ctrl+y"),
+		key.WithHelp("enter", "choose"),
+	)
+	s.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	s.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	s.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑↓", "choose"),
+	)
+	s.keyMap.Close = CloseKey
+
+	return s, nil
+}
+
+// ID implements Dialog.
+func (s *Session) ID() string {
+	return SessionsID
+}
+
+// HandleMsg implements Dialog.
+func (s *Session) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, s.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, s.keyMap.Previous):
+			s.list.Focus()
+			if s.list.IsSelectedFirst() {
+				s.list.SelectLast()
+				s.list.ScrollToBottom()
+				break
+			}
+			s.list.SelectPrev()
+			s.list.ScrollToSelected()
+		case key.Matches(msg, s.keyMap.Next):
+			s.list.Focus()
+			if s.list.IsSelectedLast() {
+				s.list.SelectFirst()
+				s.list.ScrollToTop()
+				break
+			}
+			s.list.SelectNext()
+			s.list.ScrollToSelected()
+		case key.Matches(msg, s.keyMap.Select):
+			if item := s.list.SelectedItem(); item != nil {
+				sessionItem := item.(*SessionItem)
+				return ActionSelectSession{sessionItem.Session}
+			}
+		default:
+			var cmd tea.Cmd
+			s.input, cmd = s.input.Update(msg)
+			value := s.input.Value()
+			s.list.SetFilter(value)
+			s.list.ScrollToTop()
+			s.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (s *Session) Cursor() *tea.Cursor {
+	return InputCursor(s.com.Styles, s.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := s.com.Styles
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+	s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	s.list.SetSize(innerWidth, height-heightOffset)
+	s.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Session"
+	inputView := t.Dialog.InputPrompt.Render(s.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
+	rc.AddPart(listView)
+	rc.Help = s.help.View(s)
+
+	view := rc.Render()
+
+	cur := s.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (s *Session) ShortHelp() []key.Binding {
+	return []key.Binding{
+		s.keyMap.UpDown,
+		s.keyMap.Select,
+		s.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (s *Session) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		s.keyMap.Select,
+		s.keyMap.Next,
+		s.keyMap.Previous,
+		s.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}

internal/ui/dialog/sessions_item.go πŸ”—

@@ -0,0 +1,187 @@
+package dialog
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/dustin/go-humanize"
+	"github.com/rivo/uniseg"
+	"github.com/sahilm/fuzzy"
+)
+
+// ListItem represents a selectable and searchable item in a dialog list.
+type ListItem interface {
+	list.FilterableItem
+	list.Focusable
+	list.MatchSettable
+
+	// ID returns the unique identifier of the item.
+	ID() string
+}
+
+// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
+type SessionItem struct {
+	session.Session
+	t       *styles.Styles
+	m       fuzzy.Match
+	cache   map[int]string
+	focused bool
+}
+
+var _ ListItem = &SessionItem{}
+
+// Filter returns the filterable value of the session.
+func (s *SessionItem) Filter() string {
+	return s.Title
+}
+
+// ID returns the unique identifier of the session.
+func (s *SessionItem) ID() string {
+	return s.Session.ID
+}
+
+// SetMatch sets the fuzzy match for the session item.
+func (s *SessionItem) SetMatch(m fuzzy.Match) {
+	s.cache = nil
+	s.m = m
+}
+
+// Render returns the string representation of the session item.
+func (s *SessionItem) Render(width int) string {
+	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
+	return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
+}
+
+func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+	if cache == nil {
+		cache = make(map[int]string)
+	}
+
+	cached, ok := cache[width]
+	if ok {
+		return cached
+	}
+
+	style := t.Dialog.NormalItem
+	if focused {
+		style = t.Dialog.SelectedItem
+	}
+
+	var infoText string
+	var infoWidth int
+	lineWidth := width
+	if len(info) > 0 {
+		infoText = fmt.Sprintf(" %s ", info)
+		if focused {
+			infoText = t.Base.Render(infoText)
+		} else {
+			infoText = t.Subtle.Render(infoText)
+		}
+
+		infoWidth = lipgloss.Width(infoText)
+	}
+
+	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
+	titleWidth := lipgloss.Width(title)
+	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
+	content := title
+	if matches := len(m.MatchedIndexes); matches > 0 {
+		var lastPos int
+		parts := make([]string, 0)
+		ranges := matchedRanges(m.MatchedIndexes)
+		for _, rng := range ranges {
+			start, stop := bytePosToVisibleCharPos(title, rng)
+			if start > lastPos {
+				parts = append(parts, title[lastPos:start])
+			}
+			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
+			// because we can control the underline start and stop more
+			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
+			// which only affect the underline attribute without interfering
+			// with other style
+			parts = append(parts,
+				ansi.NewStyle().Underline(true).String(),
+				title[start:stop+1],
+				ansi.NewStyle().Underline(false).String(),
+			)
+			lastPos = stop + 1
+		}
+		if lastPos < len(title) {
+			parts = append(parts, title[lastPos:])
+		}
+
+		content = strings.Join(parts, "")
+	}
+
+	content = style.Render(content + gap + infoText)
+	cache[width] = content
+	return content
+}
+
+// SetFocused sets the focus state of the session item.
+func (s *SessionItem) SetFocused(focused bool) {
+	if s.focused != focused {
+		s.cache = nil
+	}
+	s.focused = focused
+}
+
+// sessionItems takes a slice of [session.Session]s and convert them to a slice
+// of [ListItem]s.
+func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
+	items := make([]list.FilterableItem, len(sessions))
+	for i, s := range sessions {
+		items[i] = &SessionItem{Session: s, t: t}
+	}
+	return items
+}
+
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}

internal/ui/image/image.go πŸ”—

@@ -0,0 +1,299 @@
+package image
+
+import (
+	"bytes"
+	"fmt"
+	"hash/fnv"
+	"image"
+	"image/color"
+	"io"
+	"log/slog"
+	"strings"
+	"sync"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/kitty"
+	"github.com/charmbracelet/x/mosaic"
+	"github.com/disintegration/imaging"
+)
+
+// Capabilities represents the capabilities of displaying images on the
+// terminal.
+type Capabilities struct {
+	// Columns is the number of character columns in the terminal.
+	Columns int
+	// Rows is the number of character rows in the terminal.
+	Rows int
+	// PixelWidth is the width of the terminal in pixels.
+	PixelWidth int
+	// PixelHeight is the height of the terminal in pixels.
+	PixelHeight int
+	// SupportsKittyGraphics indicates whether the terminal supports the Kitty
+	// graphics protocol.
+	SupportsKittyGraphics bool
+	// Env is the terminal environment variables.
+	Env uv.Environ
+}
+
+// CellSize returns the size of a single terminal cell in pixels.
+func (c Capabilities) CellSize() CellSize {
+	return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
+}
+
+// CalculateCellSize calculates the size of a single terminal cell in pixels
+// based on the terminal's pixel dimensions and character dimensions.
+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
+	if charWidth == 0 || charHeight == 0 {
+		return CellSize{}
+	}
+
+	return CellSize{
+		Width:  pixelWidth / charWidth,
+		Height: pixelHeight / charHeight,
+	}
+}
+
+// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
+// its image related capabilities to the program.
+func RequestCapabilities(env uv.Environ) tea.Cmd {
+	winOpReq := ansi.WindowOp(14) // Window size in pixels
+	// ID 31 is just a random ID used to detect Kitty graphics support.
+	kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
+	if _, isTmux := env.LookupEnv("TMUX"); isTmux {
+		kittyReq = ansi.TmuxPassthrough(kittyReq)
+	}
+
+	return tea.Raw(winOpReq + kittyReq)
+}
+
+// TransmittedMsg is a message indicating that an image has been transmitted to
+// the terminal.
+type TransmittedMsg struct {
+	ID string
+}
+
+// Encoding represents the encoding format of the image.
+type Encoding byte
+
+// Image encodings.
+const (
+	EncodingBlocks Encoding = iota
+	EncodingKitty
+)
+
+type imageKey struct {
+	id   string
+	cols int
+	rows int
+}
+
+// Hash returns a hash value for the image key.
+// This uses FNV-32a for simplicity and speed.
+func (k imageKey) Hash() uint32 {
+	h := fnv.New32a()
+	_, _ = io.WriteString(h, k.ID())
+	return h.Sum32()
+}
+
+// ID returns a unique string representation of the image key.
+func (k imageKey) ID() string {
+	return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
+}
+
+// CellSize represents the size of a single terminal cell in pixels.
+type CellSize struct {
+	Width, Height int
+}
+
+type cachedImage struct {
+	img        image.Image
+	cols, rows int
+}
+
+var (
+	cachedImages = map[imageKey]cachedImage{}
+	cachedMutex  sync.RWMutex
+)
+
+// fitImage resizes the image to fit within the specified dimensions in
+// terminal cells, maintaining the aspect ratio.
+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return cached.img
+	}
+
+	if cs.Width == 0 || cs.Height == 0 {
+		return img
+	}
+
+	maxWidth := cols * cs.Width
+	maxHeight := rows * cs.Height
+
+	img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
+
+	cachedMutex.Lock()
+	cachedImages[key] = cachedImage{
+		img:  img,
+		cols: cols,
+		rows: rows,
+	}
+	cachedMutex.Unlock()
+
+	return img
+}
+
+// HasTransmitted checks if the image with the given ID has already been
+// transmitted to the terminal.
+func HasTransmitted(id string, cols, rows int) bool {
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	return ok
+}
+
+// Transmit transmits the image data to the terminal if needed. This is used to
+// cache the image on the terminal for later rendering.
+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return nil
+	}
+
+	cmd := func() tea.Msg {
+		if e != EncodingKitty {
+			cachedMutex.Lock()
+			cachedImages[key] = cachedImage{
+				img:  img,
+				cols: cols,
+				rows: rows,
+			}
+			cachedMutex.Unlock()
+			return TransmittedMsg{ID: key.ID()}
+		}
+
+		var buf bytes.Buffer
+		img := fitImage(id, img, cs, cols, rows)
+		bounds := img.Bounds()
+		imgWidth := bounds.Dx()
+		imgHeight := bounds.Dy()
+		imgID := int(key.Hash())
+		if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+			ID:               imgID,
+			Action:           kitty.TransmitAndPut,
+			Transmission:     kitty.Direct,
+			Format:           kitty.RGBA,
+			ImageWidth:       imgWidth,
+			ImageHeight:      imgHeight,
+			Columns:          cols,
+			Rows:             rows,
+			VirtualPlacement: true,
+			Quite:            1,
+			Chunk:            true,
+			ChunkFormatter: func(chunk string) string {
+				if tmux {
+					return ansi.TmuxPassthrough(chunk)
+				}
+				return chunk
+			},
+		}); err != nil {
+			slog.Error("failed to encode image for kitty graphics", "err", err)
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "failed to encode image",
+			}
+		}
+
+		return tea.RawMsg{Msg: buf.String()}
+	}
+
+	return cmd
+}
+
+// Render renders the given image within the specified dimensions using the
+// specified encoding.
+func (e Encoding) Render(id string, cols, rows int) string {
+	key := imageKey{id: id, cols: cols, rows: rows}
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if !ok {
+		return ""
+	}
+
+	img := cached.img
+
+	switch e {
+	case EncodingBlocks:
+		m := mosaic.New().Width(cols).Height(rows).Scale(1)
+		return strings.TrimSpace(m.Render(img))
+	case EncodingKitty:
+		// Build Kitty graphics unicode place holders
+		var fg color.Color
+		var extra int
+		var r, g, b int
+		hashedID := key.Hash()
+		id := int(hashedID)
+		extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
+
+		if id <= 255 {
+			fg = ansi.IndexedColor(b)
+		} else {
+			fg = color.RGBA{
+				R: uint8(r), //nolint:gosec
+				G: uint8(g), //nolint:gosec
+				B: uint8(b), //nolint:gosec
+				A: 0xff,
+			}
+		}
+
+		fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
+
+		var buf bytes.Buffer
+		for y := range rows {
+			// As an optimization, we only write the fg color sequence id, and
+			// column-row data once on the first cell. The terminal will handle
+			// the rest.
+			buf.WriteString(fgStyle)
+			buf.WriteRune(kitty.Placeholder)
+			buf.WriteRune(kitty.Diacritic(y))
+			buf.WriteRune(kitty.Diacritic(0))
+			if extra > 0 {
+				buf.WriteRune(kitty.Diacritic(extra))
+			}
+			for x := 1; x < cols; x++ {
+				buf.WriteString(fgStyle)
+				buf.WriteRune(kitty.Placeholder)
+			}
+			if y < rows-1 {
+				buf.WriteByte('\n')
+			}
+		}
+
+		return buf.String()
+
+	default:
+		return ""
+	}
+}

internal/ui/list/filterable.go πŸ”—

@@ -0,0 +1,125 @@
+package list
+
+import (
+	"github.com/sahilm/fuzzy"
+)
+
+// FilterableItem is an item that can be filtered via a query.
+type FilterableItem interface {
+	Item
+	// Filter returns the value to be used for filtering.
+	Filter() string
+}
+
+// MatchSettable is an interface for items that can have their match indexes
+// and match score set.
+type MatchSettable interface {
+	SetMatch(fuzzy.Match)
+}
+
+// FilterableList is a list that takes filterable items that can be filtered
+// via a settable query.
+type FilterableList struct {
+	*List
+	items []FilterableItem
+	query string
+}
+
+// NewFilterableList creates a new filterable list.
+func NewFilterableList(items ...FilterableItem) *FilterableList {
+	f := &FilterableList{
+		List:  NewList(),
+		items: items,
+	}
+	f.RegisterRenderCallback(FocusedRenderCallback(f.List))
+	f.SetItems(items...)
+	return f
+}
+
+// SetItems sets the list items and updates the filtered items.
+func (f *FilterableList) SetItems(items ...FilterableItem) {
+	f.items = items
+	fitems := make([]Item, len(items))
+	for i, item := range items {
+		fitems[i] = item
+	}
+	f.List.SetItems(fitems...)
+}
+
+// AppendItems appends items to the list and updates the filtered items.
+func (f *FilterableList) AppendItems(items ...FilterableItem) {
+	f.items = append(f.items, items...)
+	itms := make([]Item, len(f.items))
+	for i, item := range f.items {
+		itms[i] = item
+	}
+	f.List.SetItems(itms...)
+}
+
+// PrependItems prepends items to the list and updates the filtered items.
+func (f *FilterableList) PrependItems(items ...FilterableItem) {
+	f.items = append(items, f.items...)
+	itms := make([]Item, len(f.items))
+	for i, item := range f.items {
+		itms[i] = item
+	}
+	f.List.SetItems(itms...)
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *FilterableList) SetFilter(q string) {
+	f.query = q
+	f.List.SetItems(f.VisibleItems()...)
+	f.ScrollToTop()
+}
+
+// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering
+// [FilterableItem]s.
+type FilterableItemsSource []FilterableItem
+
+// Len returns the length of the source.
+func (f FilterableItemsSource) Len() int {
+	return len(f)
+}
+
+// String returns the string representation of the item at index i.
+func (f FilterableItemsSource) String(i int) string {
+	return f[i].Filter()
+}
+
+// VisibleItems returns the visible items after filtering.
+func (f *FilterableList) VisibleItems() []Item {
+	if f.query == "" {
+		items := make([]Item, len(f.items))
+		for i, item := range f.items {
+			if ms, ok := item.(MatchSettable); ok {
+				ms.SetMatch(fuzzy.Match{})
+				item = ms.(FilterableItem)
+			}
+			items[i] = item
+		}
+		return items
+	}
+
+	items := FilterableItemsSource(f.items)
+	matches := fuzzy.FindFrom(f.query, items)
+	matchedItems := []Item{}
+	resultSize := len(matches)
+	for i := range resultSize {
+		match := matches[i]
+		item := items[match.Index]
+		if ms, ok := item.(MatchSettable); ok {
+			ms.SetMatch(match)
+			item = ms.(FilterableItem)
+		}
+		matchedItems = append(matchedItems, item)
+	}
+
+	return matchedItems
+}
+
+// Render renders the filterable list.
+func (f *FilterableList) Render() string {
+	f.List.SetItems(f.VisibleItems()...)
+	return f.List.Render()
+}

internal/ui/list/focus.go πŸ”—

@@ -0,0 +1,13 @@
+package list
+
+// FocusedRenderCallback is a helper function that returns a render callback
+// that marks items as focused during rendering.
+func FocusedRenderCallback(list *List) RenderCallback {
+	return func(idx, selectedIdx int, item Item) Item {
+		if focusable, ok := item.(Focusable); ok {
+			focusable.SetFocused(list.Focused() && idx == selectedIdx)
+			return focusable.(Item)
+		}
+		return item
+	}
+}

internal/ui/list/highlight.go πŸ”—

@@ -0,0 +1,208 @@
+package list
+
+import (
+	"image"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// DefaultHighlighter is the default highlighter function that applies inverse style.
+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
+	if c == nil {
+		return c
+	}
+	c.Style.Attrs |= uv.AttrReverse
+	return c
+}
+
+// Highlighter represents a function that defines how to highlight text.
+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
+
+// HighlightContent returns the content with highlighted regions based on the specified parameters.
+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
+	var sb strings.Builder
+	pos := image.Pt(-1, -1)
+	HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
+		pos.X = x
+		if pos.Y == -1 {
+			pos.Y = y
+		} else if y > pos.Y {
+			sb.WriteString(strings.Repeat("\n", y-pos.Y))
+			pos.Y = y
+		}
+		sb.WriteString(c.Content)
+		return c
+	})
+	if sb.Len() > 0 {
+		sb.WriteString("\n")
+	}
+	return sb.String()
+}
+
+// Highlight highlights a region of text within the given content and region.
+func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
+	buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
+	if buf == nil {
+		return content
+	}
+	return buf.Render()
+}
+
+// HighlightBuffer highlights a region of text within the given content and
+// region, returning a [uv.ScreenBuffer].
+func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	if startLine < 0 || startCol < 0 {
+		return nil
+	}
+
+	if highlighter == nil {
+		highlighter = DefaultHighlighter
+	}
+
+	width, height := area.Dx(), area.Dy()
+	buf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(content)
+	styled.Draw(&buf, area)
+
+	// Treat -1 as "end of content"
+	if endLine < 0 {
+		endLine = height - 1
+	}
+	if endCol < 0 {
+		endCol = width
+	}
+
+	for y := startLine; y <= endLine && y < height; y++ {
+		if y >= buf.Height() {
+			break
+		}
+
+		line := buf.Line(y)
+
+		// Determine column range for this line
+		colStart := 0
+		if y == startLine {
+			colStart = min(startCol, len(line))
+		}
+
+		colEnd := len(line)
+		if y == endLine {
+			colEnd = min(endCol, len(line))
+		}
+
+		// Track last non-empty position as we go
+		lastContentX := -1
+
+		// Single pass: check content and track last non-empty position
+		for x := colStart; x < colEnd; x++ {
+			cell := line.At(x)
+			if cell == nil {
+				continue
+			}
+
+			// Update last content position if non-empty
+			if cell.Content != "" && cell.Content != " " {
+				lastContentX = x
+			}
+		}
+
+		// Only apply highlight up to last content position
+		highlightEnd := colEnd
+		if lastContentX >= 0 {
+			highlightEnd = lastContentX + 1
+		} else if lastContentX == -1 {
+			highlightEnd = colStart // No content on this line
+		}
+
+		// Apply highlight style only to cells with content
+		for x := colStart; x < highlightEnd; x++ {
+			if !image.Pt(x, y).In(area) {
+				continue
+			}
+			cell := line.At(x)
+			if cell != nil {
+				line.Set(x, highlighter(x, y, cell))
+			}
+		}
+	}
+
+	return &buf
+}
+
+// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
+func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
+	return func(_ int, _ int, c *uv.Cell) *uv.Cell {
+		if c != nil {
+			c.Style = ToStyle(lgStyle)
+		}
+		return c
+	}
+}
+
+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
+func ToStyle(lgStyle lipgloss.Style) uv.Style {
+	var uvStyle uv.Style
+
+	// Colors are already color.Color
+	uvStyle.Fg = lgStyle.GetForeground()
+	uvStyle.Bg = lgStyle.GetBackground()
+
+	// Build attributes using bitwise OR
+	var attrs uint8
+
+	if lgStyle.GetBold() {
+		attrs |= uv.AttrBold
+	}
+
+	if lgStyle.GetItalic() {
+		attrs |= uv.AttrItalic
+	}
+
+	if lgStyle.GetUnderline() {
+		uvStyle.Underline = uv.UnderlineSingle
+	}
+
+	if lgStyle.GetStrikethrough() {
+		attrs |= uv.AttrStrikethrough
+	}
+
+	if lgStyle.GetFaint() {
+		attrs |= uv.AttrFaint
+	}
+
+	if lgStyle.GetBlink() {
+		attrs |= uv.AttrBlink
+	}
+
+	if lgStyle.GetReverse() {
+		attrs |= uv.AttrReverse
+	}
+
+	uvStyle.Attrs = attrs
+
+	return uvStyle
+}
+
+// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
+// and padding from the style.
+func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
+	topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
+	topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
+		style.GetBorderRightSize(),
+		style.GetBorderBottomSize(),
+		style.GetBorderLeftSize()
+	topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
+
+	return image.Rectangle{
+		Min: image.Point{
+			X: area.Min.X + leftMargin + leftBorder + leftPadding,
+			Y: area.Min.Y + topMargin + topBorder + topPadding,
+		},
+		Max: image.Point{
+			X: area.Max.X - (rightMargin + rightBorder + rightPadding),
+			Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
+		},
+	}
+}

internal/ui/list/item.go πŸ”—

@@ -0,0 +1,61 @@
+package list
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+// Item represents a single item in the lazy-loaded list.
+type Item interface {
+	// Render returns the string representation of the item for the given
+	// width.
+	Render(width int) string
+}
+
+// RawRenderable represents an item that can provide a raw rendering
+// without additional styling.
+type RawRenderable interface {
+	// RawRender returns the raw rendered string without any additional
+	// styling.
+	RawRender(width int) string
+}
+
+// Focusable represents an item that can be aware of focus state changes.
+type Focusable interface {
+	// SetFocused sets the focus state of the item.
+	SetFocused(focused bool)
+}
+
+// Highlightable represents an item that can highlight a portion of its content.
+type Highlightable interface {
+	// SetHighlight highlights the content from the given start to end
+	// positions. Use -1 for no highlight.
+	SetHighlight(startLine, startCol, endLine, endCol int)
+	// Highlight returns the current highlight positions within the item.
+	Highlight() (startLine, startCol, endLine, endCol int)
+}
+
+// MouseClickable represents an item that can handle mouse click events.
+type MouseClickable interface {
+	// HandleMouseClick processes a mouse click event at the given coordinates.
+	// It returns true if the event was handled, false otherwise.
+	HandleMouseClick(btn ansi.MouseButton, x, y int) bool
+}
+
+// SpacerItem is a spacer item that adds vertical space in the list.
+type SpacerItem struct {
+	Height int
+}
+
+// NewSpacerItem creates a new [SpacerItem] with the specified height.
+func NewSpacerItem(height int) *SpacerItem {
+	return &SpacerItem{
+		Height: max(0, height-1),
+	}
+}
+
+// Render implements the Item interface for [SpacerItem].
+func (s *SpacerItem) Render(width int) string {
+	return strings.Repeat("\n", s.Height)
+}

internal/ui/list/list.go πŸ”—

@@ -0,0 +1,634 @@
+package list
+
+import (
+	"strings"
+)
+
+// List represents a list of items that can be lazily rendered. A list is
+// always rendered like a chat conversation where items are stacked vertically
+// from top to bottom.
+type List struct {
+	// Viewport size
+	width, height int
+
+	// Items in the list
+	items []Item
+
+	// Gap between items (0 or less means no gap)
+	gap int
+
+	// show list in reverse order
+	reverse bool
+
+	// Focus and selection state
+	focused     bool
+	selectedIdx int // The current selected index -1 means no selection
+
+	// offsetIdx is the index of the first visible item in the viewport.
+	offsetIdx int
+	// offsetLine is the number of lines of the item at offsetIdx that are
+	// scrolled out of view (above the viewport).
+	// It must always be >= 0.
+	offsetLine int
+
+	// renderCallbacks is a list of callbacks to apply when rendering items.
+	renderCallbacks []func(idx, selectedIdx int, item Item) Item
+}
+
+// renderedItem holds the rendered content and height of an item.
+type renderedItem struct {
+	content string
+	height  int
+}
+
+// NewList creates a new lazy-loaded list.
+func NewList(items ...Item) *List {
+	l := new(List)
+	l.items = items
+	l.selectedIdx = -1
+	return l
+}
+
+// RenderCallback defines a function that can modify an item before it is
+// rendered.
+type RenderCallback func(idx, selectedIdx int, item Item) Item
+
+// RegisterRenderCallback registers a callback to be called when rendering
+// items. This can be used to modify items before they are rendered.
+func (l *List) RegisterRenderCallback(cb RenderCallback) {
+	l.renderCallbacks = append(l.renderCallbacks, cb)
+}
+
+// SetSize sets the size of the list viewport.
+func (l *List) SetSize(width, height int) {
+	l.width = width
+	l.height = height
+}
+
+// SetGap sets the gap between items.
+func (l *List) SetGap(gap int) {
+	l.gap = gap
+}
+
+// Gap returns the gap between items.
+func (l *List) Gap() int {
+	return l.gap
+}
+
+// SetReverse shows the list in reverse order.
+func (l *List) SetReverse(reverse bool) {
+	l.reverse = reverse
+}
+
+// Width returns the width of the list viewport.
+func (l *List) Width() int {
+	return l.width
+}
+
+// Height returns the height of the list viewport.
+func (l *List) Height() int {
+	return l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+	return len(l.items)
+}
+
+// getItem renders (if needed) and returns the item at the given index.
+func (l *List) getItem(idx int) renderedItem {
+	if idx < 0 || idx >= len(l.items) {
+		return renderedItem{}
+	}
+
+	item := l.items[idx]
+	if len(l.renderCallbacks) > 0 {
+		for _, cb := range l.renderCallbacks {
+			if it := cb(idx, l.selectedIdx, item); it != nil {
+				item = it
+			}
+		}
+	}
+
+	rendered := item.Render(l.width)
+	rendered = strings.TrimRight(rendered, "\n")
+	height := countLines(rendered)
+	ri := renderedItem{
+		content: rendered,
+		height:  height,
+	}
+
+	return ri
+}
+
+// ScrollToIndex scrolls the list to the given item index.
+func (l *List) ScrollToIndex(index int) {
+	if index < 0 {
+		index = 0
+	}
+	if index >= len(l.items) {
+		index = len(l.items) - 1
+	}
+	l.offsetIdx = index
+	l.offsetLine = 0
+}
+
+// ScrollBy scrolls the list by the given number of lines.
+func (l *List) ScrollBy(lines int) {
+	if len(l.items) == 0 || lines == 0 {
+		return
+	}
+
+	if l.reverse {
+		lines = -lines
+	}
+
+	if lines > 0 {
+		// Scroll down
+		// Calculate from the bottom how many lines needed to anchor the last
+		// item to the bottom
+		var totalLines int
+		var lastItemIdx int // the last item that can be partially visible
+		for i := len(l.items) - 1; i >= 0; i-- {
+			item := l.getItem(i)
+			totalLines += item.height
+			if l.gap > 0 && i < len(l.items)-1 {
+				totalLines += l.gap
+			}
+			if totalLines > l.height-1 {
+				lastItemIdx = i
+				break
+			}
+		}
+
+		// Now scroll down by lines
+		var item renderedItem
+		l.offsetLine += lines
+		for {
+			item = l.getItem(l.offsetIdx)
+			totalHeight := item.height
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+
+			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
+				// Valid offset
+				break
+			}
+
+			// Move to next item
+			l.offsetLine -= totalHeight
+			l.offsetIdx++
+		}
+
+		if l.offsetLine >= item.height {
+			l.offsetLine = item.height
+		}
+	} else if lines < 0 {
+		// Scroll up
+		l.offsetLine += lines // lines is negative
+		for l.offsetLine < 0 {
+			if l.offsetIdx <= 0 {
+				// Reached top
+				l.ScrollToTop()
+				break
+			}
+
+			// Move to previous item
+			l.offsetIdx--
+			prevItem := l.getItem(l.offsetIdx)
+			totalHeight := prevItem.height
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+			l.offsetLine += totalHeight
+		}
+	}
+}
+
+// VisibleItemIndices finds the range of items that are visible in the viewport.
+// This is used for checking if selected item is in view.
+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
+	if len(l.items) == 0 {
+		return 0, 0
+	}
+
+	startIdx = l.offsetIdx
+	currentIdx := startIdx
+	visibleHeight := -l.offsetLine
+
+	for currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		visibleHeight += item.height
+		if l.gap > 0 {
+			visibleHeight += l.gap
+		}
+
+		if visibleHeight >= l.height {
+			break
+		}
+		currentIdx++
+	}
+
+	endIdx = currentIdx
+	if endIdx >= len(l.items) {
+		endIdx = len(l.items) - 1
+	}
+
+	return startIdx, endIdx
+}
+
+// Render renders the list and returns the visible lines.
+func (l *List) Render() string {
+	if len(l.items) == 0 {
+		return ""
+	}
+
+	var lines []string
+	currentIdx := l.offsetIdx
+	currentOffset := l.offsetLine
+
+	linesNeeded := l.height
+
+	for linesNeeded > 0 && currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		itemLines := strings.Split(item.content, "\n")
+		itemHeight := len(itemLines)
+
+		if currentOffset >= 0 && currentOffset < itemHeight {
+			// Add visible content lines
+			lines = append(lines, itemLines[currentOffset:]...)
+
+			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
+			// But in the loop we can just add it and trim later
+			if l.gap > 0 {
+				for i := 0; i < l.gap; i++ {
+					lines = append(lines, "")
+				}
+			}
+		} else {
+			// offsetLine starts in the gap
+			gapOffset := currentOffset - itemHeight
+			gapRemaining := l.gap - gapOffset
+			if gapRemaining > 0 {
+				for range gapRemaining {
+					lines = append(lines, "")
+				}
+			}
+		}
+
+		linesNeeded = l.height - len(lines)
+		currentIdx++
+		currentOffset = 0 // Reset offset for subsequent items
+	}
+
+	if len(lines) > l.height {
+		lines = lines[:l.height]
+	}
+
+	if l.reverse {
+		// Reverse the lines so the list renders bottom-to-top.
+		for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
+			lines[i], lines[j] = lines[j], lines[i]
+		}
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// PrependItems prepends items to the list.
+func (l *List) PrependItems(items ...Item) {
+	l.items = append(items, l.items...)
+
+	// Keep view position relative to the content that was visible
+	l.offsetIdx += len(items)
+
+	// Update selection index if valid
+	if l.selectedIdx != -1 {
+		l.selectedIdx += len(items)
+	}
+}
+
+// SetItems sets the items in the list.
+func (l *List) SetItems(items ...Item) {
+	l.setItems(true, items...)
+}
+
+// setItems sets the items in the list. If evict is true, it clears the
+// rendered item cache.
+func (l *List) setItems(evict bool, items ...Item) {
+	l.items = items
+	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
+	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
+	l.offsetLine = 0
+}
+
+// AppendItems appends items to the list.
+func (l *List) AppendItems(items ...Item) {
+	l.items = append(l.items, items...)
+}
+
+// RemoveItem removes the item at the given index from the list.
+func (l *List) RemoveItem(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	// Remove the item
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Adjust selection if needed
+	if l.selectedIdx == idx {
+		l.selectedIdx = -1
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	// Adjust offset if needed
+	if l.offsetIdx > idx {
+		l.offsetIdx--
+	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
+		l.offsetIdx = max(0, len(l.items)-1)
+		l.offsetLine = 0
+	}
+}
+
+// Focused returns whether the list is focused.
+func (l *List) Focused() bool {
+	return l.focused
+}
+
+// Focus sets the focus state of the list.
+func (l *List) Focus() {
+	l.focused = true
+}
+
+// Blur removes the focus state from the list.
+func (l *List) Blur() {
+	l.focused = false
+}
+
+// ScrollToTop scrolls the list to the top.
+func (l *List) ScrollToTop() {
+	l.offsetIdx = 0
+	l.offsetLine = 0
+}
+
+// ScrollToBottom scrolls the list to the bottom.
+func (l *List) ScrollToBottom() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	// Scroll to the last item
+	var totalHeight int
+	for i := len(l.items) - 1; i >= 0; i-- {
+		item := l.getItem(i)
+		totalHeight += item.height
+		if l.gap > 0 && i < len(l.items)-1 {
+			totalHeight += l.gap
+		}
+		if totalHeight >= l.height {
+			l.offsetIdx = i
+			l.offsetLine = totalHeight - l.height
+			break
+		}
+	}
+	if totalHeight < l.height {
+		// All items fit in the viewport
+		l.ScrollToTop()
+	}
+}
+
+// ScrollToSelected scrolls the list to the selected item.
+func (l *List) ScrollToSelected() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	startIdx, endIdx := l.VisibleItemIndices()
+	if l.selectedIdx < startIdx {
+		// Selected item is above the visible range
+		l.offsetIdx = l.selectedIdx
+		l.offsetLine = 0
+	} else if l.selectedIdx > endIdx {
+		// Selected item is below the visible range
+		// Scroll so that the selected item is at the bottom
+		var totalHeight int
+		for i := l.selectedIdx; i >= 0; i-- {
+			item := l.getItem(i)
+			totalHeight += item.height
+			if l.gap > 0 && i < l.selectedIdx {
+				totalHeight += l.gap
+			}
+			if totalHeight >= l.height {
+				l.offsetIdx = i
+				l.offsetLine = totalHeight - l.height
+				break
+			}
+		}
+		if totalHeight < l.height {
+			// All items fit in the viewport
+			l.ScrollToTop()
+		}
+	}
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (l *List) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+	startIdx, endIdx := l.VisibleItemIndices()
+	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
+}
+
+// SetSelected sets the selected item index in the list.
+// It returns -1 if the index is out of bounds.
+func (l *List) SetSelected(index int) {
+	if index < 0 || index >= len(l.items) {
+		l.selectedIdx = -1
+	} else {
+		l.selectedIdx = index
+	}
+}
+
+// Selected returns the index of the currently selected item. It returns -1 if
+// no item is selected.
+func (l *List) Selected() int {
+	return l.selectedIdx
+}
+
+// IsSelectedFirst returns whether the first item is selected.
+func (l *List) IsSelectedFirst() bool {
+	return l.selectedIdx == 0
+}
+
+// IsSelectedLast returns whether the last item is selected.
+func (l *List) IsSelectedLast() bool {
+	return l.selectedIdx == len(l.items)-1
+}
+
+// SelectPrev selects the visually previous item (moves toward visual top).
+// It returns whether the selection changed.
+func (l *List) SelectPrev() bool {
+	if l.reverse {
+		// In reverse, visual up = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
+	} else {
+		// Normal: visual up = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
+	}
+	return false
+}
+
+// SelectNext selects the next item in the list.
+// It returns whether the selection changed.
+func (l *List) SelectNext() bool {
+	if l.reverse {
+		// In reverse, visual down = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
+	} else {
+		// Normal: visual down = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
+	}
+	return false
+}
+
+// SelectFirst selects the first item in the list.
+// It returns whether the selection changed.
+func (l *List) SelectFirst() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	l.selectedIdx = 0
+	return true
+}
+
+// SelectLast selects the last item in the list (highest index).
+// It returns whether the selection changed.
+func (l *List) SelectLast() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	l.selectedIdx = len(l.items) - 1
+	return true
+}
+
+// WrapToStart wraps selection to the visual start (for circular navigation).
+// In normal mode, this is index 0. In reverse mode, this is the highest index.
+func (l *List) WrapToStart() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
+		l.selectedIdx = len(l.items) - 1
+	} else {
+		l.selectedIdx = 0
+	}
+	return true
+}
+
+// WrapToEnd wraps selection to the visual end (for circular navigation).
+// In normal mode, this is the highest index. In reverse mode, this is index 0.
+func (l *List) WrapToEnd() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
+		l.selectedIdx = 0
+	} else {
+		l.selectedIdx = len(l.items) - 1
+	}
+	return true
+}
+
+// SelectedItem returns the currently selected item. It may be nil if no item
+// is selected.
+func (l *List) SelectedItem() Item {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return nil
+	}
+	return l.items[l.selectedIdx]
+}
+
+// SelectFirstInView selects the first item currently in view.
+func (l *List) SelectFirstInView() {
+	startIdx, _ := l.VisibleItemIndices()
+	l.selectedIdx = startIdx
+}
+
+// SelectLastInView selects the last item currently in view.
+func (l *List) SelectLastInView() {
+	_, endIdx := l.VisibleItemIndices()
+	l.selectedIdx = endIdx
+}
+
+// ItemAt returns the item at the given index.
+func (l *List) ItemAt(index int) Item {
+	if index < 0 || index >= len(l.items) {
+		return nil
+	}
+	return l.items[index]
+}
+
+// ItemIndexAtPosition returns the item at the given viewport-relative y
+// coordinate. Returns the item index and the y offset within that item. It
+// returns -1, -1 if no item is found.
+func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
+	return l.findItemAtY(x, y)
+}
+
+// findItemAtY finds the item at the given viewport y coordinate.
+// Returns the item index and the y offset within that item. It returns -1, -1
+// if no item is found.
+func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
+	if y < 0 || y >= l.height {
+		return -1, -1
+	}
+
+	// Walk through visible items to find which one contains this y
+	currentIdx := l.offsetIdx
+	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
+
+	for currentIdx < len(l.items) && currentLine < l.height {
+		item := l.getItem(currentIdx)
+		itemEndLine := currentLine + item.height
+
+		// Check if y is within this item's visible range
+		if y >= currentLine && y < itemEndLine {
+			// Found the item, calculate itemY (offset within the item)
+			itemY = y - currentLine
+			return currentIdx, itemY
+		}
+
+		// Move to next item
+		currentLine = itemEndLine
+		if l.gap > 0 {
+			currentLine += l.gap
+		}
+		currentIdx++
+	}
+
+	return -1, -1
+}
+
+// countLines counts the number of lines in a string.
+func countLines(s string) int {
+	if s == "" {
+		return 1
+	}
+	return strings.Count(s, "\n") + 1
+}

internal/ui/logo/logo.go πŸ”—

@@ -0,0 +1,346 @@
+// Package logo renders a Crush wordmark in a stylized way.
+package logo
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/MakeNowJust/heredoc"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/slice"
+)
+
+// letterform represents a letterform. It can be stretched horizontally by
+// a given amount via the boolean argument.
+type letterform func(bool) string
+
+const diag = `β•±`
+
+// Opts are the options for rendering the Crush title art.
+type Opts struct {
+	FieldColor   color.Color // diagonal lines
+	TitleColorA  color.Color // left gradient ramp point
+	TitleColorB  color.Color // right gradient ramp point
+	CharmColor   color.Color // Charmβ„’ text color
+	VersionColor color.Color // Version text color
+	Width        int         // width of the rendered logo, used for truncation
+}
+
+// Render renders the Crush logo. Set the argument to true to render the narrow
+// version, intended for use in a sidebar.
+//
+// The compact argument determines whether it renders compact for the sidebar
+// or wider for the main pane.
+func Render(version string, compact bool, o Opts) string {
+	const charm = " Charmβ„’"
+
+	fg := func(c color.Color, s string) string {
+		return lipgloss.NewStyle().Foreground(c).Render(s)
+	}
+
+	// Title.
+	const spacing = 1
+	letterforms := []letterform{
+		letterC,
+		letterR,
+		letterU,
+		letterSStylized,
+		letterH,
+	}
+	stretchIndex := -1 // -1 means no stretching.
+	if !compact {
+		stretchIndex = cachedRandN(len(letterforms))
+	}
+
+	crush := renderWord(spacing, stretchIndex, letterforms...)
+	crushWidth := lipgloss.Width(crush)
+	b := new(strings.Builder)
+	for r := range strings.SplitSeq(crush, "\n") {
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+	}
+	crush = b.String()
+
+	// Charm and version.
+	metaRowGap := 1
+	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
+	version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
+	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
+	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
+
+	// Join the meta row and big Crush title.
+	crush = strings.TrimSpace(metaRow + "\n" + crush)
+
+	// Narrow version.
+	if compact {
+		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
+		return strings.Join([]string{field, field, crush, field, ""}, "\n")
+	}
+
+	fieldHeight := lipgloss.Height(crush)
+
+	// Left field.
+	const leftWidth = 6
+	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
+	leftField := new(strings.Builder)
+	for range fieldHeight {
+		fmt.Fprintln(leftField, leftFieldRow)
+	}
+
+	// Right field.
+	rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
+	const stepDownAt = 0
+	rightField := new(strings.Builder)
+	for i := range fieldHeight {
+		width := rightWidth
+		if i >= stepDownAt {
+			width = rightWidth - (i - stepDownAt)
+		}
+		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
+	}
+
+	// Return the wide version.
+	const hGap = " "
+	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
+}
+
+// SmallRender renders a smaller version of the Crush logo, suitable for
+// smaller windows or sidebar usage.
+func SmallRender(width int) string {
+	t := styles.CurrentTheme()
+	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ„’")
+	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
+	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
+	if remainingWidth > 0 {
+		lines := strings.Repeat("β•±", remainingWidth)
+		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
+	}
+	return title
+}
+
+// renderWord renders letterforms to fork a word. stretchIndex is the index of
+// the letter to stretch, or -1 if no letter should be stretched.
+func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
+	if spacing < 0 {
+		spacing = 0
+	}
+
+	renderedLetterforms := make([]string, len(letterforms))
+
+	// pick one letter randomly to stretch
+	for i, letter := range letterforms {
+		renderedLetterforms[i] = letter(i == stretchIndex)
+	}
+
+	if spacing > 0 {
+		// Add spaces between the letters and render.
+		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
+	}
+	return strings.TrimSpace(
+		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
+	)
+}
+
+// letterC renders the letter C in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterC(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β–„β–€β–€β–€β–€
+	// β–ˆ
+	//	β–€β–€β–€β–€
+
+	left := heredoc.Doc(`
+		β–„
+		β–ˆ
+	`)
+	right := heredoc.Doc(`
+		β–€
+
+		β–€
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(right, letterformProps{
+			stretch:    stretch,
+			width:      4,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+	)
+}
+
+// letterH renders the letter H in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterH(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β–ˆ   β–ˆ
+	// β–ˆβ–€β–€β–€β–ˆ
+	// β–€   β–€
+
+	side := heredoc.Doc(`
+		β–ˆ
+		β–ˆ
+		β–€`)
+	middle := heredoc.Doc(`
+
+		β–€
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 8,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+// letterR renders the letter R in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterR(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β–ˆβ–€β–€β–€β–„
+	// β–ˆβ–€β–€β–€β–„
+	// β–€   β–€
+
+	left := heredoc.Doc(`
+		β–ˆ
+		β–ˆ
+		β–€
+	`)
+	center := heredoc.Doc(`
+		β–€
+		β–€
+	`)
+	right := heredoc.Doc(`
+		β–„
+		β–„
+		β–€
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterSStylized renders the letter S in a stylized way, more so than
+// [letterS]. It takes an integer that determines how many cells to stretch the
+// letter. If the stretch is less than 1, it defaults to no stretching.
+func letterSStylized(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β–„β–€β–€β–€β–€β–€
+	// β–€β–€β–€β–€β–€β–ˆ
+	// β–€β–€β–€β–€β–€
+
+	left := heredoc.Doc(`
+		β–„
+		β–€
+		β–€
+	`)
+	center := heredoc.Doc(`
+		β–€
+		β–€
+		β–€
+	`)
+	right := heredoc.Doc(`
+		β–€
+		β–ˆ
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterU renders the letter U in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterU(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β–ˆ   β–ˆ
+	// β–ˆ   β–ˆ
+	//	β–€β–€β–€
+
+	side := heredoc.Doc(`
+		β–ˆ
+		β–ˆ
+	`)
+	middle := heredoc.Doc(`
+
+
+		β–€
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+func joinLetterform(letters ...string) string {
+	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
+}
+
+// letterformProps defines letterform stretching properties.
+// for readability.
+type letterformProps struct {
+	width      int
+	minStretch int
+	maxStretch int
+	stretch    bool
+}
+
+// stretchLetterformPart is a helper function for letter stretching. If randomize
+// is false the minimum number will be used.
+func stretchLetterformPart(s string, p letterformProps) string {
+	if p.maxStretch < p.minStretch {
+		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
+	}
+	n := p.width
+	if p.stretch {
+		n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+	}
+	parts := make([]string, n)
+	for i := range parts {
+		parts[i] = s
+	}
+	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+}

internal/ui/logo/rand.go πŸ”—

@@ -0,0 +1,24 @@
+package logo
+
+import (
+	"math/rand/v2"
+	"sync"
+)
+
+var (
+	randCaches   = make(map[int]int)
+	randCachesMu sync.Mutex
+)
+
+func cachedRandN(n int) int {
+	randCachesMu.Lock()
+	defer randCachesMu.Unlock()
+
+	if n, ok := randCaches[n]; ok {
+		return n
+	}
+
+	r := rand.IntN(n)
+	randCaches[n] = r
+	return r
+}

internal/ui/model/chat.go πŸ”—

@@ -0,0 +1,600 @@
+package model
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// Chat represents the chat UI model that handles chat interactions and
+// messages.
+type Chat struct {
+	com      *common.Common
+	list     *list.List
+	idInxMap map[string]int // Map of message IDs to their indices in the list
+
+	// Animation visibility optimization: track animations paused due to items
+	// being scrolled out of view. When items become visible again, their
+	// animations are restarted.
+	pausedAnimations map[string]struct{}
+
+	// Mouse state
+	mouseDown     bool
+	mouseDownItem int // Item index where mouse was pressed
+	mouseDownX    int // X position in item content (character offset)
+	mouseDownY    int // Y position in item (line offset)
+	mouseDragItem int // Current item index being dragged over
+	mouseDragX    int // Current X in item content
+	mouseDragY    int // Current Y in item
+}
+
+// NewChat creates a new instance of [Chat] that handles chat interactions and
+// messages.
+func NewChat(com *common.Common) *Chat {
+	c := &Chat{
+		com:              com,
+		idInxMap:         make(map[string]int),
+		pausedAnimations: make(map[string]struct{}),
+	}
+	l := list.NewList()
+	l.SetGap(1)
+	l.RegisterRenderCallback(c.applyHighlightRange)
+	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
+	c.list = l
+	c.mouseDownItem = -1
+	c.mouseDragItem = -1
+	return c
+}
+
+// Height returns the height of the chat view port.
+func (m *Chat) Height() int {
+	return m.list.Height()
+}
+
+// Draw renders the chat UI component to the screen and the given area.
+func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
+	uv.NewStyledString(m.list.Render()).Draw(scr, area)
+}
+
+// SetSize sets the size of the chat view port.
+func (m *Chat) SetSize(width, height int) {
+	m.list.SetSize(width, height)
+}
+
+// Len returns the number of items in the chat list.
+func (m *Chat) Len() int {
+	return m.list.Len()
+}
+
+// SetMessages sets the chat messages to the provided list of message items.
+func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+
+	items := make([]list.Item, len(msgs))
+	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = i
+		// Register nested tool IDs for tools that contain nested tools.
+		if container, ok := msg.(chat.NestedToolContainer); ok {
+			for _, nested := range container.NestedTools() {
+				m.idInxMap[nested.ID()] = i
+			}
+		}
+		items[i] = msg
+	}
+	m.list.SetItems(items...)
+	m.list.ScrollToBottom()
+}
+
+// AppendMessages appends a new message item to the chat list.
+func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
+	items := make([]list.Item, len(msgs))
+	indexOffset := m.list.Len()
+	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = indexOffset + i
+		// Register nested tool IDs for tools that contain nested tools.
+		if container, ok := msg.(chat.NestedToolContainer); ok {
+			for _, nested := range container.NestedTools() {
+				m.idInxMap[nested.ID()] = indexOffset + i
+			}
+		}
+		items[i] = msg
+	}
+	m.list.AppendItems(items...)
+}
+
+// UpdateNestedToolIDs updates the ID map for nested tools within a container.
+// Call this after modifying nested tools to ensure animations work correctly.
+func (m *Chat) UpdateNestedToolIDs(containerID string) {
+	idx, ok := m.idInxMap[containerID]
+	if !ok {
+		return
+	}
+
+	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+	if !ok {
+		return
+	}
+
+	container, ok := item.(chat.NestedToolContainer)
+	if !ok {
+		return
+	}
+
+	// Register all nested tool IDs to point to the container's index.
+	for _, nested := range container.NestedTools() {
+		m.idInxMap[nested.ID()] = idx
+	}
+}
+
+// Animate animates items in the chat list. Only propagates animation messages
+// to visible items to save CPU. When items are not visible, their animation ID
+// is tracked so it can be restarted when they become visible again.
+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
+	idx, ok := m.idInxMap[msg.ID]
+	if !ok {
+		return nil
+	}
+
+	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
+	if !ok {
+		return nil
+	}
+
+	// Check if item is currently visible.
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	isVisible := idx >= startIdx && idx <= endIdx
+
+	if !isVisible {
+		// Item not visible - pause animation by not propagating.
+		// Track it so we can restart when it becomes visible.
+		m.pausedAnimations[msg.ID] = struct{}{}
+		return nil
+	}
+
+	// Item is visible - remove from paused set and animate.
+	delete(m.pausedAnimations, msg.ID)
+	return animatable.Animate(msg)
+}
+
+// RestartPausedVisibleAnimations restarts animations for items that were paused
+// due to being scrolled out of view but are now visible again.
+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
+	if len(m.pausedAnimations) == 0 {
+		return nil
+	}
+
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	var cmds []tea.Cmd
+
+	for id := range m.pausedAnimations {
+		idx, ok := m.idInxMap[id]
+		if !ok {
+			// Item no longer exists.
+			delete(m.pausedAnimations, id)
+			continue
+		}
+
+		if idx >= startIdx && idx <= endIdx {
+			// Item is now visible - restart its animation.
+			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			delete(m.pausedAnimations, id)
+		}
+	}
+
+	if len(cmds) == 0 {
+		return nil
+	}
+	return tea.Batch(cmds...)
+}
+
+// Focus sets the focus state of the chat component.
+func (m *Chat) Focus() {
+	m.list.Focus()
+}
+
+// Blur removes the focus state from the chat component.
+func (m *Chat) Blur() {
+	m.list.Blur()
+}
+
+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
+// any paused animations that are now visible.
+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
+	m.list.ScrollToTop()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
+// restart any paused animations that are now visible.
+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
+	m.list.ScrollToBottom()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
+// a command to restart any paused animations that are now visible.
+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
+	m.list.ScrollBy(lines)
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
+// command to restart any paused animations that are now visible.
+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
+	m.list.ScrollToSelected()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (m *Chat) SelectedItemInView() bool {
+	return m.list.SelectedItemInView()
+}
+
+func (m *Chat) isSelectable(index int) bool {
+	item := m.list.ItemAt(index)
+	if item == nil {
+		return false
+	}
+	_, ok := item.(list.Focusable)
+	return ok
+}
+
+// SetSelected sets the selected message index in the chat list.
+func (m *Chat) SetSelected(index int) {
+	m.list.SetSelected(index)
+	if index < 0 || index >= m.list.Len() {
+		return
+	}
+	for {
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+		if m.list.SelectNext() {
+			continue
+		}
+		// If we're at the end and the last item isn't selectable, walk backwards
+		// to find the nearest selectable item.
+		for {
+			if !m.list.SelectPrev() {
+				return
+			}
+			if m.isSelectable(m.list.Selected()) {
+				return
+			}
+		}
+	}
+}
+
+// SelectPrev selects the previous message in the chat list.
+func (m *Chat) SelectPrev() {
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectNext selects the next message in the chat list.
+func (m *Chat) SelectNext() {
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectFirst selects the first message in the chat list.
+func (m *Chat) SelectFirst() {
+	if !m.list.SelectFirst() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectLast selects the last message in the chat list.
+func (m *Chat) SelectLast() {
+	if !m.list.SelectLast() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectFirstInView selects the first message currently in view.
+func (m *Chat) SelectFirstInView() {
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := startIdx; i <= endIdx; i++ {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
+}
+
+// SelectLastInView selects the last message currently in view.
+func (m *Chat) SelectLastInView() {
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := endIdx; i >= startIdx; i-- {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
+}
+
+// ClearMessages removes all messages from the chat list.
+func (m *Chat) ClearMessages() {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+	m.list.SetItems()
+	m.ClearMouse()
+}
+
+// RemoveMessage removes a message from the chat list by its ID.
+func (m *Chat) RemoveMessage(id string) {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return
+	}
+
+	// Remove from list
+	m.list.RemoveItem(idx)
+
+	// Remove from index map
+	delete(m.idInxMap, id)
+
+	// Rebuild index map for all items after the removed one
+	for i := idx; i < m.list.Len(); i++ {
+		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
+			m.idInxMap[item.ID()] = i
+		}
+	}
+
+	// Clean up any paused animations for this message
+	delete(m.pausedAnimations, id)
+}
+
+// MessageItem returns the message item with the given ID, or nil if not found.
+func (m *Chat) MessageItem(id string) chat.MessageItem {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return nil
+	}
+	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+	if !ok {
+		return nil
+	}
+	return item
+}
+
+// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
+func (m *Chat) ToggleExpandedSelectedItem() {
+	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
+		expandable.ToggleExpanded()
+	}
+}
+
+// HandleMouseDown handles mouse down events for the chat component.
+func (m *Chat) HandleMouseDown(x, y int) bool {
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+	if !m.isSelectable(itemIdx) {
+		return false
+	}
+
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = x
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	// Select the item that was clicked
+	m.list.SetSelected(itemIdx)
+
+	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
+		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
+	}
+
+	return true
+}
+
+// HandleMouseUp handles mouse up events for the chat component.
+func (m *Chat) HandleMouseUp(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	m.mouseDown = false
+	return true
+}
+
+// HandleMouseDrag handles mouse drag events for the chat component.
+func (m *Chat) HandleMouseDrag(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	return true
+}
+
+// HasHighlight returns whether there is currently highlighted content.
+func (m *Chat) HasHighlight() bool {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
+}
+
+// HighlighContent returns the currently highlighted content based on the mouse
+// selection. It returns an empty string if no content is highlighted.
+func (m *Chat) HighlighContent() string {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
+		return ""
+	}
+
+	var sb strings.Builder
+	for i := startItemIdx; i <= endItemIdx; i++ {
+		item := m.list.ItemAt(i)
+		if hi, ok := item.(list.Highlightable); ok {
+			startLine, startCol, endLine, endCol := hi.Highlight()
+			listWidth := m.list.Width()
+			var rendered string
+			if rr, ok := item.(list.RawRenderable); ok {
+				rendered = rr.RawRender(listWidth)
+			} else {
+				rendered = item.Render(listWidth)
+			}
+			sb.WriteString(list.HighlightContent(
+				rendered,
+				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
+				startLine,
+				startCol,
+				endLine,
+				endCol,
+			))
+			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
+		}
+	}
+
+	return strings.TrimSpace(sb.String())
+}
+
+// ClearMouse clears the current mouse interaction state.
+func (m *Chat) ClearMouse() {
+	m.mouseDown = false
+	m.mouseDownItem = -1
+	m.mouseDragItem = -1
+}
+
+// applyHighlightRange applies the current highlight range to the chat items.
+func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
+	if hi, ok := item.(list.Highlightable); ok {
+		// Apply highlight
+		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+		sLine, sCol, eLine, eCol := -1, -1, -1, -1
+		if idx >= startItemIdx && idx <= endItemIdx {
+			if idx == startItemIdx && idx == endItemIdx {
+				// Single item selection
+				sLine = startLine
+				sCol = startCol
+				eLine = endLine
+				eCol = endCol
+			} else if idx == startItemIdx {
+				// First item - from start position to end of item
+				sLine = startLine
+				sCol = startCol
+				eLine = -1
+				eCol = -1
+			} else if idx == endItemIdx {
+				// Last item - from start of item to end position
+				sLine = 0
+				sCol = 0
+				eLine = endLine
+				eCol = endCol
+			} else {
+				// Middle item - fully highlighted
+				sLine = 0
+				sCol = 0
+				eLine = -1
+				eCol = -1
+			}
+		}
+
+		hi.SetHighlight(sLine, sCol, eLine, eCol)
+		return hi.(list.Item)
+	}
+
+	return item
+}
+
+// getHighlightRange returns the current highlight range.
+func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
+	if m.mouseDownItem < 0 {
+		return -1, -1, -1, -1, -1, -1
+	}
+
+	downItemIdx := m.mouseDownItem
+	dragItemIdx := m.mouseDragItem
+
+	// Determine selection direction
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
+		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
+
+	if draggingDown {
+		// Normal forward selection
+		startItemIdx = downItemIdx
+		startLine = m.mouseDownY
+		startCol = m.mouseDownX
+		endItemIdx = dragItemIdx
+		endLine = m.mouseDragY
+		endCol = m.mouseDragX
+	} else {
+		// Backward selection (dragging up)
+		startItemIdx = dragItemIdx
+		startLine = m.mouseDragY
+		startCol = m.mouseDragX
+		endItemIdx = downItemIdx
+		endLine = m.mouseDownY
+		endCol = m.mouseDownX
+	}
+
+	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
+}

internal/ui/model/header.go πŸ”—

@@ -0,0 +1,112 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const (
+	headerDiag     = "β•±"
+	minHeaderDiags = 3
+	leftPadding    = 1
+	rightPadding   = 1
+)
+
+// renderCompactHeader renders the compact header for the given session.
+func renderCompactHeader(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	width int,
+) string {
+	if session == nil || session.ID == "" {
+		return ""
+	}
+
+	t := com.Styles
+
+	var b strings.Builder
+
+	b.WriteString(t.Header.Charm.Render("Charmβ„’"))
+	b.WriteString(" ")
+	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
+	b.WriteString(" ")
+
+	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
+	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+
+	remainingWidth := width -
+		lipgloss.Width(b.String()) -
+		lipgloss.Width(details) -
+		leftPadding -
+		rightPadding
+
+	if remainingWidth > 0 {
+		b.WriteString(t.Header.Diagonals.Render(
+			strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
+		))
+		b.WriteString(" ")
+	}
+
+	b.WriteString(details)
+
+	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+}
+
+// renderHeaderDetails renders the details section of the header.
+func renderHeaderDetails(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	availWidth int,
+) string {
+	t := com.Styles
+
+	var parts []string
+
+	errorCount := 0
+	for l := range lspClients.Seq() {
+		errorCount += l.GetDiagnosticCounts().Error
+	}
+
+	if errorCount > 0 {
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+	}
+
+	agentCfg := config.Get().Agents[config.AgentCoder]
+	model := config.Get().GetModelByType(agentCfg.Model)
+	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+	parts = append(parts, formattedPercentage)
+
+	const keystroke = "ctrl+d"
+	if detailsOpen {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
+	} else {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
+	}
+
+	dot := t.Header.Separator.Render(" β€’ ")
+	metadata := strings.Join(parts, dot)
+	metadata = dot + metadata
+
+	const dirTrimLimit = 4
+	cfg := com.Config()
+	cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
+	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
+	cwd = t.Header.WorkingDir.Render(cwd)
+
+	return cwd + metadata
+}

internal/ui/model/keys.go πŸ”—

@@ -0,0 +1,246 @@
+package model
+
+import "charm.land/bubbles/v2/key"
+
+type KeyMap struct {
+	Editor struct {
+		AddFile     key.Binding
+		SendMessage key.Binding
+		OpenEditor  key.Binding
+		Newline     key.Binding
+		AddImage    key.Binding
+		MentionFile key.Binding
+
+		// Attachments key maps
+		AttachmentDeleteMode key.Binding
+		Escape               key.Binding
+		DeleteAllAttachments key.Binding
+	}
+
+	Chat struct {
+		NewSession     key.Binding
+		AddAttachment  key.Binding
+		Cancel         key.Binding
+		Tab            key.Binding
+		Details        key.Binding
+		TogglePills    key.Binding
+		PillLeft       key.Binding
+		PillRight      key.Binding
+		Down           key.Binding
+		Up             key.Binding
+		UpDown         key.Binding
+		DownOneItem    key.Binding
+		UpOneItem      key.Binding
+		UpDownOneItem  key.Binding
+		PageDown       key.Binding
+		PageUp         key.Binding
+		HalfPageDown   key.Binding
+		HalfPageUp     key.Binding
+		Home           key.Binding
+		End            key.Binding
+		Copy           key.Binding
+		ClearHighlight key.Binding
+		Expand         key.Binding
+	}
+
+	Initialize struct {
+		Yes,
+		No,
+		Enter,
+		Switch key.Binding
+	}
+
+	// Global key maps
+	Quit     key.Binding
+	Help     key.Binding
+	Commands key.Binding
+	Models   key.Binding
+	Suspend  key.Binding
+	Sessions key.Binding
+	Tab      key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	km := KeyMap{
+		Quit: key.NewBinding(
+			key.WithKeys("ctrl+c"),
+			key.WithHelp("ctrl+c", "quit"),
+		),
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+g"),
+			key.WithHelp("ctrl+g", "more"),
+		),
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
+		),
+		Models: key.NewBinding(
+			key.WithKeys("ctrl+m", "ctrl+l"),
+			key.WithHelp("ctrl+l", "models"),
+		),
+		Suspend: key.NewBinding(
+			key.WithKeys("ctrl+z"),
+			key.WithHelp("ctrl+z", "suspend"),
+		),
+		Sessions: key.NewBinding(
+			key.WithKeys("ctrl+s"),
+			key.WithHelp("ctrl+s", "sessions"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
+	}
+
+	km.Editor.AddFile = key.NewBinding(
+		key.WithKeys("/"),
+		key.WithHelp("/", "add file"),
+	)
+	km.Editor.SendMessage = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "send"),
+	)
+	km.Editor.OpenEditor = key.NewBinding(
+		key.WithKeys("ctrl+o"),
+		key.WithHelp("ctrl+o", "open editor"),
+	)
+	km.Editor.Newline = 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 tex
+		// to reflect that.
+		key.WithHelp("ctrl+j", "newline"),
+	)
+	km.Editor.AddImage = key.NewBinding(
+		key.WithKeys("ctrl+f"),
+		key.WithHelp("ctrl+f", "add image"),
+	)
+	km.Editor.MentionFile = key.NewBinding(
+		key.WithKeys("@"),
+		key.WithHelp("@", "mention file"),
+	)
+	km.Editor.AttachmentDeleteMode = key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+	)
+	km.Editor.Escape = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "cancel delete mode"),
+	)
+	km.Editor.DeleteAllAttachments = key.NewBinding(
+		key.WithKeys("r"),
+		key.WithHelp("ctrl+r+r", "delete all attachments"),
+	)
+
+	km.Chat.NewSession = key.NewBinding(
+		key.WithKeys("ctrl+n"),
+		key.WithHelp("ctrl+n", "new session"),
+	)
+	km.Chat.AddAttachment = key.NewBinding(
+		key.WithKeys("ctrl+f"),
+		key.WithHelp("ctrl+f", "add attachment"),
+	)
+	km.Chat.Cancel = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "cancel"),
+	)
+	km.Chat.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "change focus"),
+	)
+	km.Chat.Details = key.NewBinding(
+		key.WithKeys("ctrl+d"),
+		key.WithHelp("ctrl+d", "toggle details"),
+	)
+	km.Chat.TogglePills = key.NewBinding(
+		key.WithKeys("ctrl+space"),
+		key.WithHelp("ctrl+space", "toggle tasks"),
+	)
+	km.Chat.PillLeft = key.NewBinding(
+		key.WithKeys("left"),
+		key.WithHelp("←/β†’", "switch section"),
+	)
+	km.Chat.PillRight = key.NewBinding(
+		key.WithKeys("right"),
+		key.WithHelp("←/β†’", "switch section"),
+	)
+
+	km.Chat.Down = key.NewBinding(
+		key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
+		key.WithHelp("↓", "down"),
+	)
+	km.Chat.Up = key.NewBinding(
+		key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
+		key.WithHelp("↑", "up"),
+	)
+	km.Chat.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑↓", "scroll"),
+	)
+	km.Chat.UpOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "K"),
+		key.WithHelp("shift+↑", "up one item"),
+	)
+	km.Chat.DownOneItem = key.NewBinding(
+		key.WithKeys("shift+down", "J"),
+		key.WithHelp("shift+↓", "down one item"),
+	)
+	km.Chat.UpDownOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "shift+down"),
+		key.WithHelp("shift+↑↓", "scroll one item"),
+	)
+	km.Chat.HalfPageDown = key.NewBinding(
+		key.WithKeys("d"),
+		key.WithHelp("d", "half page down"),
+	)
+	km.Chat.PageDown = key.NewBinding(
+		key.WithKeys("pgdown", " ", "f"),
+		key.WithHelp("f/pgdn", "page down"),
+	)
+	km.Chat.PageUp = key.NewBinding(
+		key.WithKeys("pgup", "b"),
+		key.WithHelp("b/pgup", "page up"),
+	)
+	km.Chat.HalfPageUp = key.NewBinding(
+		key.WithKeys("u"),
+		key.WithHelp("u", "half page up"),
+	)
+	km.Chat.Home = key.NewBinding(
+		key.WithKeys("g", "home"),
+		key.WithHelp("g", "home"),
+	)
+	km.Chat.End = key.NewBinding(
+		key.WithKeys("G", "end"),
+		key.WithHelp("G", "end"),
+	)
+	km.Chat.Copy = key.NewBinding(
+		key.WithKeys("c", "y", "C", "Y"),
+		key.WithHelp("c/y", "copy"),
+	)
+	km.Chat.ClearHighlight = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "clear selection"),
+	)
+	km.Chat.Expand = key.NewBinding(
+		key.WithKeys("space"),
+		key.WithHelp("space", "expand/collapse"),
+	)
+	km.Initialize.Yes = key.NewBinding(
+		key.WithKeys("y", "Y"),
+		key.WithHelp("y", "yes"),
+	)
+	km.Initialize.No = key.NewBinding(
+		key.WithKeys("n", "N", "esc", "alt+esc"),
+		key.WithHelp("n", "no"),
+	)
+	km.Initialize.Switch = key.NewBinding(
+		key.WithKeys("left", "right", "tab"),
+		key.WithHelp("tab", "switch"),
+	)
+	km.Initialize.Enter = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "select"),
+	)
+
+	return km
+}

internal/ui/model/landing.go πŸ”—

@@ -0,0 +1,50 @@
+package model
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// selectedLargeModel returns the currently selected large language model from
+// the agent coordinator, if one exists.
+func (m *UI) selectedLargeModel() *agent.Model {
+	if m.com.App.AgentCoordinator != nil {
+		model := m.com.App.AgentCoordinator.Model()
+		return &model
+	}
+	return nil
+}
+
+// landingView renders the landing page view showing the current working
+// directory, model information, and LSP/MCP status in a two-column layout.
+func (m *UI) landingView() string {
+	t := m.com.Styles
+	width := m.layout.main.Dx()
+	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+
+	parts := []string{
+		cwd,
+	}
+
+	parts = append(parts, "", m.modelInfo(width))
+	infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	_, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
+
+	mcpLspSectionWidth := min(30, (width-1)/2)
+
+	lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
+	mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
+
+	content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
+
+	return lipgloss.NewStyle().
+		Width(width).
+		Height(m.layout.main.Dy() - 1).
+		PaddingTop(1).
+		Render(
+			lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content),
+		)
+}

internal/ui/model/lsp.go πŸ”—

@@ -0,0 +1,118 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// LSPInfo wraps LSP client information with diagnostic counts by severity.
+type LSPInfo struct {
+	app.LSPClientInfo
+	Diagnostics map[protocol.DiagnosticSeverity]int
+}
+
+// lspInfo renders the LSP status section showing active LSP clients and their
+// diagnostic counts.
+func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
+	var lsps []LSPInfo
+	t := m.com.Styles
+
+	for _, state := range m.lspStates {
+		client, ok := m.com.App.LSPClients.Get(state.Name)
+		if !ok {
+			continue
+		}
+		counts := client.GetDiagnosticCounts()
+		lspErrs := map[protocol.DiagnosticSeverity]int{
+			protocol.SeverityError:       counts.Error,
+			protocol.SeverityWarning:     counts.Warning,
+			protocol.SeverityHint:        counts.Hint,
+			protocol.SeverityInformation: counts.Information,
+		}
+
+		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
+	}
+	title := t.Subtle.Render("LSPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
+	list := t.Subtle.Render("None")
+	if len(lsps) > 0 {
+		list = lspList(t, lsps, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
+func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
+	errs := []string{}
+	if diagnostics[protocol.SeverityError] > 0 {
+		errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError])))
+	}
+	if diagnostics[protocol.SeverityWarning] > 0 {
+		errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning])))
+	}
+	if diagnostics[protocol.SeverityHint] > 0 {
+		errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint])))
+	}
+	if diagnostics[protocol.SeverityInformation] > 0 {
+		errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation])))
+	}
+	return strings.Join(errs, " ")
+}
+
+// lspList renders a list of LSP clients with their status and diagnostics,
+// truncating to maxItems if needed.
+func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedLsps []string
+	for _, l := range lsps {
+		var icon string
+		title := l.Name
+		var description string
+		var diagnostics string
+		switch l.State {
+		case lsp.StateStarting:
+			icon = t.ItemBusyIcon.String()
+			description = t.Subtle.Render("starting...")
+		case lsp.StateReady:
+			icon = t.ItemOnlineIcon.String()
+			diagnostics = lspDiagnostics(t, l.Diagnostics)
+		case lsp.StateError:
+			icon = t.ItemErrorIcon.String()
+			description = t.Subtle.Render("error")
+			if l.Error != nil {
+				description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error()))
+			}
+		case lsp.StateDisabled:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("inactive")
+		default:
+			icon = t.ItemOfflineIcon.String()
+		}
+		renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{
+			Icon:         icon,
+			Title:        title,
+			Description:  description,
+			ExtraContent: diagnostics,
+		}, width))
+	}
+
+	if len(renderedLsps) > maxItems {
+		visibleItems := renderedLsps[:maxItems-1]
+		remaining := len(renderedLsps) - maxItems
+		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...)
+}

internal/ui/model/mcp.go πŸ”—

@@ -0,0 +1,98 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// mcpInfo renders the MCP status section showing active MCP clients and their
+// tool/prompt counts.
+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
+	var mcps []mcp.ClientInfo
+	t := m.com.Styles
+
+	for _, mcp := range m.com.Config().MCP.Sorted() {
+		if state, ok := m.mcpStates[mcp.Name]; ok {
+			mcps = append(mcps, state)
+		}
+	}
+
+	title := t.Subtle.Render("MCPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
+	list := t.Subtle.Render("None")
+	if len(mcps) > 0 {
+		list = mcpList(t, mcps, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// mcpCounts formats tool and prompt counts for display.
+func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
+	parts := []string{}
+	if counts.Tools > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools)))
+	}
+	if counts.Prompts > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
+	}
+	return strings.Join(parts, " ")
+}
+
+// mcpList renders a list of MCP clients with their status and counts,
+// truncating to maxItems if needed.
+func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedMcps []string
+
+	for _, m := range mcps {
+		var icon string
+		title := m.Name
+		var description string
+		var extraContent string
+
+		switch m.State {
+		case mcp.StateStarting:
+			icon = t.ItemBusyIcon.String()
+			description = t.Subtle.Render("starting...")
+		case mcp.StateConnected:
+			icon = t.ItemOnlineIcon.String()
+			extraContent = mcpCounts(t, m.Counts)
+		case mcp.StateError:
+			icon = t.ItemErrorIcon.String()
+			description = t.Subtle.Render("error")
+			if m.Error != nil {
+				description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error()))
+			}
+		case mcp.StateDisabled:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("disabled")
+		default:
+			icon = t.ItemOfflineIcon.String()
+		}
+
+		renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{
+			Icon:         icon,
+			Title:        title,
+			Description:  description,
+			ExtraContent: extraContent,
+		}, width))
+	}
+
+	if len(renderedMcps) > maxItems {
+		visibleItems := renderedMcps[:maxItems-1]
+		remaining := len(renderedMcps) - maxItems
+		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...)
+}

internal/ui/model/onboarding.go πŸ”—

@@ -0,0 +1,101 @@
+package model
+
+import (
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+// markProjectInitialized marks the current project as initialized in the config.
+func (m *UI) markProjectInitialized() tea.Msg {
+	// TODO: handle error so we show it in the tui footer
+	err := config.MarkProjectInitialized()
+	if err != nil {
+		slog.Error(err.Error())
+	}
+	return nil
+}
+
+// updateInitializeView handles keyboard input for the project initialization prompt.
+func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
+	switch {
+	case key.Matches(msg, m.keyMap.Initialize.Enter):
+		if m.onboarding.yesInitializeSelected {
+			cmds = append(cmds, m.initializeProject())
+		} else {
+			cmds = append(cmds, m.skipInitializeProject())
+		}
+	case key.Matches(msg, m.keyMap.Initialize.Switch):
+		m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected
+	case key.Matches(msg, m.keyMap.Initialize.Yes):
+		cmds = append(cmds, m.initializeProject())
+	case key.Matches(msg, m.keyMap.Initialize.No):
+		cmds = append(cmds, m.skipInitializeProject())
+	}
+	return cmds
+}
+
+// initializeProject starts project initialization and transitions to the landing view.
+func (m *UI) initializeProject() tea.Cmd {
+	// TODO: initialize the project
+	// for now we just go to the landing page
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	// TODO: actually send a message to the agent
+	return m.markProjectInitialized
+}
+
+// skipInitializeProject skips project initialization and transitions to the landing view.
+func (m *UI) skipInitializeProject() tea.Cmd {
+	// TODO: initialize the project
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	// mark the project as initialized
+	return m.markProjectInitialized
+}
+
+// initializeView renders the project initialization prompt with Yes/No buttons.
+func (m *UI) initializeView() string {
+	cfg := m.com.Config()
+	s := m.com.Styles.Initialize
+	cwd := home.Short(cfg.WorkingDir())
+	initFile := cfg.Options.InitializeAs
+
+	header := s.Header.Render("Would you like to initialize this project?")
+	path := s.Accent.PaddingLeft(2).Render(cwd)
+	desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile))
+	hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
+	prompt := s.Content.Render("Would you like to initialize now?")
+
+	buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
+		{Text: "Yep!", Selected: m.onboarding.yesInitializeSelected},
+		{Text: "Nope", Selected: !m.onboarding.yesInitializeSelected},
+	}, " ")
+
+	// max width 60 so the text is compact
+	width := min(m.layout.main.Dx(), 60)
+
+	return lipgloss.NewStyle().
+		Width(width).
+		Height(m.layout.main.Dy()).
+		PaddingBottom(1).
+		AlignVertical(lipgloss.Bottom).
+		Render(strings.Join(
+			[]string{
+				header,
+				path,
+				desc,
+				hint,
+				prompt,
+				buttons,
+			},
+			"\n\n",
+		))
+}

internal/ui/model/pills.go πŸ”—

@@ -0,0 +1,283 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// pillStyle returns the appropriate style for a pill based on focus state.
+func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
+	if !panelFocused || focused {
+		return t.Pills.Focused
+	}
+	return t.Pills.Blurred
+}
+
+const (
+	// pillHeightWithBorder is the height of a pill including its border.
+	pillHeightWithBorder = 3
+	// maxTaskDisplayLength is the maximum length of a task name in the pill.
+	maxTaskDisplayLength = 40
+	// maxQueueDisplayLength is the maximum length of a queue item in the list.
+	maxQueueDisplayLength = 60
+)
+
+// pillSection represents which section of the pills panel is focused.
+type pillSection int
+
+const (
+	pillSectionTodos pillSection = iota
+	pillSectionQueue
+)
+
+// hasIncompleteTodos returns true if there are any non-completed todos.
+func hasIncompleteTodos(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status != session.TodoStatusCompleted {
+			return true
+		}
+	}
+	return false
+}
+
+// hasInProgressTodo returns true if there is at least one in-progress todo.
+func hasInProgressTodo(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status == session.TodoStatusInProgress {
+			return true
+		}
+	}
+	return false
+}
+
+// queuePill renders the queue count pill with gradient triangles.
+func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
+	if queue <= 0 {
+		return ""
+	}
+	triangles := styles.ForegroundGrad(t, "β–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Ά", false, t.RedDark, t.Secondary)
+	if queue < len(triangles) {
+		triangles = triangles[:queue]
+	}
+
+	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoPill renders the todo progress pill with optional spinner and task name.
+func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
+	if !hasIncompleteTodos(todos) {
+		return ""
+	}
+
+	completed := 0
+	var currentTodo *session.Todo
+	for i := range todos {
+		switch todos[i].Status {
+		case session.TodoStatusCompleted:
+			completed++
+		case session.TodoStatusInProgress:
+			if currentTodo == nil {
+				currentTodo = &todos[i]
+			}
+		}
+	}
+
+	total := len(todos)
+
+	label := t.Base.Render("To-Do")
+	progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
+
+	var content string
+	if panelFocused {
+		content = fmt.Sprintf("%s %s", label, progress)
+	} else if currentTodo != nil {
+		taskText := currentTodo.Content
+		if currentTodo.ActiveForm != "" {
+			taskText = currentTodo.ActiveForm
+		}
+		if len(taskText) > maxTaskDisplayLength {
+			taskText = taskText[:maxTaskDisplayLength-1] + "…"
+		}
+		task := t.Subtle.Render(taskText)
+		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
+	} else {
+		content = fmt.Sprintf("%s %s", label, progress)
+	}
+
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoList renders the expanded todo list.
+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
+	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
+}
+
+// queueList renders the expanded queue items list.
+func queueList(queueItems []string, t *styles.Styles) string {
+	if len(queueItems) == 0 {
+		return ""
+	}
+
+	var lines []string
+	for _, item := range queueItems {
+		text := item
+		if len(text) > maxQueueDisplayLength {
+			text = text[:maxQueueDisplayLength-1] + "…"
+		}
+		prefix := t.Pills.QueueItemPrefix.Render() + " "
+		lines = append(lines, prefix+t.Muted.Render(text))
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// togglePillsExpanded toggles the pills panel expansion state.
+func (m *UI) togglePillsExpanded() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+	if m.layout.pills.Dy() > 0 {
+		if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
+			return cmd
+		}
+	}
+	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	m.pillsExpanded = !m.pillsExpanded
+	if m.pillsExpanded {
+		if hasIncompleteTodos(m.session.Todos) {
+			m.focusedPillSection = pillSectionTodos
+		} else {
+			m.focusedPillSection = pillSectionQueue
+		}
+	}
+	m.updateLayoutAndSize()
+	return nil
+}
+
+// switchPillSection changes focus between todo and queue sections.
+func (m *UI) switchPillSection(dir int) tea.Cmd {
+	if !m.pillsExpanded || !m.hasSession() {
+		return nil
+	}
+	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
+		m.focusedPillSection = pillSectionTodos
+		m.updateLayoutAndSize()
+		return nil
+	}
+	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
+		m.focusedPillSection = pillSectionQueue
+		m.updateLayoutAndSize()
+		return nil
+	}
+	return nil
+}
+
+// pillsAreaHeight calculates the total height needed for the pills area.
+func (m *UI) pillsAreaHeight() int {
+	if !m.hasSession() {
+		return 0
+	}
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+	hasPills := hasIncomplete || hasQueue
+	if !hasPills {
+		return 0
+	}
+
+	pillsAreaHeight := pillHeightWithBorder
+	if m.pillsExpanded {
+		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
+			pillsAreaHeight += len(m.session.Todos)
+		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
+			pillsAreaHeight += m.promptQueue
+		}
+	}
+	return pillsAreaHeight
+}
+
+// renderPills renders the pills panel and stores it in m.pillsView.
+func (m *UI) renderPills() {
+	m.pillsView = ""
+	if !m.hasSession() {
+		return
+	}
+
+	width := m.layout.pills.Dx()
+	if width <= 0 {
+		return
+	}
+
+	paddingLeft := 3
+	contentWidth := max(width-paddingLeft, 0)
+
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if !hasIncomplete && !hasQueue {
+		return
+	}
+
+	t := m.com.Styles
+	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
+	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
+
+	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
+	if m.todoIsSpinning {
+		inProgressIcon = m.todoSpinner.View()
+	}
+
+	var pills []string
+	if hasIncomplete {
+		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
+	}
+	if hasQueue {
+		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
+	}
+
+	var expandedList string
+	if m.pillsExpanded {
+		if todosFocused && hasIncomplete {
+			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
+		} else if queueFocused && hasQueue {
+			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
+				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+				expandedList = queueList(queueItems, t)
+			}
+		}
+	}
+
+	if len(pills) == 0 {
+		return
+	}
+
+	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
+
+	helpDesc := "open"
+	if m.pillsExpanded {
+		helpDesc = "close"
+	}
+	helpKey := t.Pills.HelpKey.Render("ctrl+space")
+	helpText := t.Pills.HelpText.Render(helpDesc)
+	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
+	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
+
+	pillsArea := pillsRow
+	if expandedList != "" {
+		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
+	}
+
+	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
+}

internal/ui/model/session.go πŸ”—

@@ -0,0 +1,244 @@
+package model
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// loadSessionMsg is a message indicating that a session and its files have
+// been loaded.
+type loadSessionMsg struct {
+	session *session.Session
+	files   []SessionFile
+}
+
+// SessionFile tracks the first and latest versions of a file in a session,
+// along with the total additions and deletions.
+type SessionFile struct {
+	FirstVersion  history.File
+	LatestVersion history.File
+	Additions     int
+	Deletions     int
+}
+
+// loadSession loads the session along with its associated files and computes
+// the diff statistics (additions and deletions) for each file in the session.
+// It returns a tea.Cmd that, when executed, fetches the session data and
+// returns a sessionFilesLoadedMsg containing the processed session files.
+func (m *UI) loadSession(sessionID string) tea.Cmd {
+	return func() tea.Msg {
+		session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
+		if err != nil {
+			// TODO: better error handling
+			return uiutil.ReportError(err)()
+		}
+
+		files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+		if err != nil {
+			// TODO: better error handling
+			return uiutil.ReportError(err)()
+		}
+
+		filesByPath := make(map[string][]history.File)
+		for _, f := range files {
+			filesByPath[f.Path] = append(filesByPath[f.Path], f)
+		}
+
+		sessionFiles := make([]SessionFile, 0, len(filesByPath))
+		for _, versions := range filesByPath {
+			if len(versions) == 0 {
+				continue
+			}
+
+			first := versions[0]
+			last := versions[0]
+			for _, v := range versions {
+				if v.Version < first.Version {
+					first = v
+				}
+				if v.Version > last.Version {
+					last = v
+				}
+			}
+
+			_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
+
+			sessionFiles = append(sessionFiles, SessionFile{
+				FirstVersion:  first,
+				LatestVersion: last,
+				Additions:     additions,
+				Deletions:     deletions,
+			})
+		}
+
+		slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
+			if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
+				return -1
+			}
+			if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
+				return 1
+			}
+			return 0
+		})
+
+		return loadSessionMsg{
+			session: &session,
+			files:   sessionFiles,
+		}
+	}
+}
+
+// handleFileEvent processes file change events and updates the session file
+// list with new or updated file information.
+func (m *UI) handleFileEvent(file history.File) tea.Cmd {
+	if m.session == nil || file.SessionID != m.session.ID {
+		return nil
+	}
+
+	return func() tea.Msg {
+		existingIdx := -1
+		for i, sf := range m.sessionFiles {
+			if sf.FirstVersion.Path == file.Path {
+				existingIdx = i
+				break
+			}
+		}
+
+		if existingIdx == -1 {
+			newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
+			newFiles = append(newFiles, SessionFile{
+				FirstVersion:  file,
+				LatestVersion: file,
+				Additions:     0,
+				Deletions:     0,
+			})
+			newFiles = append(newFiles, m.sessionFiles...)
+
+			return loadSessionMsg{
+				session: m.session,
+				files:   newFiles,
+			}
+		}
+
+		updated := m.sessionFiles[existingIdx]
+
+		if file.Version < updated.FirstVersion.Version {
+			updated.FirstVersion = file
+		}
+
+		if file.Version > updated.LatestVersion.Version {
+			updated.LatestVersion = file
+		}
+
+		_, additions, deletions := diff.GenerateDiff(
+			updated.FirstVersion.Content,
+			updated.LatestVersion.Content,
+			updated.FirstVersion.Path,
+		)
+		updated.Additions = additions
+		updated.Deletions = deletions
+
+		newFiles := make([]SessionFile, 0, len(m.sessionFiles))
+		newFiles = append(newFiles, updated)
+		for i, sf := range m.sessionFiles {
+			if i != existingIdx {
+				newFiles = append(newFiles, sf)
+			}
+		}
+
+		return loadSessionMsg{
+			session: m.session,
+			files:   newFiles,
+		}
+	}
+}
+
+// filesInfo renders the modified files section for the sidebar, showing files
+// with their addition/deletion counts.
+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
+	t := m.com.Styles
+
+	title := t.Subtle.Render("Modified Files")
+	if isSection {
+		title = common.Section(t, "Modified Files", width)
+	}
+	list := t.Subtle.Render("None")
+
+	if len(m.sessionFiles) > 0 {
+		list = fileList(t, cwd, m.sessionFiles, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// fileList renders a list of files with their diff statistics, truncating to
+// maxItems and showing a "...and N more" message if needed.
+func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedFiles []string
+	filesShown := 0
+
+	var filesWithChanges []SessionFile
+	for _, f := range files {
+		if f.Additions == 0 && f.Deletions == 0 {
+			continue
+		}
+		filesWithChanges = append(filesWithChanges, f)
+	}
+
+	for _, f := range filesWithChanges {
+		// Skip files with no changes
+		if filesShown >= maxItems {
+			break
+		}
+
+		// Build stats string with colors
+		var statusParts []string
+		if f.Additions > 0 {
+			statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
+		}
+		if f.Deletions > 0 {
+			statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
+		}
+		extraContent := strings.Join(statusParts, " ")
+
+		// Format file path
+		filePath := f.FirstVersion.Path
+		if rel, err := filepath.Rel(cwd, filePath); err == nil {
+			filePath = rel
+		}
+		filePath = fsext.DirTrim(filePath, 2)
+		filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…")
+
+		line := t.Files.Path.Render(filePath)
+		if extraContent != "" {
+			line = fmt.Sprintf("%s %s", line, extraContent)
+		}
+
+		renderedFiles = append(renderedFiles, line)
+		filesShown++
+	}
+
+	if len(filesWithChanges) > maxItems {
+		remaining := len(filesWithChanges) - maxItems
+		renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
+}

internal/ui/model/sidebar.go πŸ”—

@@ -0,0 +1,163 @@
+package model
+
+import (
+	"cmp"
+	"fmt"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/logo"
+	uv "github.com/charmbracelet/ultraviolet"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+// modelInfo renders the current model information including reasoning
+// settings and context usage/cost for the sidebar.
+func (m *UI) modelInfo(width int) string {
+	model := m.selectedLargeModel()
+	reasoningInfo := ""
+	providerName := ""
+
+	if model != nil {
+		// Get provider name first
+		providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
+		if ok {
+			providerName = providerConfig.Name
+
+			// Only check reasoning if model can reason
+			if model.CatwalkCfg.CanReason {
+				switch providerConfig.Type {
+				case catwalk.TypeAnthropic:
+					if model.ModelCfg.Think {
+						reasoningInfo = "Thinking On"
+					} else {
+						reasoningInfo = "Thinking Off"
+					}
+				default:
+					formatter := cases.Title(language.English, cases.NoLower)
+					reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
+					reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
+				}
+			}
+		}
+	}
+
+	var modelContext *common.ModelContextInfo
+	if m.session != nil {
+		modelContext = &common.ModelContextInfo{
+			ContextUsed:  m.session.CompletionTokens + m.session.PromptTokens,
+			Cost:         m.session.Cost,
+			ModelContext: model.CatwalkCfg.ContextWindow,
+		}
+	}
+	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
+}
+
+// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
+// some items are more important than others.
+func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
+	const (
+		minItemsPerSection      = 2
+		defaultMaxFilesShown    = 10
+		defaultMaxLSPsShown     = 8
+		defaultMaxMCPsShown     = 8
+		minAvailableHeightLimit = 10
+	)
+
+	// If we have very little space, use minimum values
+	if availableHeight < minAvailableHeightLimit {
+		return minItemsPerSection, minItemsPerSection, minItemsPerSection
+	}
+
+	// Distribute available height among the three sections
+	// Give priority to files, then LSPs, then MCPs
+	totalSections := 3
+	heightPerSection := availableHeight / totalSections
+
+	// Calculate limits for each section, ensuring minimums
+	maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
+	maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
+	maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
+
+	// If we have extra space, give it to files first
+	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+	if remainingHeight > 0 {
+		extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
+		maxFiles += extraForFiles
+		remainingHeight -= extraForFiles
+
+		if remainingHeight > 0 {
+			extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
+			maxLSPs += extraForLSPs
+			remainingHeight -= extraForLSPs
+
+			if remainingHeight > 0 {
+				maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
+			}
+		}
+	}
+
+	return maxFiles, maxLSPs, maxMCPs
+}
+
+// sidebar renders the chat sidebar containing session title, working
+// directory, model info, file list, LSP status, and MCP status.
+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	const logoHeightBreakpoint = 30
+
+	t := m.com.Styles
+	width := area.Dx()
+	height := area.Dy()
+
+	title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
+	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+	sidebarLogo := m.sidebarLogo
+	if height < logoHeightBreakpoint {
+		sidebarLogo = logo.SmallRender(width)
+	}
+	blocks := []string{
+		sidebarLogo,
+		title,
+		"",
+		cwd,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	sidebarHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	_, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
+	remainingHeight := remainingHeightArea.Dy() - 10
+	maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
+
+	lspSection := m.lspInfo(width, maxLSPs, true)
+	mcpSection := m.mcpInfo(width, maxMCPs, true)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
+
+	uv.NewStyledString(
+		lipgloss.NewStyle().
+			MaxWidth(width).
+			MaxHeight(height).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					sidebarHeader,
+					filesSection,
+					"",
+					lspSection,
+					"",
+					mcpSection,
+				),
+			),
+	).Draw(scr, area)
+}

internal/ui/model/status.go πŸ”—

@@ -0,0 +1,106 @@
+package model
+
+import (
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// DefaultStatusTTL is the default time-to-live for status messages.
+const DefaultStatusTTL = 5 * time.Second
+
+// Status is the status bar and help model.
+type Status struct {
+	com    *common.Common
+	help   help.Model
+	helpKm help.KeyMap
+	msg    uiutil.InfoMsg
+}
+
+// NewStatus creates a new status bar and help model.
+func NewStatus(com *common.Common, km help.KeyMap) *Status {
+	s := new(Status)
+	s.com = com
+	s.help = help.New()
+	s.help.Styles = com.Styles.Help
+	s.helpKm = km
+	return s
+}
+
+// SetInfoMsg sets the status info message.
+func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
+	s.msg = msg
+}
+
+// ClearInfoMsg clears the status info message.
+func (s *Status) ClearInfoMsg() {
+	s.msg = uiutil.InfoMsg{}
+}
+
+// SetWidth sets the width of the status bar and help view.
+func (s *Status) SetWidth(width int) {
+	s.help.SetWidth(width)
+}
+
+// ShowingAll returns whether the full help view is shown.
+func (s *Status) ShowingAll() bool {
+	return s.help.ShowAll
+}
+
+// ToggleHelp toggles the full help view.
+func (s *Status) ToggleHelp() {
+	s.help.ShowAll = !s.help.ShowAll
+}
+
+// Draw draws the status bar onto the screen.
+func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
+	helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
+	uv.NewStyledString(helpView).Draw(scr, area)
+
+	// Render notifications
+	if s.msg.IsEmpty() {
+		return
+	}
+
+	var indStyle lipgloss.Style
+	var msgStyle lipgloss.Style
+	switch s.msg.Type {
+	case uiutil.InfoTypeError:
+		indStyle = s.com.Styles.Status.ErrorIndicator
+		msgStyle = s.com.Styles.Status.ErrorMessage
+	case uiutil.InfoTypeWarn:
+		indStyle = s.com.Styles.Status.WarnIndicator
+		msgStyle = s.com.Styles.Status.WarnMessage
+	case uiutil.InfoTypeUpdate:
+		indStyle = s.com.Styles.Status.UpdateIndicator
+		msgStyle = s.com.Styles.Status.UpdateMessage
+	case uiutil.InfoTypeInfo:
+		indStyle = s.com.Styles.Status.InfoIndicator
+		msgStyle = s.com.Styles.Status.InfoMessage
+	case uiutil.InfoTypeSuccess:
+		indStyle = s.com.Styles.Status.SuccessIndicator
+		msgStyle = s.com.Styles.Status.SuccessMessage
+	}
+
+	ind := indStyle.String()
+	messageWidth := area.Dx() - lipgloss.Width(ind)
+	msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
+	info := msgStyle.Width(messageWidth).Render(msg)
+
+	// Draw the info message over the help view
+	uv.NewStyledString(ind+info).Draw(scr, area)
+}
+
+// clearInfoMsgCmd returns a command that clears the info message after the
+// given TTL.
+func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
+		return uiutil.ClearStatusMsg{}
+	})
+}

internal/ui/model/ui.go πŸ”—

@@ -0,0 +1,2895 @@
+package model
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"image"
+	"log/slog"
+	"math/rand"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textarea"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/atotto/clipboard"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filetracker"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/completions"
+	"github.com/charmbracelet/crush/internal/ui/dialog"
+	timage "github.com/charmbracelet/crush/internal/ui/image"
+	"github.com/charmbracelet/crush/internal/ui/logo"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/version"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/screen"
+	"github.com/charmbracelet/x/editor"
+)
+
+// Compact mode breakpoints.
+const (
+	compactModeWidthBreakpoint  = 120
+	compactModeHeightBreakpoint = 30
+)
+
+// If pasted text has more than 2 newlines, treat it as a file attachment.
+const pasteLinesThreshold = 10
+
+// Session details panel max height.
+const sessionDetailsMaxHeight = 20
+
+// uiFocusState represents the current focus state of the UI.
+type uiFocusState uint8
+
+// Possible uiFocusState values.
+const (
+	uiFocusNone uiFocusState = iota
+	uiFocusEditor
+	uiFocusMain
+)
+
+type uiState uint8
+
+// Possible uiState values.
+const (
+	uiConfigure uiState = iota
+	uiInitialize
+	uiLanding
+	uiChat
+)
+
+type openEditorMsg struct {
+	Text string
+}
+
+type (
+	// cancelTimerExpiredMsg is sent when the cancel timer expires.
+	cancelTimerExpiredMsg struct{}
+	// userCommandsLoadedMsg is sent when user commands are loaded.
+	userCommandsLoadedMsg struct {
+		Commands []commands.CustomCommand
+	}
+	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
+	mcpPromptsLoadedMsg struct {
+		Prompts []commands.MCPPrompt
+	}
+	// sendMessageMsg is sent to send a message.
+	// currently only used for mcp prompts.
+	sendMessageMsg struct {
+		Content     string
+		Attachments []message.Attachment
+	}
+
+	// closeDialogMsg is sent to close the current dialog.
+	closeDialogMsg struct{}
+
+	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
+	copyChatHighlightMsg struct{}
+)
+
+// UI represents the main user interface model.
+type UI struct {
+	com          *common.Common
+	session      *session.Session
+	sessionFiles []SessionFile
+
+	lastUserMessageTime int64
+
+	// The width and height of the terminal in cells.
+	width  int
+	height int
+	layout layout
+
+	focus uiFocusState
+	state uiState
+
+	keyMap KeyMap
+	keyenh tea.KeyboardEnhancementsMsg
+
+	dialog *dialog.Overlay
+	status *Status
+
+	// isCanceling tracks whether the user has pressed escape once to cancel.
+	isCanceling bool
+
+	// header is the last cached header logo
+	header string
+
+	// sendProgressBar instructs the TUI to send progress bar updates to the
+	// terminal.
+	sendProgressBar bool
+
+	// QueryVersion instructs the TUI to query for the terminal version when it
+	// starts.
+	QueryVersion bool
+
+	// Editor components
+	textarea textarea.Model
+
+	// Attachment list
+	attachments *attachments.Attachments
+
+	readyPlaceholder   string
+	workingPlaceholder string
+
+	// Completions state
+	completions              *completions.Completions
+	completionsOpen          bool
+	completionsStartIndex    int
+	completionsQuery         string
+	completionsPositionStart image.Point // x,y where user typed '@'
+
+	// Chat components
+	chat *Chat
+
+	// onboarding state
+	onboarding struct {
+		yesInitializeSelected bool
+	}
+
+	// lsp
+	lspStates map[string]app.LSPClientInfo
+
+	// mcp
+	mcpStates map[string]mcp.ClientInfo
+
+	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
+	sidebarLogo string
+
+	// imgCaps stores the terminal image capabilities.
+	imgCaps timage.Capabilities
+
+	// custom commands & mcp commands
+	customCommands []commands.CustomCommand
+	mcpPrompts     []commands.MCPPrompt
+
+	// forceCompactMode tracks whether compact mode is forced by user toggle
+	forceCompactMode bool
+
+	// isCompact tracks whether we're currently in compact layout mode (either
+	// by user toggle or auto-switch based on window size)
+	isCompact bool
+
+	// detailsOpen tracks whether the details panel is open (in compact mode)
+	detailsOpen bool
+
+	// pills state
+	pillsExpanded      bool
+	focusedPillSection pillSection
+	promptQueue        int
+	pillsView          string
+
+	// Todo spinner
+	todoSpinner    spinner.Model
+	todoIsSpinning bool
+
+	// mouse highlighting related state
+	lastClickTime time.Time
+}
+
+// New creates a new instance of the [UI] model.
+func New(com *common.Common) *UI {
+	// Editor components
+	ta := textarea.New()
+	ta.SetStyles(com.Styles.TextArea)
+	ta.ShowLineNumbers = false
+	ta.CharLimit = -1
+	ta.SetVirtualCursor(false)
+	ta.Focus()
+
+	ch := NewChat(com)
+
+	keyMap := DefaultKeyMap()
+
+	// Completions component
+	comp := completions.New(
+		com.Styles.Completions.Normal,
+		com.Styles.Completions.Focused,
+		com.Styles.Completions.Match,
+	)
+
+	todoSpinner := spinner.New(
+		spinner.WithSpinner(spinner.MiniDot),
+		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
+	)
+
+	// Attachments component
+	attachments := attachments.New(
+		attachments.NewRenderer(
+			com.Styles.Attachments.Normal,
+			com.Styles.Attachments.Deleting,
+			com.Styles.Attachments.Image,
+			com.Styles.Attachments.Text,
+		),
+		attachments.Keymap{
+			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
+			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
+			Escape:     keyMap.Editor.Escape,
+		},
+	)
+
+	ui := &UI{
+		com:         com,
+		dialog:      dialog.NewOverlay(),
+		keyMap:      keyMap,
+		focus:       uiFocusNone,
+		state:       uiConfigure,
+		textarea:    ta,
+		chat:        ch,
+		completions: comp,
+		attachments: attachments,
+		todoSpinner: todoSpinner,
+	}
+
+	status := NewStatus(com, ui)
+
+	// set onboarding state defaults
+	ui.onboarding.yesInitializeSelected = true
+
+	// If no provider is configured show the user the provider list
+	if !com.Config().IsConfigured() {
+		ui.state = uiConfigure
+		// if the project needs initialization show the user the question
+	} else if n, _ := config.ProjectNeedsInitialization(); n {
+		ui.state = uiInitialize
+		// otherwise go to the landing UI
+	} else {
+		ui.state = uiLanding
+		ui.focus = uiFocusEditor
+	}
+
+	ui.setEditorPrompt(false)
+	ui.randomizePlaceholders()
+	ui.textarea.Placeholder = ui.readyPlaceholder
+	ui.status = status
+
+	// Initialize compact mode from config
+	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+
+	return ui
+}
+
+// Init initializes the UI model.
+func (m *UI) Init() tea.Cmd {
+	var cmds []tea.Cmd
+	if m.QueryVersion {
+		cmds = append(cmds, tea.RequestTerminalVersion)
+	}
+	// load the user commands async
+	cmds = append(cmds, m.loadCustomCommands())
+	return tea.Batch(cmds...)
+}
+
+// loadCustomCommands loads the custom commands asynchronously.
+func (m *UI) loadCustomCommands() tea.Cmd {
+	return func() tea.Msg {
+		customCommands, err := commands.LoadCustomCommands(m.com.Config())
+		if err != nil {
+			slog.Error("failed to load custom commands", "error", err)
+		}
+		return userCommandsLoadedMsg{Commands: customCommands}
+	}
+}
+
+// loadMCPrompts loads the MCP prompts asynchronously.
+func (m *UI) loadMCPrompts() tea.Cmd {
+	return func() tea.Msg {
+		prompts, err := commands.LoadMCPPrompts()
+		if err != nil {
+			slog.Error("failed to load mcp prompts", "error", err)
+		}
+		if prompts == nil {
+			// flag them as loaded even if there is none or an error
+			prompts = []commands.MCPPrompt{}
+		}
+		return mcpPromptsLoadedMsg{Prompts: prompts}
+	}
+}
+
+// Update handles updates to the UI model.
+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	if m.hasSession() && m.isAgentBusy() {
+		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+		if queueSize != m.promptQueue {
+			m.promptQueue = queueSize
+			m.updateLayoutAndSize()
+		}
+	}
+	switch msg := msg.(type) {
+	case tea.EnvMsg:
+		// Is this Windows Terminal?
+		if !m.sendProgressBar {
+			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
+		}
+		m.imgCaps.Env = uv.Environ(msg)
+		// XXX: Right now, we're using the same logic to determine image
+		// support. Terminals like Apple Terminal and possibly others might
+		// bleed characters when querying for Kitty graphics via APC escape
+		// sequences.
+		cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
+	case loadSessionMsg:
+		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		m.session = msg.session
+		m.sessionFiles = msg.files
+		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
+		if err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+			break
+		}
+		if cmd := m.setSessionMessages(msgs); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if hasInProgressTodo(m.session.Todos) {
+			// only start spinner if there is an in-progress todo
+			if m.isAgentBusy() {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+			}
+			m.updateLayoutAndSize()
+		}
+
+	case sendMessageMsg:
+		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
+
+	case userCommandsLoadedMsg:
+		m.customCommands = msg.Commands
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetCustomCommands(m.customCommands)
+		}
+	case mcpPromptsLoadedMsg:
+		m.mcpPrompts = msg.Prompts
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetMCPPrompts(m.mcpPrompts)
+		}
+
+	case closeDialogMsg:
+		m.dialog.CloseFrontDialog()
+
+	case pubsub.Event[session.Session]:
+		if m.session != nil && msg.Payload.ID == m.session.ID {
+			prevHasInProgress := hasInProgressTodo(m.session.Todos)
+			m.session = &msg.Payload
+			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+				m.updateLayoutAndSize()
+			}
+		}
+	case pubsub.Event[message.Message]:
+		// Check if this is a child session message for an agent tool.
+		if m.session == nil {
+			break
+		}
+		if msg.Payload.SessionID != m.session.ID {
+			// This might be a child session message from an agent tool.
+			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			break
+		}
+		switch msg.Type {
+		case pubsub.CreatedEvent:
+			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
+		case pubsub.UpdatedEvent:
+			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+		case pubsub.DeletedEvent:
+			m.chat.RemoveMessage(msg.Payload.ID)
+		}
+		// start the spinner if there is a new message
+		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
+			m.todoIsSpinning = true
+			cmds = append(cmds, m.todoSpinner.Tick)
+		}
+		// stop the spinner if the agent is not busy anymore
+		if m.todoIsSpinning && !m.isAgentBusy() {
+			m.todoIsSpinning = false
+		}
+		// there is a number of things that could change the pills here so we want to re-render
+		m.renderPills()
+	case pubsub.Event[history.File]:
+		cmds = append(cmds, m.handleFileEvent(msg.Payload))
+	case pubsub.Event[app.LSPEvent]:
+		m.lspStates = app.GetLSPStates()
+	case pubsub.Event[mcp.Event]:
+		m.mcpStates = mcp.GetStates()
+		// check if all mcps are initialized
+		initialized := true
+		for _, state := range m.mcpStates {
+			if state.State == mcp.StateStarting {
+				initialized = false
+				break
+			}
+		}
+		if initialized && m.mcpPrompts == nil {
+			cmds = append(cmds, m.loadMCPrompts())
+		}
+	case pubsub.Event[permission.PermissionRequest]:
+		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case pubsub.Event[permission.PermissionNotification]:
+		m.handlePermissionNotification(msg.Payload)
+	case cancelTimerExpiredMsg:
+		m.isCanceling = false
+	case tea.TerminalVersionMsg:
+		termVersion := strings.ToLower(msg.Name)
+		// Only enable progress bar for the following terminals.
+		if !m.sendProgressBar {
+			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
+		}
+		return m, nil
+	case tea.WindowSizeMsg:
+		m.width, m.height = msg.Width, msg.Height
+		m.handleCompactMode(m.width, m.height)
+		m.updateLayoutAndSize()
+		// XXX: We need to store cell dimensions for image rendering.
+		m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
+	case tea.KeyboardEnhancementsMsg:
+		m.keyenh = msg
+		if msg.SupportsKeyDisambiguation() {
+			m.keyMap.Models.SetHelp("ctrl+m", "models")
+			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
+		}
+	case copyChatHighlightMsg:
+		cmds = append(cmds, m.copyChatHighlight())
+	case tea.MouseClickMsg:
+		switch m.state {
+		case uiChat:
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			if m.chat.HandleMouseDown(x, y) {
+				m.lastClickTime = time.Now()
+			}
+		}
+
+	case tea.MouseMotionMsg:
+		switch m.state {
+		case uiChat:
+			if msg.Y <= 0 {
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			} else if msg.Y >= m.chat.Height()-1 {
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			}
+
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			m.chat.HandleMouseDrag(x, y)
+		}
+
+	case tea.MouseReleaseMsg:
+		const doubleClickThreshold = 500 * time.Millisecond
+
+		switch m.state {
+		case uiChat:
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			if m.chat.HandleMouseUp(x, y) {
+				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+					if time.Since(m.lastClickTime) >= doubleClickThreshold {
+						return copyChatHighlightMsg{}
+					}
+					return nil
+				}))
+			}
+		}
+	case tea.MouseWheelMsg:
+		// Pass mouse events to dialogs first if any are open.
+		if m.dialog.HasDialogs() {
+			m.dialog.Update(msg)
+			return m, tea.Batch(cmds...)
+		}
+
+		// Otherwise handle mouse wheel for chat.
+		switch m.state {
+		case uiChat:
+			switch msg.Button {
+			case tea.MouseWheelUp:
+				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case tea.MouseWheelDown:
+				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			}
+		}
+	case anim.StepMsg:
+		if m.state == uiChat {
+			if cmd := m.chat.Animate(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case spinner.TickMsg:
+		if m.dialog.HasDialogs() {
+			// route to dialog
+			if cmd := m.handleDialogMsg(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
+			var cmd tea.Cmd
+			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
+			if cmd != nil {
+				m.renderPills()
+				cmds = append(cmds, cmd)
+			}
+		}
+
+	case tea.KeyPressMsg:
+		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case tea.PasteMsg:
+		if cmd := m.handlePasteMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case openEditorMsg:
+		m.textarea.SetValue(msg.Text)
+		m.textarea.MoveToEnd()
+	case uiutil.InfoMsg:
+		m.status.SetInfoMsg(msg)
+		ttl := msg.TTL
+		if ttl <= 0 {
+			ttl = DefaultStatusTTL
+		}
+		cmds = append(cmds, clearInfoMsgCmd(ttl))
+	case uiutil.ClearStatusMsg:
+		m.status.ClearInfoMsg()
+	case completions.FilesLoadedMsg:
+		// Handle async file loading for completions.
+		if m.completionsOpen {
+			m.completions.SetFiles(msg.Files)
+		}
+	case uv.WindowPixelSizeEvent:
+		// [timage.RequestCapabilities] requests the terminal to send a window
+		// size event to help determine pixel dimensions.
+		m.imgCaps.PixelWidth = msg.Width
+		m.imgCaps.PixelHeight = msg.Height
+	case uv.KittyGraphicsEvent:
+		// [timage.RequestCapabilities] sends a Kitty graphics query and this
+		// captures the response. Any response means the terminal understands
+		// the protocol.
+		m.imgCaps.SupportsKittyGraphics = true
+		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
+			slog.Warn("unexpected Kitty graphics response",
+				"response", string(msg.Payload),
+				"options", msg.Options)
+		}
+	default:
+		if m.dialog.HasDialogs() {
+			if cmd := m.handleDialogMsg(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	// This logic gets triggered on any message type, but should it?
+	switch m.focus {
+	case uiFocusMain:
+	case uiFocusEditor:
+		// Textarea placeholder logic
+		if m.isAgentBusy() {
+			m.textarea.Placeholder = m.workingPlaceholder
+		} else {
+			m.textarea.Placeholder = m.readyPlaceholder
+		}
+		if m.com.App.Permissions.SkipRequests() {
+			m.textarea.Placeholder = "Yolo mode!"
+		}
+	}
+
+	// at this point this can only handle [message.Attachment] message, and we
+	// should return all cmds anyway.
+	_ = m.attachments.Update(msg)
+	return m, tea.Batch(cmds...)
+}
+
+// setSessionMessages sets the messages for the current session in the chat
+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	// Build tool result map to link tool calls with their results
+	msgPtrs := make([]*message.Message, len(msgs))
+	for i := range msgs {
+		msgPtrs[i] = &msgs[i]
+	}
+	toolResultMap := chat.BuildToolResultMap(msgPtrs)
+	if len(msgPtrs) > 0 {
+		m.lastUserMessageTime = msgPtrs[0].CreatedAt
+	}
+
+	// Add messages to chat with linked tool results
+	items := make([]chat.MessageItem, 0, len(msgs)*2)
+	for _, msg := range msgPtrs {
+		switch msg.Role {
+		case message.User:
+			m.lastUserMessageTime = msg.CreatedAt
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		case message.Assistant:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
+				items = append(items, infoItem)
+			}
+		default:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		}
+	}
+
+	// Load nested tool calls for agent/agentic_fetch tools.
+	m.loadNestedToolCalls(items)
+
+	// If the user switches between sessions while the agent is working we want
+	// to make sure the animations are shown.
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	m.chat.SetMessages(items...)
+	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	m.chat.SelectLast()
+	return tea.Batch(cmds...)
+}
+
+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
+	for _, item := range items {
+		nestedContainer, ok := item.(chat.NestedToolContainer)
+		if !ok {
+			continue
+		}
+		toolItem, ok := item.(chat.ToolMessageItem)
+		if !ok {
+			continue
+		}
+
+		tc := toolItem.ToolCall()
+		messageID := toolItem.MessageID()
+
+		// Get the agent tool session ID.
+		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
+
+		// Fetch nested messages.
+		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+		if err != nil || len(nestedMsgs) == 0 {
+			continue
+		}
+
+		// Build tool result map for nested messages.
+		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
+		for i := range nestedMsgs {
+			nestedMsgPtrs[i] = &nestedMsgs[i]
+		}
+		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
+
+		// Extract nested tool items.
+		var nestedTools []chat.ToolMessageItem
+		for _, nestedMsg := range nestedMsgPtrs {
+			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
+			for _, nestedItem := range nestedItems {
+				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
+					// Mark nested tools as simple (compact) rendering.
+					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
+						simplifiable.SetCompact(true)
+					}
+					nestedTools = append(nestedTools, nestedToolItem)
+				}
+			}
+		}
+
+		// Recursively load nested tool calls for any agent tools within.
+		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
+		for i, nt := range nestedTools {
+			nestedMessageItems[i] = nt
+		}
+		m.loadNestedToolCalls(nestedMessageItems)
+
+		// Set nested tools on the parent.
+		nestedContainer.SetNestedTools(nestedTools)
+	}
+}
+
+// appendSessionMessage appends a new message to the current session in the chat
+// if the message is a tool result it will update the corresponding tool call message
+func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	existing := m.chat.MessageItem(msg.ID)
+	if existing != nil {
+		// message already exists, skip
+		return nil
+	}
+	switch msg.Role {
+	case message.User:
+		m.lastUserMessageTime = msg.CreatedAt
+		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case message.Assistant:
+		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			m.chat.AppendMessages(infoItem)
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case message.Tool:
+		for _, tr := range msg.ToolResults() {
+			toolItem := m.chat.MessageItem(tr.ToolCallID)
+			if toolItem == nil {
+				// we should have an item!
+				continue
+			}
+			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
+				toolMsgItem.SetResult(&tr)
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// updateSessionMessage updates an existing message in the current session in the chat
+// when an assistant message is updated it may include updated tool calls as well
+// that is why we need to handle creating/updating each tool call message too
+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	existingItem := m.chat.MessageItem(msg.ID)
+
+	if existingItem != nil {
+		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+			assistantItem.SetMessage(&msg)
+		}
+	}
+
+	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
+	// if the message of the assistant does not have any  response just tool calls we need to remove it
+	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
+		m.chat.RemoveMessage(msg.ID)
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
+			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
+		}
+	}
+
+	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
+			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			m.chat.AppendMessages(newInfoItem)
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	var items []chat.MessageItem
+	for _, tc := range msg.ToolCalls() {
+		existingToolItem := m.chat.MessageItem(tc.ID)
+		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
+			existingToolCall := toolItem.ToolCall()
+			// only update if finished state changed or input changed
+			// to avoid clearing the cache
+			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
+				toolItem.SetToolCall(tc)
+			}
+		}
+		if existingToolItem == nil {
+			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
+		}
+	}
+
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+	m.chat.AppendMessages(items...)
+	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// handleChildSessionMessage handles messages from child sessions (agent tools).
+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
+	var cmds []tea.Cmd
+
+	// Only process messages with tool calls or results.
+	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
+		return nil
+	}
+
+	// Check if this is an agent tool session and parse it.
+	childSessionID := event.Payload.SessionID
+	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+	if !ok {
+		return nil
+	}
+
+	// Find the parent agent tool item.
+	var agentItem chat.NestedToolContainer
+	for i := 0; i < m.chat.Len(); i++ {
+		item := m.chat.MessageItem(toolCallID)
+		if item == nil {
+			continue
+		}
+		if agent, ok := item.(chat.NestedToolContainer); ok {
+			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
+				if toolMessageItem.ToolCall().ID == toolCallID {
+					// Verify this agent belongs to the correct parent message.
+					// We can't directly check parentMessageID on the item, so we trust the session parsing.
+					agentItem = agent
+					break
+				}
+			}
+		}
+	}
+
+	if agentItem == nil {
+		return nil
+	}
+
+	// Get existing nested tools.
+	nestedTools := agentItem.NestedTools()
+
+	// Update or create nested tool calls.
+	for _, tc := range event.Payload.ToolCalls() {
+		found := false
+		for _, existingTool := range nestedTools {
+			if existingTool.ToolCall().ID == tc.ID {
+				existingTool.SetToolCall(tc)
+				found = true
+				break
+			}
+		}
+		if !found {
+			// Create a new nested tool item.
+			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
+			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
+				simplifiable.SetCompact(true)
+			}
+			if animatable, ok := nestedItem.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			nestedTools = append(nestedTools, nestedItem)
+		}
+	}
+
+	// Update nested tool results.
+	for _, tr := range event.Payload.ToolResults() {
+		for _, nestedTool := range nestedTools {
+			if nestedTool.ToolCall().ID == tr.ToolCallID {
+				nestedTool.SetResult(&tr)
+				break
+			}
+		}
+	}
+
+	// Update the agent item with the new nested tools.
+	agentItem.SetNestedTools(nestedTools)
+
+	// Update the chat so it updates the index map for animations to work as expected
+	m.chat.UpdateNestedToolIDs(toolCallID)
+
+	return tea.Batch(cmds...)
+}
+
+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
+	var cmds []tea.Cmd
+	action := m.dialog.Update(msg)
+	if action == nil {
+		return tea.Batch(cmds...)
+	}
+
+	switch msg := action.(type) {
+	// Generic dialog messages
+	case dialog.ActionClose:
+		m.dialog.CloseFrontDialog()
+		if m.focus == uiFocusEditor {
+			cmds = append(cmds, m.textarea.Focus())
+		}
+	case dialog.ActionCmd:
+		if msg.Cmd != nil {
+			cmds = append(cmds, msg.Cmd)
+		}
+
+	// Session dialog messages
+	case dialog.ActionSelectSession:
+		m.dialog.CloseDialog(dialog.SessionsID)
+		cmds = append(cmds, m.loadSession(msg.Session.ID))
+
+	// Open dialog message
+	case dialog.ActionOpenDialog:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		if cmd := m.openDialog(msg.DialogID); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
+	// Command dialog messages
+	case dialog.ActionToggleYoloMode:
+		yolo := !m.com.App.Permissions.SkipRequests()
+		m.com.App.Permissions.SetSkipRequests(yolo)
+		m.setEditorPrompt(yolo)
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionNewSession:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+			break
+		}
+		m.newSession()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionSummarize:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, func() tea.Msg {
+			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				return uiutil.ReportError(err)()
+			}
+			return nil
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleHelp:
+		m.status.ToggleHelp()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionExternalEditor:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+			break
+		}
+		cmds = append(cmds, m.openEditor(m.textarea.Value()))
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleCompactMode:
+		cmds = append(cmds, m.toggleCompactMode())
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleThinking:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			cfg := m.com.Config()
+			if cfg == nil {
+				return uiutil.ReportError(errors.New("configuration not found"))()
+			}
+
+			agentCfg, ok := cfg.Agents[config.AgentCoder]
+			if !ok {
+				return uiutil.ReportError(errors.New("agent configuration not found"))()
+			}
+
+			currentModel := cfg.Models[agentCfg.Model]
+			currentModel.Think = !currentModel.Think
+			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+				return uiutil.ReportError(err)()
+			}
+			m.com.App.UpdateAgentModel(context.TODO())
+			status := "disabled"
+			if currentModel.Think {
+				status = "enabled"
+			}
+			return uiutil.NewInfoMsg("Thinking mode " + status)
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionQuit:
+		cmds = append(cmds, tea.Quit)
+	case dialog.ActionInitializeProject:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, m.initializeProject())
+
+	case dialog.ActionSelectModel:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		var (
+			providerID   = msg.Model.Provider
+			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
+			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
+		)
+
+		// Attempt to import GitHub Copilot tokens from VSCode if available.
+		if isCopilot && !isConfigured() {
+			config.Get().ImportCopilot()
+		}
+
+		if !isConfigured() {
+			m.dialog.CloseDialog(dialog.ModelsID)
+			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			break
+		}
+
+		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+
+			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+
+			return uiutil.NewInfoMsg(modelMsg)
+		})
+
+		m.dialog.CloseDialog(dialog.APIKeyInputID)
+		m.dialog.CloseDialog(dialog.OAuthID)
+		m.dialog.CloseDialog(dialog.ModelsID)
+	case dialog.ActionSelectReasoningEffort:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		agentCfg, ok := cfg.Agents[config.AgentCoder]
+		if !ok {
+			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+			break
+		}
+
+		currentModel := cfg.Models[agentCfg.Model]
+		currentModel.ReasoningEffort = msg.Effort
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+			break
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+		})
+		m.dialog.CloseDialog(dialog.ReasoningID)
+	case dialog.ActionPermissionResponse:
+		m.dialog.CloseDialog(dialog.PermissionsID)
+		switch msg.Action {
+		case dialog.PermissionAllow:
+			m.com.App.Permissions.Grant(msg.Permission)
+		case dialog.PermissionAllowForSession:
+			m.com.App.Permissions.GrantPersistent(msg.Permission)
+		case dialog.PermissionDeny:
+			m.com.App.Permissions.Deny(msg.Permission)
+		}
+
+	case dialog.ActionFilePickerSelected:
+		cmds = append(cmds, tea.Sequence(
+			msg.Cmd(),
+			func() tea.Msg {
+				m.dialog.CloseDialog(dialog.FilePickerID)
+				return nil
+			},
+		))
+
+	case dialog.ActionRunCustomCommand:
+		if len(msg.Arguments) > 0 && msg.Args == nil {
+			m.dialog.CloseFrontDialog()
+			argsDialog := dialog.NewArguments(
+				m.com,
+				"Custom Command Arguments",
+				"",
+				msg.Arguments,
+				msg, // Pass the action as the result
+			)
+			m.dialog.OpenDialog(argsDialog)
+			break
+		}
+		content := msg.Content
+		if msg.Args != nil {
+			content = substituteArgs(content, msg.Args)
+		}
+		cmds = append(cmds, m.sendMessage(content))
+		m.dialog.CloseFrontDialog()
+	case dialog.ActionRunMCPPrompt:
+		if len(msg.Arguments) > 0 && msg.Args == nil {
+			m.dialog.CloseFrontDialog()
+			title := msg.Title
+			if title == "" {
+				title = "MCP Prompt Arguments"
+			}
+			argsDialog := dialog.NewArguments(
+				m.com,
+				title,
+				msg.Description,
+				msg.Arguments,
+				msg, // Pass the action as the result
+			)
+			m.dialog.OpenDialog(argsDialog)
+			break
+		}
+		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
+	default:
+		cmds = append(cmds, uiutil.CmdHandler(msg))
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
+func substituteArgs(content string, args map[string]string) string {
+	for name, value := range args {
+		placeholder := "$" + name
+		content = strings.ReplaceAll(content, placeholder, value)
+	}
+	return content
+}
+
+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
+	var (
+		dlg dialog.Dialog
+		cmd tea.Cmd
+	)
+
+	switch provider.ID {
+	case "hyper":
+		dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
+	case catwalk.InferenceProviderCopilot:
+		dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
+	default:
+		dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
+	}
+
+	if m.dialog.ContainsDialog(dlg.ID()) {
+		m.dialog.BringToFront(dlg.ID())
+		return nil
+	}
+
+	m.dialog.OpenDialog(dlg)
+	return cmd
+}
+
+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	var cmds []tea.Cmd
+
+	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
+		switch {
+		case key.Matches(msg, m.keyMap.Help):
+			m.status.ToggleHelp()
+			m.updateLayoutAndSize()
+			return true
+		case key.Matches(msg, m.keyMap.Commands):
+			if cmd := m.openCommandsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Models):
+			if cmd := m.openModelsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Sessions):
+			if cmd := m.openSessionsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
+			m.detailsOpen = !m.detailsOpen
+			m.updateLayoutAndSize()
+			return true
+		case key.Matches(msg, m.keyMap.Chat.TogglePills):
+			if m.state == uiChat && m.hasSession() {
+				if cmd := m.togglePillsExpanded(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Chat.PillLeft):
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+				if cmd := m.switchPillSection(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Chat.PillRight):
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+				if cmd := m.switchPillSection(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		}
+		return false
+	}
+
+	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
+		// Always handle quit keys first
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
+		return tea.Batch(cmds...)
+	}
+
+	// Route all messages to dialog if one is open.
+	if m.dialog.HasDialogs() {
+		return m.handleDialogMsg(msg)
+	}
+
+	// Handle cancel key when agent is busy.
+	if key.Matches(msg, m.keyMap.Chat.Cancel) {
+		if m.isAgentBusy() {
+			if cmd := m.cancelAgent(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return tea.Batch(cmds...)
+		}
+	}
+
+	switch m.state {
+	case uiConfigure:
+		return tea.Batch(cmds...)
+	case uiInitialize:
+		cmds = append(cmds, m.updateInitializeView(msg)...)
+		return tea.Batch(cmds...)
+	case uiChat, uiLanding:
+		switch m.focus {
+		case uiFocusEditor:
+			// Handle completions if open.
+			if m.completionsOpen {
+				if msg, ok := m.completions.Update(msg); ok {
+					switch msg := msg.(type) {
+					case completions.SelectionMsg:
+						// Handle file completion selection.
+						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
+							cmds = append(cmds, m.insertFileCompletion(item.Path))
+						}
+						if !msg.Insert {
+							m.closeCompletions()
+						}
+					case completions.ClosedMsg:
+						m.completionsOpen = false
+					}
+					return tea.Batch(cmds...)
+				}
+			}
+
+			if ok := m.attachments.Update(msg); ok {
+				return tea.Batch(cmds...)
+			}
+
+			switch {
+			case key.Matches(msg, m.keyMap.Editor.AddImage):
+				if cmd := m.openFilesDialog(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+
+			case key.Matches(msg, m.keyMap.Editor.SendMessage):
+				value := m.textarea.Value()
+				if before, ok := strings.CutSuffix(value, "\\"); ok {
+					// If the last character is a backslash, remove it and add a newline.
+					m.textarea.SetValue(before)
+					break
+				}
+
+				// Otherwise, send the message
+				m.textarea.Reset()
+
+				value = strings.TrimSpace(value)
+				if value == "exit" || value == "quit" {
+					return m.openQuitDialog()
+				}
+
+				attachments := m.attachments.List()
+				m.attachments.Reset()
+				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
+					return nil
+				}
+
+				m.randomizePlaceholders()
+
+				return m.sendMessage(value, attachments...)
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if !m.hasSession() {
+					break
+				}
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.newSession()
+			case key.Matches(msg, m.keyMap.Tab):
+				m.focus = uiFocusMain
+				m.textarea.Blur()
+				m.chat.Focus()
+				m.chat.SetSelected(m.chat.Len() - 1)
+			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+					break
+				}
+				cmds = append(cmds, m.openEditor(m.textarea.Value()))
+			case key.Matches(msg, m.keyMap.Editor.Newline):
+				m.textarea.InsertRune('\n')
+				m.closeCompletions()
+			default:
+				if handleGlobalKeys(msg) {
+					// Handle global keys first before passing to textarea.
+					break
+				}
+
+				// Check for @ trigger before passing to textarea.
+				curValue := m.textarea.Value()
+				curIdx := len(curValue)
+
+				// Trigger completions on @.
+				if msg.String() == "@" && !m.completionsOpen {
+					// Only show if beginning of prompt or after whitespace.
+					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
+						m.completionsOpen = true
+						m.completionsQuery = ""
+						m.completionsStartIndex = curIdx
+						m.completionsPositionStart = m.completionsPosition()
+						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
+						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
+					}
+				}
+
+				// remove the details if they are open when user starts typing
+				if m.detailsOpen {
+					m.detailsOpen = false
+					m.updateLayoutAndSize()
+				}
+
+				ta, cmd := m.textarea.Update(msg)
+				m.textarea = ta
+				cmds = append(cmds, cmd)
+
+				// After updating textarea, check if we need to filter completions.
+				// Skip filtering on the initial @ keystroke since items are loading async.
+				if m.completionsOpen && msg.String() != "@" {
+					newValue := m.textarea.Value()
+					newIdx := len(newValue)
+
+					// Close completions if cursor moved before start.
+					if newIdx <= m.completionsStartIndex {
+						m.closeCompletions()
+					} else if msg.String() == "space" {
+						// Close on space.
+						m.closeCompletions()
+					} else {
+						// Extract current word and filter.
+						word := m.textareaWord()
+						if strings.HasPrefix(word, "@") {
+							m.completionsQuery = word[1:]
+							m.completions.Filter(m.completionsQuery)
+						} else if m.completionsOpen {
+							m.closeCompletions()
+						}
+					}
+				}
+			}
+		case uiFocusMain:
+			switch {
+			case key.Matches(msg, m.keyMap.Tab):
+				m.focus = uiFocusEditor
+				cmds = append(cmds, m.textarea.Focus())
+				m.chat.Blur()
+			case key.Matches(msg, m.keyMap.Chat.Expand):
+				m.chat.ToggleExpandedSelectedItem()
+			case key.Matches(msg, m.keyMap.Chat.Up):
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case key.Matches(msg, m.keyMap.Chat.Down):
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
+				m.chat.SelectPrev()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
+				m.chat.SelectNext()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.PageUp):
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.PageDown):
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.Home):
+				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirst()
+			case key.Matches(msg, m.keyMap.Chat.End):
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLast()
+			default:
+				handleGlobalKeys(msg)
+			}
+		default:
+			handleGlobalKeys(msg)
+		}
+	default:
+		handleGlobalKeys(msg)
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// Draw implements [uv.Drawable] and draws the UI model.
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	layout := m.generateLayout(area.Dx(), area.Dy())
+
+	if m.layout != layout {
+		m.layout = layout
+		m.updateSize()
+	}
+
+	// Clear the screen first
+	screen.Clear(scr)
+
+	switch m.state {
+	case uiConfigure:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+
+		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
+			Height(layout.main.Dy()).
+			Background(lipgloss.ANSIColor(rand.Intn(256))).
+			Render(" Configure ")
+		main := uv.NewStyledString(mainView)
+		main.Draw(scr, layout.main)
+
+	case uiInitialize:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+
+		main := uv.NewStyledString(m.initializeView())
+		main.Draw(scr, layout.main)
+
+	case uiLanding:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+		main := uv.NewStyledString(m.landingView())
+		main.Draw(scr, layout.main)
+
+		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
+		editor.Draw(scr, layout.editor)
+
+	case uiChat:
+		if m.isCompact {
+			header := uv.NewStyledString(m.header)
+			header.Draw(scr, layout.header)
+		} else {
+			m.drawSidebar(scr, layout.sidebar)
+		}
+
+		m.chat.Draw(scr, layout.main)
+		if layout.pills.Dy() > 0 && m.pillsView != "" {
+			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
+		}
+
+		editorWidth := scr.Bounds().Dx()
+		if !m.isCompact {
+			editorWidth -= layout.sidebar.Dx()
+		}
+		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
+		editor.Draw(scr, layout.editor)
+
+		// Draw details overlay in compact mode when open
+		if m.isCompact && m.detailsOpen {
+			m.drawSessionDetails(scr, layout.sessionDetails)
+		}
+	}
+
+	// Add status and help layer
+	m.status.Draw(scr, layout.status)
+
+	// Draw completions popup if open
+	if m.completionsOpen && m.completions.HasItems() {
+		w, h := m.completions.Size()
+		x := m.completionsPositionStart.X
+		y := m.completionsPositionStart.Y - h
+
+		screenW := area.Dx()
+		if x+w > screenW {
+			x = screenW - w
+		}
+		x = max(0, x)
+		y = max(0, y)
+
+		completionsView := uv.NewStyledString(m.completions.Render())
+		completionsView.Draw(scr, image.Rectangle{
+			Min: image.Pt(x, y),
+			Max: image.Pt(x+w, y+h),
+		})
+	}
+
+	// Debugging rendering (visually see when the tui rerenders)
+	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
+		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
+		debug := uv.NewStyledString(debugView.String())
+		debug.Draw(scr, image.Rectangle{
+			Min: image.Pt(4, 1),
+			Max: image.Pt(8, 3),
+		})
+	}
+
+	// This needs to come last to overlay on top of everything. We always pass
+	// the full screen bounds because the dialogs will position themselves
+	// accordingly.
+	if m.dialog.HasDialogs() {
+		return m.dialog.Draw(scr, scr.Bounds())
+	}
+
+	switch m.focus {
+	case uiFocusEditor:
+		if m.layout.editor.Dy() <= 0 {
+			// Don't show cursor if editor is not visible
+			return nil
+		}
+		if m.detailsOpen && m.isCompact {
+			// Don't show cursor if details overlay is open
+			return nil
+		}
+
+		if m.textarea.Focused() {
+			cur := m.textarea.Cursor()
+			cur.X++ // Adjust for app margins
+			cur.Y += m.layout.editor.Min.Y
+			// Offset for attachment row if present.
+			if len(m.attachments.List()) > 0 {
+				cur.Y++
+			}
+			return cur
+		}
+	}
+	return nil
+}
+
+// View renders the UI model's view.
+func (m *UI) View() tea.View {
+	var v tea.View
+	v.AltScreen = true
+	v.BackgroundColor = m.com.Styles.Background
+	v.MouseMode = tea.MouseModeCellMotion
+	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
+
+	canvas := uv.NewScreenBuffer(m.width, m.height)
+	v.Cursor = m.Draw(canvas, canvas.Bounds())
+
+	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
+	contentLines := strings.Split(content, "\n")
+	for i, line := range contentLines {
+		// Trim trailing spaces for concise rendering
+		contentLines[i] = strings.TrimRight(line, " ")
+	}
+
+	content = strings.Join(contentLines, "\n")
+
+	v.Content = content
+	if m.sendProgressBar && m.isAgentBusy() {
+		// HACK: use a random percentage to prevent ghostty from hiding it
+		// after a timeout.
+		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
+	}
+
+	return v
+}
+
+// ShortHelp implements [help.KeyMap].
+func (m *UI) ShortHelp() []key.Binding {
+	var binds []key.Binding
+	k := &m.keyMap
+	tab := k.Tab
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
+
+	switch m.state {
+	case uiInitialize:
+		binds = append(binds, k.Quit)
+	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.isAgentBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, cancelBinding)
+		}
+
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		binds = append(binds,
+			tab,
+			commands,
+			k.Models,
+		)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				k.Editor.Newline,
+			)
+		case uiFocusMain:
+			binds = append(binds,
+				k.Chat.UpDown,
+				k.Chat.UpDownOneItem,
+				k.Chat.PageUp,
+				k.Chat.PageDown,
+				k.Chat.Copy,
+			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, k.Chat.PillLeft)
+			}
+		}
+	default:
+		// TODO: other states
+		// if m.session == nil {
+		// no session selected
+		binds = append(binds,
+			commands,
+			k.Models,
+			k.Editor.Newline,
+		)
+	}
+
+	binds = append(binds,
+		k.Quit,
+		k.Help,
+	)
+
+	return binds
+}
+
+// FullHelp implements [help.KeyMap].
+func (m *UI) FullHelp() [][]key.Binding {
+	var binds [][]key.Binding
+	k := &m.keyMap
+	help := k.Help
+	help.SetHelp("ctrl+g", "less")
+	hasAttachments := len(m.attachments.List()) > 0
+	hasSession := m.hasSession()
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
+
+	switch m.state {
+	case uiInitialize:
+		binds = append(binds,
+			[]key.Binding{
+				k.Quit,
+			})
+	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.isAgentBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, []key.Binding{cancelBinding})
+		}
+
+		mainBinds := []key.Binding{}
+		tab := k.Tab
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		mainBinds = append(mainBinds,
+			tab,
+			commands,
+			k.Models,
+			k.Sessions,
+		)
+		if hasSession {
+			mainBinds = append(mainBinds, k.Chat.NewSession)
+		}
+
+		binds = append(binds, mainBinds)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+		case uiFocusMain:
+			binds = append(binds,
+				[]key.Binding{
+					k.Chat.UpDown,
+					k.Chat.UpDownOneItem,
+					k.Chat.PageUp,
+					k.Chat.PageDown,
+				},
+				[]key.Binding{
+					k.Chat.HalfPageUp,
+					k.Chat.HalfPageDown,
+					k.Chat.Home,
+					k.Chat.End,
+				},
+				[]key.Binding{
+					k.Chat.Copy,
+					k.Chat.ClearHighlight,
+				},
+			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, []key.Binding{k.Chat.PillLeft})
+			}
+		}
+	default:
+		if m.session == nil {
+			// no session selected
+			binds = append(binds,
+				[]key.Binding{
+					commands,
+					k.Models,
+					k.Sessions,
+				},
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+			binds = append(binds,
+				[]key.Binding{
+					help,
+				},
+			)
+		}
+	}
+
+	binds = append(binds,
+		[]key.Binding{
+			help,
+			k.Quit,
+		},
+	)
+
+	return binds
+}
+
+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
+func (m *UI) toggleCompactMode() tea.Cmd {
+	m.forceCompactMode = !m.forceCompactMode
+
+	err := m.com.Config().SetCompactMode(m.forceCompactMode)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.handleCompactMode(m.width, m.height)
+	m.updateLayoutAndSize()
+
+	return nil
+}
+
+// handleCompactMode updates the UI state based on window size and compact mode setting.
+func (m *UI) handleCompactMode(newWidth, newHeight int) {
+	if m.state == uiChat {
+		if m.forceCompactMode {
+			m.isCompact = true
+			return
+		}
+		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
+			m.isCompact = true
+		} else {
+			m.isCompact = false
+		}
+	}
+}
+
+// updateLayoutAndSize updates the layout and sizes of UI components.
+func (m *UI) updateLayoutAndSize() {
+	m.layout = m.generateLayout(m.width, m.height)
+	m.updateSize()
+}
+
+// updateSize updates the sizes of UI components based on the current layout.
+func (m *UI) updateSize() {
+	// Set status width
+	m.status.SetWidth(m.layout.status.Dx())
+
+	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
+	m.textarea.SetWidth(m.layout.editor.Dx())
+	m.textarea.SetHeight(m.layout.editor.Dy())
+	m.renderPills()
+
+	// Handle different app states
+	switch m.state {
+	case uiConfigure, uiInitialize, uiLanding:
+		m.renderHeader(false, m.layout.header.Dx())
+
+	case uiChat:
+		if m.isCompact {
+			m.renderHeader(true, m.layout.header.Dx())
+		} else {
+			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		}
+	}
+}
+
+// generateLayout calculates the layout rectangles for all UI components based
+// on the current UI state and terminal dimensions.
+func (m *UI) generateLayout(w, h int) layout {
+	// The screen area we're working with
+	area := image.Rect(0, 0, w, h)
+
+	// The help height
+	helpHeight := 1
+	// The editor height
+	editorHeight := 5
+	// The sidebar width
+	sidebarWidth := 30
+	// The header height
+	const landingHeaderHeight = 4
+
+	var helpKeyMap help.KeyMap = m
+	if m.status.ShowingAll() {
+		for _, row := range helpKeyMap.FullHelp() {
+			helpHeight = max(helpHeight, len(row))
+		}
+	}
+
+	// Add app margins
+	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
+	appRect.Min.Y += 1
+	appRect.Max.Y -= 1
+	helpRect.Min.Y -= 1
+	appRect.Min.X += 1
+	appRect.Max.X -= 1
+
+	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
+		// extra padding on left and right for these states
+		appRect.Min.X += 1
+		appRect.Max.X -= 1
+	}
+
+	layout := layout{
+		area:   area,
+		status: helpRect,
+	}
+
+	// Handle different app states
+	switch m.state {
+	case uiConfigure, uiInitialize:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// help
+
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
+		layout.header = headerRect
+		layout.main = mainRect
+
+	case uiLanding:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// editor
+		// ------
+		// help
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
+		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		// Remove extra padding from editor (but keep it for header and main)
+		editorRect.Min.X -= 1
+		editorRect.Max.X += 1
+		layout.header = headerRect
+		layout.main = mainRect
+		layout.editor = editorRect
+
+	case uiChat:
+		if m.isCompact {
+			// Layout
+			//
+			// compact-header
+			// ------
+			// main
+			// ------
+			// editor
+			// ------
+			// help
+			const compactHeaderHeight = 1
+			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
+			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
+			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
+			layout.sessionDetails = sessionDetailsArea
+			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
+			// Add one line gap between header and main content
+			mainRect.Min.Y += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			layout.header = headerRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
+			layout.editor = editorRect
+		} else {
+			// Layout
+			//
+			// ------|---
+			// main  |
+			// ------| side
+			// editor|
+			// ----------
+			// help
+
+			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+			// Add padding left
+			sideRect.Min.X += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			layout.sidebar = sideRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
+			layout.editor = editorRect
+		}
+	}
+
+	if !layout.editor.Empty() {
+		// Add editor margins 1 top and bottom
+		layout.editor.Min.Y += 1
+		layout.editor.Max.Y -= 1
+	}
+
+	return layout
+}
+
+// layout defines the positioning of UI elements.
+type layout struct {
+	// area is the overall available area.
+	area uv.Rectangle
+
+	// header is the header shown in special cases
+	// e.x when the sidebar is collapsed
+	// or when in the landing page
+	// or in init/config
+	header uv.Rectangle
+
+	// main is the area for the main pane. (e.x chat, configure, landing)
+	main uv.Rectangle
+
+	// pills is the area for the pills panel.
+	pills uv.Rectangle
+
+	// editor is the area for the editor pane.
+	editor uv.Rectangle
+
+	// sidebar is the area for the sidebar.
+	sidebar uv.Rectangle
+
+	// status is the area for the status view.
+	status uv.Rectangle
+
+	// session details is the area for the session details overlay in compact mode.
+	sessionDetails uv.Rectangle
+}
+
+func (m *UI) openEditor(value string) tea.Cmd {
+	tmpfile, err := os.CreateTemp("", "msg_*.md")
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+	defer tmpfile.Close() //nolint:errcheck
+	if _, err := tmpfile.WriteString(value); err != nil {
+		return uiutil.ReportError(err)
+	}
+	cmd, err := editor.Command(
+		"crush",
+		tmpfile.Name(),
+		editor.AtPosition(
+			m.textarea.Line()+1,
+			m.textarea.Column()+1,
+		),
+	)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+	return tea.ExecProcess(cmd, func(err error) tea.Msg {
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		content, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if len(content) == 0 {
+			return uiutil.ReportWarn("Message is empty")
+		}
+		os.Remove(tmpfile.Name())
+		return openEditorMsg{
+			Text: strings.TrimSpace(string(content)),
+		}
+	})
+}
+
+// setEditorPrompt configures the textarea prompt function based on whether
+// yolo mode is enabled.
+func (m *UI) setEditorPrompt(yolo bool) {
+	if yolo {
+		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
+		return
+	}
+	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
+}
+
+// normalPromptFunc returns the normal editor prompt style ("  > " on first
+// line, "::: " on subsequent lines).
+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
+	t := m.com.Styles
+	if info.LineNumber == 0 {
+		if info.Focused {
+			return "  > "
+		}
+		return "::: "
+	}
+	if info.Focused {
+		return t.EditorPromptNormalFocused.Render()
+	}
+	return t.EditorPromptNormalBlurred.Render()
+}
+
+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
+// and colored dots.
+func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
+	t := m.com.Styles
+	if info.LineNumber == 0 {
+		if info.Focused {
+			return t.EditorPromptYoloIconFocused.Render()
+		} else {
+			return t.EditorPromptYoloIconBlurred.Render()
+		}
+	}
+	if info.Focused {
+		return t.EditorPromptYoloDotsFocused.Render()
+	}
+	return t.EditorPromptYoloDotsBlurred.Render()
+}
+
+// closeCompletions closes the completions popup and resets state.
+func (m *UI) closeCompletions() {
+	m.completionsOpen = false
+	m.completionsQuery = ""
+	m.completionsStartIndex = 0
+	m.completions.Close()
+}
+
+// insertFileCompletion inserts the selected file path into the textarea,
+// replacing the @query, and adds the file as an attachment.
+func (m *UI) insertFileCompletion(path string) tea.Cmd {
+	value := m.textarea.Value()
+	word := m.textareaWord()
+
+	// Find the @ and query to replace.
+	if m.completionsStartIndex > len(value) {
+		return nil
+	}
+
+	// Build the new value: everything before @, the path, everything after query.
+	endIdx := min(m.completionsStartIndex+len(word), len(value))
+
+	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
+	m.textarea.SetValue(newValue)
+	m.textarea.MoveToEnd()
+	m.textarea.InsertRune(' ')
+
+	return func() tea.Msg {
+		absPath, _ := filepath.Abs(path)
+		// Skip attachment if file was already read and hasn't been modified.
+		lastRead := filetracker.LastReadTime(absPath)
+		if !lastRead.IsZero() {
+			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+				return nil
+			}
+		}
+
+		// Add file as attachment.
+		content, err := os.ReadFile(path)
+		if err != nil {
+			// If it fails, let the LLM handle it later.
+			return nil
+		}
+		filetracker.RecordRead(absPath)
+
+		return message.Attachment{
+			FilePath: path,
+			FileName: filepath.Base(path),
+			MimeType: mimeOf(content),
+			Content:  content,
+		}
+	}
+}
+
+// completionsPosition returns the X and Y position for the completions popup.
+func (m *UI) completionsPosition() image.Point {
+	cur := m.textarea.Cursor()
+	if cur == nil {
+		return image.Point{
+			X: m.layout.editor.Min.X,
+			Y: m.layout.editor.Min.Y,
+		}
+	}
+	return image.Point{
+		X: cur.X + m.layout.editor.Min.X,
+		Y: m.layout.editor.Min.Y + cur.Y,
+	}
+}
+
+// textareaWord returns the current word at the cursor position.
+func (m *UI) textareaWord() string {
+	return m.textarea.Word()
+}
+
+// isWhitespace returns true if the byte is a whitespace character.
+func isWhitespace(b byte) bool {
+	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
+}
+
+// isAgentBusy returns true if the agent coordinator exists and is currently
+// busy processing a request.
+func (m *UI) isAgentBusy() bool {
+	return m.com.App != nil &&
+		m.com.App.AgentCoordinator != nil &&
+		m.com.App.AgentCoordinator.IsBusy()
+}
+
+// hasSession returns true if there is an active session with a valid ID.
+func (m *UI) hasSession() bool {
+	return m.session != nil && m.session.ID != ""
+}
+
+// mimeOf detects the MIME type of the given content.
+func mimeOf(content []byte) string {
+	mimeBufferSize := min(512, len(content))
+	return http.DetectContentType(content[:mimeBufferSize])
+}
+
+var readyPlaceholders = [...]string{
+	"Ready!",
+	"Ready...",
+	"Ready?",
+	"Ready for instructions",
+}
+
+var workingPlaceholders = [...]string{
+	"Working!",
+	"Working...",
+	"Brrrrr...",
+	"Prrrrrrrr...",
+	"Processing...",
+	"Thinking...",
+}
+
+// randomizePlaceholders selects random placeholder text for the textarea's
+// ready and working states.
+func (m *UI) randomizePlaceholders() {
+	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
+	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
+}
+
+// renderEditorView renders the editor view with attachments if any.
+func (m *UI) renderEditorView(width int) string {
+	if len(m.attachments.List()) == 0 {
+		return m.textarea.View()
+	}
+	return lipgloss.JoinVertical(
+		lipgloss.Top,
+		m.attachments.Render(width),
+		m.textarea.View(),
+	)
+}
+
+// renderHeader renders and caches the header logo at the specified width.
+func (m *UI) renderHeader(compact bool, width int) {
+	if compact && m.session != nil && m.com.App != nil {
+		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
+	} else {
+		m.header = renderLogo(m.com.Styles, compact, width)
+	}
+}
+
+// renderSidebarLogo renders and caches the sidebar logo at the specified
+// width.
+func (m *UI) renderSidebarLogo(width int) {
+	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
+}
+
+// sendMessage sends a message with the given content and attachments.
+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
+	if m.com.App.AgentCoordinator == nil {
+		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
+	}
+
+	var cmds []tea.Cmd
+	if !m.hasSession() {
+		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		if newSession.ID != "" {
+			m.session = &newSession
+			cmds = append(cmds, m.loadSession(newSession.ID))
+		}
+	}
+
+	// Capture session ID to avoid race with main goroutine updating m.session.
+	sessionID := m.session.ID
+	cmds = append(cmds, func() tea.Msg {
+		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
+		if err != nil {
+			isCancelErr := errors.Is(err, context.Canceled)
+			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+			if isCancelErr || isPermissionErr {
+				return nil
+			}
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  err.Error(),
+			}
+		}
+		return nil
+	})
+	return tea.Batch(cmds...)
+}
+
+const cancelTimerDuration = 2 * time.Second
+
+// cancelTimerCmd creates a command that expires the cancel timer.
+func cancelTimerCmd() tea.Cmd {
+	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
+		return cancelTimerExpiredMsg{}
+	})
+}
+
+// cancelAgent handles the cancel key press. The first press sets isCanceling to true
+// and starts a timer. The second press (before the timer expires) actually
+// cancels the agent.
+func (m *UI) cancelAgent() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+
+	coordinator := m.com.App.AgentCoordinator
+	if coordinator == nil {
+		return nil
+	}
+
+	if m.isCanceling {
+		// Second escape press - actually cancel the agent.
+		m.isCanceling = false
+		coordinator.Cancel(m.session.ID)
+		// Stop the spinning todo indicator.
+		m.todoIsSpinning = false
+		m.renderPills()
+		return nil
+	}
+
+	// Check if there are queued prompts - if so, clear the queue.
+	if coordinator.QueuedPrompts(m.session.ID) > 0 {
+		coordinator.ClearQueue(m.session.ID)
+		return nil
+	}
+
+	// First escape press - set canceling state and start timer.
+	m.isCanceling = true
+	return cancelTimerCmd()
+}
+
+// openDialog opens a dialog by its ID.
+func (m *UI) openDialog(id string) tea.Cmd {
+	var cmds []tea.Cmd
+	switch id {
+	case dialog.SessionsID:
+		if cmd := m.openSessionsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.ModelsID:
+		if cmd := m.openModelsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.CommandsID:
+		if cmd := m.openCommandsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.ReasoningID:
+		if cmd := m.openReasoningDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.QuitID:
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	default:
+		// Unknown dialog
+		break
+	}
+	return tea.Batch(cmds...)
+}
+
+// openQuitDialog opens the quit confirmation dialog.
+func (m *UI) openQuitDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.QuitID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.QuitID)
+		return nil
+	}
+
+	quitDialog := dialog.NewQuit(m.com)
+	m.dialog.OpenDialog(quitDialog)
+	return nil
+}
+
+// openModelsDialog opens the models dialog.
+func (m *UI) openModelsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ModelsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.ModelsID)
+		return nil
+	}
+
+	modelsDialog, err := dialog.NewModels(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(modelsDialog)
+
+	return nil
+}
+
+// openCommandsDialog opens the commands dialog.
+func (m *UI) openCommandsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.CommandsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.CommandsID)
+		return nil
+	}
+
+	sessionID := ""
+	if m.session != nil {
+		sessionID = m.session.ID
+	}
+
+	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(commands)
+
+	return nil
+}
+
+// openReasoningDialog opens the reasoning effort dialog.
+func (m *UI) openReasoningDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ReasoningID) {
+		m.dialog.BringToFront(dialog.ReasoningID)
+		return nil
+	}
+
+	reasoningDialog, err := dialog.NewReasoning(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(reasoningDialog)
+	return nil
+}
+
+// openSessionsDialog opens the sessions dialog. If the dialog is already open,
+// it brings it to the front. Otherwise, it will list all the sessions and open
+// the dialog.
+func (m *UI) openSessionsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.SessionsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.SessionsID)
+		return nil
+	}
+
+	selectedSessionID := ""
+	if m.session != nil {
+		selectedSessionID = m.session.ID
+	}
+
+	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(dialog)
+	return nil
+}
+
+// openFilesDialog opens the file picker dialog.
+func (m *UI) openFilesDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.FilePickerID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.FilePickerID)
+		return nil
+	}
+
+	filePicker, cmd := dialog.NewFilePicker(m.com)
+	filePicker.SetImageCapabilities(&m.imgCaps)
+	m.dialog.OpenDialog(filePicker)
+
+	return cmd
+}
+
+// openPermissionsDialog opens the permissions dialog for a permission request.
+func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
+	// Close any existing permissions dialog first.
+	m.dialog.CloseDialog(dialog.PermissionsID)
+
+	// Get diff mode from config.
+	var opts []dialog.PermissionsOption
+	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
+		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
+	}
+
+	permDialog := dialog.NewPermissions(m.com, perm, opts...)
+	m.dialog.OpenDialog(permDialog)
+	return nil
+}
+
+// handlePermissionNotification updates tool items when permission state changes.
+func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
+	toolItem := m.chat.MessageItem(notification.ToolCallID)
+	if toolItem == nil {
+		return
+	}
+
+	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
+		if notification.Granted {
+			permItem.SetStatus(chat.ToolStatusRunning)
+		} else {
+			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
+		}
+	}
+}
+
+// newSession clears the current session state and prepares for a new session.
+// The actual session creation happens when the user sends their first message.
+func (m *UI) newSession() {
+	if !m.hasSession() {
+		return
+	}
+
+	m.session = nil
+	m.sessionFiles = nil
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	m.textarea.Focus()
+	m.chat.Blur()
+	m.chat.ClearMessages()
+	m.pillsExpanded = false
+	m.promptQueue = 0
+	m.pillsView = ""
+}
+
+// handlePasteMsg handles a paste message.
+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
+	if m.dialog.HasDialogs() {
+		return m.handleDialogMsg(msg)
+	}
+
+	if m.focus != uiFocusEditor {
+		return nil
+	}
+
+	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
+		return func() tea.Msg {
+			content := []byte(msg.Content)
+			if int64(len(content)) > common.MaxAttachmentSize {
+				return uiutil.ReportWarn("Paste is too big (>5mb)")
+			}
+			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
+			mimeBufferSize := min(512, len(content))
+			mimeType := http.DetectContentType(content[:mimeBufferSize])
+			return message.Attachment{
+				FileName: name,
+				FilePath: name,
+				MimeType: mimeType,
+				Content:  content,
+			}
+		}
+	}
+
+	var cmd tea.Cmd
+	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
+	// Try to get an image.
+	path, err := filepath.Abs(strings.TrimSpace(path))
+	if err != nil {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+
+	// Check if file has an allowed image extension.
+	isAllowedType := false
+	lowerPath := strings.ToLower(path)
+	for _, ext := range common.AllowedImageTypes {
+		if strings.HasSuffix(lowerPath, ext) {
+			isAllowedType = true
+			break
+		}
+	}
+	if !isAllowedType {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+
+	return func() tea.Msg {
+		fileInfo, err := os.Stat(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if fileInfo.Size() > common.MaxAttachmentSize {
+			return uiutil.ReportWarn("File is too big (>5mb)")
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
+	}
+}
+
+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
+
+func (m *UI) pasteIdx() int {
+	result := 0
+	for _, at := range m.attachments.List() {
+		found := pasteRE.FindStringSubmatch(at.FileName)
+		if len(found) == 0 {
+			continue
+		}
+		idx, err := strconv.Atoi(found[1])
+		if err == nil {
+			result = max(result, idx)
+		}
+	}
+	return result + 1
+}
+
+// drawSessionDetails draws the session details in compact mode.
+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	s := m.com.Styles
+
+	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
+	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
+
+	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
+	blocks := []string{
+		title,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	detailsHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
+
+	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
+
+	const maxSectionWidth = 50
+	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
+	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
+
+	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
+	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
+	uv.NewStyledString(
+		s.CompactDetails.View.
+			Width(area.Dx()).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					detailsHeader,
+					sections,
+					version,
+				),
+			),
+	).Draw(scr, area)
+}
+
+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
+	load := func() tea.Msg {
+		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
+		if err != nil {
+			// TODO: make this better
+			return uiutil.ReportError(err)()
+		}
+
+		if prompt == "" {
+			return nil
+		}
+		return sendMessageMsg{
+			Content: prompt,
+		}
+	}
+
+	var cmds []tea.Cmd
+	if cmd := m.dialog.StartLoading(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	cmds = append(cmds, load, func() tea.Msg {
+		return closeDialogMsg{}
+	})
+
+	return tea.Sequence(cmds...)
+}
+
+func (m *UI) copyChatHighlight() tea.Cmd {
+	text := m.chat.HighlighContent()
+	return tea.Sequence(
+		tea.SetClipboard(text),
+		func() tea.Msg {
+			_ = clipboard.WriteAll(text)
+			return nil
+		},
+		func() tea.Msg {
+			m.chat.ClearMouse()
+			return nil
+		},
+		uiutil.ReportInfo("Selected text copied to clipboard"),
+	)
+}
+
+// renderLogo renders the Crush logo with the given styles and dimensions.
+func renderLogo(t *styles.Styles, compact bool, width int) string {
+	return logo.Render(version.Version, compact, logo.Opts{
+		FieldColor:   t.LogoFieldColor,
+		TitleColorA:  t.LogoTitleColorA,
+		TitleColorB:  t.LogoTitleColorB,
+		CharmColor:   t.LogoCharmColor,
+		VersionColor: t.LogoVersionColor,
+		Width:        width,
+	})
+}

internal/ui/styles/grad.go πŸ”—

@@ -0,0 +1,117 @@
+package styles
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/rivo/uniseg"
+)
+
+// ForegroundGrad returns a slice of strings representing the input string
+// rendered with a horizontal gradient foreground from color1 to color2. Each
+// string in the returned slice corresponds to a grapheme cluster in the input
+// string. If bold is true, the rendered strings will be bolded.
+func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string {
+	if input == "" {
+		return []string{""}
+	}
+	if len(input) == 1 {
+		style := t.Base.Foreground(color1)
+		if bold {
+			style.Bold(true)
+		}
+		return []string{style.Render(input)}
+	}
+	var clusters []string
+	gr := uniseg.NewGraphemes(input)
+	for gr.Next() {
+		clusters = append(clusters, string(gr.Runes()))
+	}
+
+	ramp := blendColors(len(clusters), color1, color2)
+	for i, c := range ramp {
+		style := t.Base.Foreground(c)
+		if bold {
+			style.Bold(true)
+		}
+		clusters[i] = style.Render(clusters[i])
+	}
+	return clusters
+}
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+	var o strings.Builder
+	clusters := ForegroundGrad(t, input, false, color1, color2)
+	for _, c := range clusters {
+		fmt.Fprint(&o, c)
+	}
+	return o.String()
+}
+
+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+	var o strings.Builder
+	clusters := ForegroundGrad(t, input, true, color1, color2)
+	for _, c := range clusters {
+		fmt.Fprint(&o, c)
+	}
+	return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	stopsPrime := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		stopsPrime[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stopsPrime) - 1
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := stopsPrime[i]
+		c2 := stopsPrime[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			var t float64
+			if segmentSize > 1 {
+				t = float64(j) / float64(segmentSize-1)
+			}
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}

internal/ui/styles/styles.go πŸ”—

@@ -0,0 +1,1344 @@
+package styles
+
+import (
+	"image/color"
+
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/textarea"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/glamour/v2/ansi"
+	"charm.land/lipgloss/v2"
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+const (
+	CheckIcon   string = "βœ“"
+	ErrorIcon   string = "Γ—"
+	WarningIcon string = "⚠"
+	InfoIcon    string = "β“˜"
+	HintIcon    string = "∡"
+	SpinnerIcon string = "β‹―"
+	LoadingIcon string = "⟳"
+	ModelIcon   string = "β—‡"
+
+	ArrowRightIcon string = "β†’"
+
+	ToolPending string = "●"
+	ToolSuccess string = "βœ“"
+	ToolError   string = "Γ—"
+
+	RadioOn  string = "β—‰"
+	RadioOff string = "β—‹"
+
+	BorderThin  string = "β”‚"
+	BorderThick string = "β–Œ"
+
+	SectionSeparator string = "─"
+
+	TodoCompletedIcon  string = "βœ“"
+	TodoPendingIcon    string = "β€’"
+	TodoInProgressIcon string = "β†’"
+
+	ImageIcon string = "β– "
+	TextIcon  string = "≑"
+
+	ScrollbarThumb string = "┃"
+	ScrollbarTrack string = "β”‚"
+)
+
+const (
+	defaultMargin     = 2
+	defaultListIndent = 2
+)
+
+type Styles struct {
+	WindowTooSmall lipgloss.Style
+
+	// Reusable text styles
+	Base      lipgloss.Style
+	Muted     lipgloss.Style
+	HalfMuted lipgloss.Style
+	Subtle    lipgloss.Style
+
+	// Tags
+	TagBase  lipgloss.Style
+	TagError lipgloss.Style
+	TagInfo  lipgloss.Style
+
+	// Header
+	Header struct {
+		Charm        lipgloss.Style // Style for "Charmβ„’" label
+		Diagonals    lipgloss.Style // Style for diagonal separators (β•±)
+		Percentage   lipgloss.Style // Style for context percentage
+		Keystroke    lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
+		KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
+		WorkingDir   lipgloss.Style // Style for current working directory
+		Separator    lipgloss.Style // Style for separator dots (β€’)
+	}
+
+	CompactDetails struct {
+		View    lipgloss.Style
+		Version lipgloss.Style
+		Title   lipgloss.Style
+	}
+
+	// Panels
+	PanelMuted lipgloss.Style
+	PanelBase  lipgloss.Style
+
+	// Line numbers for code blocks
+	LineNumber lipgloss.Style
+
+	// Message borders
+	FocusedMessageBorder lipgloss.Border
+
+	// Tool calls
+	ToolCallPending   lipgloss.Style
+	ToolCallError     lipgloss.Style
+	ToolCallSuccess   lipgloss.Style
+	ToolCallCancelled lipgloss.Style
+	EarlyStateMessage lipgloss.Style
+
+	// Text selection
+	TextSelection lipgloss.Style
+
+	// LSP and MCP status indicators
+	ItemOfflineIcon lipgloss.Style
+	ItemBusyIcon    lipgloss.Style
+	ItemErrorIcon   lipgloss.Style
+	ItemOnlineIcon  lipgloss.Style
+
+	// Markdown & Chroma
+	Markdown      ansi.StyleConfig
+	PlainMarkdown ansi.StyleConfig
+
+	// Inputs
+	TextInput textinput.Styles
+	TextArea  textarea.Styles
+
+	// Help
+	Help help.Styles
+
+	// Diff
+	Diff diffview.Style
+
+	// FilePicker
+	FilePicker filepicker.Styles
+
+	// Buttons
+	ButtonFocus lipgloss.Style
+	ButtonBlur  lipgloss.Style
+
+	// Borders
+	BorderFocus lipgloss.Style
+	BorderBlur  lipgloss.Style
+
+	// Editor
+	EditorPromptNormalFocused   lipgloss.Style
+	EditorPromptNormalBlurred   lipgloss.Style
+	EditorPromptYoloIconFocused lipgloss.Style
+	EditorPromptYoloIconBlurred lipgloss.Style
+	EditorPromptYoloDotsFocused lipgloss.Style
+	EditorPromptYoloDotsBlurred lipgloss.Style
+
+	// Radio
+	RadioOn  lipgloss.Style
+	RadioOff lipgloss.Style
+
+	// Background
+	Background color.Color
+
+	// Logo
+	LogoFieldColor   color.Color
+	LogoTitleColorA  color.Color
+	LogoTitleColorB  color.Color
+	LogoCharmColor   color.Color
+	LogoVersionColor color.Color
+
+	// Colors - semantic colors for tool rendering.
+	Primary       color.Color
+	Secondary     color.Color
+	Tertiary      color.Color
+	BgBase        color.Color
+	BgBaseLighter color.Color
+	BgSubtle      color.Color
+	BgOverlay     color.Color
+	FgBase        color.Color
+	FgMuted       color.Color
+	FgHalfMuted   color.Color
+	FgSubtle      color.Color
+	Border        color.Color
+	BorderColor   color.Color // Border focus color
+	Error         color.Color
+	Warning       color.Color
+	Info          color.Color
+	White         color.Color
+	BlueLight     color.Color
+	Blue          color.Color
+	BlueDark      color.Color
+	GreenLight    color.Color
+	Green         color.Color
+	GreenDark     color.Color
+	Red           color.Color
+	RedDark       color.Color
+	Yellow        color.Color
+
+	// Section Title
+	Section struct {
+		Title lipgloss.Style
+		Line  lipgloss.Style
+	}
+
+	// Initialize
+	Initialize struct {
+		Header  lipgloss.Style
+		Content lipgloss.Style
+		Accent  lipgloss.Style
+	}
+
+	// LSP
+	LSP struct {
+		ErrorDiagnostic   lipgloss.Style
+		WarningDiagnostic lipgloss.Style
+		HintDiagnostic    lipgloss.Style
+		InfoDiagnostic    lipgloss.Style
+	}
+
+	// Files
+	Files struct {
+		Path      lipgloss.Style
+		Additions lipgloss.Style
+		Deletions lipgloss.Style
+	}
+
+	// Chat
+	Chat struct {
+		// Message item styles
+		Message struct {
+			UserBlurred      lipgloss.Style
+			UserFocused      lipgloss.Style
+			AssistantBlurred lipgloss.Style
+			AssistantFocused lipgloss.Style
+			NoContent        lipgloss.Style
+			Thinking         lipgloss.Style
+			ErrorTag         lipgloss.Style
+			ErrorTitle       lipgloss.Style
+			ErrorDetails     lipgloss.Style
+			ToolCallFocused  lipgloss.Style
+			ToolCallCompact  lipgloss.Style
+			ToolCallBlurred  lipgloss.Style
+			SectionHeader    lipgloss.Style
+
+			// Thinking section styles
+			ThinkingBox            lipgloss.Style // Background for thinking content
+			ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
+			ThinkingFooterTitle    lipgloss.Style // "Thought for" text
+			ThinkingFooterDuration lipgloss.Style // Duration value
+			AssistantInfoIcon      lipgloss.Style
+			AssistantInfoModel     lipgloss.Style
+			AssistantInfoProvider  lipgloss.Style
+			AssistantInfoDuration  lipgloss.Style
+		}
+	}
+
+	// Tool - styles for tool call rendering
+	Tool struct {
+		// Icon styles with tool status
+		IconPending   lipgloss.Style // Pending operation icon
+		IconSuccess   lipgloss.Style // Successful operation icon
+		IconError     lipgloss.Style // Error operation icon
+		IconCancelled lipgloss.Style // Cancelled operation icon
+
+		// Tool name styles
+		NameNormal lipgloss.Style // Normal tool name
+		NameNested lipgloss.Style // Nested tool name
+
+		// Parameter list styles
+		ParamMain lipgloss.Style // Main parameter
+		ParamKey  lipgloss.Style // Parameter keys
+
+		// Content rendering styles
+		ContentLine           lipgloss.Style // Individual content line with background and width
+		ContentTruncation     lipgloss.Style // Truncation message "… (N lines)"
+		ContentCodeLine       lipgloss.Style // Code line with background and width
+		ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase
+		ContentCodeBg         color.Color    // Background color for syntax highlighting
+		Body                  lipgloss.Style // Body content padding (PaddingLeft(2))
+
+		// Deprecated - kept for backward compatibility
+		ContentBg         lipgloss.Style // Content background
+		ContentText       lipgloss.Style // Content text
+		ContentLineNumber lipgloss.Style // Line numbers in code
+
+		// State message styles
+		StateWaiting   lipgloss.Style // "Waiting for tool response..."
+		StateCancelled lipgloss.Style // "Canceled."
+
+		// Error styles
+		ErrorTag     lipgloss.Style // ERROR tag
+		ErrorMessage lipgloss.Style // Error message text
+
+		// Diff styles
+		DiffTruncation lipgloss.Style // Diff truncation message with padding
+
+		// Multi-edit note styles
+		NoteTag     lipgloss.Style // NOTE tag (yellow background)
+		NoteMessage lipgloss.Style // Note message text
+
+		// Job header styles (for bash jobs)
+		JobIconPending lipgloss.Style // Pending job icon (green dark)
+		JobIconError   lipgloss.Style // Error job icon (red dark)
+		JobIconSuccess lipgloss.Style // Success job icon (green)
+		JobToolName    lipgloss.Style // Job tool name "Bash" (blue)
+		JobAction      lipgloss.Style // Action text (Start, Output, Kill)
+		JobPID         lipgloss.Style // PID text
+		JobDescription lipgloss.Style // Description text
+
+		// Agent task styles
+		AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
+		AgentPrompt  lipgloss.Style // Agent prompt text
+
+		// Agentic fetch styles
+		AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold)
+
+		// Todo styles
+		TodoRatio          lipgloss.Style // Todo ratio (e.g., "2/5")
+		TodoCompletedIcon  lipgloss.Style // Completed todo icon
+		TodoInProgressIcon lipgloss.Style // In-progress todo icon
+		TodoPendingIcon    lipgloss.Style // Pending todo icon
+
+		// MCP tools
+		MCPName     lipgloss.Style // The mcp name
+		MCPToolName lipgloss.Style // The mcp tool name
+		MCPArrow    lipgloss.Style // The mcp arrow icon
+	}
+
+	// Dialog styles
+	Dialog struct {
+		Title       lipgloss.Style
+		TitleText   lipgloss.Style
+		TitleError  lipgloss.Style
+		TitleAccent lipgloss.Style
+		// View is the main content area style.
+		View          lipgloss.Style
+		PrimaryText   lipgloss.Style
+		SecondaryText lipgloss.Style
+		// HelpView is the line that contains the help.
+		HelpView lipgloss.Style
+		Help     struct {
+			Ellipsis       lipgloss.Style
+			ShortKey       lipgloss.Style
+			ShortDesc      lipgloss.Style
+			ShortSeparator lipgloss.Style
+			FullKey        lipgloss.Style
+			FullDesc       lipgloss.Style
+			FullSeparator  lipgloss.Style
+		}
+		NormalItem   lipgloss.Style
+		SelectedItem lipgloss.Style
+		InputPrompt  lipgloss.Style
+
+		List lipgloss.Style
+
+		Spinner lipgloss.Style
+
+		// ContentPanel is used for content blocks with subtle background.
+		ContentPanel lipgloss.Style
+
+		// Scrollbar styles for scrollable content.
+		ScrollbarThumb lipgloss.Style
+		ScrollbarTrack lipgloss.Style
+
+		// Arguments
+		Arguments struct {
+			Content                  lipgloss.Style
+			Description              lipgloss.Style
+			InputLabelBlurred        lipgloss.Style
+			InputLabelFocused        lipgloss.Style
+			InputRequiredMarkBlurred lipgloss.Style
+			InputRequiredMarkFocused lipgloss.Style
+		}
+
+		Commands struct{}
+
+		ImagePreview lipgloss.Style
+	}
+
+	// Status bar and help
+	Status struct {
+		Help lipgloss.Style
+
+		ErrorIndicator   lipgloss.Style
+		WarnIndicator    lipgloss.Style
+		InfoIndicator    lipgloss.Style
+		UpdateIndicator  lipgloss.Style
+		SuccessIndicator lipgloss.Style
+
+		ErrorMessage   lipgloss.Style
+		WarnMessage    lipgloss.Style
+		InfoMessage    lipgloss.Style
+		UpdateMessage  lipgloss.Style
+		SuccessMessage lipgloss.Style
+	}
+
+	// Completions popup styles
+	Completions struct {
+		Normal  lipgloss.Style
+		Focused lipgloss.Style
+		Match   lipgloss.Style
+	}
+
+	// Attachments styles
+	Attachments struct {
+		Normal   lipgloss.Style
+		Image    lipgloss.Style
+		Text     lipgloss.Style
+		Deleting lipgloss.Style
+	}
+
+	// Pills styles for todo/queue pills
+	Pills struct {
+		Base            lipgloss.Style // Base pill style with padding
+		Focused         lipgloss.Style // Focused pill with visible border
+		Blurred         lipgloss.Style // Blurred pill with hidden border
+		QueueItemPrefix lipgloss.Style // Prefix for queue list items
+		HelpKey         lipgloss.Style // Keystroke hint style
+		HelpText        lipgloss.Style // Help action text style
+		Area            lipgloss.Style // Pills area container
+		TodoSpinner     lipgloss.Style // Todo spinner style
+	}
+}
+
+// ChromaTheme converts the current markdown chroma styles to a chroma
+// StyleEntries map.
+func (s *Styles) ChromaTheme() chroma.StyleEntries {
+	rules := s.Markdown.CodeBlock
+
+	return chroma.StyleEntries{
+		chroma.Text:                chromaStyle(rules.Chroma.Text),
+		chroma.Error:               chromaStyle(rules.Chroma.Error),
+		chroma.Comment:             chromaStyle(rules.Chroma.Comment),
+		chroma.CommentPreproc:      chromaStyle(rules.Chroma.CommentPreproc),
+		chroma.Keyword:             chromaStyle(rules.Chroma.Keyword),
+		chroma.KeywordReserved:     chromaStyle(rules.Chroma.KeywordReserved),
+		chroma.KeywordNamespace:    chromaStyle(rules.Chroma.KeywordNamespace),
+		chroma.KeywordType:         chromaStyle(rules.Chroma.KeywordType),
+		chroma.Operator:            chromaStyle(rules.Chroma.Operator),
+		chroma.Punctuation:         chromaStyle(rules.Chroma.Punctuation),
+		chroma.Name:                chromaStyle(rules.Chroma.Name),
+		chroma.NameBuiltin:         chromaStyle(rules.Chroma.NameBuiltin),
+		chroma.NameTag:             chromaStyle(rules.Chroma.NameTag),
+		chroma.NameAttribute:       chromaStyle(rules.Chroma.NameAttribute),
+		chroma.NameClass:           chromaStyle(rules.Chroma.NameClass),
+		chroma.NameConstant:        chromaStyle(rules.Chroma.NameConstant),
+		chroma.NameDecorator:       chromaStyle(rules.Chroma.NameDecorator),
+		chroma.NameException:       chromaStyle(rules.Chroma.NameException),
+		chroma.NameFunction:        chromaStyle(rules.Chroma.NameFunction),
+		chroma.NameOther:           chromaStyle(rules.Chroma.NameOther),
+		chroma.Literal:             chromaStyle(rules.Chroma.Literal),
+		chroma.LiteralNumber:       chromaStyle(rules.Chroma.LiteralNumber),
+		chroma.LiteralDate:         chromaStyle(rules.Chroma.LiteralDate),
+		chroma.LiteralString:       chromaStyle(rules.Chroma.LiteralString),
+		chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+		chroma.GenericDeleted:      chromaStyle(rules.Chroma.GenericDeleted),
+		chroma.GenericEmph:         chromaStyle(rules.Chroma.GenericEmph),
+		chroma.GenericInserted:     chromaStyle(rules.Chroma.GenericInserted),
+		chroma.GenericStrong:       chromaStyle(rules.Chroma.GenericStrong),
+		chroma.GenericSubheading:   chromaStyle(rules.Chroma.GenericSubheading),
+		chroma.Background:          chromaStyle(rules.Chroma.Background),
+	}
+}
+
+// DialogHelpStyles returns the styles for dialog help.
+func (s *Styles) DialogHelpStyles() help.Styles {
+	return help.Styles(s.Dialog.Help)
+}
+
+// DefaultStyles returns the default styles for the UI.
+func DefaultStyles() Styles {
+	var (
+		primary   = charmtone.Charple
+		secondary = charmtone.Dolly
+		tertiary  = charmtone.Bok
+		// accent    = charmtone.Zest
+
+		// Backgrounds
+		bgBase        = charmtone.Pepper
+		bgBaseLighter = charmtone.BBQ
+		bgSubtle      = charmtone.Charcoal
+		bgOverlay     = charmtone.Iron
+
+		// Foregrounds
+		fgBase      = charmtone.Ash
+		fgMuted     = charmtone.Squid
+		fgHalfMuted = charmtone.Smoke
+		fgSubtle    = charmtone.Oyster
+		// fgSelected  = charmtone.Salt
+
+		// Borders
+		border      = charmtone.Charcoal
+		borderFocus = charmtone.Charple
+
+		// Status
+		error   = charmtone.Sriracha
+		warning = charmtone.Zest
+		info    = charmtone.Malibu
+
+		// Colors
+		white = charmtone.Butter
+
+		blueLight = charmtone.Sardine
+		blue      = charmtone.Malibu
+		blueDark  = charmtone.Damson
+
+		// yellow = charmtone.Mustard
+		yellow = charmtone.Mustard
+		// citron = charmtone.Citron
+
+		greenLight = charmtone.Bok
+		green      = charmtone.Julep
+		greenDark  = charmtone.Guac
+		// greenLight = charmtone.Bok
+
+		red     = charmtone.Coral
+		redDark = charmtone.Sriracha
+		// redLight = charmtone.Salmon
+		// cherry   = charmtone.Cherry
+	)
+
+	normalBorder := lipgloss.NormalBorder()
+
+	base := lipgloss.NewStyle().Foreground(fgBase)
+
+	s := Styles{}
+
+	s.Background = bgBase
+
+	// Populate color fields
+	s.Primary = primary
+	s.Secondary = secondary
+	s.Tertiary = tertiary
+	s.BgBase = bgBase
+	s.BgBaseLighter = bgBaseLighter
+	s.BgSubtle = bgSubtle
+	s.BgOverlay = bgOverlay
+	s.FgBase = fgBase
+	s.FgMuted = fgMuted
+	s.FgHalfMuted = fgHalfMuted
+	s.FgSubtle = fgSubtle
+	s.Border = border
+	s.BorderColor = borderFocus
+	s.Error = error
+	s.Warning = warning
+	s.Info = info
+	s.White = white
+	s.BlueLight = blueLight
+	s.Blue = blue
+	s.BlueDark = blueDark
+	s.GreenLight = greenLight
+	s.Green = green
+	s.GreenDark = greenDark
+	s.Red = red
+	s.RedDark = redDark
+	s.Yellow = yellow
+
+	s.TextInput = textinput.Styles{
+		Focused: textinput.StyleState{
+			Text:        base,
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(tertiary),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Blurred: textinput.StyleState{
+			Text:        base.Foreground(fgMuted),
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(fgMuted),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Cursor: textinput.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBlock,
+			Blink: true,
+		},
+	}
+
+	s.TextArea = textarea.Styles{
+		Focused: textarea.StyleState{
+			Base:             base,
+			Text:             base,
+			LineNumber:       base.Foreground(fgSubtle),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgSubtle),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(tertiary),
+		},
+		Blurred: textarea.StyleState{
+			Base:             base,
+			Text:             base.Foreground(fgMuted),
+			LineNumber:       base.Foreground(fgMuted),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgMuted),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(fgMuted),
+		},
+		Cursor: textarea.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBlock,
+			Blink: true,
+		},
+	}
+
+	s.Markdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				// BlockPrefix: "\n",
+				// BlockSuffix: "\n",
+				Color: stringPtr(charmtone.Smoke.Hex()),
+			},
+			// Margin: uintPtr(defaultMargin),
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{},
+			Indent:         uintPtr(1),
+			IndentToken:    stringPtr("β”‚ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix: "\n",
+				Color:       stringPtr(charmtone.Malibu.Hex()),
+				Bold:        boolPtr(true),
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Zest.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charple.Hex()),
+				Bold:            boolPtr(true),
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "## ",
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "### ",
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "#### ",
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "##### ",
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "###### ",
+				Color:  stringPtr(charmtone.Guac.Hex()),
+				Bold:   boolPtr(false),
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut: boolPtr(true),
+		},
+		Emph: ansi.StylePrimitive{
+			Italic: boolPtr(true),
+		},
+		Strong: ansi.StylePrimitive{
+			Bold: boolPtr(true),
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Charcoal.Hex()),
+			Format: "\n--------\n",
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix: "β€’ ",
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix: ". ",
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{},
+			Ticked:         "[βœ“] ",
+			Unticked:       "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Zinc.Hex()),
+			Underline: boolPtr(true),
+		},
+		LinkText: ansi.StylePrimitive{
+			Color: stringPtr(charmtone.Guac.Hex()),
+			Bold:  boolPtr(true),
+		},
+		Image: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Cheeky.Hex()),
+			Underline: boolPtr(true),
+		},
+		ImageText: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Squid.Hex()),
+			Format: "Image: {{.text}} β†’",
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Coral.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Charcoal.Hex()),
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+			Chroma: &ansi.Chroma{
+				Text: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				Error: ansi.StylePrimitive{
+					Color:           stringPtr(charmtone.Butter.Hex()),
+					BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
+				},
+				Comment: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Oyster.Hex()),
+				},
+				CommentPreproc: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bengal.Hex()),
+				},
+				Keyword: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Malibu.Hex()),
+				},
+				KeywordReserved: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordNamespace: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordType: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guppy.Hex()),
+				},
+				Operator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Salmon.Hex()),
+				},
+				Punctuation: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Zest.Hex()),
+				},
+				Name: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				NameBuiltin: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cheeky.Hex()),
+				},
+				NameTag: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Mauve.Hex()),
+				},
+				NameAttribute: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Hazy.Hex()),
+				},
+				NameClass: ansi.StylePrimitive{
+					Color:     stringPtr(charmtone.Salt.Hex()),
+					Underline: boolPtr(true),
+					Bold:      boolPtr(true),
+				},
+				NameDecorator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Citron.Hex()),
+				},
+				NameFunction: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				LiteralNumber: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Julep.Hex()),
+				},
+				LiteralString: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cumin.Hex()),
+				},
+				LiteralStringEscape: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bok.Hex()),
+				},
+				GenericDeleted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Coral.Hex()),
+				},
+				GenericEmph: ansi.StylePrimitive{
+					Italic: boolPtr(true),
+				},
+				GenericInserted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				GenericStrong: ansi.StylePrimitive{
+					Bold: boolPtr(true),
+				},
+				GenericSubheading: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Squid.Hex()),
+				},
+				Background: ansi.StylePrimitive{
+					BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+				},
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix: "\n ",
+		},
+	}
+
+	// PlainMarkdown style - muted colors on subtle background for thinking content.
+	plainBg := stringPtr(bgBaseLighter.Hex())
+	plainFg := stringPtr(fgMuted.Hex())
+	s.PlainMarkdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Indent:      uintPtr(1),
+			IndentToken: stringPtr("β”‚ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix:     "\n",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "## ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "#### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "##### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "###### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut:      boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Emph: ansi.StylePrimitive{
+			Italic:          boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Strong: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Format:          "\n--------\n",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix:     "β€’ ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix:     ". ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Ticked:   "[βœ“] ",
+			Unticked: "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		LinkText: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Image: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		ImageText: ansi.StylePrimitive{
+			Format:          "Image: {{.text}} β†’",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix:     "\n ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+	}
+
+	s.Help = help.Styles{
+		ShortKey:       base.Foreground(fgMuted),
+		ShortDesc:      base.Foreground(fgSubtle),
+		ShortSeparator: base.Foreground(border),
+		Ellipsis:       base.Foreground(border),
+		FullKey:        base.Foreground(fgMuted),
+		FullDesc:       base.Foreground(fgSubtle),
+		FullSeparator:  base.Foreground(border),
+	}
+
+	s.Diff = diffview.Style{
+		DividerLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+		},
+		MissingLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+		},
+		EqualLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+			Code: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+		},
+		InsertLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#2b322a")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#323931")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#323931")),
+		},
+		DeleteLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#312929")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#383030")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#383030")),
+		},
+	}
+
+	s.FilePicker = filepicker.Styles{
+		DisabledCursor:   base.Foreground(fgMuted),
+		Cursor:           base.Foreground(fgBase),
+		Symlink:          base.Foreground(fgSubtle),
+		Directory:        base.Foreground(primary),
+		File:             base.Foreground(fgBase),
+		DisabledFile:     base.Foreground(fgMuted),
+		DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted),
+		Permission:       base.Foreground(fgMuted),
+		Selected:         base.Background(primary).Foreground(fgBase),
+		FileSize:         base.Foreground(fgMuted),
+		EmptyDirectory:   base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"),
+	}
+
+	// borders
+	s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
+
+	// text presets
+	s.Base = lipgloss.NewStyle().Foreground(fgBase)
+	s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
+	s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	s.WindowTooSmall = s.Muted
+
+	// tag presets
+	s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white)
+	s.TagError = s.TagBase.Background(redDark)
+	s.TagInfo = s.TagBase.Background(blueLight)
+
+	// Compact header styles
+	s.Header.Charm = base.Foreground(secondary)
+	s.Header.Diagonals = base.Foreground(primary)
+	s.Header.Percentage = s.Muted
+	s.Header.Keystroke = s.Muted
+	s.Header.KeystrokeTip = s.Subtle
+	s.Header.WorkingDir = s.Muted
+	s.Header.Separator = s.Subtle
+
+	s.CompactDetails.Title = s.Base
+	s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.CompactDetails.Version = s.Muted
+
+	// panels
+	s.PanelMuted = s.Muted.Background(bgBaseLighter)
+	s.PanelBase = lipgloss.NewStyle().Background(bgBase)
+
+	// code line number
+	s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
+
+	// Tool calls
+	s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending)
+	s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError)
+	s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess)
+	// Cancelled uses muted tone but same glyph as pending
+	s.ToolCallCancelled = s.Muted.SetString(ToolPending)
+	s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
+
+	// Tool rendering styles
+	s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
+	s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
+	s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
+	s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
+
+	s.Tool.NameNormal = base.Foreground(blue)
+	s.Tool.NameNested = base.Foreground(fgHalfMuted)
+
+	s.Tool.ParamMain = s.Subtle
+	s.Tool.ParamKey = s.Subtle
+
+	// Content rendering - prepared styles that accept width parameter
+	s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentCodeLine = s.Base.Background(bgBase)
+	s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
+	s.Tool.ContentCodeBg = bgBase
+	s.Tool.Body = base.PaddingLeft(2)
+
+	// Deprecated - kept for backward compatibility
+	s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentText = s.Muted
+	s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
+
+	s.Tool.StateWaiting = base.Foreground(fgSubtle)
+	s.Tool.StateCancelled = base.Foreground(fgSubtle)
+
+	s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
+	s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
+
+	// Diff and multi-edit styles
+	s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
+	s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white)
+	s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
+
+	// Job header styles
+	s.Tool.JobIconPending = base.Foreground(greenDark)
+	s.Tool.JobIconError = base.Foreground(redDark)
+	s.Tool.JobIconSuccess = base.Foreground(green)
+	s.Tool.JobToolName = base.Foreground(blue)
+	s.Tool.JobAction = base.Foreground(blueDark)
+	s.Tool.JobPID = s.Muted
+	s.Tool.JobDescription = s.Subtle
+
+	// Agent task styles
+	s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
+	s.Tool.AgentPrompt = s.Muted
+
+	// Agentic fetch styles
+	s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border)
+
+	// Todo styles
+	s.Tool.TodoRatio = base.Foreground(blueDark)
+	s.Tool.TodoCompletedIcon = base.Foreground(green)
+	s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
+	s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
+
+	// MCP styles
+	s.Tool.MCPName = base.Foreground(blue)
+	s.Tool.MCPToolName = base.Foreground(blueDark)
+	s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon)
+
+	// Buttons
+	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
+	s.ButtonBlur = s.Base.Background(bgSubtle)
+
+	// Borders
+	s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
+
+	// Editor
+	s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ")
+	s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted)
+	s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
+	s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
+	s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::")
+	s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
+
+	s.RadioOn = s.HalfMuted.SetString(RadioOn)
+	s.RadioOff = s.HalfMuted.SetString(RadioOff)
+
+	// Logo colors
+	s.LogoFieldColor = primary
+	s.LogoTitleColorA = secondary
+	s.LogoTitleColorB = primary
+	s.LogoCharmColor = secondary
+	s.LogoVersionColor = primary
+
+	// Section
+	s.Section.Title = s.Subtle
+	s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
+
+	// Initialize
+	s.Initialize.Header = s.Base
+	s.Initialize.Content = s.Muted
+	s.Initialize.Accent = s.Base.Foreground(greenDark)
+
+	// LSP and MCP status.
+	s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
+	s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron)
+	s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral)
+	s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac)
+
+	// LSP
+	s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark)
+	s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
+	s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
+	s.LSP.InfoDiagnostic = s.Base.Foreground(info)
+
+	// Files
+	s.Files.Path = s.Muted
+	s.Files.Additions = s.Base.Foreground(greenDark)
+	s.Files.Deletions = s.Base.Foreground(redDark)
+
+	// Chat
+	messageFocussedBorder := lipgloss.Border{
+		Left: "β–Œ",
+	}
+
+	s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
+	s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(normalBorder)
+	s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(messageFocussedBorder)
+	s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
+	s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
+	s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
+	s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
+		Background(red).Foreground(white)
+	s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	// Message item styles
+	s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
+		BorderStyle(messageFocussedBorder).
+		BorderLeft(true).
+		BorderForeground(greenDark)
+	s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
+	// No padding or border for compact tool calls within messages
+	s.Chat.Message.ToolCallCompact = s.Muted
+	s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
+	s.Chat.Message.AssistantInfoIcon = s.Subtle
+	s.Chat.Message.AssistantInfoModel = s.Muted
+	s.Chat.Message.AssistantInfoProvider = s.Subtle
+	s.Chat.Message.AssistantInfoDuration = s.Subtle
+
+	// Thinking section styles
+	s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
+	s.Chat.Message.ThinkingTruncationHint = s.Muted
+	s.Chat.Message.ThinkingFooterTitle = s.Muted
+	s.Chat.Message.ThinkingFooterDuration = s.Subtle
+
+	// Text selection.
+	s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
+
+	// Dialog styles
+	s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
+	s.Dialog.TitleText = base.Foreground(primary)
+	s.Dialog.TitleError = base.Foreground(red)
+	s.Dialog.TitleAccent = base.Foreground(green).Bold(true)
+	s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary)
+	s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle)
+	s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
+	s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
+	s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)
+	s.Dialog.Help.ShortSeparator = base.Foreground(border)
+	s.Dialog.Help.Ellipsis = base.Foreground(border)
+	s.Dialog.Help.FullKey = base.Foreground(fgMuted)
+	s.Dialog.Help.FullDesc = base.Foreground(fgSubtle)
+	s.Dialog.Help.FullSeparator = base.Foreground(border)
+	s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
+	s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
+	s.Dialog.InputPrompt = base.Margin(1, 1)
+
+	s.Dialog.List = base.Margin(0, 0, 1, 0)
+	s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
+	s.Dialog.Spinner = base.Foreground(secondary)
+	s.Dialog.ScrollbarThumb = base.Foreground(secondary)
+	s.Dialog.ScrollbarTrack = base.Foreground(border)
+
+	s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
+
+	s.Dialog.Arguments.Content = base.Padding(1)
+	s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
+	s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
+	s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
+	s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
+	s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
+
+	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
+	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
+	s.Status.InfoIndicator = s.Status.SuccessIndicator
+	s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
+	s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING")
+	s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR")
+	s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1)
+	s.Status.InfoMessage = s.Status.SuccessMessage
+	s.Status.UpdateMessage = s.Status.SuccessMessage
+	s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
+	s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
+
+	// Completions styles
+	s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase)
+	s.Completions.Focused = base.Background(primary).Foreground(white)
+	s.Completions.Match = base.Underline(true)
+
+	// Attachments styles
+	attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1)
+	s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
+	s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
+	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
+
+	// Pills styles
+	s.Pills.Base = base.Padding(0, 1)
+	s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay)
+	s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
+	s.Pills.QueueItemPrefix = s.Muted.SetString("  β€’")
+	s.Pills.HelpKey = s.Muted
+	s.Pills.HelpText = s.Subtle
+	s.Pills.Area = base
+	s.Pills.TodoSpinner = base.Foreground(greenDark)
+
+	return s
+}
+
+// Helper functions for style pointers
+func boolPtr(b bool) *bool       { return &b }
+func stringPtr(s string) *string { return &s }
+func uintPtr(u uint) *uint       { return &u }
+func chromaStyle(style ansi.StylePrimitive) string {
+	var s string
+
+	if style.Color != nil {
+		s = *style.Color
+	}
+	if style.BackgroundColor != nil {
+		if s != "" {
+			s += " "
+		}
+		s += "bg:" + *style.BackgroundColor
+	}
+	if style.Italic != nil && *style.Italic {
+		if s != "" {
+			s += " "
+		}
+		s += "italic"
+	}
+	if style.Bold != nil && *style.Bold {
+		if s != "" {
+			s += " "
+		}
+		s += "bold"
+	}
+	if style.Underline != nil && *style.Underline {
+		if s != "" {
+			s += " "
+		}
+		s += "underline"
+	}
+
+	return s
+}

internal/uicmd/uicmd.go πŸ”—

@@ -1,6 +1,7 @@
 // Package uicmd provides functionality to load and handle custom commands
 // from markdown files and MCP prompts.
 // TODO: Move this into internal/ui after refactoring.
+// TODO: DELETE when we delete the old tui
 package uicmd
 
 import (

internal/uiutil/uiutil.go πŸ”—

@@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
 
 func ReportError(err error) tea.Cmd {
 	slog.Error("Error reported", "error", err)
-	return CmdHandler(InfoMsg{
-		Type: InfoTypeError,
-		Msg:  err.Error(),
-	})
+	return CmdHandler(NewErrorMsg(err))
 }
 
 type InfoType int
@@ -42,18 +39,33 @@ const (
 	InfoTypeUpdate
 )
 
-func ReportInfo(info string) tea.Cmd {
-	return CmdHandler(InfoMsg{
+func NewInfoMsg(info string) InfoMsg {
+	return InfoMsg{
 		Type: InfoTypeInfo,
 		Msg:  info,
-	})
+	}
 }
 
-func ReportWarn(warn string) tea.Cmd {
-	return CmdHandler(InfoMsg{
+func NewWarnMsg(warn string) InfoMsg {
+	return InfoMsg{
 		Type: InfoTypeWarn,
 		Msg:  warn,
-	})
+	}
+}
+
+func NewErrorMsg(err error) InfoMsg {
+	return InfoMsg{
+		Type: InfoTypeError,
+		Msg:  err.Error(),
+	}
+}
+
+func ReportInfo(info string) tea.Cmd {
+	return CmdHandler(NewInfoMsg(info))
+}
+
+func ReportWarn(warn string) tea.Cmd {
+	return CmdHandler(NewWarnMsg(warn))
 }
 
 type (
@@ -65,6 +77,12 @@ type (
 	ClearStatusMsg struct{}
 )
 
+// IsEmpty checks if the [InfoMsg] is empty.
+func (m InfoMsg) IsEmpty() bool {
+	var zero InfoMsg
+	return m == zero
+}
+
 // ExecShell parses a shell command string and executes it with exec.Command.
 // Uses shell.Fields for proper handling of shell syntax like quotes and
 // arguments while preserving TTY handling for terminal editors.