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.Expand):
 581				m.chat.ToggleExpandedSelectedItem()
 582			case key.Matches(msg, m.keyMap.Chat.Up):
 583				if cmd := m.chat.ScrollBy(-1); cmd != nil {
 584					cmds = append(cmds, cmd)
 585				}
 586				if !m.chat.SelectedItemInView() {
 587					m.chat.SelectPrev()
 588					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 589						cmds = append(cmds, cmd)
 590					}
 591				}
 592			case key.Matches(msg, m.keyMap.Chat.Down):
 593				if cmd := m.chat.ScrollBy(1); cmd != nil {
 594					cmds = append(cmds, cmd)
 595				}
 596				if !m.chat.SelectedItemInView() {
 597					m.chat.SelectNext()
 598					if cmd := m.chat.ScrollToSelected(); cmd != nil {
 599						cmds = append(cmds, cmd)
 600					}
 601				}
 602			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 603				m.chat.SelectPrev()
 604				if cmd := m.chat.ScrollToSelected(); cmd != nil {
 605					cmds = append(cmds, cmd)
 606				}
 607			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 608				m.chat.SelectNext()
 609				if cmd := m.chat.ScrollToSelected(); cmd != nil {
 610					cmds = append(cmds, cmd)
 611				}
 612			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
 613				if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
 614					cmds = append(cmds, cmd)
 615				}
 616				m.chat.SelectFirstInView()
 617			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
 618				if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
 619					cmds = append(cmds, cmd)
 620				}
 621				m.chat.SelectLastInView()
 622			case key.Matches(msg, m.keyMap.Chat.PageUp):
 623				if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
 624					cmds = append(cmds, cmd)
 625				}
 626				m.chat.SelectFirstInView()
 627			case key.Matches(msg, m.keyMap.Chat.PageDown):
 628				if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
 629					cmds = append(cmds, cmd)
 630				}
 631				m.chat.SelectLastInView()
 632			case key.Matches(msg, m.keyMap.Chat.Home):
 633				if cmd := m.chat.ScrollToTop(); cmd != nil {
 634					cmds = append(cmds, cmd)
 635				}
 636				m.chat.SelectFirst()
 637			case key.Matches(msg, m.keyMap.Chat.End):
 638				if cmd := m.chat.ScrollToBottom(); cmd != nil {
 639					cmds = append(cmds, cmd)
 640				}
 641				m.chat.SelectLast()
 642			default:
 643				handleGlobalKeys(msg)
 644			}
 645		default:
 646			handleGlobalKeys(msg)
 647		}
 648	default:
 649		handleGlobalKeys(msg)
 650	}
 651
 652	return tea.Batch(cmds...)
 653}
 654
 655// Draw implements [tea.Layer] and draws the UI model.
 656func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 657	layout := m.generateLayout(area.Dx(), area.Dy())
 658
 659	if m.layout != layout {
 660		m.layout = layout
 661		m.updateSize()
 662	}
 663
 664	// Clear the screen first
 665	screen.Clear(scr)
 666
 667	switch m.state {
 668	case uiConfigure:
 669		header := uv.NewStyledString(m.header)
 670		header.Draw(scr, layout.header)
 671
 672		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 673			Height(layout.main.Dy()).
 674			Background(lipgloss.ANSIColor(rand.Intn(256))).
 675			Render(" Configure ")
 676		main := uv.NewStyledString(mainView)
 677		main.Draw(scr, layout.main)
 678
 679	case uiInitialize:
 680		header := uv.NewStyledString(m.header)
 681		header.Draw(scr, layout.header)
 682
 683		main := uv.NewStyledString(m.initializeView())
 684		main.Draw(scr, layout.main)
 685
 686	case uiLanding:
 687		header := uv.NewStyledString(m.header)
 688		header.Draw(scr, layout.header)
 689		main := uv.NewStyledString(m.landingView())
 690		main.Draw(scr, layout.main)
 691
 692		editor := uv.NewStyledString(m.textarea.View())
 693		editor.Draw(scr, layout.editor)
 694
 695	case uiChat:
 696		m.chat.Draw(scr, layout.main)
 697
 698		header := uv.NewStyledString(m.header)
 699		header.Draw(scr, layout.header)
 700		m.drawSidebar(scr, layout.sidebar)
 701
 702		editor := uv.NewStyledString(m.textarea.View())
 703		editor.Draw(scr, layout.editor)
 704
 705	case uiChatCompact:
 706		header := uv.NewStyledString(m.header)
 707		header.Draw(scr, layout.header)
 708
 709		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 710			Height(layout.main.Dy()).
 711			Background(lipgloss.ANSIColor(rand.Intn(256))).
 712			Render(" Compact Chat Messages ")
 713		main := uv.NewStyledString(mainView)
 714		main.Draw(scr, layout.main)
 715
 716		editor := uv.NewStyledString(m.textarea.View())
 717		editor.Draw(scr, layout.editor)
 718	}
 719
 720	// Add help layer
 721	help := uv.NewStyledString(m.help.View(m))
 722	help.Draw(scr, layout.help)
 723
 724	// Debugging rendering (visually see when the tui rerenders)
 725	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
 726		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
 727		debug := uv.NewStyledString(debugView.String())
 728		debug.Draw(scr, image.Rectangle{
 729			Min: image.Pt(4, 1),
 730			Max: image.Pt(8, 3),
 731		})
 732	}
 733
 734	// This needs to come last to overlay on top of everything
 735	if m.dialog.HasDialogs() {
 736		m.dialog.Draw(scr, area)
 737	}
 738}
 739
 740// Cursor returns the cursor position and properties for the UI model. It
 741// returns nil if the cursor should not be shown.
 742func (m *UI) Cursor() *tea.Cursor {
 743	if m.layout.editor.Dy() <= 0 {
 744		// Don't show cursor if editor is not visible
 745		return nil
 746	}
 747	if m.dialog.HasDialogs() {
 748		if front := m.dialog.DialogLast(); front != nil {
 749			c, ok := front.(uiutil.Cursor)
 750			if ok {
 751				cur := c.Cursor()
 752				if cur != nil {
 753					pos := m.dialog.CenterPosition(m.layout.area, front.ID())
 754					cur.X += pos.Min.X
 755					cur.Y += pos.Min.Y
 756					return cur
 757				}
 758			}
 759		}
 760		return nil
 761	}
 762	switch m.focus {
 763	case uiFocusEditor:
 764		if m.textarea.Focused() {
 765			cur := m.textarea.Cursor()
 766			cur.X++ // Adjust for app margins
 767			cur.Y += m.layout.editor.Min.Y
 768			return cur
 769		}
 770	}
 771	return nil
 772}
 773
 774// View renders the UI model's view.
 775func (m *UI) View() tea.View {
 776	var v tea.View
 777	v.AltScreen = true
 778	v.BackgroundColor = m.com.Styles.Background
 779	v.Cursor = m.Cursor()
 780	v.MouseMode = tea.MouseModeCellMotion
 781
 782	canvas := uv.NewScreenBuffer(m.width, m.height)
 783	m.Draw(canvas, canvas.Bounds())
 784
 785	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
 786	contentLines := strings.Split(content, "\n")
 787	for i, line := range contentLines {
 788		// Trim trailing spaces for concise rendering
 789		contentLines[i] = strings.TrimRight(line, " ")
 790	}
 791
 792	content = strings.Join(contentLines, "\n")
 793
 794	v.Content = content
 795	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 796		// HACK: use a random percentage to prevent ghostty from hiding it
 797		// after a timeout.
 798		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
 799	}
 800
 801	return v
 802}
 803
 804// ShortHelp implements [help.KeyMap].
 805func (m *UI) ShortHelp() []key.Binding {
 806	var binds []key.Binding
 807	k := &m.keyMap
 808	tab := k.Tab
 809	commands := k.Commands
 810	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 811		commands.SetHelp("/ or ctrl+p", "commands")
 812	}
 813
 814	switch m.state {
 815	case uiInitialize:
 816		binds = append(binds, k.Quit)
 817	case uiChat:
 818		if m.focus == uiFocusEditor {
 819			tab.SetHelp("tab", "focus chat")
 820		} else {
 821			tab.SetHelp("tab", "focus editor")
 822		}
 823
 824		binds = append(binds,
 825			tab,
 826			commands,
 827			k.Models,
 828		)
 829
 830		switch m.focus {
 831		case uiFocusEditor:
 832			binds = append(binds,
 833				k.Editor.Newline,
 834			)
 835		case uiFocusMain:
 836			binds = append(binds,
 837				k.Chat.UpDown,
 838				k.Chat.UpDownOneItem,
 839				k.Chat.PageUp,
 840				k.Chat.PageDown,
 841				k.Chat.Copy,
 842			)
 843		}
 844	default:
 845		// TODO: other states
 846		// if m.session == nil {
 847		// no session selected
 848		binds = append(binds,
 849			commands,
 850			k.Models,
 851			k.Editor.Newline,
 852		)
 853	}
 854
 855	binds = append(binds,
 856		k.Quit,
 857		k.Help,
 858	)
 859
 860	return binds
 861}
 862
 863// FullHelp implements [help.KeyMap].
 864func (m *UI) FullHelp() [][]key.Binding {
 865	var binds [][]key.Binding
 866	k := &m.keyMap
 867	help := k.Help
 868	help.SetHelp("ctrl+g", "less")
 869	hasAttachments := false // TODO: implement attachments
 870	hasSession := m.session != nil && m.session.ID != ""
 871	commands := k.Commands
 872	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 873		commands.SetHelp("/ or ctrl+p", "commands")
 874	}
 875
 876	switch m.state {
 877	case uiInitialize:
 878		binds = append(binds,
 879			[]key.Binding{
 880				k.Quit,
 881			})
 882	case uiChat:
 883		mainBinds := []key.Binding{}
 884		tab := k.Tab
 885		if m.focus == uiFocusEditor {
 886			tab.SetHelp("tab", "focus chat")
 887		} else {
 888			tab.SetHelp("tab", "focus editor")
 889		}
 890
 891		mainBinds = append(mainBinds,
 892			tab,
 893			commands,
 894			k.Models,
 895			k.Sessions,
 896		)
 897		if hasSession {
 898			mainBinds = append(mainBinds, k.Chat.NewSession)
 899		}
 900
 901		binds = append(binds, mainBinds)
 902
 903		switch m.focus {
 904		case uiFocusEditor:
 905			binds = append(binds,
 906				[]key.Binding{
 907					k.Editor.Newline,
 908					k.Editor.AddImage,
 909					k.Editor.MentionFile,
 910					k.Editor.OpenEditor,
 911				},
 912			)
 913			if hasAttachments {
 914				binds = append(binds,
 915					[]key.Binding{
 916						k.Editor.AttachmentDeleteMode,
 917						k.Editor.DeleteAllAttachments,
 918						k.Editor.Escape,
 919					},
 920				)
 921			}
 922		case uiFocusMain:
 923			binds = append(binds,
 924				[]key.Binding{
 925					k.Chat.UpDown,
 926					k.Chat.UpDownOneItem,
 927					k.Chat.PageUp,
 928					k.Chat.PageDown,
 929				},
 930				[]key.Binding{
 931					k.Chat.HalfPageUp,
 932					k.Chat.HalfPageDown,
 933					k.Chat.Home,
 934					k.Chat.End,
 935				},
 936				[]key.Binding{
 937					k.Chat.Copy,
 938					k.Chat.ClearHighlight,
 939				},
 940			)
 941		}
 942	default:
 943		if m.session == nil {
 944			// no session selected
 945			binds = append(binds,
 946				[]key.Binding{
 947					commands,
 948					k.Models,
 949					k.Sessions,
 950				},
 951				[]key.Binding{
 952					k.Editor.Newline,
 953					k.Editor.AddImage,
 954					k.Editor.MentionFile,
 955					k.Editor.OpenEditor,
 956				},
 957				[]key.Binding{
 958					help,
 959				},
 960			)
 961		}
 962	}
 963
 964	binds = append(binds,
 965		[]key.Binding{
 966			help,
 967			k.Quit,
 968		},
 969	)
 970
 971	return binds
 972}
 973
 974// updateLayoutAndSize updates the layout and sizes of UI components.
 975func (m *UI) updateLayoutAndSize() {
 976	m.layout = m.generateLayout(m.width, m.height)
 977	m.updateSize()
 978}
 979
 980// updateSize updates the sizes of UI components based on the current layout.
 981func (m *UI) updateSize() {
 982	// Set help width
 983	m.help.SetWidth(m.layout.help.Dx())
 984
 985	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 986	m.textarea.SetWidth(m.layout.editor.Dx())
 987	m.textarea.SetHeight(m.layout.editor.Dy())
 988
 989	// Handle different app states
 990	switch m.state {
 991	case uiConfigure, uiInitialize, uiLanding:
 992		m.renderHeader(false, m.layout.header.Dx())
 993
 994	case uiChat:
 995		m.renderSidebarLogo(m.layout.sidebar.Dx())
 996
 997	case uiChatCompact:
 998		// TODO: set the width and heigh of the chat component
 999		m.renderHeader(true, m.layout.header.Dx())
1000	}
1001}
1002
1003// generateLayout calculates the layout rectangles for all UI components based
1004// on the current UI state and terminal dimensions.
1005func (m *UI) generateLayout(w, h int) layout {
1006	// The screen area we're working with
1007	area := image.Rect(0, 0, w, h)
1008
1009	// The help height
1010	helpHeight := 1
1011	// The editor height
1012	editorHeight := 5
1013	// The sidebar width
1014	sidebarWidth := 30
1015	// The header height
1016	// TODO: handle compact
1017	headerHeight := 4
1018
1019	var helpKeyMap help.KeyMap = m
1020	if m.help.ShowAll {
1021		for _, row := range helpKeyMap.FullHelp() {
1022			helpHeight = max(helpHeight, len(row))
1023		}
1024	}
1025
1026	// Add app margins
1027	appRect := area
1028	appRect.Min.X += 1
1029	appRect.Min.Y += 1
1030	appRect.Max.X -= 1
1031	appRect.Max.Y -= 1
1032
1033	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1034		// extra padding on left and right for these states
1035		appRect.Min.X += 1
1036		appRect.Max.X -= 1
1037	}
1038
1039	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1040
1041	layout := layout{
1042		area: area,
1043		help: helpRect,
1044	}
1045
1046	// Handle different app states
1047	switch m.state {
1048	case uiConfigure, uiInitialize:
1049		// Layout
1050		//
1051		// header
1052		// ------
1053		// main
1054		// ------
1055		// help
1056
1057		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1058		layout.header = headerRect
1059		layout.main = mainRect
1060
1061	case uiLanding:
1062		// Layout
1063		//
1064		// header
1065		// ------
1066		// main
1067		// ------
1068		// editor
1069		// ------
1070		// help
1071		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1072		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1073		// Remove extra padding from editor (but keep it for header and main)
1074		editorRect.Min.X -= 1
1075		editorRect.Max.X += 1
1076		layout.header = headerRect
1077		layout.main = mainRect
1078		layout.editor = editorRect
1079
1080	case uiChat:
1081		// Layout
1082		//
1083		// ------|---
1084		// main  |
1085		// ------| side
1086		// editor|
1087		// ----------
1088		// help
1089
1090		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1091		// Add padding left
1092		sideRect.Min.X += 1
1093		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1094		mainRect.Max.X -= 1 // Add padding right
1095		// Add bottom margin to main
1096		mainRect.Max.Y -= 1
1097		layout.sidebar = sideRect
1098		layout.main = mainRect
1099		layout.editor = editorRect
1100
1101	case uiChatCompact:
1102		// Layout
1103		//
1104		// compact-header
1105		// ------
1106		// main
1107		// ------
1108		// editor
1109		// ------
1110		// help
1111		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1112		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1113		layout.header = headerRect
1114		layout.main = mainRect
1115		layout.editor = editorRect
1116	}
1117
1118	if !layout.editor.Empty() {
1119		// Add editor margins 1 top and bottom
1120		layout.editor.Min.Y += 1
1121		layout.editor.Max.Y -= 1
1122	}
1123
1124	return layout
1125}
1126
1127// layout defines the positioning of UI elements.
1128type layout struct {
1129	// area is the overall available area.
1130	area uv.Rectangle
1131
1132	// header is the header shown in special cases
1133	// e.x when the sidebar is collapsed
1134	// or when in the landing page
1135	// or in init/config
1136	header uv.Rectangle
1137
1138	// main is the area for the main pane. (e.x chat, configure, landing)
1139	main uv.Rectangle
1140
1141	// editor is the area for the editor pane.
1142	editor uv.Rectangle
1143
1144	// sidebar is the area for the sidebar.
1145	sidebar uv.Rectangle
1146
1147	// help is the area for the help view.
1148	help uv.Rectangle
1149}
1150
1151func (m *UI) openEditor(value string) tea.Cmd {
1152	editor := os.Getenv("EDITOR")
1153	if editor == "" {
1154		// Use platform-appropriate default editor
1155		if runtime.GOOS == "windows" {
1156			editor = "notepad"
1157		} else {
1158			editor = "nvim"
1159		}
1160	}
1161
1162	tmpfile, err := os.CreateTemp("", "msg_*.md")
1163	if err != nil {
1164		return uiutil.ReportError(err)
1165	}
1166	defer tmpfile.Close() //nolint:errcheck
1167	if _, err := tmpfile.WriteString(value); err != nil {
1168		return uiutil.ReportError(err)
1169	}
1170	cmdStr := editor + " " + tmpfile.Name()
1171	return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1172		if err != nil {
1173			return uiutil.ReportError(err)
1174		}
1175		content, err := os.ReadFile(tmpfile.Name())
1176		if err != nil {
1177			return uiutil.ReportError(err)
1178		}
1179		if len(content) == 0 {
1180			return uiutil.ReportWarn("Message is empty")
1181		}
1182		os.Remove(tmpfile.Name())
1183		return openEditorMsg{
1184			Text: strings.TrimSpace(string(content)),
1185		}
1186	})
1187}
1188
1189// setEditorPrompt configures the textarea prompt function based on whether
1190// yolo mode is enabled.
1191func (m *UI) setEditorPrompt(yolo bool) {
1192	if yolo {
1193		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1194		return
1195	}
1196	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1197}
1198
1199// normalPromptFunc returns the normal editor prompt style ("  > " on first
1200// line, "::: " on subsequent lines).
1201func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1202	t := m.com.Styles
1203	if info.LineNumber == 0 {
1204		if info.Focused {
1205			return "  > "
1206		}
1207		return "::: "
1208	}
1209	if info.Focused {
1210		return t.EditorPromptNormalFocused.Render()
1211	}
1212	return t.EditorPromptNormalBlurred.Render()
1213}
1214
1215// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1216// and colored dots.
1217func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1218	t := m.com.Styles
1219	if info.LineNumber == 0 {
1220		if info.Focused {
1221			return t.EditorPromptYoloIconFocused.Render()
1222		} else {
1223			return t.EditorPromptYoloIconBlurred.Render()
1224		}
1225	}
1226	if info.Focused {
1227		return t.EditorPromptYoloDotsFocused.Render()
1228	}
1229	return t.EditorPromptYoloDotsBlurred.Render()
1230}
1231
1232var readyPlaceholders = [...]string{
1233	"Ready!",
1234	"Ready...",
1235	"Ready?",
1236	"Ready for instructions",
1237}
1238
1239var workingPlaceholders = [...]string{
1240	"Working!",
1241	"Working...",
1242	"Brrrrr...",
1243	"Prrrrrrrr...",
1244	"Processing...",
1245	"Thinking...",
1246}
1247
1248// randomizePlaceholders selects random placeholder text for the textarea's
1249// ready and working states.
1250func (m *UI) randomizePlaceholders() {
1251	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1252	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1253}
1254
1255// renderHeader renders and caches the header logo at the specified width.
1256func (m *UI) renderHeader(compact bool, width int) {
1257	// TODO: handle the compact case differently
1258	m.header = renderLogo(m.com.Styles, compact, width)
1259}
1260
1261// renderSidebarLogo renders and caches the sidebar logo at the specified
1262// width.
1263func (m *UI) renderSidebarLogo(width int) {
1264	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1265}
1266
1267// sendMessage sends a message with the given content and attachments.
1268func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1269	if m.com.App.AgentCoordinator == nil {
1270		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1271	}
1272
1273	var cmds []tea.Cmd
1274	if m.session == nil || m.session.ID == "" {
1275		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1276		if err != nil {
1277			return uiutil.ReportError(err)
1278		}
1279		m.state = uiChat
1280		m.session = &newSession
1281		cmds = append(cmds, m.loadSession(newSession.ID))
1282	}
1283
1284	// Capture session ID to avoid race with main goroutine updating m.session.
1285	sessionID := m.session.ID
1286	cmds = append(cmds, func() tea.Msg {
1287		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1288		if err != nil {
1289			isCancelErr := errors.Is(err, context.Canceled)
1290			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1291			if isCancelErr || isPermissionErr {
1292				return nil
1293			}
1294			return uiutil.InfoMsg{
1295				Type: uiutil.InfoTypeError,
1296				Msg:  err.Error(),
1297			}
1298		}
1299		return nil
1300	})
1301	return tea.Batch(cmds...)
1302}
1303
1304// openQuitDialog opens the quit confirmation dialog.
1305func (m *UI) openQuitDialog() tea.Cmd {
1306	if m.dialog.ContainsDialog(dialog.QuitID) {
1307		// Bring to front
1308		m.dialog.BringToFront(dialog.QuitID)
1309		return nil
1310	}
1311
1312	quitDialog := dialog.NewQuit(m.com)
1313	m.dialog.OpenDialog(quitDialog)
1314	return nil
1315}
1316
1317// openCommandsDialog opens the commands dialog.
1318func (m *UI) openCommandsDialog() tea.Cmd {
1319	if m.dialog.ContainsDialog(dialog.CommandsID) {
1320		// Bring to front
1321		m.dialog.BringToFront(dialog.CommandsID)
1322		return nil
1323	}
1324
1325	sessionID := ""
1326	if m.session != nil {
1327		sessionID = m.session.ID
1328	}
1329
1330	commands, err := dialog.NewCommands(m.com, sessionID)
1331	if err != nil {
1332		return uiutil.ReportError(err)
1333	}
1334
1335	// TODO: Get. Rid. Of. Magic numbers!
1336	commands.SetSize(min(120, m.width-8), 30)
1337	m.dialog.OpenDialog(commands)
1338
1339	return nil
1340}
1341
1342// openSessionsDialog opens the sessions dialog with the given sessions.
1343func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1344	if m.dialog.ContainsDialog(dialog.SessionsID) {
1345		// Bring to front
1346		m.dialog.BringToFront(dialog.SessionsID)
1347		return nil
1348	}
1349
1350	dialog := dialog.NewSessions(m.com, sessions...)
1351	// TODO: Get. Rid. Of. Magic numbers!
1352	dialog.SetSize(min(120, m.width-8), 30)
1353	m.dialog.OpenDialog(dialog)
1354
1355	return nil
1356}
1357
1358// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1359// [listSessionsMsg].
1360func (m *UI) listSessions() tea.Msg {
1361	allSessions, _ := m.com.App.Sessions.List(context.TODO())
1362	return listSessionsMsg{sessions: allSessions}
1363}
1364
1365// handlePasteMsg handles a paste message.
1366func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1367	if m.focus != uiFocusEditor {
1368		return nil
1369	}
1370
1371	var cmd tea.Cmd
1372	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1373	// try to get an image
1374	path, err := filepath.Abs(strings.TrimSpace(path))
1375	if err != nil {
1376		m.textarea, cmd = m.textarea.Update(msg)
1377		return cmd
1378	}
1379	isAllowedType := false
1380	for _, ext := range filepicker.AllowedTypes {
1381		if strings.HasSuffix(path, ext) {
1382			isAllowedType = true
1383			break
1384		}
1385	}
1386	if !isAllowedType {
1387		m.textarea, cmd = m.textarea.Update(msg)
1388		return cmd
1389	}
1390	tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1391	if tooBig {
1392		m.textarea, cmd = m.textarea.Update(msg)
1393		return cmd
1394	}
1395
1396	content, err := os.ReadFile(path)
1397	if err != nil {
1398		m.textarea, cmd = m.textarea.Update(msg)
1399		return cmd
1400	}
1401	mimeBufferSize := min(512, len(content))
1402	mimeType := http.DetectContentType(content[:mimeBufferSize])
1403	fileName := filepath.Base(path)
1404	attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1405	return uiutil.CmdHandler(filepicker.FilePickedMsg{
1406		Attachment: attachment,
1407	})
1408}
1409
1410// renderLogo renders the Crush logo with the given styles and dimensions.
1411func renderLogo(t *styles.Styles, compact bool, width int) string {
1412	return logo.Render(version.Version, compact, logo.Opts{
1413		FieldColor:   t.LogoFieldColor,
1414		TitleColorA:  t.LogoTitleColorA,
1415		TitleColorB:  t.LogoTitleColorB,
1416		CharmColor:   t.LogoCharmColor,
1417		VersionColor: t.LogoVersionColor,
1418		Width:        width,
1419	})
1420}