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