ui.go

   1package model
   2
   3import (
   4	"bytes"
   5	"cmp"
   6	"context"
   7	"errors"
   8	"fmt"
   9	"image"
  10	"log/slog"
  11	"math/rand"
  12	"net/http"
  13	"os"
  14	"path/filepath"
  15	"regexp"
  16	"slices"
  17	"strconv"
  18	"strings"
  19	"time"
  20
  21	"charm.land/bubbles/v2/help"
  22	"charm.land/bubbles/v2/key"
  23	"charm.land/bubbles/v2/spinner"
  24	"charm.land/bubbles/v2/textarea"
  25	tea "charm.land/bubbletea/v2"
  26	"charm.land/catwalk/pkg/catwalk"
  27	"charm.land/lipgloss/v2"
  28	"github.com/charmbracelet/crush/internal/agent/notify"
  29	agenttools "github.com/charmbracelet/crush/internal/agent/tools"
  30	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  31	"github.com/charmbracelet/crush/internal/app"
  32	"github.com/charmbracelet/crush/internal/commands"
  33	"github.com/charmbracelet/crush/internal/config"
  34	"github.com/charmbracelet/crush/internal/fsext"
  35	"github.com/charmbracelet/crush/internal/history"
  36	"github.com/charmbracelet/crush/internal/home"
  37	"github.com/charmbracelet/crush/internal/message"
  38	"github.com/charmbracelet/crush/internal/permission"
  39	"github.com/charmbracelet/crush/internal/pubsub"
  40	"github.com/charmbracelet/crush/internal/session"
  41	"github.com/charmbracelet/crush/internal/ui/anim"
  42	"github.com/charmbracelet/crush/internal/ui/attachments"
  43	"github.com/charmbracelet/crush/internal/ui/chat"
  44	"github.com/charmbracelet/crush/internal/ui/common"
  45	"github.com/charmbracelet/crush/internal/ui/completions"
  46	"github.com/charmbracelet/crush/internal/ui/dialog"
  47	fimage "github.com/charmbracelet/crush/internal/ui/image"
  48	"github.com/charmbracelet/crush/internal/ui/logo"
  49	"github.com/charmbracelet/crush/internal/ui/notification"
  50	"github.com/charmbracelet/crush/internal/ui/styles"
  51	"github.com/charmbracelet/crush/internal/ui/util"
  52	"github.com/charmbracelet/crush/internal/version"
  53	uv "github.com/charmbracelet/ultraviolet"
  54	"github.com/charmbracelet/ultraviolet/layout"
  55	"github.com/charmbracelet/ultraviolet/screen"
  56	"github.com/charmbracelet/x/editor"
  57)
  58
  59// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
  60// wheel event occurs.
  61const MouseScrollThreshold = 5
  62
  63// Compact mode breakpoints.
  64const (
  65	compactModeWidthBreakpoint  = 120
  66	compactModeHeightBreakpoint = 30
  67)
  68
  69// If pasted text has more than 2 newlines, treat it as a file attachment.
  70const pasteLinesThreshold = 10
  71
  72// Session details panel max height.
  73const sessionDetailsMaxHeight = 20
  74
  75// uiFocusState represents the current focus state of the UI.
  76type uiFocusState uint8
  77
  78// Possible uiFocusState values.
  79const (
  80	uiFocusNone uiFocusState = iota
  81	uiFocusEditor
  82	uiFocusMain
  83)
  84
  85type uiState uint8
  86
  87// Possible uiState values.
  88const (
  89	uiOnboarding uiState = iota
  90	uiInitialize
  91	uiLanding
  92	uiChat
  93)
  94
  95type openEditorMsg struct {
  96	Text string
  97}
  98
  99type (
 100	// cancelTimerExpiredMsg is sent when the cancel timer expires.
 101	cancelTimerExpiredMsg struct{}
 102	// userCommandsLoadedMsg is sent when user commands are loaded.
 103	userCommandsLoadedMsg struct {
 104		Commands []commands.CustomCommand
 105	}
 106	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
 107	mcpPromptsLoadedMsg struct {
 108		Prompts []commands.MCPPrompt
 109	}
 110	// mcpStateChangedMsg is sent when there is a change in MCP client states.
 111	mcpStateChangedMsg struct {
 112		states map[string]mcp.ClientInfo
 113	}
 114	// sendMessageMsg is sent to send a message.
 115	// currently only used for mcp prompts.
 116	sendMessageMsg struct {
 117		Content     string
 118		Attachments []message.Attachment
 119	}
 120
 121	// closeDialogMsg is sent to close the current dialog.
 122	closeDialogMsg struct{}
 123
 124	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
 125	copyChatHighlightMsg struct{}
 126
 127	// sessionFilesUpdatesMsg is sent when the files for this session have been updated
 128	sessionFilesUpdatesMsg struct {
 129		sessionFiles []SessionFile
 130	}
 131)
 132
 133// UI represents the main user interface model.
 134type UI struct {
 135	com          *common.Common
 136	session      *session.Session
 137	sessionFiles []SessionFile
 138
 139	// keeps track of read files while we don't have a session id
 140	sessionFileReads []string
 141
 142	lastUserMessageTime int64
 143
 144	// The width and height of the terminal in cells.
 145	width  int
 146	height int
 147	layout uiLayout
 148
 149	isTransparent bool
 150
 151	focus uiFocusState
 152	state uiState
 153
 154	keyMap KeyMap
 155	keyenh tea.KeyboardEnhancementsMsg
 156
 157	dialog *dialog.Overlay
 158	status *Status
 159
 160	// isCanceling tracks whether the user has pressed escape once to cancel.
 161	isCanceling bool
 162
 163	header *header
 164
 165	// sendProgressBar instructs the TUI to send progress bar updates to the
 166	// terminal.
 167	sendProgressBar    bool
 168	progressBarEnabled bool
 169
 170	// caps hold different terminal capabilities that we query for.
 171	caps common.Capabilities
 172
 173	// Editor components
 174	textarea textarea.Model
 175
 176	// Attachment list
 177	attachments *attachments.Attachments
 178
 179	readyPlaceholder   string
 180	workingPlaceholder string
 181
 182	// Completions state
 183	completions              *completions.Completions
 184	completionsOpen          bool
 185	completionsStartIndex    int
 186	completionsQuery         string
 187	completionsPositionStart image.Point // x,y where user typed '@'
 188
 189	// Chat components
 190	chat *Chat
 191
 192	// onboarding state
 193	onboarding struct {
 194		yesInitializeSelected bool
 195	}
 196
 197	// lsp
 198	lspStates map[string]app.LSPClientInfo
 199
 200	// mcp
 201	mcpStates map[string]mcp.ClientInfo
 202
 203	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 204	sidebarLogo string
 205
 206	// Notification state
 207	notifyBackend       notification.Backend
 208	notifyWindowFocused bool
 209	// custom commands & mcp commands
 210	customCommands []commands.CustomCommand
 211	mcpPrompts     []commands.MCPPrompt
 212
 213	// forceCompactMode tracks whether compact mode is forced by user toggle
 214	forceCompactMode bool
 215
 216	// isCompact tracks whether we're currently in compact layout mode (either
 217	// by user toggle or auto-switch based on window size)
 218	isCompact bool
 219
 220	// detailsOpen tracks whether the details panel is open (in compact mode)
 221	detailsOpen bool
 222
 223	// pills state
 224	pillsExpanded      bool
 225	focusedPillSection pillSection
 226	promptQueue        int
 227	pillsView          string
 228
 229	// Todo spinner
 230	todoSpinner    spinner.Model
 231	todoIsSpinning bool
 232
 233	// mouse highlighting related state
 234	lastClickTime time.Time
 235
 236	// Prompt history for up/down navigation through previous messages.
 237	promptHistory struct {
 238		messages []string
 239		index    int
 240		draft    string
 241	}
 242}
 243
 244// New creates a new instance of the [UI] model.
 245func New(com *common.Common) *UI {
 246	// Editor components
 247	ta := textarea.New()
 248	ta.SetStyles(com.Styles.TextArea)
 249	ta.ShowLineNumbers = false
 250	ta.CharLimit = -1
 251	ta.SetVirtualCursor(false)
 252	ta.Focus()
 253
 254	ch := NewChat(com)
 255
 256	keyMap := DefaultKeyMap()
 257
 258	// Completions component
 259	comp := completions.New(
 260		com.Styles.Completions.Normal,
 261		com.Styles.Completions.Focused,
 262		com.Styles.Completions.Match,
 263	)
 264
 265	todoSpinner := spinner.New(
 266		spinner.WithSpinner(spinner.MiniDot),
 267		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
 268	)
 269
 270	// Attachments component
 271	attachments := attachments.New(
 272		attachments.NewRenderer(
 273			com.Styles.Attachments.Normal,
 274			com.Styles.Attachments.Deleting,
 275			com.Styles.Attachments.Image,
 276			com.Styles.Attachments.Text,
 277		),
 278		attachments.Keymap{
 279			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 280			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 281			Escape:     keyMap.Editor.Escape,
 282		},
 283	)
 284
 285	header := newHeader(com)
 286
 287	ui := &UI{
 288		com:                 com,
 289		dialog:              dialog.NewOverlay(),
 290		keyMap:              keyMap,
 291		textarea:            ta,
 292		chat:                ch,
 293		header:              header,
 294		completions:         comp,
 295		attachments:         attachments,
 296		todoSpinner:         todoSpinner,
 297		lspStates:           make(map[string]app.LSPClientInfo),
 298		mcpStates:           make(map[string]mcp.ClientInfo),
 299		notifyBackend:       notification.NoopBackend{},
 300		notifyWindowFocused: true,
 301	}
 302
 303	status := NewStatus(com, ui)
 304
 305	ui.setEditorPrompt(false)
 306	ui.randomizePlaceholders()
 307	ui.textarea.Placeholder = ui.readyPlaceholder
 308	ui.status = status
 309
 310	// Initialize compact mode from config
 311	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
 312
 313	// set onboarding state defaults
 314	ui.onboarding.yesInitializeSelected = true
 315
 316	desiredState := uiLanding
 317	desiredFocus := uiFocusEditor
 318	if !com.Config().IsConfigured() {
 319		desiredState = uiOnboarding
 320	} else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
 321		desiredState = uiInitialize
 322	}
 323
 324	// set initial state
 325	ui.setState(desiredState, desiredFocus)
 326
 327	opts := com.Config().Options
 328
 329	// disable indeterminate progress bar
 330	ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
 331	// enable transparent mode
 332	ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
 333
 334	return ui
 335}
 336
 337// Init initializes the UI model.
 338func (m *UI) Init() tea.Cmd {
 339	var cmds []tea.Cmd
 340	if m.state == uiOnboarding {
 341		if cmd := m.openModelsDialog(); cmd != nil {
 342			cmds = append(cmds, cmd)
 343		}
 344	}
 345	// load the user commands async
 346	cmds = append(cmds, m.loadCustomCommands())
 347	// load prompt history async
 348	cmds = append(cmds, m.loadPromptHistory())
 349	return tea.Batch(cmds...)
 350}
 351
 352// sendNotification returns a command that sends a notification if allowed by policy.
 353func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
 354	if !m.shouldSendNotification() {
 355		return nil
 356	}
 357
 358	backend := m.notifyBackend
 359	return func() tea.Msg {
 360		if err := backend.Send(n); err != nil {
 361			slog.Error("Failed to send notification", "error", err)
 362		}
 363		return nil
 364	}
 365}
 366
 367// shouldSendNotification returns true if notifications should be sent based on
 368// current state. Focus reporting must be supported, window must not focused,
 369// and notifications must not be disabled in config.
 370func (m *UI) shouldSendNotification() bool {
 371	cfg := m.com.Config()
 372	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
 373		return false
 374	}
 375	return m.caps.ReportFocusEvents && !m.notifyWindowFocused
 376}
 377
 378// setState changes the UI state and focus.
 379func (m *UI) setState(state uiState, focus uiFocusState) {
 380	if state == uiLanding {
 381		// Always turn off compact mode when going to landing
 382		m.isCompact = false
 383	}
 384	m.state = state
 385	m.focus = focus
 386	// Changing the state may change layout, so update it.
 387	m.updateLayoutAndSize()
 388}
 389
 390// loadCustomCommands loads the custom commands asynchronously.
 391func (m *UI) loadCustomCommands() tea.Cmd {
 392	return func() tea.Msg {
 393		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 394		if err != nil {
 395			slog.Error("Failed to load custom commands", "error", err)
 396		}
 397		return userCommandsLoadedMsg{Commands: customCommands}
 398	}
 399}
 400
 401// loadMCPrompts loads the MCP prompts asynchronously.
 402func (m *UI) loadMCPrompts() tea.Msg {
 403	prompts, err := commands.LoadMCPPrompts()
 404	if err != nil {
 405		slog.Error("Failed to load MCP prompts", "error", err)
 406	}
 407	if prompts == nil {
 408		// flag them as loaded even if there is none or an error
 409		prompts = []commands.MCPPrompt{}
 410	}
 411	return mcpPromptsLoadedMsg{Prompts: prompts}
 412}
 413
 414// Update handles updates to the UI model.
 415func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 416	var cmds []tea.Cmd
 417	if m.hasSession() && m.isAgentBusy() {
 418		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
 419		if queueSize != m.promptQueue {
 420			m.promptQueue = queueSize
 421			m.updateLayoutAndSize()
 422		}
 423	}
 424	// Update terminal capabilities
 425	m.caps.Update(msg)
 426	switch msg := msg.(type) {
 427	case tea.EnvMsg:
 428		// Is this Windows Terminal?
 429		if !m.sendProgressBar {
 430			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 431		}
 432		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 433	case tea.ModeReportMsg:
 434		if m.caps.ReportFocusEvents {
 435			m.notifyBackend = notification.NewNativeBackend(notification.Icon)
 436		}
 437	case tea.FocusMsg:
 438		m.notifyWindowFocused = true
 439	case tea.BlurMsg:
 440		m.notifyWindowFocused = false
 441	case pubsub.Event[notify.Notification]:
 442		if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
 443			cmds = append(cmds, cmd)
 444		}
 445	case loadSessionMsg:
 446		if m.forceCompactMode {
 447			m.isCompact = true
 448		}
 449		m.setState(uiChat, m.focus)
 450		m.session = msg.session
 451		m.sessionFiles = msg.files
 452		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
 453		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 454		if err != nil {
 455			cmds = append(cmds, util.ReportError(err))
 456			break
 457		}
 458		if cmd := m.setSessionMessages(msgs); cmd != nil {
 459			cmds = append(cmds, cmd)
 460		}
 461		if hasInProgressTodo(m.session.Todos) {
 462			// only start spinner if there is an in-progress todo
 463			if m.isAgentBusy() {
 464				m.todoIsSpinning = true
 465				cmds = append(cmds, m.todoSpinner.Tick)
 466			}
 467			m.updateLayoutAndSize()
 468		}
 469		// Reload prompt history for the new session.
 470		m.historyReset()
 471		cmds = append(cmds, m.loadPromptHistory())
 472		m.updateLayoutAndSize()
 473
 474	case sessionFilesUpdatesMsg:
 475		m.sessionFiles = msg.sessionFiles
 476		var paths []string
 477		for _, f := range msg.sessionFiles {
 478			paths = append(paths, f.LatestVersion.Path)
 479		}
 480		cmds = append(cmds, m.startLSPs(paths))
 481
 482	case sendMessageMsg:
 483		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 484
 485	case userCommandsLoadedMsg:
 486		m.customCommands = msg.Commands
 487		dia := m.dialog.Dialog(dialog.CommandsID)
 488		if dia == nil {
 489			break
 490		}
 491
 492		commands, ok := dia.(*dialog.Commands)
 493		if ok {
 494			commands.SetCustomCommands(m.customCommands)
 495		}
 496
 497	case mcpStateChangedMsg:
 498		m.mcpStates = msg.states
 499	case mcpPromptsLoadedMsg:
 500		m.mcpPrompts = msg.Prompts
 501		dia := m.dialog.Dialog(dialog.CommandsID)
 502		if dia == nil {
 503			break
 504		}
 505
 506		commands, ok := dia.(*dialog.Commands)
 507		if ok {
 508			commands.SetMCPPrompts(m.mcpPrompts)
 509		}
 510
 511	case promptHistoryLoadedMsg:
 512		m.promptHistory.messages = msg.messages
 513		m.promptHistory.index = -1
 514		m.promptHistory.draft = ""
 515
 516	case closeDialogMsg:
 517		m.dialog.CloseFrontDialog()
 518
 519	case pubsub.Event[session.Session]:
 520		if msg.Type == pubsub.DeletedEvent {
 521			if m.session != nil && m.session.ID == msg.Payload.ID {
 522				if cmd := m.newSession(); cmd != nil {
 523					cmds = append(cmds, cmd)
 524				}
 525			}
 526			break
 527		}
 528		if m.session != nil && msg.Payload.ID == m.session.ID {
 529			prevHasInProgress := hasInProgressTodo(m.session.Todos)
 530			m.session = &msg.Payload
 531			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
 532				m.todoIsSpinning = true
 533				cmds = append(cmds, m.todoSpinner.Tick)
 534				m.updateLayoutAndSize()
 535			}
 536		}
 537	case pubsub.Event[message.Message]:
 538		// Check if this is a child session message for an agent tool.
 539		if m.session == nil {
 540			break
 541		}
 542		if msg.Payload.SessionID != m.session.ID {
 543			// This might be a child session message from an agent tool.
 544			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 545				cmds = append(cmds, cmd)
 546			}
 547			break
 548		}
 549		switch msg.Type {
 550		case pubsub.CreatedEvent:
 551			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 552		case pubsub.UpdatedEvent:
 553			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 554		case pubsub.DeletedEvent:
 555			m.chat.RemoveMessage(msg.Payload.ID)
 556		}
 557		// start the spinner if there is a new message
 558		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
 559			m.todoIsSpinning = true
 560			cmds = append(cmds, m.todoSpinner.Tick)
 561		}
 562		// stop the spinner if the agent is not busy anymore
 563		if m.todoIsSpinning && !m.isAgentBusy() {
 564			m.todoIsSpinning = false
 565		}
 566		// there is a number of things that could change the pills here so we want to re-render
 567		m.renderPills()
 568	case pubsub.Event[history.File]:
 569		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 570	case pubsub.Event[app.LSPEvent]:
 571		m.lspStates = app.GetLSPStates()
 572	case pubsub.Event[mcp.Event]:
 573		switch msg.Payload.Type {
 574		case mcp.EventStateChanged:
 575			return m, tea.Batch(
 576				m.handleStateChanged(),
 577				m.loadMCPrompts,
 578			)
 579		case mcp.EventPromptsListChanged:
 580			return m, handleMCPPromptsEvent(msg.Payload.Name)
 581		case mcp.EventToolsListChanged:
 582			return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
 583		case mcp.EventResourcesListChanged:
 584			return m, handleMCPResourcesEvent(msg.Payload.Name)
 585		}
 586	case pubsub.Event[permission.PermissionRequest]:
 587		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
 588			cmds = append(cmds, cmd)
 589		}
 590		if cmd := m.sendNotification(notification.Notification{
 591			Title:   "Crush is waiting...",
 592			Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
 593		}); cmd != nil {
 594			cmds = append(cmds, cmd)
 595		}
 596	case pubsub.Event[permission.PermissionNotification]:
 597		m.handlePermissionNotification(msg.Payload)
 598	case cancelTimerExpiredMsg:
 599		m.isCanceling = false
 600	case tea.TerminalVersionMsg:
 601		termVersion := strings.ToLower(msg.Name)
 602		// Only enable progress bar for the following terminals.
 603		if !m.sendProgressBar {
 604			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 605		}
 606		return m, nil
 607	case tea.WindowSizeMsg:
 608		m.width, m.height = msg.Width, msg.Height
 609		m.updateLayoutAndSize()
 610		if m.state == uiChat && m.chat.Follow() {
 611			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 612				cmds = append(cmds, cmd)
 613			}
 614		}
 615	case tea.KeyboardEnhancementsMsg:
 616		m.keyenh = msg
 617		if msg.SupportsKeyDisambiguation() {
 618			m.keyMap.Models.SetHelp("ctrl+m", "models")
 619			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 620		}
 621	case copyChatHighlightMsg:
 622		cmds = append(cmds, m.copyChatHighlight())
 623	case DelayedClickMsg:
 624		// Handle delayed single-click action (e.g., expansion).
 625		m.chat.HandleDelayedClick(msg)
 626	case tea.MouseClickMsg:
 627		// Pass mouse events to dialogs first if any are open.
 628		if m.dialog.HasDialogs() {
 629			m.dialog.Update(msg)
 630			return m, tea.Batch(cmds...)
 631		}
 632
 633		if cmd := m.handleClickFocus(msg); cmd != nil {
 634			cmds = append(cmds, cmd)
 635		}
 636
 637		switch m.state {
 638		case uiChat:
 639			x, y := msg.X, msg.Y
 640			// Adjust for chat area position
 641			x -= m.layout.main.Min.X
 642			y -= m.layout.main.Min.Y
 643			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
 644				if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
 645					m.lastClickTime = time.Now()
 646					if cmd != nil {
 647						cmds = append(cmds, cmd)
 648					}
 649				}
 650			}
 651		}
 652
 653	case tea.MouseMotionMsg:
 654		// Pass mouse events to dialogs first if any are open.
 655		if m.dialog.HasDialogs() {
 656			m.dialog.Update(msg)
 657			return m, tea.Batch(cmds...)
 658		}
 659
 660		switch m.state {
 661		case uiChat:
 662			if msg.Y <= 0 {
 663				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 664					cmds = append(cmds, cmd)
 665				}
 666				if !m.chat.SelectedItemInView() {
 667					m.chat.SelectPrev()
 668					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 669						cmds = append(cmds, cmd)
 670					}
 671				}
 672			} else if msg.Y >= m.chat.Height()-1 {
 673				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 674					cmds = append(cmds, cmd)
 675				}
 676				if !m.chat.SelectedItemInView() {
 677					m.chat.SelectNext()
 678					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 679						cmds = append(cmds, cmd)
 680					}
 681				}
 682			}
 683
 684			x, y := msg.X, msg.Y
 685			// Adjust for chat area position
 686			x -= m.layout.main.Min.X
 687			y -= m.layout.main.Min.Y
 688			m.chat.HandleMouseDrag(x, y)
 689		}
 690
 691	case tea.MouseReleaseMsg:
 692		// Pass mouse events to dialogs first if any are open.
 693		if m.dialog.HasDialogs() {
 694			m.dialog.Update(msg)
 695			return m, tea.Batch(cmds...)
 696		}
 697
 698		switch m.state {
 699		case uiChat:
 700			x, y := msg.X, msg.Y
 701			// Adjust for chat area position
 702			x -= m.layout.main.Min.X
 703			y -= m.layout.main.Min.Y
 704			if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
 705				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
 706					if time.Since(m.lastClickTime) >= doubleClickThreshold {
 707						return copyChatHighlightMsg{}
 708					}
 709					return nil
 710				}))
 711			}
 712		}
 713	case tea.MouseWheelMsg:
 714		// Pass mouse events to dialogs first if any are open.
 715		if m.dialog.HasDialogs() {
 716			m.dialog.Update(msg)
 717			return m, tea.Batch(cmds...)
 718		}
 719
 720		// Otherwise handle mouse wheel for chat.
 721		switch m.state {
 722		case uiChat:
 723			switch msg.Button {
 724			case tea.MouseWheelUp:
 725				if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
 726					cmds = append(cmds, cmd)
 727				}
 728				if !m.chat.SelectedItemInView() {
 729					m.chat.SelectPrev()
 730					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 731						cmds = append(cmds, cmd)
 732					}
 733				}
 734			case tea.MouseWheelDown:
 735				if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
 736					cmds = append(cmds, cmd)
 737				}
 738				if !m.chat.SelectedItemInView() {
 739					if m.chat.AtBottom() {
 740						m.chat.SelectLast()
 741					} else {
 742						m.chat.SelectNext()
 743					}
 744					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 745						cmds = append(cmds, cmd)
 746					}
 747				}
 748			}
 749		}
 750	case anim.StepMsg:
 751		if m.state == uiChat {
 752			if cmd := m.chat.Animate(msg); cmd != nil {
 753				cmds = append(cmds, cmd)
 754			}
 755			if m.chat.Follow() {
 756				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 757					cmds = append(cmds, cmd)
 758				}
 759			}
 760		}
 761	case spinner.TickMsg:
 762		if m.dialog.HasDialogs() {
 763			// route to dialog
 764			if cmd := m.handleDialogMsg(msg); cmd != nil {
 765				cmds = append(cmds, cmd)
 766			}
 767		}
 768		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
 769			var cmd tea.Cmd
 770			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
 771			if cmd != nil {
 772				m.renderPills()
 773				cmds = append(cmds, cmd)
 774			}
 775		}
 776
 777	case tea.KeyPressMsg:
 778		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 779			cmds = append(cmds, cmd)
 780		}
 781	case tea.PasteMsg:
 782		if cmd := m.handlePasteMsg(msg); cmd != nil {
 783			cmds = append(cmds, cmd)
 784		}
 785	case openEditorMsg:
 786		var cmd tea.Cmd
 787		m.textarea.SetValue(msg.Text)
 788		m.textarea.MoveToEnd()
 789		m.textarea, cmd = m.textarea.Update(msg)
 790		if cmd != nil {
 791			cmds = append(cmds, cmd)
 792		}
 793	case util.InfoMsg:
 794		m.status.SetInfoMsg(msg)
 795		ttl := msg.TTL
 796		if ttl <= 0 {
 797			ttl = DefaultStatusTTL
 798		}
 799		cmds = append(cmds, clearInfoMsgCmd(ttl))
 800	case util.ClearStatusMsg:
 801		m.status.ClearInfoMsg()
 802	case completions.CompletionItemsLoadedMsg:
 803		if m.completionsOpen {
 804			m.completions.SetItems(msg.Files, msg.Resources)
 805		}
 806	case uv.KittyGraphicsEvent:
 807		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 808			slog.Warn("Unexpected Kitty graphics response",
 809				"response", string(msg.Payload),
 810				"options", msg.Options)
 811		}
 812	default:
 813		if m.dialog.HasDialogs() {
 814			if cmd := m.handleDialogMsg(msg); cmd != nil {
 815				cmds = append(cmds, cmd)
 816			}
 817		}
 818	}
 819
 820	// This logic gets triggered on any message type, but should it?
 821	switch m.focus {
 822	case uiFocusMain:
 823	case uiFocusEditor:
 824		// Textarea placeholder logic
 825		if m.isAgentBusy() {
 826			m.textarea.Placeholder = m.workingPlaceholder
 827		} else {
 828			m.textarea.Placeholder = m.readyPlaceholder
 829		}
 830		if m.com.App.Permissions.SkipRequests() {
 831			m.textarea.Placeholder = "Yolo mode!"
 832		}
 833	}
 834
 835	// at this point this can only handle [message.Attachment] message, and we
 836	// should return all cmds anyway.
 837	_ = m.attachments.Update(msg)
 838	return m, tea.Batch(cmds...)
 839}
 840
 841// setSessionMessages sets the messages for the current session in the chat
 842func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 843	var cmds []tea.Cmd
 844	// Build tool result map to link tool calls with their results
 845	msgPtrs := make([]*message.Message, len(msgs))
 846	for i := range msgs {
 847		msgPtrs[i] = &msgs[i]
 848	}
 849	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 850	if len(msgPtrs) > 0 {
 851		m.lastUserMessageTime = msgPtrs[0].CreatedAt
 852	}
 853
 854	// Add messages to chat with linked tool results
 855	items := make([]chat.MessageItem, 0, len(msgs)*2)
 856	for _, msg := range msgPtrs {
 857		switch msg.Role {
 858		case message.User:
 859			m.lastUserMessageTime = msg.CreatedAt
 860			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 861		case message.Assistant:
 862			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 863			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 864				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 865				items = append(items, infoItem)
 866			}
 867		default:
 868			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 869		}
 870	}
 871
 872	// Load nested tool calls for agent/agentic_fetch tools.
 873	m.loadNestedToolCalls(items)
 874
 875	// If the user switches between sessions while the agent is working we want
 876	// to make sure the animations are shown.
 877	for _, item := range items {
 878		if animatable, ok := item.(chat.Animatable); ok {
 879			if cmd := animatable.StartAnimation(); cmd != nil {
 880				cmds = append(cmds, cmd)
 881			}
 882		}
 883	}
 884
 885	m.chat.SetMessages(items...)
 886	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 887		cmds = append(cmds, cmd)
 888	}
 889	m.chat.SelectLast()
 890	return tea.Sequence(cmds...)
 891}
 892
 893// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 894func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 895	for _, item := range items {
 896		nestedContainer, ok := item.(chat.NestedToolContainer)
 897		if !ok {
 898			continue
 899		}
 900		toolItem, ok := item.(chat.ToolMessageItem)
 901		if !ok {
 902			continue
 903		}
 904
 905		tc := toolItem.ToolCall()
 906		messageID := toolItem.MessageID()
 907
 908		// Get the agent tool session ID.
 909		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 910
 911		// Fetch nested messages.
 912		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
 913		if err != nil || len(nestedMsgs) == 0 {
 914			continue
 915		}
 916
 917		// Build tool result map for nested messages.
 918		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 919		for i := range nestedMsgs {
 920			nestedMsgPtrs[i] = &nestedMsgs[i]
 921		}
 922		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 923
 924		// Extract nested tool items.
 925		var nestedTools []chat.ToolMessageItem
 926		for _, nestedMsg := range nestedMsgPtrs {
 927			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 928			for _, nestedItem := range nestedItems {
 929				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 930					// Mark nested tools as simple (compact) rendering.
 931					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 932						simplifiable.SetCompact(true)
 933					}
 934					nestedTools = append(nestedTools, nestedToolItem)
 935				}
 936			}
 937		}
 938
 939		// Recursively load nested tool calls for any agent tools within.
 940		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 941		for i, nt := range nestedTools {
 942			nestedMessageItems[i] = nt
 943		}
 944		m.loadNestedToolCalls(nestedMessageItems)
 945
 946		// Set nested tools on the parent.
 947		nestedContainer.SetNestedTools(nestedTools)
 948	}
 949}
 950
 951// appendSessionMessage appends a new message to the current session in the chat
 952// if the message is a tool result it will update the corresponding tool call message
 953func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 954	var cmds []tea.Cmd
 955
 956	existing := m.chat.MessageItem(msg.ID)
 957	if existing != nil {
 958		// message already exists, skip
 959		return nil
 960	}
 961
 962	switch msg.Role {
 963	case message.User:
 964		m.lastUserMessageTime = msg.CreatedAt
 965		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 966		for _, item := range items {
 967			if animatable, ok := item.(chat.Animatable); ok {
 968				if cmd := animatable.StartAnimation(); cmd != nil {
 969					cmds = append(cmds, cmd)
 970				}
 971			}
 972		}
 973		m.chat.AppendMessages(items...)
 974		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 975			cmds = append(cmds, cmd)
 976		}
 977	case message.Assistant:
 978		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 979		for _, item := range items {
 980			if animatable, ok := item.(chat.Animatable); ok {
 981				if cmd := animatable.StartAnimation(); cmd != nil {
 982					cmds = append(cmds, cmd)
 983				}
 984			}
 985		}
 986		m.chat.AppendMessages(items...)
 987		if m.chat.Follow() {
 988			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 989				cmds = append(cmds, cmd)
 990			}
 991		}
 992		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 993			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 994			m.chat.AppendMessages(infoItem)
 995			if m.chat.Follow() {
 996				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 997					cmds = append(cmds, cmd)
 998				}
 999			}
