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