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