1000		}
1001	case message.Tool:
1002		for _, tr := range msg.ToolResults() {
1003			toolItem := m.chat.MessageItem(tr.ToolCallID)
1004			if toolItem == nil {
1005				// we should have an item!
1006				continue
1007			}
1008			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1009				toolMsgItem.SetResult(&tr)
1010				if m.chat.Follow() {
1011					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1012						cmds = append(cmds, cmd)
1013					}
1014				}
1015			}
1016		}
1017	}
1018	return tea.Sequence(cmds...)
1019}
1020
1021func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1022	switch {
1023	case m.state != uiChat:
1024		return nil
1025	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1026		return nil
1027	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1028		m.focus = uiFocusEditor
1029		cmd = m.textarea.Focus()
1030		m.chat.Blur()
1031	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1032		m.focus = uiFocusMain
1033		m.textarea.Blur()
1034		m.chat.Focus()
1035	}
1036	return cmd
1037}
1038
1039// updateSessionMessage updates an existing message in the current session in the chat
1040// when an assistant message is updated it may include updated tool calls as well
1041// that is why we need to handle creating/updating each tool call message too
1042func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1043	var cmds []tea.Cmd
1044	existingItem := m.chat.MessageItem(msg.ID)
1045
1046	if existingItem != nil {
1047		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1048			assistantItem.SetMessage(&msg)
1049		}
1050	}
1051
1052	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1053	// if the message of the assistant does not have any  response just tool calls we need to remove it
1054	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1055		m.chat.RemoveMessage(msg.ID)
1056		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1057			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1058		}
1059	}
1060
1061	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1062		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1063			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1064			m.chat.AppendMessages(newInfoItem)
1065		}
1066	}
1067
1068	var items []chat.MessageItem
1069	for _, tc := range msg.ToolCalls() {
1070		existingToolItem := m.chat.MessageItem(tc.ID)
1071		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1072			existingToolCall := toolItem.ToolCall()
1073			// only update if finished state changed or input changed
1074			// to avoid clearing the cache
1075			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1076				toolItem.SetToolCall(tc)
1077			}
1078		}
1079		if existingToolItem == nil {
1080			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1081		}
1082	}
1083
1084	for _, item := range items {
1085		if animatable, ok := item.(chat.Animatable); ok {
1086			if cmd := animatable.StartAnimation(); cmd != nil {
1087				cmds = append(cmds, cmd)
1088			}
1089		}
1090	}
1091
1092	m.chat.AppendMessages(items...)
1093	if m.chat.Follow() {
1094		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1095			cmds = append(cmds, cmd)
1096		}
1097		m.chat.SelectLast()
1098	}
1099
1100	return tea.Sequence(cmds...)
1101}
1102
1103// handleChildSessionMessage handles messages from child sessions (agent tools).
1104func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1105	var cmds []tea.Cmd
1106
1107	// Only process messages with tool calls or results.
1108	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1109		return nil
1110	}
1111
1112	// Check if this is an agent tool session and parse it.
1113	childSessionID := event.Payload.SessionID
1114	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1115	if !ok {
1116		return nil
1117	}
1118
1119	// Find the parent agent tool item.
1120	var agentItem chat.NestedToolContainer
1121	for i := 0; i < m.chat.Len(); i++ {
1122		item := m.chat.MessageItem(toolCallID)
1123		if item == nil {
1124			continue
1125		}
1126		if agent, ok := item.(chat.NestedToolContainer); ok {
1127			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1128				if toolMessageItem.ToolCall().ID == toolCallID {
1129					// Verify this agent belongs to the correct parent message.
1130					// We can't directly check parentMessageID on the item, so we trust the session parsing.
1131					agentItem = agent
1132					break
1133				}
1134			}
1135		}
1136	}
1137
1138	if agentItem == nil {
1139		return nil
1140	}
1141
1142	// Get existing nested tools.
1143	nestedTools := agentItem.NestedTools()
1144
1145	// Update or create nested tool calls.
1146	for _, tc := range event.Payload.ToolCalls() {
1147		found := false
1148		for _, existingTool := range nestedTools {
1149			if existingTool.ToolCall().ID == tc.ID {
1150				existingTool.SetToolCall(tc)
1151				found = true
1152				break
1153			}
1154		}
1155		if !found {
1156			// Create a new nested tool item.
1157			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1158			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1159				simplifiable.SetCompact(true)
1160			}
1161			if animatable, ok := nestedItem.(chat.Animatable); ok {
1162				if cmd := animatable.StartAnimation(); cmd != nil {
1163					cmds = append(cmds, cmd)
1164				}
1165			}
1166			nestedTools = append(nestedTools, nestedItem)
1167		}
1168	}
1169
1170	// Update nested tool results.
1171	for _, tr := range event.Payload.ToolResults() {
1172		for _, nestedTool := range nestedTools {
1173			if nestedTool.ToolCall().ID == tr.ToolCallID {
1174				nestedTool.SetResult(&tr)
1175				break
1176			}
1177		}
1178	}
1179
1180	// Update the agent item with the new nested tools.
1181	agentItem.SetNestedTools(nestedTools)
1182
1183	// Update the chat so it updates the index map for animations to work as expected
1184	m.chat.UpdateNestedToolIDs(toolCallID)
1185
1186	if m.chat.Follow() {
1187		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1188			cmds = append(cmds, cmd)
1189		}
1190		m.chat.SelectLast()
1191	}
1192
1193	return tea.Sequence(cmds...)
1194}
1195
1196func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1197	var cmds []tea.Cmd
1198	action := m.dialog.Update(msg)
1199	if action == nil {
1200		return tea.Batch(cmds...)
1201	}
1202
1203	isOnboarding := m.state == uiOnboarding
1204
1205	switch msg := action.(type) {
1206	// Generic dialog messages
1207	case dialog.ActionClose:
1208		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1209			break
1210		}
1211
1212		if m.dialog.ContainsDialog(dialog.FilePickerID) {
1213			defer fimage.ResetCache()
1214		}
1215
1216		m.dialog.CloseFrontDialog()
1217
1218		if isOnboarding {
1219			if cmd := m.openModelsDialog(); cmd != nil {
1220				cmds = append(cmds, cmd)
1221			}
1222		}
1223
1224		if m.focus == uiFocusEditor {
1225			cmds = append(cmds, m.textarea.Focus())
1226		}
1227	case dialog.ActionCmd:
1228		if msg.Cmd != nil {
1229			cmds = append(cmds, msg.Cmd)
1230		}
1231
1232	// Session dialog messages.
1233	case dialog.ActionSelectSession:
1234		m.dialog.CloseDialog(dialog.SessionsID)
1235		cmds = append(cmds, m.loadSession(msg.Session.ID))
1236
1237	// Open dialog message.
1238	case dialog.ActionOpenDialog:
1239		m.dialog.CloseDialog(dialog.CommandsID)
1240		if cmd := m.openDialog(msg.DialogID); cmd != nil {
1241			cmds = append(cmds, cmd)
1242		}
1243
1244	// Command dialog messages.
1245	case dialog.ActionToggleYoloMode:
1246		yolo := !m.com.App.Permissions.SkipRequests()
1247		m.com.App.Permissions.SetSkipRequests(yolo)
1248		m.setEditorPrompt(yolo)
1249		m.dialog.CloseDialog(dialog.CommandsID)
1250	case dialog.ActionToggleNotifications:
1251		cfg := m.com.Config()
1252		if cfg != nil && cfg.Options != nil {
1253			disabled := !cfg.Options.DisableNotifications
1254			cfg.Options.DisableNotifications = disabled
1255			if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1256				cmds = append(cmds, util.ReportError(err))
1257			} else {
1258				status := "enabled"
1259				if disabled {
1260					status = "disabled"
1261				}
1262				cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1263			}
1264		}
1265		m.dialog.CloseDialog(dialog.CommandsID)
1266	case dialog.ActionNewSession:
1267		if m.isAgentBusy() {
1268			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1269			break
1270		}
1271		if cmd := m.newSession(); cmd != nil {
1272			cmds = append(cmds, cmd)
1273		}
1274		m.dialog.CloseDialog(dialog.CommandsID)
1275	case dialog.ActionSummarize:
1276		if m.isAgentBusy() {
1277			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1278			break
1279		}
1280		cmds = append(cmds, func() tea.Msg {
1281			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1282			if err != nil {
1283				return util.ReportError(err)()
1284			}
1285			return nil
1286		})
1287		m.dialog.CloseDialog(dialog.CommandsID)
1288	case dialog.ActionToggleHelp:
1289		m.status.ToggleHelp()
1290		m.dialog.CloseDialog(dialog.CommandsID)
1291	case dialog.ActionExternalEditor:
1292		if m.isAgentBusy() {
1293			cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1294			break
1295		}
1296		cmds = append(cmds, m.openEditor(m.textarea.Value()))
1297		m.dialog.CloseDialog(dialog.CommandsID)
1298	case dialog.ActionToggleCompactMode:
1299		cmds = append(cmds, m.toggleCompactMode())
1300		m.dialog.CloseDialog(dialog.CommandsID)
1301	case dialog.ActionTogglePills:
1302		if cmd := m.togglePillsExpanded(); cmd != nil {
1303			cmds = append(cmds, cmd)
1304		}
1305		m.dialog.CloseDialog(dialog.CommandsID)
1306	case dialog.ActionToggleThinking:
1307		cmds = append(cmds, func() tea.Msg {
1308			cfg := m.com.Config()
1309			if cfg == nil {
1310				return util.ReportError(errors.New("configuration not found"))()
1311			}
1312
1313			agentCfg, ok := cfg.Agents[config.AgentCoder]
1314			if !ok {
1315				return util.ReportError(errors.New("agent configuration not found"))()
1316			}
1317
1318			currentModel := cfg.Models[agentCfg.Model]
1319			currentModel.Think = !currentModel.Think
1320			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1321				return util.ReportError(err)()
1322			}
1323			m.com.App.UpdateAgentModel(context.TODO())
1324			status := "disabled"
1325			if currentModel.Think {
1326				status = "enabled"
1327			}
1328			return util.NewInfoMsg("Thinking mode " + status)
1329		})
1330		m.dialog.CloseDialog(dialog.CommandsID)
1331	case dialog.ActionToggleTransparentBackground:
1332		cmds = append(cmds, func() tea.Msg {
1333			cfg := m.com.Config()
1334			if cfg == nil {
1335				return util.ReportError(errors.New("configuration not found"))()
1336			}
1337
1338			isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1339			newValue := !isTransparent
1340			if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
1341				return util.ReportError(err)()
1342			}
1343			m.isTransparent = newValue
1344
1345			status := "disabled"
1346			if newValue {
1347				status = "enabled"
1348			}
1349			return util.NewInfoMsg("Transparent background " + status)
1350		})
1351		m.dialog.CloseDialog(dialog.CommandsID)
1352	case dialog.ActionQuit:
1353		cmds = append(cmds, tea.Quit)
1354	case dialog.ActionInitializeProject:
1355		if m.isAgentBusy() {
1356			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1357			break
1358		}
1359		cmds = append(cmds, m.initializeProject())
1360		m.dialog.CloseDialog(dialog.CommandsID)
1361
1362	case dialog.ActionSelectModel:
1363		if m.isAgentBusy() {
1364			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1365			break
1366		}
1367
1368		cfg := m.com.Config()
1369		if cfg == nil {
1370			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1371			break
1372		}
1373
1374		var (
1375			providerID   = msg.Model.Provider
1376			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1377			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1378		)
1379
1380		// Attempt to import GitHub Copilot tokens from VSCode if available.
1381		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1382			m.com.Store().ImportCopilot()
1383		}
1384
1385		if !isConfigured() || msg.ReAuthenticate {
1386			m.dialog.CloseDialog(dialog.ModelsID)
1387			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1388				cmds = append(cmds, cmd)
1389			}
1390			break
1391		}
1392
1393		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1394			cmds = append(cmds, util.ReportError(err))
1395		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1396			// Ensure small model is set is unset.
1397			smallModel := m.com.App.GetDefaultSmallModel(providerID)
1398			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1399				cmds = append(cmds, util.ReportError(err))
1400			}
1401		}
1402
1403		cmds = append(cmds, func() tea.Msg {
1404			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1405				return util.ReportError(err)
1406			}
1407
1408			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1409
1410			return util.NewInfoMsg(modelMsg)
1411		})
1412
1413		m.dialog.CloseDialog(dialog.APIKeyInputID)
1414		m.dialog.CloseDialog(dialog.OAuthID)
1415		m.dialog.CloseDialog(dialog.ModelsID)
1416
1417		if isOnboarding {
1418			m.setState(uiLanding, uiFocusEditor)
1419			m.com.Config().SetupAgents()
1420			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1421				cmds = append(cmds, util.ReportError(err))
1422			}
1423		}
1424	case dialog.ActionSelectReasoningEffort:
1425		if m.isAgentBusy() {
1426			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1427			break
1428		}
1429
1430		cfg := m.com.Config()
1431		if cfg == nil {
1432			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1433			break
1434		}
1435
1436		agentCfg, ok := cfg.Agents[config.AgentCoder]
1437		if !ok {
1438			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1439			break
1440		}
1441
1442		currentModel := cfg.Models[agentCfg.Model]
1443		currentModel.ReasoningEffort = msg.Effort
1444		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1445			cmds = append(cmds, util.ReportError(err))
1446			break
1447		}
1448
1449		cmds = append(cmds, func() tea.Msg {
1450			m.com.App.UpdateAgentModel(context.TODO())
1451			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1452		})
1453		m.dialog.CloseDialog(dialog.ReasoningID)
1454	case dialog.ActionPermissionResponse:
1455		m.dialog.CloseDialog(dialog.PermissionsID)
1456		switch msg.Action {
1457		case dialog.PermissionAllow:
1458			m.com.App.Permissions.Grant(msg.Permission)
1459		case dialog.PermissionAllowForSession:
1460			m.com.App.Permissions.GrantPersistent(msg.Permission)
1461		case dialog.PermissionDeny:
1462			m.com.App.Permissions.Deny(msg.Permission)
1463		}
1464
1465	case dialog.ActionFilePickerSelected:
1466		cmds = append(cmds, tea.Sequence(
1467			msg.Cmd(),
1468			func() tea.Msg {
1469				m.dialog.CloseDialog(dialog.FilePickerID)
1470				return nil
1471			},
1472			func() tea.Msg {
1473				fimage.ResetCache()
1474				return nil
1475			},
1476		))
1477
1478	case dialog.ActionRunCustomCommand:
1479		if len(msg.Arguments) > 0 && msg.Args == nil {
1480			m.dialog.CloseFrontDialog()
1481			argsDialog := dialog.NewArguments(
1482				m.com,
1483				"Custom Command Arguments",
1484				"",
1485				msg.Arguments,
1486				msg, // Pass the action as the result
1487			)
1488			m.dialog.OpenDialog(argsDialog)
1489			break
1490		}
1491		content := msg.Content
1492		if msg.Args != nil {
1493			content = substituteArgs(content, msg.Args)
1494		}
1495		cmds = append(cmds, m.sendMessage(content))
1496		m.dialog.CloseFrontDialog()
1497	case dialog.ActionRunMCPPrompt:
1498		if len(msg.Arguments) > 0 && msg.Args == nil {
1499			m.dialog.CloseFrontDialog()
1500			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1501			argsDialog := dialog.NewArguments(
1502				m.com,
1503				title,
1504				msg.Description,
1505				msg.Arguments,
1506				msg, // Pass the action as the result
1507			)
1508			m.dialog.OpenDialog(argsDialog)
1509			break
1510		}
1511		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1512	default:
1513		cmds = append(cmds, util.CmdHandler(msg))
1514	}
1515
1516	return tea.Batch(cmds...)
1517}
1518
1519// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1520func substituteArgs(content string, args map[string]string) string {
1521	for name, value := range args {
1522		placeholder := "$" + name
1523		content = strings.ReplaceAll(content, placeholder, value)
1524	}
1525	return content
1526}
1527
1528func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1529	var (
1530		dlg dialog.Dialog
1531		cmd tea.Cmd
1532
1533		isOnboarding = m.state == uiOnboarding
1534	)
1535
1536	switch provider.ID {
1537	case "hyper":
1538		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1539	case catwalk.InferenceProviderCopilot:
1540		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1541	default:
1542		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1543	}
1544
1545	if m.dialog.ContainsDialog(dlg.ID()) {
1546		m.dialog.BringToFront(dlg.ID())
1547		return nil
1548	}
1549
1550	m.dialog.OpenDialog(dlg)
1551	return cmd
1552}
1553
1554func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1555	var cmds []tea.Cmd
1556
1557	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1558		switch {
1559		case key.Matches(msg, m.keyMap.Help):
1560			m.status.ToggleHelp()
1561			m.updateLayoutAndSize()
1562			return true
1563		case key.Matches(msg, m.keyMap.Commands):
1564			if cmd := m.openCommandsDialog(); cmd != nil {
1565				cmds = append(cmds, cmd)
1566			}
1567			return true
1568		case key.Matches(msg, m.keyMap.Models):
1569			if cmd := m.openModelsDialog(); cmd != nil {
1570				cmds = append(cmds, cmd)
1571			}
1572			return true
1573		case key.Matches(msg, m.keyMap.Sessions):
1574			if cmd := m.openSessionsDialog(); cmd != nil {
1575				cmds = append(cmds, cmd)
1576			}
1577			return true
1578		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1579			m.detailsOpen = !m.detailsOpen
1580			m.updateLayoutAndSize()
1581			return true
1582		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1583			if m.state == uiChat && m.hasSession() {
1584				if cmd := m.togglePillsExpanded(); cmd != nil {
1585					cmds = append(cmds, cmd)
1586				}
1587				return true
1588			}
1589		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1590			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1591				if cmd := m.switchPillSection(-1); cmd != nil {
1592					cmds = append(cmds, cmd)
1593				}
1594				return true
1595			}
1596		case key.Matches(msg, m.keyMap.Chat.PillRight):
1597			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1598				if cmd := m.switchPillSection(1); cmd != nil {
1599					cmds = append(cmds, cmd)
1600				}
1601				return true
1602			}
1603		case key.Matches(msg, m.keyMap.Suspend):
1604			if m.isAgentBusy() {
1605				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1606				return true
1607			}
1608			cmds = append(cmds, tea.Suspend)
1609			return true
1610		}
1611		return false
1612	}
1613
1614	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1615		// Always handle quit keys first
1616		if cmd := m.openQuitDialog(); cmd != nil {
1617			cmds = append(cmds, cmd)
1618		}
1619
1620		return tea.Batch(cmds...)
1621	}
1622
1623	// Route all messages to dialog if one is open.
1624	if m.dialog.HasDialogs() {
1625		return m.handleDialogMsg(msg)
1626	}
1627
1628	// Handle cancel key when agent is busy.
1629	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1630		if m.isAgentBusy() {
1631			if cmd := m.cancelAgent(); cmd != nil {
1632				cmds = append(cmds, cmd)
1633			}
1634			return tea.Batch(cmds...)
1635		}
1636	}
1637
1638	switch m.state {
1639	case uiOnboarding:
1640		return tea.Batch(cmds...)
1641	case uiInitialize:
1642		cmds = append(cmds, m.updateInitializeView(msg)...)
1643		return tea.Batch(cmds...)
1644	case uiChat, uiLanding:
1645		switch m.focus {
1646		case uiFocusEditor:
1647			// Handle completions if open.
1648			if m.completionsOpen {
1649				if msg, ok := m.completions.Update(msg); ok {
1650					switch msg := msg.(type) {
1651					case completions.SelectionMsg[completions.FileCompletionValue]:
1652						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1653						if !msg.KeepOpen {
1654							m.closeCompletions()
1655						}
1656					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1657						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1658						if !msg.KeepOpen {
1659							m.closeCompletions()
1660						}
1661					case completions.ClosedMsg:
1662						m.completionsOpen = false
1663					}
1664					return tea.Batch(cmds...)
1665				}
1666			}
1667
1668			if ok := m.attachments.Update(msg); ok {
1669				return tea.Batch(cmds...)
1670			}
1671
1672			switch {
1673			case key.Matches(msg, m.keyMap.Editor.AddImage):
1674				if cmd := m.openFilesDialog(); cmd != nil {
1675					cmds = append(cmds, cmd)
1676				}
1677
1678			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1679				cmds = append(cmds, m.pasteImageFromClipboard)
1680
1681			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1682				value := m.textarea.Value()
1683				if before, ok := strings.CutSuffix(value, "\\"); ok {
1684					// If the last character is a backslash, remove it and add a newline.
1685					m.textarea.SetValue(before)
1686					break
1687				}
1688
1689				// Otherwise, send the message
1690				m.textarea.Reset()
1691
1692				value = strings.TrimSpace(value)
1693				if value == "exit" || value == "quit" {
1694					return m.openQuitDialog()
1695				}
1696
1697				attachments := m.attachments.List()
1698				m.attachments.Reset()
1699				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1700					return nil
1701				}
1702
1703				m.randomizePlaceholders()
1704				m.historyReset()
1705
1706				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1707			case key.Matches(msg, m.keyMap.Chat.NewSession):
1708				if !m.hasSession() {
1709					break
1710				}
1711				if m.isAgentBusy() {
1712					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1713					break
1714				}
1715				if cmd := m.newSession(); cmd != nil {
1716					cmds = append(cmds, cmd)
1717				}
1718			case key.Matches(msg, m.keyMap.Tab):
1719				if m.state != uiLanding {
1720					m.setState(m.state, uiFocusMain)
1721					m.textarea.Blur()
1722					m.chat.Focus()
1723					m.chat.SetSelected(m.chat.Len() - 1)
1724				}
1725			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1726				if m.isAgentBusy() {
1727					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1728					break
1729				}
1730				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1731			case key.Matches(msg, m.keyMap.Editor.Newline):
1732				m.textarea.InsertRune('\n')
1733				m.closeCompletions()
1734				ta, cmd := m.textarea.Update(msg)
1735				m.textarea = ta
1736				cmds = append(cmds, cmd)
1737			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1738				cmd := m.handleHistoryUp(msg)
1739				if cmd != nil {
1740					cmds = append(cmds, cmd)
1741				}
1742			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1743				cmd := m.handleHistoryDown(msg)
1744				if cmd != nil {
1745					cmds = append(cmds, cmd)
1746				}
1747			case key.Matches(msg, m.keyMap.Editor.Escape):
1748				cmd := m.handleHistoryEscape(msg)
1749				if cmd != nil {
1750					cmds = append(cmds, cmd)
1751				}
1752			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1753				if cmd := m.openCommandsDialog(); cmd != nil {
1754					cmds = append(cmds, cmd)
1755				}
1756			default:
1757				if handleGlobalKeys(msg) {
1758					// Handle global keys first before passing to textarea.
1759					break
1760				}
1761
1762				// Check for @ trigger before passing to textarea.
1763				curValue := m.textarea.Value()
1764				curIdx := len(curValue)
1765
1766				// Trigger completions on @.
1767				if msg.String() == "@" && !m.completionsOpen {
1768					// Only show if beginning of prompt or after whitespace.
1769					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1770						m.completionsOpen = true
1771						m.completionsQuery = ""
1772						m.completionsStartIndex = curIdx
1773						m.completionsPositionStart = m.completionsPosition()
1774						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1775						cmds = append(cmds, m.completions.Open(depth, limit))
1776					}
1777				}
1778
1779				// remove the details if they are open when user starts typing
1780				if m.detailsOpen {
1781					m.detailsOpen = false
1782					m.updateLayoutAndSize()
1783				}
1784
1785				ta, cmd := m.textarea.Update(msg)
1786				m.textarea = ta
1787				cmds = append(cmds, cmd)
1788
1789				// Any text modification becomes the current draft.
1790				m.updateHistoryDraft(curValue)
1791
1792				// After updating textarea, check if we need to filter completions.
1793				// Skip filtering on the initial @ keystroke since items are loading async.
1794				if m.completionsOpen && msg.String() != "@" {
1795					newValue := m.textarea.Value()
1796					newIdx := len(newValue)
1797
1798					// Close completions if cursor moved before start.
1799					if newIdx <= m.completionsStartIndex {
1800						m.closeCompletions()
1801					} else if msg.String() == "space" {
1802						// Close on space.
1803						m.closeCompletions()
1804					} else {
1805						// Extract current word and filter.
1806						word := m.textareaWord()
1807						if strings.HasPrefix(word, "@") {
1808							m.completionsQuery = word[1:]
1809							m.completions.Filter(m.completionsQuery)
1810						} else if m.completionsOpen {
1811							m.closeCompletions()
1812						}
1813					}
1814				}
1815			}
1816		case uiFocusMain:
1817			switch {
1818			case key.Matches(msg, m.keyMap.Tab):
1819				m.focus = uiFocusEditor
1820				cmds = append(cmds, m.textarea.Focus())
1821				m.chat.Blur()
1822			case key.Matches(msg, m.keyMap.Chat.NewSession):
1823				if !m.hasSession() {
1824					break
1825				}
1826				if m.isAgentBusy() {
1827					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1828					break
1829				}
1830				m.focus = uiFocusEditor
1831				if cmd := m.newSession(); cmd != nil {
1832					cmds = append(cmds, cmd)
1833				}
1834			case key.Matches(msg, m.keyMap.Chat.Expand):
1835				m.chat.ToggleExpandedSelectedItem()
1836			case key.Matches(msg, m.keyMap.Chat.Up):
1837				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1838					cmds = append(cmds, cmd)
1839				}
1840				if !m.chat.SelectedItemInView() {
1841					m.chat.SelectPrev()
1842					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1843						cmds = append(cmds, cmd)
1844					}
1845				}
1846			case key.Matches(msg, m.keyMap.Chat.Down):
1847				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1848					cmds = append(cmds, cmd)
1849				}
1850				if !m.chat.SelectedItemInView() {
1851					m.chat.SelectNext()
1852					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1853						cmds = append(cmds, cmd)
1854					}
1855				}
1856			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1857				m.chat.SelectPrev()
1858				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1859					cmds = append(cmds, cmd)
1860				}
1861			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1862				m.chat.SelectNext()
1863				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1864					cmds = append(cmds, cmd)
1865				}
1866			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1867				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1868					cmds = append(cmds, cmd)
1869				}
1870				m.chat.SelectFirstInView()
1871			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1872				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1873					cmds = append(cmds, cmd)
1874				}
1875				m.chat.SelectLastInView()
1876			case key.Matches(msg, m.keyMap.Chat.PageUp):
1877				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1878					cmds = append(cmds, cmd)
1879				}
1880				m.chat.SelectFirstInView()
1881			case key.Matches(msg, m.keyMap.Chat.PageDown):
1882				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1883					cmds = append(cmds, cmd)
1884				}
1885				m.chat.SelectLastInView()
1886			case key.Matches(msg, m.keyMap.Chat.Home):
1887				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1888					cmds = append(cmds, cmd)
1889				}
1890				m.chat.SelectFirst()
1891			case key.Matches(msg, m.keyMap.Chat.End):
1892				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1893					cmds = append(cmds, cmd)
1894				}
1895				m.chat.SelectLast()
1896			default:
1897				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1898					cmds = append(cmds, cmd)
1899				} else {
1900					handleGlobalKeys(msg)
1901				}
1902			}
1903		default:
1904			handleGlobalKeys(msg)
1905		}
1906	default:
1907		handleGlobalKeys(msg)
1908	}
1909
1910	return tea.Sequence(cmds...)
1911}
1912
1913// drawHeader draws the header section of the UI.
1914func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1915	m.header.drawHeader(
1916		scr,
1917		area,
1918		m.session,
1919		m.isCompact,
1920		m.detailsOpen,
1921		area.Dx(),
1922	)
1923}
1924
1925// Draw implements [uv.Drawable] and draws the UI model.
1926func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1927	layout := m.generateLayout(area.Dx(), area.Dy())
1928
1929	if m.layout != layout {
1930		m.layout = layout
1931		m.updateSize()
1932	}
1933
1934	// Clear the screen first
1935	screen.Clear(scr)
1936
1937	switch m.state {
1938	case uiOnboarding:
1939		m.drawHeader(scr, layout.header)
1940
1941		// NOTE: Onboarding flow will be rendered as dialogs below, but
1942		// positioned at the bottom left of the screen.
1943
1944	case uiInitialize:
1945		m.drawHeader(scr, layout.header)
1946
1947		main := uv.NewStyledString(m.initializeView())
1948		main.Draw(scr, layout.main)
1949
1950	case uiLanding:
1951		m.drawHeader(scr, layout.header)
1952		main := uv.NewStyledString(m.landingView())
1953		main.Draw(scr, layout.main)
1954
1955		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1956		editor.Draw(scr, layout.editor)
1957
1958	case uiChat:
1959		if m.isCompact {
1960			m.drawHeader(scr, layout.header)
1961		} else {
1962			m.drawSidebar(scr, layout.sidebar)
1963		}
1964
1965		m.chat.Draw(scr, layout.main)
1966		if layout.pills.Dy() > 0 && m.pillsView != "" {
1967			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1968		}
1969
1970		editorWidth := scr.Bounds().Dx()
1971		if !m.isCompact {
1972			editorWidth -= layout.sidebar.Dx()
1973		}
1974		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1975		editor.Draw(scr, layout.editor)
1976
1977		// Draw details overlay in compact mode when open
1978		if m.isCompact && m.detailsOpen {
1979			m.drawSessionDetails(scr, layout.sessionDetails)
1980		}
1981	}
1982
1983	isOnboarding := m.state == uiOnboarding
1984
1985	// Add status and help layer
1986	m.status.SetHideHelp(isOnboarding)
1987	m.status.Draw(scr, layout.status)
1988
1989	// Draw completions popup if open
1990	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1991		w, h := m.completions.Size()
1992		x := m.completionsPositionStart.X
1993		y := m.completionsPositionStart.Y - h
1994
1995		screenW := area.Dx()
1996		if x+w > screenW {
1997			x = screenW - w
1998		}
1999		x = max(0, x)
2000		y = max(0, y+1) // Offset for attachments row
2001
2002		completionsView := uv.NewStyledString(m.completions.Render())
2003		completionsView.Draw(scr, image.Rectangle{
2004			Min: image.Pt(x, y),
2005			Max: image.Pt(x+w, y+h),
2006		})
2007	}
2008
2009	// Debugging rendering (visually see when the tui rerenders)
2010	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2011		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2012		debug := uv.NewStyledString(debugView.String())
2013		debug.Draw(scr, image.Rectangle{
2014			Min: image.Pt(4, 1),
2015			Max: image.Pt(8, 3),
2016		})
2017	}
2018
2019	// This needs to come last to overlay on top of everything. We always pass
2020	// the full screen bounds because the dialogs will position themselves
2021	// accordingly.
2022	if m.dialog.HasDialogs() {
2023		return m.dialog.Draw(scr, scr.Bounds())
2024	}
2025
2026	switch m.focus {
2027	case uiFocusEditor:
2028		if m.layout.editor.Dy() <= 0 {
2029			// Don't show cursor if editor is not visible
2030			return nil
2031		}
2032		if m.detailsOpen && m.isCompact {
2033			// Don't show cursor if details overlay is open
2034			return nil
2035		}
2036
2037		if m.textarea.Focused() {
2038			cur := m.textarea.Cursor()
2039			cur.X++                            // Adjust for app margins
2040			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2041			return cur
2042		}
2043	}
2044	return nil
2045}
2046
2047// View renders the UI model's view.
2048func (m *UI) View() tea.View {
2049	var v tea.View
2050	v.AltScreen = true
2051	if !m.isTransparent {
2052		v.BackgroundColor = m.com.Styles.Background
2053	}
2054	v.MouseMode = tea.MouseModeCellMotion
2055	v.ReportFocus = m.caps.ReportFocusEvents
2056	v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2057
2058	canvas := uv.NewScreenBuffer(m.width, m.height)
2059	v.Cursor = m.Draw(canvas, canvas.Bounds())
2060
2061	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2062	contentLines := strings.Split(content, "\n")
2063	for i, line := range contentLines {
2064		// Trim trailing spaces for concise rendering
2065		contentLines[i] = strings.TrimRight(line, " ")
2066	}
2067
2068	content = strings.Join(contentLines, "\n")
2069
2070	v.Content = content
2071	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2072		// HACK: use a random percentage to prevent ghostty from hiding it
2073		// after a timeout.
2074		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2075	}
2076
2077	return v
2078}
2079
2080// ShortHelp implements [help.KeyMap].
2081func (m *UI) ShortHelp() []key.Binding {
2082	var binds []key.Binding
2083	k := &m.keyMap
2084	tab := k.Tab
2085	commands := k.Commands
2086	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2087		commands.SetHelp("/ or ctrl+p", "commands")
2088	}
2089
2090	switch m.state {
2091	case uiInitialize:
2092		binds = append(binds, k.Quit)
2093	case uiChat:
2094		// Show cancel binding if agent is busy.
2095		if m.isAgentBusy() {
2096			cancelBinding := k.Chat.Cancel
2097			if m.isCanceling {
2098				cancelBinding.SetHelp("esc", "press again to cancel")
2099			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2100				cancelBinding.SetHelp("esc", "clear queue")
2101			}
2102			binds = append(binds, cancelBinding)
2103		}
2104
2105		if m.focus == uiFocusEditor {
2106			tab.SetHelp("tab", "focus chat")
2107		} else {
2108			tab.SetHelp("tab", "focus editor")
2109		}
2110
2111		binds = append(binds,
2112			tab,
2113			commands,
2114			k.Models,
2115		)
2116
2117		switch m.focus {
2118		case uiFocusEditor:
2119			binds = append(binds,
2120				k.Editor.Newline,
2121			)
2122		case uiFocusMain:
2123			binds = append(binds,
2124				k.Chat.UpDown,
2125				k.Chat.UpDownOneItem,
2126				k.Chat.PageUp,
2127				k.Chat.PageDown,
2128				k.Chat.Copy,
2129			)
2130			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2131				binds = append(binds, k.Chat.PillLeft)
2132			}
2133		}
2134	default:
2135		// TODO: other states
2136		// if m.session == nil {
2137		// no session selected
2138		binds = append(binds,
2139			commands,
2140			k.Models,
2141			k.Editor.Newline,
2142		)
2143	}
2144
2145	binds = append(binds,
2146		k.Quit,
2147		k.Help,
2148	)
2149
2150	return binds
2151}
2152
2153// FullHelp implements [help.KeyMap].
2154func (m *UI) FullHelp() [][]key.Binding {
2155	var binds [][]key.Binding
2156	k := &m.keyMap
2157	help := k.Help
2158	help.SetHelp("ctrl+g", "less")
2159	hasAttachments := len(m.attachments.List()) > 0
2160	hasSession := m.hasSession()
2161	commands := k.Commands
2162	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2163		commands.SetHelp("/ or ctrl+p", "commands")
2164	}
2165
2166	switch m.state {
2167	case uiInitialize:
2168		binds = append(binds,
2169			[]key.Binding{
2170				k.Quit,
2171			})
2172	case uiChat:
2173		// Show cancel binding if agent is busy.
2174		if m.isAgentBusy() {
2175			cancelBinding := k.Chat.Cancel
2176			if m.isCanceling {
2177				cancelBinding.SetHelp("esc", "press again to cancel")
2178			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2179				cancelBinding.SetHelp("esc", "clear queue")
2180			}
2181			binds = append(binds, []key.Binding{cancelBinding})
2182		}
2183
2184		mainBinds := []key.Binding{}
2185		tab := k.Tab
2186		if m.focus == uiFocusEditor {
2187			tab.SetHelp("tab", "focus chat")
2188		} else {
2189			tab.SetHelp("tab", "focus editor")
2190		}
2191
2192		mainBinds = append(mainBinds,
2193			tab,
2194			commands,
2195			k.Models,
2196			k.Sessions,
2197		)
2198		if hasSession {
2199			mainBinds = append(mainBinds, k.Chat.NewSession)
2200		}
2201
2202		binds = append(binds, mainBinds)
2203
2204		switch m.focus {
2205		case uiFocusEditor:
2206			binds = append(binds,
2207				[]key.Binding{
2208					k.Editor.Newline,
2209					k.Editor.AddImage,
2210					k.Editor.PasteImage,
2211					k.Editor.MentionFile,
2212					k.Editor.OpenEditor,
2213				},
2214			)
2215			if hasAttachments {
2216				binds = append(binds,
2217					[]key.Binding{
2218						k.Editor.AttachmentDeleteMode,
2219						k.Editor.DeleteAllAttachments,
2220						k.Editor.Escape,
2221					},
2222				)
2223			}
2224		case uiFocusMain:
2225			binds = append(binds,
2226				[]key.Binding{
2227					k.Chat.UpDown,
2228					k.Chat.UpDownOneItem,
2229					k.Chat.PageUp,
2230					k.Chat.PageDown,
2231				},
2232				[]key.Binding{
2233					k.Chat.HalfPageUp,
2234					k.Chat.HalfPageDown,
2235					k.Chat.Home,
2236					k.Chat.End,
2237				},
2238				[]key.Binding{
2239					k.Chat.Copy,
2240					k.Chat.ClearHighlight,
2241				},
2242			)
2243			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2244				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2245			}
2246		}
2247	default:
2248		if m.session == nil {
2249			// no session selected
2250			binds = append(binds,
2251				[]key.Binding{
2252					commands,
2253					k.Models,
2254					k.Sessions,
2255				},
2256				[]key.Binding{
2257					k.Editor.Newline,
2258					k.Editor.AddImage,
2259					k.Editor.PasteImage,
2260					k.Editor.MentionFile,
2261					k.Editor.OpenEditor,
2262				},
2263			)
2264			if hasAttachments {
2265				binds = append(binds,
2266					[]key.Binding{
2267						k.Editor.AttachmentDeleteMode,
2268						k.Editor.DeleteAllAttachments,
2269						k.Editor.Escape,
2270					},
2271				)
2272			}
2273			binds = append(binds,
2274				[]key.Binding{
2275					help,
2276				},
2277			)
2278		}
2279	}
2280
2281	binds = append(binds,
2282		[]key.Binding{
2283			help,
2284			k.Quit,
2285		},
2286	)
2287
2288	return binds
2289}
2290
2291// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2292func (m *UI) toggleCompactMode() tea.Cmd {
2293	m.forceCompactMode = !m.forceCompactMode
2294
2295	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2296	if err != nil {
2297		return util.ReportError(err)
2298	}
2299
2300	m.updateLayoutAndSize()
2301
2302	return nil
2303}
2304
2305// updateLayoutAndSize updates the layout and sizes of UI components.
2306func (m *UI) updateLayoutAndSize() {
2307	// Determine if we should be in compact mode
2308	if m.state == uiChat {
2309		if m.forceCompactMode {
2310			m.isCompact = true
2311			return
2312		}
2313		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2314			m.isCompact = true
2315		} else {
2316			m.isCompact = false
2317		}
2318	}
2319
2320	m.layout = m.generateLayout(m.width, m.height)
2321	m.updateSize()
2322}
2323
2324// updateSize updates the sizes of UI components based on the current layout.
2325func (m *UI) updateSize() {
2326	// Set status width
2327	m.status.SetWidth(m.layout.status.Dx())
2328
2329	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2330	m.textarea.SetWidth(m.layout.editor.Dx())
2331	// TODO: Abstract the textarea and attachments into a single editor
2332	// component so we don't have to manually account for the attachments
2333	// height here.
2334	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2335	m.renderPills()
2336
2337	// Handle different app states
2338	switch m.state {
2339	case uiChat:
2340		if !m.isCompact {
2341			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2342		}
2343	}
2344}
2345
2346// generateLayout calculates the layout rectangles for all UI components based
2347// on the current UI state and terminal dimensions.
2348func (m *UI) generateLayout(w, h int) uiLayout {
2349	// The screen area we're working with
2350	area := image.Rect(0, 0, w, h)
2351
2352	// The help height
2353	helpHeight := 1
2354	// The editor height
2355	editorHeight := 5
2356	// The sidebar width
2357	sidebarWidth := 30
2358	// The header height
2359	const landingHeaderHeight = 4
2360
2361	var helpKeyMap help.KeyMap = m
2362	if m.status != nil && m.status.ShowingAll() {
2363		for _, row := range helpKeyMap.FullHelp() {
2364			helpHeight = max(helpHeight, len(row))
2365		}
2366	}
2367
2368	// Add app margins
2369	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2370	appRect.Min.Y += 1
2371	appRect.Max.Y -= 1
2372	helpRect.Min.Y -= 1
2373	appRect.Min.X += 1
2374	appRect.Max.X -= 1
2375
2376	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2377		// extra padding on left and right for these states
2378		appRect.Min.X += 1
2379		appRect.Max.X -= 1
2380	}
2381
2382	uiLayout := uiLayout{
2383		area:   area,
2384		status: helpRect,
2385	}
2386
2387	// Handle different app states
2388	switch m.state {
2389	case uiOnboarding, uiInitialize:
2390		// Layout
2391		//
2392		// header
2393		// ------
2394		// main
2395		// ------
2396		// help
2397
2398		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2399		uiLayout.header = headerRect
2400		uiLayout.main = mainRect
2401
2402	case uiLanding:
2403		// Layout
2404		//
2405		// header
2406		// ------
2407		// main
2408		// ------
2409		// editor
2410		// ------
2411		// help
2412		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2413		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2414		// Remove extra padding from editor (but keep it for header and main)
2415		editorRect.Min.X -= 1
2416		editorRect.Max.X += 1
2417		uiLayout.header = headerRect
2418		uiLayout.main = mainRect
2419		uiLayout.editor = editorRect
2420
2421	case uiChat:
2422		if m.isCompact {
2423			// Layout
2424			//
2425			// compact-header
2426			// ------
2427			// main
2428			// ------
2429			// editor
2430			// ------
2431			// help
2432			const compactHeaderHeight = 1
2433			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2434			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2435			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2436			uiLayout.sessionDetails = sessionDetailsArea
2437			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2438			// Add one line gap between header and main content
2439			mainRect.Min.Y += 1
2440			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2441			mainRect.Max.X -= 1 // Add padding right
2442			uiLayout.header = headerRect
2443			pillsHeight := m.pillsAreaHeight()
2444			if pillsHeight > 0 {
2445				pillsHeight = min(pillsHeight, mainRect.Dy())
2446				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2447				uiLayout.main = chatRect
2448				uiLayout.pills = pillsRect
2449			} else {
2450				uiLayout.main = mainRect
2451			}
2452			// Add bottom margin to main
2453			uiLayout.main.Max.Y -= 1
2454			uiLayout.editor = editorRect
2455		} else {
2456			// Layout
2457			//
2458			// ------|---
2459			// main  |
2460			// ------| side
2461			// editor|
2462			// ----------
2463			// help
2464
2465			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2466			// Add padding left
2467			sideRect.Min.X += 1
2468			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2469			mainRect.Max.X -= 1 // Add padding right
2470			uiLayout.sidebar = sideRect
2471			pillsHeight := m.pillsAreaHeight()
2472			if pillsHeight > 0 {
2473				pillsHeight = min(pillsHeight, mainRect.Dy())
2474				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2475				uiLayout.main = chatRect
2476				uiLayout.pills = pillsRect
2477			} else {
2478				uiLayout.main = mainRect
2479			}
2480			// Add bottom margin to main
2481			uiLayout.main.Max.Y -= 1
2482			uiLayout.editor = editorRect
2483		}
2484	}
2485
2486	return uiLayout
2487}
2488
2489// uiLayout defines the positioning of UI elements.
2490type uiLayout struct {
2491	// area is the overall available area.
2492	area uv.Rectangle
2493
2494	// header is the header shown in special cases
2495	// e.x when the sidebar is collapsed
2496	// or when in the landing page
2497	// or in init/config
2498	header uv.Rectangle
2499
2500	// main is the area for the main pane. (e.x chat, configure, landing)
2501	main uv.Rectangle
2502
2503	// pills is the area for the pills panel.
2504	pills uv.Rectangle
2505
2506	// editor is the area for the editor pane.
2507	editor uv.Rectangle
2508
2509	// sidebar is the area for the sidebar.
2510	sidebar uv.Rectangle
2511
2512	// status is the area for the status view.
2513	status uv.Rectangle
2514
2515	// session details is the area for the session details overlay in compact mode.
2516	sessionDetails uv.Rectangle
2517}
2518
2519func (m *UI) openEditor(value string) tea.Cmd {
2520	tmpfile, err := os.CreateTemp("", "msg_*.md")
2521	if err != nil {
2522		return util.ReportError(err)
2523	}
2524	defer tmpfile.Close() //nolint:errcheck
2525	if _, err := tmpfile.WriteString(value); err != nil {
2526		return util.ReportError(err)
2527	}
2528	cmd, err := editor.Command(
2529		"crush",
2530		tmpfile.Name(),
2531		editor.AtPosition(
2532			m.textarea.Line()+1,
2533			m.textarea.Column()+1,
2534		),
2535	)
2536	if err != nil {
2537		return util.ReportError(err)
2538	}
2539	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2540		if err != nil {
2541			return util.ReportError(err)
2542		}
2543		content, err := os.ReadFile(tmpfile.Name())
2544		if err != nil {
2545			return util.ReportError(err)
2546		}
2547		if len(content) == 0 {
2548			return util.ReportWarn("Message is empty")
2549		}
2550		os.Remove(tmpfile.Name())
2551		return openEditorMsg{
2552			Text: strings.TrimSpace(string(content)),
2553		}
2554	})
2555}
2556
2557// setEditorPrompt configures the textarea prompt function based on whether
2558// yolo mode is enabled.
2559func (m *UI) setEditorPrompt(yolo bool) {
2560	if yolo {
2561		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2562		return
2563	}
2564	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2565}
2566
2567// normalPromptFunc returns the normal editor prompt style ("  > " on first
2568// line, "::: " on subsequent lines).
2569func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2570	t := m.com.Styles
2571	if info.LineNumber == 0 {
2572		if info.Focused {
2573			return "  > "
2574		}
2575		return "::: "
2576	}
2577	if info.Focused {
2578		return t.EditorPromptNormalFocused.Render()
2579	}
2580	return t.EditorPromptNormalBlurred.Render()
2581}
2582
2583// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2584// and colored dots.
2585func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2586	t := m.com.Styles
2587	if info.LineNumber == 0 {
2588		if info.Focused {
2589			return t.EditorPromptYoloIconFocused.Render()
2590		} else {
2591			return t.EditorPromptYoloIconBlurred.Render()
2592		}
2593	}
2594	if info.Focused {
2595		return t.EditorPromptYoloDotsFocused.Render()
2596	}
2597	return t.EditorPromptYoloDotsBlurred.Render()
2598}
2599
2600// closeCompletions closes the completions popup and resets state.
2601func (m *UI) closeCompletions() {
2602	m.completionsOpen = false
2603	m.completionsQuery = ""
2604	m.completionsStartIndex = 0
2605	m.completions.Close()
2606}
2607
2608// insertCompletionText replaces the @query in the textarea with the given text.
2609// Returns false if the replacement cannot be performed.
2610func (m *UI) insertCompletionText(text string) bool {
2611	value := m.textarea.Value()
2612	if m.completionsStartIndex > len(value) {
2613		return false
2614	}
2615
2616	word := m.textareaWord()
2617	endIdx := min(m.completionsStartIndex+len(word), len(value))
2618	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2619	m.textarea.SetValue(newValue)
2620	m.textarea.MoveToEnd()
2621	m.textarea.InsertRune(' ')
2622	return true
2623}
2624
2625// insertFileCompletion inserts the selected file path into the textarea,
2626// replacing the @query, and adds the file as an attachment.
2627func (m *UI) insertFileCompletion(path string) tea.Cmd {
2628	if !m.insertCompletionText(path) {
2629		return nil
2630	}
2631
2632	return func() tea.Msg {
2633		absPath, _ := filepath.Abs(path)
2634
2635		if m.hasSession() {
2636			// Skip attachment if file was already read and hasn't been modified.
2637			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2638			if !lastRead.IsZero() {
2639				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2640					return nil
2641				}
2642			}
2643		} else if slices.Contains(m.sessionFileReads, absPath) {
2644			return nil
2645		}
2646
2647		m.sessionFileReads = append(m.sessionFileReads, absPath)
2648
2649		// Add file as attachment.
2650		content, err := os.ReadFile(path)
2651		if err != nil {
2652			// If it fails, let the LLM handle it later.
2653			return nil
2654		}
2655
2656		return message.Attachment{
2657			FilePath: path,
2658			FileName: filepath.Base(path),
2659			MimeType: mimeOf(content),
2660			Content:  content,
2661		}
2662	}
2663}
2664
2665// insertMCPResourceCompletion inserts the selected resource into the textarea,
2666// replacing the @query, and adds the resource as an attachment.
2667func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2668	displayText := cmp.Or(item.Title, item.URI)
2669
2670	if !m.insertCompletionText(displayText) {
2671		return nil
2672	}
2673
2674	return func() tea.Msg {
2675		contents, err := mcp.ReadResource(
2676			context.Background(),
2677			m.com.Store(),
2678			item.MCPName,
2679			item.URI,
2680		)
2681		if err != nil {
2682			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2683			return nil
2684		}
2685		if len(contents) == 0 {
2686			return nil
2687		}
2688
2689		content := contents[0]
2690		var data []byte
2691		if content.Text != "" {
2692			data = []byte(content.Text)
2693		} else if len(content.Blob) > 0 {
2694			data = content.Blob
2695		}
2696		if len(data) == 0 {
2697			return nil
2698		}
2699
2700		mimeType := item.MIMEType
2701		if mimeType == "" && content.MIMEType != "" {
2702			mimeType = content.MIMEType
2703		}
2704		if mimeType == "" {
2705			mimeType = "text/plain"
2706		}
2707
2708		return message.Attachment{
2709			FilePath: item.URI,
2710			FileName: displayText,
2711			MimeType: mimeType,
2712			Content:  data,
2713		}
2714	}
2715}
2716
2717// completionsPosition returns the X and Y position for the completions popup.
2718func (m *UI) completionsPosition() image.Point {
2719	cur := m.textarea.Cursor()
2720	if cur == nil {
2721		return image.Point{
2722			X: m.layout.editor.Min.X,
2723			Y: m.layout.editor.Min.Y,
2724		}
2725	}
2726	return image.Point{
2727		X: cur.X + m.layout.editor.Min.X,
2728		Y: m.layout.editor.Min.Y + cur.Y,
2729	}
2730}
2731
2732// textareaWord returns the current word at the cursor position.
2733func (m *UI) textareaWord() string {
2734	return m.textarea.Word()
2735}
2736
2737// isWhitespace returns true if the byte is a whitespace character.
2738func isWhitespace(b byte) bool {
2739	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2740}
2741
2742// isAgentBusy returns true if the agent coordinator exists and is currently
2743// busy processing a request.
2744func (m *UI) isAgentBusy() bool {
2745	return m.com.App != nil &&
2746		m.com.App.AgentCoordinator != nil &&
2747		m.com.App.AgentCoordinator.IsBusy()
2748}
2749
2750// hasSession returns true if there is an active session with a valid ID.
2751func (m *UI) hasSession() bool {
2752	return m.session != nil && m.session.ID != ""
2753}
2754
2755// mimeOf detects the MIME type of the given content.
2756func mimeOf(content []byte) string {
2757	mimeBufferSize := min(512, len(content))
2758	return http.DetectContentType(content[:mimeBufferSize])
2759}
2760
2761var readyPlaceholders = [...]string{
2762	"Ready!",
2763	"Ready...",
2764	"Ready?",
2765	"Ready for instructions",
2766}
2767
2768var workingPlaceholders = [...]string{
2769	"Working!",
2770	"Working...",
2771	"Brrrrr...",
2772	"Prrrrrrrr...",
2773	"Processing...",
2774	"Thinking...",
2775}
2776
2777// randomizePlaceholders selects random placeholder text for the textarea's
2778// ready and working states.
2779func (m *UI) randomizePlaceholders() {
2780	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2781	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2782}
2783
2784// renderEditorView renders the editor view with attachments if any.
2785func (m *UI) renderEditorView(width int) string {
2786	var attachmentsView string
2787	if len(m.attachments.List()) > 0 {
2788		attachmentsView = m.attachments.Render(width)
2789	}
2790	return strings.Join([]string{
2791		attachmentsView,
2792		m.textarea.View(),
2793		"", // margin at bottom of editor
2794	}, "\n")
2795}
2796
2797// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2798func (m *UI) cacheSidebarLogo(width int) {
2799	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2800}
2801
2802// sendMessage sends a message with the given content and attachments.
2803func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2804	if m.com.App.AgentCoordinator == nil {
2805		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2806	}
2807
2808	var cmds []tea.Cmd
2809	if !m.hasSession() {
2810		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2811		if err != nil {
2812			return util.ReportError(err)
2813		}
2814		if m.forceCompactMode {
2815			m.isCompact = true
2816		}
2817		if newSession.ID != "" {
2818			m.session = &newSession
2819			cmds = append(cmds, m.loadSession(newSession.ID))
2820		}
2821		m.setState(uiChat, m.focus)
2822	}
2823
2824	ctx := context.Background()
2825	cmds = append(cmds, func() tea.Msg {
2826		for _, path := range m.sessionFileReads {
2827			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2828			m.com.App.LSPManager.Start(ctx, path)
2829		}
2830		return nil
2831	})
2832
2833	// Capture session ID to avoid race with main goroutine updating m.session.
2834	sessionID := m.session.ID
2835	cmds = append(cmds, func() tea.Msg {
2836		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2837		if err != nil {
2838			isCancelErr := errors.Is(err, context.Canceled)
2839			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2840			if isCancelErr || isPermissionErr {
2841				return nil
2842			}
2843			return util.InfoMsg{
2844				Type: util.InfoTypeError,
2845				Msg:  err.Error(),
2846			}
2847		}
2848		return nil
2849	})
2850	return tea.Batch(cmds...)
2851}
2852
2853const cancelTimerDuration = 2 * time.Second
2854
2855// cancelTimerCmd creates a command that expires the cancel timer.
2856func cancelTimerCmd() tea.Cmd {
2857	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2858		return cancelTimerExpiredMsg{}
2859	})
2860}
2861
2862// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2863// and starts a timer. The second press (before the timer expires) actually
2864// cancels the agent.
2865func (m *UI) cancelAgent() tea.Cmd {
2866	if !m.hasSession() {
2867		return nil
2868	}
2869
2870	coordinator := m.com.App.AgentCoordinator
2871	if coordinator == nil {
2872		return nil
2873	}
2874
2875	if m.isCanceling {
2876		// Second escape press - actually cancel the agent.
2877		m.isCanceling = false
2878		coordinator.Cancel(m.session.ID)
2879		// Stop the spinning todo indicator.
2880		m.todoIsSpinning = false
2881		m.renderPills()
2882		return nil
2883	}
2884
2885	// Check if there are queued prompts - if so, clear the queue.
2886	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2887		coordinator.ClearQueue(m.session.ID)
2888		return nil
2889	}
2890
2891	// First escape press - set canceling state and start timer.
2892	m.isCanceling = true
2893	return cancelTimerCmd()
2894}
2895
2896// openDialog opens a dialog by its ID.
2897func (m *UI) openDialog(id string) tea.Cmd {
2898	var cmds []tea.Cmd
2899	switch id {
2900	case dialog.SessionsID:
2901		if cmd := m.openSessionsDialog(); cmd != nil {
2902			cmds = append(cmds, cmd)
2903		}
2904	case dialog.ModelsID:
2905		if cmd := m.openModelsDialog(); cmd != nil {
2906			cmds = append(cmds, cmd)
2907		}
2908	case dialog.CommandsID:
2909		if cmd := m.openCommandsDialog(); cmd != nil {
2910			cmds = append(cmds, cmd)
2911		}
2912	case dialog.ReasoningID:
2913		if cmd := m.openReasoningDialog(); cmd != nil {
2914			cmds = append(cmds, cmd)
2915		}
2916	case dialog.QuitID:
2917		if cmd := m.openQuitDialog(); cmd != nil {
2918			cmds = append(cmds, cmd)
2919		}
2920	default:
2921		// Unknown dialog
2922		break
2923	}
2924	return tea.Batch(cmds...)
2925}
2926
2927// openQuitDialog opens the quit confirmation dialog.
2928func (m *UI) openQuitDialog() tea.Cmd {
2929	if m.dialog.ContainsDialog(dialog.QuitID) {
2930		// Bring to front
2931		m.dialog.BringToFront(dialog.QuitID)
2932		return nil
2933	}
2934
2935	quitDialog := dialog.NewQuit(m.com)
2936	m.dialog.OpenDialog(quitDialog)
2937	return nil
2938}
2939
2940// openModelsDialog opens the models dialog.
2941func (m *UI) openModelsDialog() tea.Cmd {
2942	if m.dialog.ContainsDialog(dialog.ModelsID) {
2943		// Bring to front
2944		m.dialog.BringToFront(dialog.ModelsID)
2945		return nil
2946	}
2947
2948	isOnboarding := m.state == uiOnboarding
2949	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2950	if err != nil {
2951		return util.ReportError(err)
2952	}
2953
2954	m.dialog.OpenDialog(modelsDialog)
2955
2956	return nil
2957}
2958
2959// openCommandsDialog opens the commands dialog.
2960func (m *UI) openCommandsDialog() tea.Cmd {
2961	if m.dialog.ContainsDialog(dialog.CommandsID) {
2962		// Bring to front
2963		m.dialog.BringToFront(dialog.CommandsID)
2964		return nil
2965	}
2966
2967	var sessionID string
2968	hasSession := m.session != nil
2969	if hasSession {
2970		sessionID = m.session.ID
2971	}
2972	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2973	hasQueue := m.promptQueue > 0
2974
2975	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2976	if err != nil {
2977		return util.ReportError(err)
2978	}
2979
2980	m.dialog.OpenDialog(commands)
2981
2982	return nil
2983}
2984
2985// openReasoningDialog opens the reasoning effort dialog.
2986func (m *UI) openReasoningDialog() tea.Cmd {
2987	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2988		m.dialog.BringToFront(dialog.ReasoningID)
2989		return nil
2990	}
2991
2992	reasoningDialog, err := dialog.NewReasoning(m.com)
2993	if err != nil {
2994		return util.ReportError(err)
2995	}
2996
2997	m.dialog.OpenDialog(reasoningDialog)
2998	return nil
2999}
3000
3001// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3002// it brings it to the front. Otherwise, it will list all the sessions and open
3003// the dialog.
3004func (m *UI) openSessionsDialog() tea.Cmd {
3005	if m.dialog.ContainsDialog(dialog.SessionsID) {
3006		// Bring to front
3007		m.dialog.BringToFront(dialog.SessionsID)
3008		return nil
3009	}
3010
3011	selectedSessionID := ""
3012	if m.session != nil {
3013		selectedSessionID = m.session.ID
3014	}
3015
3016	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3017	if err != nil {
3018		return util.ReportError(err)
3019	}
3020
3021	m.dialog.OpenDialog(dialog)
3022	return nil
3023}
3024
3025// openFilesDialog opens the file picker dialog.
3026func (m *UI) openFilesDialog() tea.Cmd {
3027	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3028		// Bring to front
3029		m.dialog.BringToFront(dialog.FilePickerID)
3030		return nil
3031	}
3032
3033	filePicker, cmd := dialog.NewFilePicker(m.com)
3034	filePicker.SetImageCapabilities(&m.caps)
3035	m.dialog.OpenDialog(filePicker)
3036
3037	return cmd
3038}
3039
3040// openPermissionsDialog opens the permissions dialog for a permission request.
3041func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3042	// Close any existing permissions dialog first.
3043	m.dialog.CloseDialog(dialog.PermissionsID)
3044
3045	// Get diff mode from config.
3046	var opts []dialog.PermissionsOption
3047	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3048		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3049	}
3050
3051	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3052	m.dialog.OpenDialog(permDialog)
3053	return nil
3054}
3055
3056// handlePermissionNotification updates tool items when permission state changes.
3057func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3058	toolItem := m.chat.MessageItem(notification.ToolCallID)
3059	if toolItem == nil {
3060		return
3061	}
3062
3063	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3064		if notification.Granted {
3065			permItem.SetStatus(chat.ToolStatusRunning)
3066		} else {
3067			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3068		}
3069	}
3070}
3071
3072// handleAgentNotification translates domain agent events into desktop
3073// notifications using the UI notification backend.
3074func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3075	switch n.Type {
3076	case notify.TypeAgentFinished:
3077		return m.sendNotification(notification.Notification{
3078			Title:   "Crush is waiting...",
3079			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3080		})
3081	default:
3082		return nil
3083	}
3084}
3085
3086// newSession clears the current session state and prepares for a new session.
3087// The actual session creation happens when the user sends their first message.
3088// Returns a command to reload prompt history.
3089func (m *UI) newSession() tea.Cmd {
3090	if !m.hasSession() {
3091		return nil
3092	}
3093
3094	m.session = nil
3095	m.sessionFiles = nil
3096	m.sessionFileReads = nil
3097	m.setState(uiLanding, uiFocusEditor)
3098	m.textarea.Focus()
3099	m.chat.Blur()
3100	m.chat.ClearMessages()
3101	m.pillsExpanded = false
3102	m.promptQueue = 0
3103	m.pillsView = ""
3104	m.historyReset()
3105	agenttools.ResetCache()
3106	return tea.Batch(
3107		func() tea.Msg {
3108			m.com.App.LSPManager.StopAll(context.Background())
3109			return nil
3110		},
3111		m.loadPromptHistory(),
3112	)
3113}
3114
3115// handlePasteMsg handles a paste message.
3116func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3117	if m.dialog.HasDialogs() {
3118		return m.handleDialogMsg(msg)
3119	}
3120
3121	if m.focus != uiFocusEditor {
3122		return nil
3123	}
3124
3125	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3126		return func() tea.Msg {
3127			content := []byte(msg.Content)
3128			if int64(len(content)) > common.MaxAttachmentSize {
3129				return util.ReportWarn("Paste is too big (>5mb)")
3130			}
3131			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3132			mimeBufferSize := min(512, len(content))
3133			mimeType := http.DetectContentType(content[:mimeBufferSize])
3134			return message.Attachment{
3135				FileName: name,
3136				FilePath: name,
3137				MimeType: mimeType,
3138				Content:  content,
3139			}
3140		}
3141	}
3142
3143	// Attempt to parse pasted content as file paths. If possible to parse,
3144	// all files exist and are valid, add as attachments.
3145	// Otherwise, paste as text.
3146	paths := fsext.ParsePastedFiles(msg.Content)
3147	allExistsAndValid := func() bool {
3148		if len(paths) == 0 {
3149			return false
3150		}
3151		for _, path := range paths {
3152			if _, err := os.Stat(path); os.IsNotExist(err) {
3153				return false
3154			}
3155
3156			lowerPath := strings.ToLower(path)
3157			isValid := false
3158			for _, ext := range common.AllowedImageTypes {
3159				if strings.HasSuffix(lowerPath, ext) {
3160					isValid = true
3161					break
3162				}
3163			}
3164			if !isValid {
3165				return false
3166			}
3167		}
3168		return true
3169	}
3170	if !allExistsAndValid() {
3171		var cmd tea.Cmd
3172		m.textarea, cmd = m.textarea.Update(msg)
3173		return cmd
3174	}
3175
3176	var cmds []tea.Cmd
3177	for _, path := range paths {
3178		cmds = append(cmds, m.handleFilePathPaste(path))
3179	}
3180	return tea.Batch(cmds...)
3181}
3182
3183// handleFilePathPaste handles a pasted file path.
3184func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3185	return func() tea.Msg {
3186		fileInfo, err := os.Stat(path)
3187		if err != nil {
3188			return util.ReportError(err)
3189		}
3190		if fileInfo.IsDir() {
3191			return util.ReportWarn("Cannot attach a directory")
3192		}
3193		if fileInfo.Size() > common.MaxAttachmentSize {
3194			return util.ReportWarn("File is too big (>5mb)")
3195		}
3196
3197		content, err := os.ReadFile(path)
3198		if err != nil {
3199			return util.ReportError(err)
3200		}
3201
3202		mimeBufferSize := min(512, len(content))
3203		mimeType := http.DetectContentType(content[:mimeBufferSize])
3204		fileName := filepath.Base(path)
3205		return message.Attachment{
3206			FilePath: path,
3207			FileName: fileName,
3208			MimeType: mimeType,
3209			Content:  content,
3210		}
3211	}
3212}
3213
3214// pasteImageFromClipboard reads image data from the system clipboard and
3215// creates an attachment. If no image data is found, it falls back to
3216// interpreting clipboard text as a file path.
3217func (m *UI) pasteImageFromClipboard() tea.Msg {
3218	imageData, err := readClipboard(clipboardFormatImage)
3219	if int64(len(imageData)) > common.MaxAttachmentSize {
3220		return util.InfoMsg{
3221			Type: util.InfoTypeError,
3222			Msg:  "File too large, max 5MB",
3223		}
3224	}
3225	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3226	if err == nil {
3227		return message.Attachment{
3228			FilePath: name,
3229			FileName: name,
3230			MimeType: mimeOf(imageData),
3231			Content:  imageData,
3232		}
3233	}
3234
3235	textData, textErr := readClipboard(clipboardFormatText)
3236	if textErr != nil || len(textData) == 0 {
3237		return nil // Clipboard is empty or does not contain an image
3238	}
3239
3240	path := strings.TrimSpace(string(textData))
3241	path = strings.ReplaceAll(path, "\\ ", " ")
3242	if _, statErr := os.Stat(path); statErr != nil {
3243		return nil // Clipboard does not contain an image or valid file path
3244	}
3245
3246	lowerPath := strings.ToLower(path)
3247	isAllowed := false
3248	for _, ext := range common.AllowedImageTypes {
3249		if strings.HasSuffix(lowerPath, ext) {
3250			isAllowed = true
3251			break
3252		}
3253	}
3254	if !isAllowed {
3255		return util.NewInfoMsg("File type is not a supported image format")
3256	}
3257
3258	fileInfo, statErr := os.Stat(path)
3259	if statErr != nil {
3260		return util.InfoMsg{
3261			Type: util.InfoTypeError,
3262			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3263		}
3264	}
3265	if fileInfo.Size() > common.MaxAttachmentSize {
3266		return util.InfoMsg{
3267			Type: util.InfoTypeError,
3268			Msg:  "File too large, max 5MB",
3269		}
3270	}
3271
3272	content, readErr := os.ReadFile(path)
3273	if readErr != nil {
3274		return util.InfoMsg{
3275			Type: util.InfoTypeError,
3276			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3277		}
3278	}
3279
3280	return message.Attachment{
3281		FilePath: path,
3282		FileName: filepath.Base(path),
3283		MimeType: mimeOf(content),
3284		Content:  content,
3285	}
3286}
3287
3288var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3289
3290func (m *UI) pasteIdx() int {
3291	result := 0
3292	for _, at := range m.attachments.List() {
3293		found := pasteRE.FindStringSubmatch(at.FileName)
3294		if len(found) == 0 {
3295			continue
3296		}
3297		idx, err := strconv.Atoi(found[1])
3298		if err == nil {
3299			result = max(result, idx)
3300		}
3301	}
3302	return result + 1
3303}
3304
3305// drawSessionDetails draws the session details in compact mode.
3306func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3307	if m.session == nil {
3308		return
3309	}
3310
3311	s := m.com.Styles
3312
3313	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3314	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3315
3316	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3317	blocks := []string{
3318		title,
3319		"",
3320		m.modelInfo(width),
3321		"",
3322	}
3323
3324	detailsHeader := lipgloss.JoinVertical(
3325		lipgloss.Left,
3326		blocks...,
3327	)
3328
3329	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3330
3331	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3332
3333	const maxSectionWidth = 50
3334	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3335	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3336
3337	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3338	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3339	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3340	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3341	uv.NewStyledString(
3342		s.CompactDetails.View.
3343			Width(area.Dx()).
3344			Render(
3345				lipgloss.JoinVertical(
3346					lipgloss.Left,
3347					detailsHeader,
3348					sections,
3349					version,
3350				),
3351			),
3352	).Draw(scr, area)
3353}
3354
3355func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3356	load := func() tea.Msg {
3357		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3358		if err != nil {
3359			// TODO: make this better
3360			return util.ReportError(err)()
3361		}
3362
3363		if prompt == "" {
3364			return nil
3365		}
3366		return sendMessageMsg{
3367			Content: prompt,
3368		}
3369	}
3370
3371	var cmds []tea.Cmd
3372	if cmd := m.dialog.StartLoading(); cmd != nil {
3373		cmds = append(cmds, cmd)
3374	}
3375	cmds = append(cmds, load, func() tea.Msg {
3376		return closeDialogMsg{}
3377	})
3378
3379	return tea.Sequence(cmds...)
3380}
3381
3382func (m *UI) handleStateChanged() tea.Cmd {
3383	return func() tea.Msg {
3384		m.com.App.UpdateAgentModel(context.Background())
3385		return mcpStateChangedMsg{
3386			states: mcp.GetStates(),
3387		}
3388	}
3389}
3390
3391func handleMCPPromptsEvent(name string) tea.Cmd {
3392	return func() tea.Msg {
3393		mcp.RefreshPrompts(context.Background(), name)
3394		return nil
3395	}
3396}
3397
3398func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3399	return func() tea.Msg {
3400		mcp.RefreshTools(
3401			context.Background(),
3402			cfg,
3403			name,
3404		)
3405		return nil
3406	}
3407}
3408
3409func handleMCPResourcesEvent(name string) tea.Cmd {
3410	return func() tea.Msg {
3411		mcp.RefreshResources(context.Background(), name)
3412		return nil
3413	}
3414}
3415
3416func (m *UI) copyChatHighlight() tea.Cmd {
3417	text := m.chat.HighlightContent()
3418	return common.CopyToClipboardWithCallback(
3419		text,
3420		"Selected text copied to clipboard",
3421		func() tea.Msg {
3422			m.chat.ClearMouse()
3423			return nil
3424		},
3425	)
3426}
3427
3428// renderLogo renders the Crush logo with the given styles and dimensions.
3429func renderLogo(t *styles.Styles, compact bool, width int) string {
3430	return logo.Render(t, version.Version, compact, logo.Opts{
3431		FieldColor:   t.LogoFieldColor,
3432		TitleColorA:  t.LogoTitleColorA,
3433		TitleColorB:  t.LogoTitleColorB,
3434		CharmColor:   t.LogoCharmColor,
3435		VersionColor: t.LogoVersionColor,
3436		Width:        width,
3437	})
3438}