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