ui.go

   1package model
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"image"
   8	"math/rand"
   9	"net/http"
  10	"os"
  11	"path/filepath"
  12	"runtime"
  13	"slices"
  14	"strings"
  15
  16	"charm.land/bubbles/v2/help"
  17	"charm.land/bubbles/v2/key"
  18	"charm.land/bubbles/v2/textarea"
  19	tea "charm.land/bubbletea/v2"
  20	"charm.land/lipgloss/v2"
  21	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  22	"github.com/charmbracelet/crush/internal/app"
  23	"github.com/charmbracelet/crush/internal/config"
  24	"github.com/charmbracelet/crush/internal/history"
  25	"github.com/charmbracelet/crush/internal/message"
  26	"github.com/charmbracelet/crush/internal/permission"
  27	"github.com/charmbracelet/crush/internal/pubsub"
  28	"github.com/charmbracelet/crush/internal/session"
  29	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  30	"github.com/charmbracelet/crush/internal/ui/anim"
  31	"github.com/charmbracelet/crush/internal/ui/chat"
  32	"github.com/charmbracelet/crush/internal/ui/common"
  33	"github.com/charmbracelet/crush/internal/ui/dialog"
  34	"github.com/charmbracelet/crush/internal/ui/logo"
  35	"github.com/charmbracelet/crush/internal/ui/styles"
  36	"github.com/charmbracelet/crush/internal/uiutil"
  37	"github.com/charmbracelet/crush/internal/version"
  38	uv "github.com/charmbracelet/ultraviolet"
  39	"github.com/charmbracelet/ultraviolet/screen"
  40)
  41
  42// uiFocusState represents the current focus state of the UI.
  43type uiFocusState uint8
  44
  45// Possible uiFocusState values.
  46const (
  47	uiFocusNone uiFocusState = iota
  48	uiFocusEditor
  49	uiFocusMain
  50)
  51
  52type uiState uint8
  53
  54// Possible uiState values.
  55const (
  56	uiConfigure uiState = iota
  57	uiInitialize
  58	uiLanding
  59	uiChat
  60	uiChatCompact
  61)
  62
  63type openEditorMsg struct {
  64	Text string
  65}
  66
  67// listSessionsMsg is a message to list available sessions.
  68type listSessionsMsg struct {
  69	sessions []session.Session
  70}
  71
  72// UI represents the main user interface model.
  73type UI struct {
  74	com          *common.Common
  75	session      *session.Session
  76	sessionFiles []SessionFile
  77
  78	// The width and height of the terminal in cells.
  79	width  int
  80	height int
  81	layout layout
  82
  83	focus uiFocusState
  84	state uiState
  85
  86	keyMap KeyMap
  87	keyenh tea.KeyboardEnhancementsMsg
  88
  89	dialog *dialog.Overlay
  90	help   help.Model
  91
  92	// header is the last cached header logo
  93	header string
  94
  95	// sendProgressBar instructs the TUI to send progress bar updates to the
  96	// terminal.
  97	sendProgressBar bool
  98
  99	// QueryVersion instructs the TUI to query for the terminal version when it
 100	// starts.
 101	QueryVersion bool
 102
 103	// Editor components
 104	textarea textarea.Model
 105
 106	attachments []message.Attachment // TODO: Implement attachments
 107
 108	readyPlaceholder   string
 109	workingPlaceholder string
 110
 111	// Chat components
 112	chat *Chat
 113
 114	// onboarding state
 115	onboarding struct {
 116		yesInitializeSelected bool
 117	}
 118
 119	// lsp
 120	lspStates map[string]app.LSPClientInfo
 121
 122	// mcp
 123	mcpStates map[string]mcp.ClientInfo
 124
 125	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 126	sidebarLogo string
 127}
 128
 129// New creates a new instance of the [UI] model.
 130func New(com *common.Common) *UI {
 131	// Editor components
 132	ta := textarea.New()
 133	ta.SetStyles(com.Styles.TextArea)
 134	ta.ShowLineNumbers = false
 135	ta.CharLimit = -1
 136	ta.SetVirtualCursor(false)
 137	ta.Focus()
 138
 139	ch := NewChat(com)
 140
 141	ui := &UI{
 142		com:      com,
 143		dialog:   dialog.NewOverlay(),
 144		keyMap:   DefaultKeyMap(),
 145		help:     help.New(),
 146		focus:    uiFocusNone,
 147		state:    uiConfigure,
 148		textarea: ta,
 149		chat:     ch,
 150	}
 151
 152	// set onboarding state defaults
 153	ui.onboarding.yesInitializeSelected = true
 154
 155	// If no provider is configured show the user the provider list
 156	if !com.Config().IsConfigured() {
 157		ui.state = uiConfigure
 158		// if the project needs initialization show the user the question
 159	} else if n, _ := config.ProjectNeedsInitialization(); n {
 160		ui.state = uiInitialize
 161		// otherwise go to the landing UI
 162	} else {
 163		ui.state = uiLanding
 164		ui.focus = uiFocusEditor
 165	}
 166
 167	ui.setEditorPrompt(false)
 168	ui.randomizePlaceholders()
 169	ui.textarea.Placeholder = ui.readyPlaceholder
 170	ui.help.Styles = com.Styles.Help
 171
 172	return ui
 173}
 174
 175// Init initializes the UI model.
 176func (m *UI) Init() tea.Cmd {
 177	var cmds []tea.Cmd
 178	if m.QueryVersion {
 179		cmds = append(cmds, tea.RequestTerminalVersion)
 180	}
 181	return tea.Batch(cmds...)
 182}
 183
 184// Update handles updates to the UI model.
 185func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 186	var cmds []tea.Cmd
 187	switch msg := msg.(type) {
 188	case tea.EnvMsg:
 189		// Is this Windows Terminal?
 190		if !m.sendProgressBar {
 191			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 192		}
 193	case listSessionsMsg:
 194		if cmd := m.openSessionsDialog(msg.sessions); cmd != nil {
 195			cmds = append(cmds, cmd)
 196		}
 197	case loadSessionMsg:
 198		m.state = uiChat
 199		m.session = msg.session
 200		m.sessionFiles = msg.files
 201		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 202		if err != nil {
 203			cmds = append(cmds, uiutil.ReportError(err))
 204			break
 205		}
 206		if cmd := m.setSessionMessages(msgs); cmd != nil {
 207			cmds = append(cmds, cmd)
 208		}
 209
 210	case pubsub.Event[message.Message]:
 211		if m.session == nil || msg.Payload.SessionID != m.session.ID {
 212			break
 213		}
 214		switch msg.Type {
 215		case pubsub.CreatedEvent:
 216			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 217		case pubsub.UpdatedEvent:
 218			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 219		}
 220		// TODO: Finish implementing me
 221		// cmds = append(cmds, m.setMessageEvents(msg.Payload))
 222	case pubsub.Event[history.File]:
 223		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 224	case pubsub.Event[app.LSPEvent]:
 225		m.lspStates = app.GetLSPStates()
 226	case pubsub.Event[mcp.Event]:
 227		m.mcpStates = mcp.GetStates()
 228		if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
 229			dia := m.dialog.Dialog(dialog.CommandsID)
 230			if dia == nil {
 231				break
 232			}
 233
 234			commands, ok := dia.(*dialog.Commands)
 235			if ok {
 236				if cmd := commands.ReloadMCPPrompts(); cmd != nil {
 237					cmds = append(cmds, cmd)
 238				}
 239			}
 240		}
 241	case tea.TerminalVersionMsg:
 242		termVersion := strings.ToLower(msg.Name)
 243		// Only enable progress bar for the following terminals.
 244		if !m.sendProgressBar {
 245			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 246		}
 247		return m, nil
 248	case tea.WindowSizeMsg:
 249		m.width, m.height = msg.Width, msg.Height
 250		m.updateLayoutAndSize()
 251	case tea.KeyboardEnhancementsMsg:
 252		m.keyenh = msg
 253		if msg.SupportsKeyDisambiguation() {
 254			m.keyMap.Models.SetHelp("ctrl+m", "models")
 255			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 256		}
 257	case tea.MouseClickMsg:
 258		switch m.state {
 259		case uiChat:
 260			x, y := msg.X, msg.Y
 261			// Adjust for chat area position
 262			x -= m.layout.main.Min.X
 263			y -= m.layout.main.Min.Y
 264			m.chat.HandleMouseDown(x, y)
 265		}
 266
 267	case tea.MouseMotionMsg:
 268		switch m.state {
 269		case uiChat:
 270			if msg.Y <= 0 {
 271				if cmd := m.chat.ScrollBy(-1); cmd != nil {
 272					cmds = append(cmds, cmd)
 273				}
 274				if !m.chat.SelectedItemInView() {
 275					m.chat.SelectPrev()
 276					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 277						cmds = append(cmds, cmd)
 278					}
 279				}
 280			} else if msg.Y >= m.chat.Height()-1 {
 281				if cmd := m.chat.ScrollBy(1); cmd != nil {
 282					cmds = append(cmds, cmd)
 283				}
 284				if !m.chat.SelectedItemInView() {
 285					m.chat.SelectNext()
 286					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 287						cmds = append(cmds, cmd)
 288					}
 289				}
 290			}
 291
 292			x, y := msg.X, msg.Y
 293			// Adjust for chat area position
 294			x -= m.layout.main.Min.X
 295			y -= m.layout.main.Min.Y
 296			m.chat.HandleMouseDrag(x, y)
 297		}
 298
 299	case tea.MouseReleaseMsg:
 300		switch m.state {
 301		case uiChat:
 302			x, y := msg.X, msg.Y
 303			// Adjust for chat area position
 304			x -= m.layout.main.Min.X
 305			y -= m.layout.main.Min.Y
 306			m.chat.HandleMouseUp(x, y)
 307		}
 308	case tea.MouseWheelMsg:
 309		switch m.state {
 310		case uiChat:
 311			switch msg.Button {
 312			case tea.MouseWheelUp:
 313				if cmd := m.chat.ScrollBy(-5); cmd != nil {
 314					cmds = append(cmds, cmd)
 315				}
 316				if !m.chat.SelectedItemInView() {
 317					m.chat.SelectPrev()
 318					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 319						cmds = append(cmds, cmd)
 320					}
 321				}
 322			case tea.MouseWheelDown:
 323				if cmd := m.chat.ScrollBy(5); cmd != nil {
 324					cmds = append(cmds, cmd)
 325				}
 326				if !m.chat.SelectedItemInView() {
 327					m.chat.SelectNext()
 328					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 329						cmds = append(cmds, cmd)
 330					}
 331				}
 332			}
 333		}
 334	case anim.StepMsg:
 335		if m.state == uiChat {
 336			if cmd := m.chat.Animate(msg); cmd != nil {
 337				cmds = append(cmds, cmd)
 338			}
 339		}
 340	case tea.KeyPressMsg:
 341		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 342			cmds = append(cmds, cmd)
 343		}
 344	case tea.PasteMsg:
 345		if cmd := m.handlePasteMsg(msg); cmd != nil {
 346			cmds = append(cmds, cmd)
 347		}
 348	case openEditorMsg:
 349		m.textarea.SetValue(msg.Text)
 350		m.textarea.MoveToEnd()
 351	}
 352
 353	// This logic gets triggered on any message type, but should it?
 354	switch m.focus {
 355	case uiFocusMain:
 356	case uiFocusEditor:
 357		// Textarea placeholder logic
 358		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 359			m.textarea.Placeholder = m.workingPlaceholder
 360		} else {
 361			m.textarea.Placeholder = m.readyPlaceholder
 362		}
 363		if m.com.App.Permissions.SkipRequests() {
 364			m.textarea.Placeholder = "Yolo mode!"
 365		}
 366	}
 367
 368	return m, tea.Batch(cmds...)
 369}
 370
 371// setSessionMessages sets the messages for the current session in the chat
 372func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 373	var cmds []tea.Cmd
 374	// Build tool result map to link tool calls with their results
 375	msgPtrs := make([]*message.Message, len(msgs))
 376	for i := range msgs {
 377		msgPtrs[i] = &msgs[i]
 378	}
 379	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 380
 381	// Add messages to chat with linked tool results
 382	items := make([]chat.MessageItem, 0, len(msgs)*2)
 383	for _, msg := range msgPtrs {
 384		items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
 385	}
 386
 387	// If the user switches between sessions while the agent is working we want
 388	// to make sure the animations are shown.
 389	for _, item := range items {
 390		if animatable, ok := item.(chat.Animatable); ok {
 391			if cmd := animatable.StartAnimation(); cmd != nil {
 392				cmds = append(cmds, cmd)
 393			}
 394		}
 395	}
 396
 397	m.chat.SetMessages(items...)
 398	if cmd := m.chat.ScrollToBottom(); cmd != nil {
 399		cmds = append(cmds, cmd)
 400	}
 401	m.chat.SelectLast()
 402	return tea.Batch(cmds...)
 403}
 404
 405// appendSessionMessage appends a new message to the current session in the chat
 406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 407	items := chat.GetMessageItems(m.com.Styles, &msg, nil)
 408	var cmds []tea.Cmd
 409	for _, item := range items {
 410		if animatable, ok := item.(chat.Animatable); ok {
 411			if cmd := animatable.StartAnimation(); cmd != nil {
 412				cmds = append(cmds, cmd)
 413			}
 414		}
 415	}
 416	m.chat.AppendMessages(items...)
 417	if cmd := m.chat.ScrollToBottom(); cmd != nil {
 418		cmds = append(cmds, cmd)
 419	}
 420	return tea.Batch(cmds...)
 421}
 422
 423// updateSessionMessage updates an existing message in the current session in the chat
 424// INFO: currently only updates the assistant when I add tools this will get a bit more complex
 425func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 426	existingItem := m.chat.GetMessageItem(msg.ID)
 427	switch msg.Role {
 428	case message.Assistant:
 429		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
 430			assistantItem.SetMessage(&msg)
 431		}
 432	}
 433
 434	return nil
 435}
 436
 437func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 438	var cmds []tea.Cmd
 439
 440	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
 441		switch {
 442		case key.Matches(msg, m.keyMap.Help):
 443			m.help.ShowAll = !m.help.ShowAll
 444			m.updateLayoutAndSize()
 445			return true
 446		case key.Matches(msg, m.keyMap.Commands):
 447			if cmd := m.openCommandsDialog(); cmd != nil {
 448				cmds = append(cmds, cmd)
 449			}
 450			return true
 451		case key.Matches(msg, m.keyMap.Models):
 452			// TODO: Implement me
 453			return true
 454		case key.Matches(msg, m.keyMap.Sessions):
 455			if m.dialog.ContainsDialog(dialog.SessionsID) {
 456				// Bring to front
 457				m.dialog.BringToFront(dialog.SessionsID)
 458			} else {
 459				cmds = append(cmds, m.listSessions)
 460			}
 461			return true
 462		}
 463		return false
 464	}
 465
 466	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
 467		// Always handle quit keys first
 468		if cmd := m.openQuitDialog(); cmd != nil {
 469			cmds = append(cmds, cmd)
 470		}
 471
 472		return tea.Batch(cmds...)
 473	}
 474
 475	// Route all messages to dialog if one is open.
 476	if m.dialog.HasDialogs() {
 477		msg := m.dialog.Update(msg)
 478		if msg == nil {
 479			return tea.Batch(cmds...)
 480		}
 481
 482		switch msg := msg.(type) {
 483		// Generic dialog messages
 484		case dialog.CloseMsg:
 485			m.dialog.CloseFrontDialog()
 486
 487		// Session dialog messages
 488		case dialog.SessionSelectedMsg:
 489			m.dialog.CloseDialog(dialog.SessionsID)
 490			cmds = append(cmds, m.loadSession(msg.Session.ID))
 491
 492		// Command dialog messages
 493		case dialog.ToggleYoloModeMsg:
 494			yolo := !m.com.App.Permissions.SkipRequests()
 495			m.com.App.Permissions.SetSkipRequests(yolo)
 496			m.setEditorPrompt(yolo)
 497			m.dialog.CloseDialog(dialog.CommandsID)
 498		case dialog.SwitchSessionsMsg:
 499			cmds = append(cmds, m.listSessions)
 500			m.dialog.CloseDialog(dialog.CommandsID)
 501		case dialog.CompactMsg:
 502			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 503			if err != nil {
 504				cmds = append(cmds, uiutil.ReportError(err))
 505			}
 506		case dialog.ToggleHelpMsg:
 507			m.help.ShowAll = !m.help.ShowAll
 508			m.dialog.CloseDialog(dialog.CommandsID)
 509		case dialog.QuitMsg:
 510			cmds = append(cmds, tea.Quit)
 511		}
 512
 513		return tea.Batch(cmds...)
 514	}
 515
 516	switch m.state {
 517	case uiConfigure:
 518		return tea.Batch(cmds...)
 519	case uiInitialize:
 520		cmds = append(cmds, m.updateInitializeView(msg)...)
 521		return tea.Batch(cmds...)
 522	case uiChat, uiLanding, uiChatCompact:
 523		switch m.focus {
 524		case uiFocusEditor:
 525			switch {
 526			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 527				value := m.textarea.Value()
 528				if strings.HasSuffix(value, "\\") {
 529					// If the last character is a backslash, remove it and add a newline.
 530					m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
 531					break
 532				}
 533
 534				// Otherwise, send the message
 535				m.textarea.Reset()
 536
 537				value = strings.TrimSpace(value)
 538				if value == "exit" || value == "quit" {
 539					return m.openQuitDialog()
 540				}
 541
 542				attachments := m.attachments
 543				m.attachments = nil
 544				if len(value) == 0 {
 545					return nil
 546				}
 547
 548				m.randomizePlaceholders()
 549
 550				return m.sendMessage(value, attachments)
 551			case key.Matches(msg, m.keyMap.Tab):
 552				m.focus = uiFocusMain
 553				m.textarea.Blur()
 554				m.chat.Focus()
 555				m.chat.SetSelected(m.chat.Len() - 1)
 556			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
 557				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
 558					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
 559					break
 560				}
 561				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 562			case key.Matches(msg, m.keyMap.Editor.Newline):
 563				m.textarea.InsertRune('\n')
 564			default:
 565				if handleGlobalKeys(msg) {
 566					// Handle global keys first before passing to textarea.
 567					break
 568				}
 569
 570				ta, cmd := m.textarea.Update(msg)
 571				m.textarea = ta
 572				cmds = append(cmds, cmd)
 573			}
 574		case uiFocusMain:
 575			switch {
 576			case key.Matches(msg, m.keyMap.Tab):
 577				m.focus = uiFocusEditor
 578				cmds = append(cmds, m.textarea.Focus())
 579				m.chat.Blur()
 580			case key.Matches(msg, m.keyMap.Chat.Up):
 581				if cmd := m.chat.ScrollBy(-1); cmd != nil {
 582					cmds = append(cmds, cmd)
 583				}
 584				if !m.chat.SelectedItemInView() {
 585					m.chat.SelectPrev()
 586					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 587						cmds = append(cmds, cmd)
 588					}
 589				}
 590			case key.Matches(msg, m.keyMap.Chat.Down):
 591				if cmd := m.chat.ScrollBy(1); cmd != nil {
 592					cmds = append(cmds, cmd)
 593				}
 594				if !m.chat.SelectedItemInView() {
 595					m.chat.SelectNext()
 596					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 597						cmds = append(cmds, cmd)
 598					}
 599				}
 600			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 601				m.chat.SelectPrev()
 602				if cmd := m.chat.ScrollToSelected(); cmd != nil {
 603					cmds = append(cmds, cmd)
 604				}
 605			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 606				m.chat.SelectNext()
 607				if cmd := m.chat.ScrollToSelected(); cmd != nil {
 608					cmds = append(cmds, cmd)
 609				}
 610			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
 611				if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
 612					cmds = append(cmds, cmd)
 613				}
 614				m.chat.SelectFirstInView()
 615			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
 616				if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
 617					cmds = append(cmds, cmd)
 618				}
 619				m.chat.SelectLastInView()
 620			case key.Matches(msg, m.keyMap.Chat.PageUp):
 621				if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
 622					cmds = append(cmds, cmd)
 623				}
 624				m.chat.SelectFirstInView()
 625			case key.Matches(msg, m.keyMap.Chat.PageDown):
 626				if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
 627					cmds = append(cmds, cmd)
 628				}
 629				m.chat.SelectLastInView()
 630			case key.Matches(msg, m.keyMap.Chat.Home):
 631				if cmd := m.chat.ScrollToTop(); cmd != nil {
 632					cmds = append(cmds, cmd)
 633				}
 634				m.chat.SelectFirst()
 635			case key.Matches(msg, m.keyMap.Chat.End):
 636				if cmd := m.chat.ScrollToBottom(); cmd != nil {
 637					cmds = append(cmds, cmd)
 638				}
 639				m.chat.SelectLast()
 640			default:
 641				handleGlobalKeys(msg)
 642			}
 643		default:
 644			handleGlobalKeys(msg)
 645		}
 646	default:
 647		handleGlobalKeys(msg)
 648	}
 649
 650	return tea.Batch(cmds...)
 651}
 652
 653// Draw implements [tea.Layer] and draws the UI model.
 654func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 655	layout := m.generateLayout(area.Dx(), area.Dy())
 656
 657	if m.layout != layout {
 658		m.layout = layout
 659		m.updateSize()
 660	}
 661
 662	// Clear the screen first
 663	screen.Clear(scr)
 664
 665	switch m.state {
 666	case uiConfigure:
 667		header := uv.NewStyledString(m.header)
 668		header.Draw(scr, layout.header)
 669
 670		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 671			Height(layout.main.Dy()).
 672			Background(lipgloss.ANSIColor(rand.Intn(256))).
 673			Render(" Configure ")
 674		main := uv.NewStyledString(mainView)
 675		main.Draw(scr, layout.main)
 676
 677	case uiInitialize:
 678		header := uv.NewStyledString(m.header)
 679		header.Draw(scr, layout.header)
 680
 681		main := uv.NewStyledString(m.initializeView())
 682		main.Draw(scr, layout.main)
 683
 684	case uiLanding:
 685		header := uv.NewStyledString(m.header)
 686		header.Draw(scr, layout.header)
 687		main := uv.NewStyledString(m.landingView())
 688		main.Draw(scr, layout.main)
 689
 690		editor := uv.NewStyledString(m.textarea.View())
 691		editor.Draw(scr, layout.editor)
 692
 693	case uiChat:
 694		m.chat.Draw(scr, layout.main)
 695
 696		header := uv.NewStyledString(m.header)
 697		header.Draw(scr, layout.header)
 698		m.drawSidebar(scr, layout.sidebar)
 699
 700		editor := uv.NewStyledString(m.textarea.View())
 701		editor.Draw(scr, layout.editor)
 702
 703	case uiChatCompact:
 704		header := uv.NewStyledString(m.header)
 705		header.Draw(scr, layout.header)
 706
 707		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 708			Height(layout.main.Dy()).
 709			Background(lipgloss.ANSIColor(rand.Intn(256))).
 710			Render(" Compact Chat Messages ")
 711		main := uv.NewStyledString(mainView)
 712		main.Draw(scr, layout.main)
 713
 714		editor := uv.NewStyledString(m.textarea.View())
 715		editor.Draw(scr, layout.editor)
 716	}
 717
 718	// Add help layer
 719	help := uv.NewStyledString(m.help.View(m))
 720	help.Draw(scr, layout.help)
 721
 722	// Debugging rendering (visually see when the tui rerenders)
 723	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
 724		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
 725		debug := uv.NewStyledString(debugView.String())
 726		debug.Draw(scr, image.Rectangle{
 727			Min: image.Pt(4, 1),
 728			Max: image.Pt(8, 3),
 729		})
 730	}
 731
 732	// This needs to come last to overlay on top of everything
 733	if m.dialog.HasDialogs() {
 734		m.dialog.Draw(scr, area)
 735	}
 736}
 737
 738// Cursor returns the cursor position and properties for the UI model. It
 739// returns nil if the cursor should not be shown.
 740func (m *UI) Cursor() *tea.Cursor {
 741	if m.layout.editor.Dy() <= 0 {
 742		// Don't show cursor if editor is not visible
 743		return nil
 744	}
 745	if m.dialog.HasDialogs() {
 746		if front := m.dialog.DialogLast(); front != nil {
 747			c, ok := front.(uiutil.Cursor)
 748			if ok {
 749				cur := c.Cursor()
 750				if cur != nil {
 751					pos := m.dialog.CenterPosition(m.layout.area, front.ID())
 752					cur.X += pos.Min.X
 753					cur.Y += pos.Min.Y
 754					return cur
 755				}
 756			}
 757		}
 758		return nil
 759	}
 760	switch m.focus {
 761	case uiFocusEditor:
 762		if m.textarea.Focused() {
 763			cur := m.textarea.Cursor()
 764			cur.X++ // Adjust for app margins
 765			cur.Y += m.layout.editor.Min.Y
 766			return cur
 767		}
 768	}
 769	return nil
 770}
 771
 772// View renders the UI model's view.
 773func (m *UI) View() tea.View {
 774	var v tea.View
 775	v.AltScreen = true
 776	v.BackgroundColor = m.com.Styles.Background
 777	v.Cursor = m.Cursor()
 778	v.MouseMode = tea.MouseModeCellMotion
 779
 780	canvas := uv.NewScreenBuffer(m.width, m.height)
 781	m.Draw(canvas, canvas.Bounds())
 782
 783	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
 784	contentLines := strings.Split(content, "\n")
 785	for i, line := range contentLines {
 786		// Trim trailing spaces for concise rendering
 787		contentLines[i] = strings.TrimRight(line, " ")
 788	}
 789
 790	content = strings.Join(contentLines, "\n")
 791
 792	v.Content = content
 793	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 794		// HACK: use a random percentage to prevent ghostty from hiding it
 795		// after a timeout.
 796		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
 797	}
 798
 799	return v
 800}
 801
 802// ShortHelp implements [help.KeyMap].
 803func (m *UI) ShortHelp() []key.Binding {
 804	var binds []key.Binding
 805	k := &m.keyMap
 806	tab := k.Tab
 807	commands := k.Commands
 808	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 809		commands.SetHelp("/ or ctrl+p", "commands")
 810	}
 811
 812	switch m.state {
 813	case uiInitialize:
 814		binds = append(binds, k.Quit)
 815	case uiChat:
 816		if m.focus == uiFocusEditor {
 817			tab.SetHelp("tab", "focus chat")
 818		} else {
 819			tab.SetHelp("tab", "focus editor")
 820		}
 821
 822		binds = append(binds,
 823			tab,
 824			commands,
 825			k.Models,
 826		)
 827
 828		switch m.focus {
 829		case uiFocusEditor:
 830			binds = append(binds,
 831				k.Editor.Newline,
 832			)
 833		case uiFocusMain:
 834			binds = append(binds,
 835				k.Chat.UpDown,
 836				k.Chat.UpDownOneItem,
 837				k.Chat.PageUp,
 838				k.Chat.PageDown,
 839				k.Chat.Copy,
 840			)
 841		}
 842	default:
 843		// TODO: other states
 844		// if m.session == nil {
 845		// no session selected
 846		binds = append(binds,
 847			commands,
 848			k.Models,
 849			k.Editor.Newline,
 850		)
 851	}
 852
 853	binds = append(binds,
 854		k.Quit,
 855		k.Help,
 856	)
 857
 858	return binds
 859}
 860
 861// FullHelp implements [help.KeyMap].
 862func (m *UI) FullHelp() [][]key.Binding {
 863	var binds [][]key.Binding
 864	k := &m.keyMap
 865	help := k.Help
 866	help.SetHelp("ctrl+g", "less")
 867	hasAttachments := false // TODO: implement attachments
 868	hasSession := m.session != nil && m.session.ID != ""
 869	commands := k.Commands
 870	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 871		commands.SetHelp("/ or ctrl+p", "commands")
 872	}
 873
 874	switch m.state {
 875	case uiInitialize:
 876		binds = append(binds,
 877			[]key.Binding{
 878				k.Quit,
 879			})
 880	case uiChat:
 881		mainBinds := []key.Binding{}
 882		tab := k.Tab
 883		if m.focus == uiFocusEditor {
 884			tab.SetHelp("tab", "focus chat")
 885		} else {
 886			tab.SetHelp("tab", "focus editor")
 887		}
 888
 889		mainBinds = append(mainBinds,
 890			tab,
 891			commands,
 892			k.Models,
 893			k.Sessions,
 894		)
 895		if hasSession {
 896			mainBinds = append(mainBinds, k.Chat.NewSession)
 897		}
 898
 899		binds = append(binds, mainBinds)
 900
 901		switch m.focus {
 902		case uiFocusEditor:
 903			binds = append(binds,
 904				[]key.Binding{
 905					k.Editor.Newline,
 906					k.Editor.AddImage,
 907					k.Editor.MentionFile,
 908					k.Editor.OpenEditor,
 909				},
 910			)
 911			if hasAttachments {
 912				binds = append(binds,
 913					[]key.Binding{
 914						k.Editor.AttachmentDeleteMode,
 915						k.Editor.DeleteAllAttachments,
 916						k.Editor.Escape,
 917					},
 918				)
 919			}
 920		case uiFocusMain:
 921			binds = append(binds,
 922				[]key.Binding{
 923					k.Chat.UpDown,
 924					k.Chat.UpDownOneItem,
 925					k.Chat.PageUp,
 926					k.Chat.PageDown,
 927				},
 928				[]key.Binding{
 929					k.Chat.HalfPageUp,
 930					k.Chat.HalfPageDown,
 931					k.Chat.Home,
 932					k.Chat.End,
 933				},
 934				[]key.Binding{
 935					k.Chat.Copy,
 936					k.Chat.ClearHighlight,
 937				},
 938			)
 939		}
 940	default:
 941		if m.session == nil {
 942			// no session selected
 943			binds = append(binds,
 944				[]key.Binding{
 945					commands,
 946					k.Models,
 947					k.Sessions,
 948				},
 949				[]key.Binding{
 950					k.Editor.Newline,
 951					k.Editor.AddImage,
 952					k.Editor.MentionFile,
 953					k.Editor.OpenEditor,
 954				},
 955				[]key.Binding{
 956					help,
 957				},
 958			)
 959		}
 960	}
 961
 962	binds = append(binds,
 963		[]key.Binding{
 964			help,
 965			k.Quit,
 966		},
 967	)
 968
 969	return binds
 970}
 971
 972// updateLayoutAndSize updates the layout and sizes of UI components.
 973func (m *UI) updateLayoutAndSize() {
 974	m.layout = m.generateLayout(m.width, m.height)
 975	m.updateSize()
 976}
 977
 978// updateSize updates the sizes of UI components based on the current layout.
 979func (m *UI) updateSize() {
 980	// Set help width
 981	m.help.SetWidth(m.layout.help.Dx())
 982
 983	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 984	m.textarea.SetWidth(m.layout.editor.Dx())
 985	m.textarea.SetHeight(m.layout.editor.Dy())
 986
 987	// Handle different app states
 988	switch m.state {
 989	case uiConfigure, uiInitialize, uiLanding:
 990		m.renderHeader(false, m.layout.header.Dx())
 991
 992	case uiChat:
 993		m.renderSidebarLogo(m.layout.sidebar.Dx())
 994
 995	case uiChatCompact:
 996		// TODO: set the width and heigh of the chat component
 997		m.renderHeader(true, m.layout.header.Dx())
 998	}
 999}
