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