1000
1001// generateLayout calculates the layout rectangles for all UI components based
1002// on the current UI state and terminal dimensions.
1003func (m *UI) generateLayout(w, h int) layout {
1004	// The screen area we're working with
1005	area := image.Rect(0, 0, w, h)
1006
1007	// The help height
1008	helpHeight := 1
1009	// The editor height
1010	editorHeight := 5
1011	// The sidebar width
1012	sidebarWidth := 30
1013	// The header height
1014	// TODO: handle compact
1015	headerHeight := 4
1016
1017	var helpKeyMap help.KeyMap = m
1018	if m.help.ShowAll {
1019		for _, row := range helpKeyMap.FullHelp() {
1020			helpHeight = max(helpHeight, len(row))
1021		}
1022	}
1023
1024	// Add app margins
1025	appRect := area
1026	appRect.Min.X += 1
1027	appRect.Min.Y += 1
1028	appRect.Max.X -= 1
1029	appRect.Max.Y -= 1
1030
1031	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1032		// extra padding on left and right for these states
1033		appRect.Min.X += 1
1034		appRect.Max.X -= 1
1035	}
1036
1037	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1038
1039	layout := layout{
1040		area: area,
1041		help: helpRect,
1042	}
1043
1044	// Handle different app states
1045	switch m.state {
1046	case uiConfigure, uiInitialize:
1047		// Layout
1048		//
1049		// header
1050		// ------
1051		// main
1052		// ------
1053		// help
1054
1055		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1056		layout.header = headerRect
1057		layout.main = mainRect
1058
1059	case uiLanding:
1060		// Layout
1061		//
1062		// header
1063		// ------
1064		// main
1065		// ------
1066		// editor
1067		// ------
1068		// help
1069		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1070		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1071		// Remove extra padding from editor (but keep it for header and main)
1072		editorRect.Min.X -= 1
1073		editorRect.Max.X += 1
1074		layout.header = headerRect
1075		layout.main = mainRect
1076		layout.editor = editorRect
1077
1078	case uiChat:
1079		// Layout
1080		//
1081		// ------|---
1082		// main  |
1083		// ------| side
1084		// editor|
1085		// ----------
1086		// help
1087
1088		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1089		// Add padding left
1090		sideRect.Min.X += 1
1091		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1092		mainRect.Max.X -= 1 // Add padding right
1093		// Add bottom margin to main
1094		mainRect.Max.Y -= 1
1095		layout.sidebar = sideRect
1096		layout.main = mainRect
1097		layout.editor = editorRect
1098
1099	case uiChatCompact:
1100		// Layout
1101		//
1102		// compact-header
1103		// ------
1104		// main
1105		// ------
1106		// editor
1107		// ------
1108		// help
1109		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1110		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1111		layout.header = headerRect
1112		layout.main = mainRect
1113		layout.editor = editorRect
1114	}
1115
1116	if !layout.editor.Empty() {
1117		// Add editor margins 1 top and bottom
1118		layout.editor.Min.Y += 1
1119		layout.editor.Max.Y -= 1
1120	}
1121
1122	return layout
1123}
1124
1125// layout defines the positioning of UI elements.
1126type layout struct {
1127	// area is the overall available area.
1128	area uv.Rectangle
1129
1130	// header is the header shown in special cases
1131	// e.x when the sidebar is collapsed
1132	// or when in the landing page
1133	// or in init/config
1134	header uv.Rectangle
1135
1136	// main is the area for the main pane. (e.x chat, configure, landing)
1137	main uv.Rectangle
1138
1139	// editor is the area for the editor pane.
1140	editor uv.Rectangle
1141
1142	// sidebar is the area for the sidebar.
1143	sidebar uv.Rectangle
1144
1145	// help is the area for the help view.
1146	help uv.Rectangle
1147}
1148
1149func (m *UI) openEditor(value string) tea.Cmd {
1150	editor := os.Getenv("EDITOR")
1151	if editor == "" {
1152		// Use platform-appropriate default editor
1153		if runtime.GOOS == "windows" {
1154			editor = "notepad"
1155		} else {
1156			editor = "nvim"
1157		}
1158	}
1159
1160	tmpfile, err := os.CreateTemp("", "msg_*.md")
1161	if err != nil {
1162		return uiutil.ReportError(err)
1163	}
1164	defer tmpfile.Close() //nolint:errcheck
1165	if _, err := tmpfile.WriteString(value); err != nil {
1166		return uiutil.ReportError(err)
1167	}
1168	cmdStr := editor + " " + tmpfile.Name()
1169	return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1170		if err != nil {
1171			return uiutil.ReportError(err)
1172		}
1173		content, err := os.ReadFile(tmpfile.Name())
1174		if err != nil {
1175			return uiutil.ReportError(err)
1176		}
1177		if len(content) == 0 {
1178			return uiutil.ReportWarn("Message is empty")
1179		}
1180		os.Remove(tmpfile.Name())
1181		return openEditorMsg{
1182			Text: strings.TrimSpace(string(content)),
1183		}
1184	})
1185}
1186
1187// setEditorPrompt configures the textarea prompt function based on whether
1188// yolo mode is enabled.
1189func (m *UI) setEditorPrompt(yolo bool) {
1190	if yolo {
1191		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1192		return
1193	}
1194	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1195}
1196
1197// normalPromptFunc returns the normal editor prompt style ("  > " on first
1198// line, "::: " on subsequent lines).
1199func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1200	t := m.com.Styles
1201	if info.LineNumber == 0 {
1202		if info.Focused {
1203			return "  > "
1204		}
1205		return "::: "
1206	}
1207	if info.Focused {
1208		return t.EditorPromptNormalFocused.Render()
1209	}
1210	return t.EditorPromptNormalBlurred.Render()
1211}
1212
1213// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1214// and colored dots.
1215func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1216	t := m.com.Styles
1217	if info.LineNumber == 0 {
1218		if info.Focused {
1219			return t.EditorPromptYoloIconFocused.Render()
1220		} else {
1221			return t.EditorPromptYoloIconBlurred.Render()
1222		}
1223	}
1224	if info.Focused {
1225		return t.EditorPromptYoloDotsFocused.Render()
1226	}
1227	return t.EditorPromptYoloDotsBlurred.Render()
1228}
1229
1230var readyPlaceholders = [...]string{
1231	"Ready!",
1232	"Ready...",
1233	"Ready?",
1234	"Ready for instructions",
1235}
1236
1237var workingPlaceholders = [...]string{
1238	"Working!",
1239	"Working...",
1240	"Brrrrr...",
1241	"Prrrrrrrr...",
1242	"Processing...",
1243	"Thinking...",
1244}
1245
1246// randomizePlaceholders selects random placeholder text for the textarea's
1247// ready and working states.
1248func (m *UI) randomizePlaceholders() {
1249	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1250	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1251}
1252
1253// renderHeader renders and caches the header logo at the specified width.
1254func (m *UI) renderHeader(compact bool, width int) {
1255	// TODO: handle the compact case differently
1256	m.header = renderLogo(m.com.Styles, compact, width)
1257}
1258
1259// renderSidebarLogo renders and caches the sidebar logo at the specified
1260// width.
1261func (m *UI) renderSidebarLogo(width int) {
1262	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1263}
1264
1265// sendMessage sends a message with the given content and attachments.
1266func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1267	if m.com.App.AgentCoordinator == nil {
1268		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1269	}
1270
1271	var cmds []tea.Cmd
1272	if m.session == nil || m.session.ID == "" {
1273		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1274		if err != nil {
1275			return uiutil.ReportError(err)
1276		}
1277		m.state = uiChat
1278		m.session = &newSession
1279		cmds = append(cmds, m.loadSession(newSession.ID))
1280	}
1281
1282	// Capture session ID to avoid race with main goroutine updating m.session.
1283	sessionID := m.session.ID
1284	cmds = append(cmds, func() tea.Msg {
1285		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1286		if err != nil {
1287			isCancelErr := errors.Is(err, context.Canceled)
1288			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1289			if isCancelErr || isPermissionErr {
1290				return nil
1291			}
1292			return uiutil.InfoMsg{
1293				Type: uiutil.InfoTypeError,
1294				Msg:  err.Error(),
1295			}
1296		}
1297		return nil
1298	})
1299	return tea.Batch(cmds...)
1300}
1301
1302// openQuitDialog opens the quit confirmation dialog.
1303func (m *UI) openQuitDialog() tea.Cmd {
1304	if m.dialog.ContainsDialog(dialog.QuitID) {
1305		// Bring to front
1306		m.dialog.BringToFront(dialog.QuitID)
1307		return nil
1308	}
1309
1310	quitDialog := dialog.NewQuit(m.com)
1311	m.dialog.OpenDialog(quitDialog)
1312	return nil
1313}
1314
1315// openCommandsDialog opens the commands dialog.
1316func (m *UI) openCommandsDialog() tea.Cmd {
1317	if m.dialog.ContainsDialog(dialog.CommandsID) {
1318		// Bring to front
1319		m.dialog.BringToFront(dialog.CommandsID)
1320		return nil
1321	}
1322
1323	sessionID := ""
1324	if m.session != nil {
1325		sessionID = m.session.ID
1326	}
1327
1328	commands, err := dialog.NewCommands(m.com, sessionID)
1329	if err != nil {
1330		return uiutil.ReportError(err)
1331	}
1332
1333	// TODO: Get. Rid. Of. Magic numbers!
1334	commands.SetSize(min(120, m.width-8), 30)
1335	m.dialog.OpenDialog(commands)
1336
1337	return nil
1338}
1339
1340// openSessionsDialog opens the sessions dialog with the given sessions.
1341func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1342	if m.dialog.ContainsDialog(dialog.SessionsID) {
1343		// Bring to front
1344		m.dialog.BringToFront(dialog.SessionsID)
1345		return nil
1346	}
1347
1348	dialog := dialog.NewSessions(m.com, sessions...)
1349	// TODO: Get. Rid. Of. Magic numbers!
1350	dialog.SetSize(min(120, m.width-8), 30)
1351	m.dialog.OpenDialog(dialog)
1352
1353	return nil
1354}
1355
1356// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1357// [listSessionsMsg].
1358func (m *UI) listSessions() tea.Msg {
1359	allSessions, _ := m.com.App.Sessions.List(context.TODO())
1360	return listSessionsMsg{sessions: allSessions}
1361}
1362
1363// handlePasteMsg handles a paste message.
1364func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1365	if m.focus != uiFocusEditor {
1366		return nil
1367	}
1368
1369	var cmd tea.Cmd
1370	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1371	// try to get an image
1372	path, err := filepath.Abs(strings.TrimSpace(path))
1373	if err != nil {
1374		m.textarea, cmd = m.textarea.Update(msg)
1375		return cmd
1376	}
1377	isAllowedType := false
1378	for _, ext := range filepicker.AllowedTypes {
1379		if strings.HasSuffix(path, ext) {
1380			isAllowedType = true
1381			break
1382		}
1383	}
1384	if !isAllowedType {
1385		m.textarea, cmd = m.textarea.Update(msg)
1386		return cmd
1387	}
1388	tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1389	if tooBig {
1390		m.textarea, cmd = m.textarea.Update(msg)
1391		return cmd
1392	}
1393
1394	content, err := os.ReadFile(path)
1395	if err != nil {
1396		m.textarea, cmd = m.textarea.Update(msg)
1397		return cmd
1398	}
1399	mimeBufferSize := min(512, len(content))
1400	mimeType := http.DetectContentType(content[:mimeBufferSize])
1401	fileName := filepath.Base(path)
1402	attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1403	return uiutil.CmdHandler(filepicker.FilePickedMsg{
1404		Attachment: attachment,
1405	})
1406}
1407
1408// renderLogo renders the Crush logo with the given styles and dimensions.
1409func renderLogo(t *styles.Styles, compact bool, width int) string {
1410	return logo.Render(version.Version, compact, logo.Opts{
1411		FieldColor:   t.LogoFieldColor,
1412		TitleColorA:  t.LogoTitleColorA,
1413		TitleColorB:  t.LogoTitleColorB,
1414		CharmColor:   t.LogoCharmColor,
1415		VersionColor: t.LogoVersionColor,
1416		Width:        width,
1417	})
1418}