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.ActionQuit:
1332		cmds = append(cmds, tea.Quit)
1333	case dialog.ActionInitializeProject:
1334		if m.isAgentBusy() {
1335			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1336			break
1337		}
1338		cmds = append(cmds, m.initializeProject())
1339		m.dialog.CloseDialog(dialog.CommandsID)
1340
1341	case dialog.ActionSelectModel:
1342		if m.isAgentBusy() {
1343			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1344			break
1345		}
1346
1347		cfg := m.com.Config()
1348		if cfg == nil {
1349			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1350			break
1351		}
1352
1353		var (
1354			providerID   = msg.Model.Provider
1355			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1356			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1357		)
1358
1359		// Attempt to import GitHub Copilot tokens from VSCode if available.
1360		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1361			m.com.Store().ImportCopilot()
1362		}
1363
1364		if !isConfigured() || msg.ReAuthenticate {
1365			m.dialog.CloseDialog(dialog.ModelsID)
1366			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1367				cmds = append(cmds, cmd)
1368			}
1369			break
1370		}
1371
1372		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1373			cmds = append(cmds, util.ReportError(err))
1374		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1375			// Ensure small model is set is unset.
1376			smallModel := m.com.App.GetDefaultSmallModel(providerID)
1377			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1378				cmds = append(cmds, util.ReportError(err))
1379			}
1380		}
1381
1382		cmds = append(cmds, func() tea.Msg {
1383			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1384				return util.ReportError(err)
1385			}
1386
1387			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1388
1389			return util.NewInfoMsg(modelMsg)
1390		})
1391
1392		m.dialog.CloseDialog(dialog.APIKeyInputID)
1393		m.dialog.CloseDialog(dialog.OAuthID)
1394		m.dialog.CloseDialog(dialog.ModelsID)
1395
1396		if isOnboarding {
1397			m.setState(uiLanding, uiFocusEditor)
1398			m.com.Config().SetupAgents()
1399			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1400				cmds = append(cmds, util.ReportError(err))
1401			}
1402		}
1403	case dialog.ActionSelectReasoningEffort:
1404		if m.isAgentBusy() {
1405			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1406			break
1407		}
1408
1409		cfg := m.com.Config()
1410		if cfg == nil {
1411			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1412			break
1413		}
1414
1415		agentCfg, ok := cfg.Agents[config.AgentCoder]
1416		if !ok {
1417			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1418			break
1419		}
1420
1421		currentModel := cfg.Models[agentCfg.Model]
1422		currentModel.ReasoningEffort = msg.Effort
1423		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1424			cmds = append(cmds, util.ReportError(err))
1425			break
1426		}
1427
1428		cmds = append(cmds, func() tea.Msg {
1429			m.com.App.UpdateAgentModel(context.TODO())
1430			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1431		})
1432		m.dialog.CloseDialog(dialog.ReasoningID)
1433	case dialog.ActionPermissionResponse:
1434		m.dialog.CloseDialog(dialog.PermissionsID)
1435		switch msg.Action {
1436		case dialog.PermissionAllow:
1437			m.com.App.Permissions.Grant(msg.Permission)
1438		case dialog.PermissionAllowForSession:
1439			m.com.App.Permissions.GrantPersistent(msg.Permission)
1440		case dialog.PermissionDeny:
1441			m.com.App.Permissions.Deny(msg.Permission)
1442		}
1443
1444	case dialog.ActionFilePickerSelected:
1445		cmds = append(cmds, tea.Sequence(
1446			msg.Cmd(),
1447			func() tea.Msg {
1448				m.dialog.CloseDialog(dialog.FilePickerID)
1449				return nil
1450			},
1451			func() tea.Msg {
1452				fimage.ResetCache()
1453				return nil
1454			},
1455		))
1456
1457	case dialog.ActionRunCustomCommand:
1458		if len(msg.Arguments) > 0 && msg.Args == nil {
1459			m.dialog.CloseFrontDialog()
1460			argsDialog := dialog.NewArguments(
1461				m.com,
1462				"Custom Command Arguments",
1463				"",
1464				msg.Arguments,
1465				msg, // Pass the action as the result
1466			)
1467			m.dialog.OpenDialog(argsDialog)
1468			break
1469		}
1470		content := msg.Content
1471		if msg.Args != nil {
1472			content = substituteArgs(content, msg.Args)
1473		}
1474		cmds = append(cmds, m.sendMessage(content))
1475		m.dialog.CloseFrontDialog()
1476	case dialog.ActionRunMCPPrompt:
1477		if len(msg.Arguments) > 0 && msg.Args == nil {
1478			m.dialog.CloseFrontDialog()
1479			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1480			argsDialog := dialog.NewArguments(
1481				m.com,
1482				title,
1483				msg.Description,
1484				msg.Arguments,
1485				msg, // Pass the action as the result
1486			)
1487			m.dialog.OpenDialog(argsDialog)
1488			break
1489		}
1490		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1491	default:
1492		cmds = append(cmds, util.CmdHandler(msg))
1493	}
1494
1495	return tea.Batch(cmds...)
1496}
1497
1498// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1499func substituteArgs(content string, args map[string]string) string {
1500	for name, value := range args {
1501		placeholder := "$" + name
1502		content = strings.ReplaceAll(content, placeholder, value)
1503	}
1504	return content
1505}
1506
1507func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1508	var (
1509		dlg dialog.Dialog
1510		cmd tea.Cmd
1511
1512		isOnboarding = m.state == uiOnboarding
1513	)
1514
1515	switch provider.ID {
1516	case "hyper":
1517		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1518	case catwalk.InferenceProviderCopilot:
1519		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1520	default:
1521		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1522	}
1523
1524	if m.dialog.ContainsDialog(dlg.ID()) {
1525		m.dialog.BringToFront(dlg.ID())
1526		return nil
1527	}
1528
1529	m.dialog.OpenDialog(dlg)
1530	return cmd
1531}
1532
1533func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1534	var cmds []tea.Cmd
1535
1536	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1537		switch {
1538		case key.Matches(msg, m.keyMap.Help):
1539			m.status.ToggleHelp()
1540			m.updateLayoutAndSize()
1541			return true
1542		case key.Matches(msg, m.keyMap.Commands):
1543			if cmd := m.openCommandsDialog(); cmd != nil {
1544				cmds = append(cmds, cmd)
1545			}
1546			return true
1547		case key.Matches(msg, m.keyMap.Models):
1548			if cmd := m.openModelsDialog(); cmd != nil {
1549				cmds = append(cmds, cmd)
1550			}
1551			return true
1552		case key.Matches(msg, m.keyMap.Sessions):
1553			if cmd := m.openSessionsDialog(); cmd != nil {
1554				cmds = append(cmds, cmd)
1555			}
1556			return true
1557		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1558			m.detailsOpen = !m.detailsOpen
1559			m.updateLayoutAndSize()
1560			return true
1561		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1562			if m.state == uiChat && m.hasSession() {
1563				if cmd := m.togglePillsExpanded(); cmd != nil {
1564					cmds = append(cmds, cmd)
1565				}
1566				return true
1567			}
1568		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1569			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1570				if cmd := m.switchPillSection(-1); cmd != nil {
1571					cmds = append(cmds, cmd)
1572				}
1573				return true
1574			}
1575		case key.Matches(msg, m.keyMap.Chat.PillRight):
1576			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1577				if cmd := m.switchPillSection(1); cmd != nil {
1578					cmds = append(cmds, cmd)
1579				}
1580				return true
1581			}
1582		case key.Matches(msg, m.keyMap.Suspend):
1583			if m.isAgentBusy() {
1584				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1585				return true
1586			}
1587			cmds = append(cmds, tea.Suspend)
1588			return true
1589		}
1590		return false
1591	}
1592
1593	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1594		// Always handle quit keys first
1595		if cmd := m.openQuitDialog(); cmd != nil {
1596			cmds = append(cmds, cmd)
1597		}
1598
1599		return tea.Batch(cmds...)
1600	}
1601
1602	// Route all messages to dialog if one is open.
1603	if m.dialog.HasDialogs() {
1604		return m.handleDialogMsg(msg)
1605	}
1606
1607	// Handle cancel key when agent is busy.
1608	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1609		if m.isAgentBusy() {
1610			if cmd := m.cancelAgent(); cmd != nil {
1611				cmds = append(cmds, cmd)
1612			}
1613			return tea.Batch(cmds...)
1614		}
1615	}
1616
1617	switch m.state {
1618	case uiOnboarding:
1619		return tea.Batch(cmds...)
1620	case uiInitialize:
1621		cmds = append(cmds, m.updateInitializeView(msg)...)
1622		return tea.Batch(cmds...)
1623	case uiChat, uiLanding:
1624		switch m.focus {
1625		case uiFocusEditor:
1626			// Handle completions if open.
1627			if m.completionsOpen {
1628				if msg, ok := m.completions.Update(msg); ok {
1629					switch msg := msg.(type) {
1630					case completions.SelectionMsg[completions.FileCompletionValue]:
1631						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1632						if !msg.KeepOpen {
1633							m.closeCompletions()
1634						}
1635					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1636						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1637						if !msg.KeepOpen {
1638							m.closeCompletions()
1639						}
1640					case completions.ClosedMsg:
1641						m.completionsOpen = false
1642					}
1643					return tea.Batch(cmds...)
1644				}
1645			}
1646
1647			if ok := m.attachments.Update(msg); ok {
1648				return tea.Batch(cmds...)
1649			}
1650
1651			switch {
1652			case key.Matches(msg, m.keyMap.Editor.AddImage):
1653				if cmd := m.openFilesDialog(); cmd != nil {
1654					cmds = append(cmds, cmd)
1655				}
1656
1657			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1658				cmds = append(cmds, m.pasteImageFromClipboard)
1659
1660			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1661				value := m.textarea.Value()
1662				if before, ok := strings.CutSuffix(value, "\\"); ok {
1663					// If the last character is a backslash, remove it and add a newline.
1664					m.textarea.SetValue(before)
1665					break
1666				}
1667
1668				// Otherwise, send the message
1669				m.textarea.Reset()
1670
1671				value = strings.TrimSpace(value)
1672				if value == "exit" || value == "quit" {
1673					return m.openQuitDialog()
1674				}
1675
1676				attachments := m.attachments.List()
1677				m.attachments.Reset()
1678				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1679					return nil
1680				}
1681
1682				m.randomizePlaceholders()
1683				m.historyReset()
1684
1685				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1686			case key.Matches(msg, m.keyMap.Chat.NewSession):
1687				if !m.hasSession() {
1688					break
1689				}
1690				if m.isAgentBusy() {
1691					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1692					break
1693				}
1694				if cmd := m.newSession(); cmd != nil {
1695					cmds = append(cmds, cmd)
1696				}
1697			case key.Matches(msg, m.keyMap.Tab):
1698				if m.state != uiLanding {
1699					m.setState(m.state, uiFocusMain)
1700					m.textarea.Blur()
1701					m.chat.Focus()
1702					m.chat.SetSelected(m.chat.Len() - 1)
1703				}
1704			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1705				if m.isAgentBusy() {
1706					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1707					break
1708				}
1709				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1710			case key.Matches(msg, m.keyMap.Editor.Newline):
1711				m.textarea.InsertRune('\n')
1712				m.closeCompletions()
1713				ta, cmd := m.textarea.Update(msg)
1714				m.textarea = ta
1715				cmds = append(cmds, cmd)
1716			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1717				cmd := m.handleHistoryUp(msg)
1718				if cmd != nil {
1719					cmds = append(cmds, cmd)
1720				}
1721			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1722				cmd := m.handleHistoryDown(msg)
1723				if cmd != nil {
1724					cmds = append(cmds, cmd)
1725				}
1726			case key.Matches(msg, m.keyMap.Editor.Escape):
1727				cmd := m.handleHistoryEscape(msg)
1728				if cmd != nil {
1729					cmds = append(cmds, cmd)
1730				}
1731			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1732				if cmd := m.openCommandsDialog(); cmd != nil {
1733					cmds = append(cmds, cmd)
1734				}
1735			default:
1736				if handleGlobalKeys(msg) {
1737					// Handle global keys first before passing to textarea.
1738					break
1739				}
1740
1741				// Check for @ trigger before passing to textarea.
1742				curValue := m.textarea.Value()
1743				curIdx := len(curValue)
1744
1745				// Trigger completions on @.
1746				if msg.String() == "@" && !m.completionsOpen {
1747					// Only show if beginning of prompt or after whitespace.
1748					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1749						m.completionsOpen = true
1750						m.completionsQuery = ""
1751						m.completionsStartIndex = curIdx
1752						m.completionsPositionStart = m.completionsPosition()
1753						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1754						cmds = append(cmds, m.completions.Open(depth, limit))
1755					}
1756				}
1757
1758				// remove the details if they are open when user starts typing
1759				if m.detailsOpen {
1760					m.detailsOpen = false
1761					m.updateLayoutAndSize()
1762				}
1763
1764				ta, cmd := m.textarea.Update(msg)
1765				m.textarea = ta
1766				cmds = append(cmds, cmd)
1767
1768				// Any text modification becomes the current draft.
1769				m.updateHistoryDraft(curValue)
1770
1771				// After updating textarea, check if we need to filter completions.
1772				// Skip filtering on the initial @ keystroke since items are loading async.
1773				if m.completionsOpen && msg.String() != "@" {
1774					newValue := m.textarea.Value()
1775					newIdx := len(newValue)
1776
1777					// Close completions if cursor moved before start.
1778					if newIdx <= m.completionsStartIndex {
1779						m.closeCompletions()
1780					} else if msg.String() == "space" {
1781						// Close on space.
1782						m.closeCompletions()
1783					} else {
1784						// Extract current word and filter.
1785						word := m.textareaWord()
1786						if strings.HasPrefix(word, "@") {
1787							m.completionsQuery = word[1:]
1788							m.completions.Filter(m.completionsQuery)
1789						} else if m.completionsOpen {
1790							m.closeCompletions()
1791						}
1792					}
1793				}
1794			}
1795		case uiFocusMain:
1796			switch {
1797			case key.Matches(msg, m.keyMap.Tab):
1798				m.focus = uiFocusEditor
1799				cmds = append(cmds, m.textarea.Focus())
1800				m.chat.Blur()
1801			case key.Matches(msg, m.keyMap.Chat.NewSession):
1802				if !m.hasSession() {
1803					break
1804				}
1805				if m.isAgentBusy() {
1806					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1807					break
1808				}
1809				m.focus = uiFocusEditor
1810				if cmd := m.newSession(); cmd != nil {
1811					cmds = append(cmds, cmd)
1812				}
1813			case key.Matches(msg, m.keyMap.Chat.Expand):
1814				m.chat.ToggleExpandedSelectedItem()
1815			case key.Matches(msg, m.keyMap.Chat.Up):
1816				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1817					cmds = append(cmds, cmd)
1818				}
1819				if !m.chat.SelectedItemInView() {
1820					m.chat.SelectPrev()
1821					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1822						cmds = append(cmds, cmd)
1823					}
1824				}
1825			case key.Matches(msg, m.keyMap.Chat.Down):
1826				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1827					cmds = append(cmds, cmd)
1828				}
1829				if !m.chat.SelectedItemInView() {
1830					m.chat.SelectNext()
1831					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1832						cmds = append(cmds, cmd)
1833					}
1834				}
1835			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1836				m.chat.SelectPrev()
1837				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1838					cmds = append(cmds, cmd)
1839				}
1840			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1841				m.chat.SelectNext()
1842				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1843					cmds = append(cmds, cmd)
1844				}
1845			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1846				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1847					cmds = append(cmds, cmd)
1848				}
1849				m.chat.SelectFirstInView()
1850			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1851				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1852					cmds = append(cmds, cmd)
1853				}
1854				m.chat.SelectLastInView()
1855			case key.Matches(msg, m.keyMap.Chat.PageUp):
1856				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1857					cmds = append(cmds, cmd)
1858				}
1859				m.chat.SelectFirstInView()
1860			case key.Matches(msg, m.keyMap.Chat.PageDown):
1861				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1862					cmds = append(cmds, cmd)
1863				}
1864				m.chat.SelectLastInView()
1865			case key.Matches(msg, m.keyMap.Chat.Home):
1866				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1867					cmds = append(cmds, cmd)
1868				}
1869				m.chat.SelectFirst()
1870			case key.Matches(msg, m.keyMap.Chat.End):
1871				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1872					cmds = append(cmds, cmd)
1873				}
1874				m.chat.SelectLast()
1875			default:
1876				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1877					cmds = append(cmds, cmd)
1878				} else {
1879					handleGlobalKeys(msg)
1880				}
1881			}
1882		default:
1883			handleGlobalKeys(msg)
1884		}
1885	default:
1886		handleGlobalKeys(msg)
1887	}
1888
1889	return tea.Sequence(cmds...)
1890}
1891
1892// drawHeader draws the header section of the UI.
1893func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1894	m.header.drawHeader(
1895		scr,
1896		area,
1897		m.session,
1898		m.isCompact,
1899		m.detailsOpen,
1900		area.Dx(),
1901	)
1902}
1903
1904// Draw implements [uv.Drawable] and draws the UI model.
1905func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1906	layout := m.generateLayout(area.Dx(), area.Dy())
1907
1908	if m.layout != layout {
1909		m.layout = layout
1910		m.updateSize()
1911	}
1912
1913	// Clear the screen first
1914	screen.Clear(scr)
1915
1916	switch m.state {
1917	case uiOnboarding:
1918		m.drawHeader(scr, layout.header)
1919
1920		// NOTE: Onboarding flow will be rendered as dialogs below, but
1921		// positioned at the bottom left of the screen.
1922
1923	case uiInitialize:
1924		m.drawHeader(scr, layout.header)
1925
1926		main := uv.NewStyledString(m.initializeView())
1927		main.Draw(scr, layout.main)
1928
1929	case uiLanding:
1930		m.drawHeader(scr, layout.header)
1931		main := uv.NewStyledString(m.landingView())
1932		main.Draw(scr, layout.main)
1933
1934		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1935		editor.Draw(scr, layout.editor)
1936
1937	case uiChat:
1938		if m.isCompact {
1939			m.drawHeader(scr, layout.header)
1940		} else {
1941			m.drawSidebar(scr, layout.sidebar)
1942		}
1943
1944		m.chat.Draw(scr, layout.main)
1945		if layout.pills.Dy() > 0 && m.pillsView != "" {
1946			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1947		}
1948
1949		editorWidth := scr.Bounds().Dx()
1950		if !m.isCompact {
1951			editorWidth -= layout.sidebar.Dx()
1952		}
1953		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1954		editor.Draw(scr, layout.editor)
1955
1956		// Draw details overlay in compact mode when open
1957		if m.isCompact && m.detailsOpen {
1958			m.drawSessionDetails(scr, layout.sessionDetails)
1959		}
1960	}
1961
1962	isOnboarding := m.state == uiOnboarding
1963
1964	// Add status and help layer
1965	m.status.SetHideHelp(isOnboarding)
1966	m.status.Draw(scr, layout.status)
1967
1968	// Draw completions popup if open
1969	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1970		w, h := m.completions.Size()
1971		x := m.completionsPositionStart.X
1972		y := m.completionsPositionStart.Y - h
1973
1974		screenW := area.Dx()
1975		if x+w > screenW {
1976			x = screenW - w
1977		}
1978		x = max(0, x)
1979		y = max(0, y+1) // Offset for attachments row
1980
1981		completionsView := uv.NewStyledString(m.completions.Render())
1982		completionsView.Draw(scr, image.Rectangle{
1983			Min: image.Pt(x, y),
1984			Max: image.Pt(x+w, y+h),
1985		})
1986	}
1987
1988	// Debugging rendering (visually see when the tui rerenders)
1989	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1990		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1991		debug := uv.NewStyledString(debugView.String())
1992		debug.Draw(scr, image.Rectangle{
1993			Min: image.Pt(4, 1),
1994			Max: image.Pt(8, 3),
1995		})
1996	}
1997
1998	// This needs to come last to overlay on top of everything. We always pass
1999	// the full screen bounds because the dialogs will position themselves
2000	// accordingly.
2001	if m.dialog.HasDialogs() {
2002		return m.dialog.Draw(scr, scr.Bounds())
2003	}
2004
2005	switch m.focus {
2006	case uiFocusEditor:
2007		if m.layout.editor.Dy() <= 0 {
2008			// Don't show cursor if editor is not visible
2009			return nil
2010		}
2011		if m.detailsOpen && m.isCompact {
2012			// Don't show cursor if details overlay is open
2013			return nil
2014		}
2015
2016		if m.textarea.Focused() {
2017			cur := m.textarea.Cursor()
2018			cur.X++                            // Adjust for app margins
2019			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2020			return cur
2021		}
2022	}
2023	return nil
2024}
2025
2026// View renders the UI model's view.
2027func (m *UI) View() tea.View {
2028	var v tea.View
2029	v.AltScreen = true
2030	if !m.isTransparent {
2031		v.BackgroundColor = m.com.Styles.Background
2032	}
2033	v.MouseMode = tea.MouseModeCellMotion
2034	v.ReportFocus = m.caps.ReportFocusEvents
2035	v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2036
2037	canvas := uv.NewScreenBuffer(m.width, m.height)
2038	v.Cursor = m.Draw(canvas, canvas.Bounds())
2039
2040	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2041	contentLines := strings.Split(content, "\n")
2042	for i, line := range contentLines {
2043		// Trim trailing spaces for concise rendering
2044		contentLines[i] = strings.TrimRight(line, " ")
2045	}
2046
2047	content = strings.Join(contentLines, "\n")
2048
2049	v.Content = content
2050	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2051		// HACK: use a random percentage to prevent ghostty from hiding it
2052		// after a timeout.
2053		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2054	}
2055
2056	return v
2057}
2058
2059// ShortHelp implements [help.KeyMap].
2060func (m *UI) ShortHelp() []key.Binding {
2061	var binds []key.Binding
2062	k := &m.keyMap
2063	tab := k.Tab
2064	commands := k.Commands
2065	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2066		commands.SetHelp("/ or ctrl+p", "commands")
2067	}
2068
2069	switch m.state {
2070	case uiInitialize:
2071		binds = append(binds, k.Quit)
2072	case uiChat:
2073		// Show cancel binding if agent is busy.
2074		if m.isAgentBusy() {
2075			cancelBinding := k.Chat.Cancel
2076			if m.isCanceling {
2077				cancelBinding.SetHelp("esc", "press again to cancel")
2078			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2079				cancelBinding.SetHelp("esc", "clear queue")
2080			}
2081			binds = append(binds, cancelBinding)
2082		}
2083
2084		if m.focus == uiFocusEditor {
2085			tab.SetHelp("tab", "focus chat")
2086		} else {
2087			tab.SetHelp("tab", "focus editor")
2088		}
2089
2090		binds = append(binds,
2091			tab,
2092			commands,
2093			k.Models,
2094		)
2095
2096		switch m.focus {
2097		case uiFocusEditor:
2098			binds = append(binds,
2099				k.Editor.Newline,
2100			)
2101		case uiFocusMain:
2102			binds = append(binds,
2103				k.Chat.UpDown,
2104				k.Chat.UpDownOneItem,
2105				k.Chat.PageUp,
2106				k.Chat.PageDown,
2107				k.Chat.Copy,
2108			)
2109			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2110				binds = append(binds, k.Chat.PillLeft)
2111			}
2112		}
2113	default:
2114		// TODO: other states
2115		// if m.session == nil {
2116		// no session selected
2117		binds = append(binds,
2118			commands,
2119			k.Models,
2120			k.Editor.Newline,
2121		)
2122	}
2123
2124	binds = append(binds,
2125		k.Quit,
2126		k.Help,
2127	)
2128
2129	return binds
2130}
2131
2132// FullHelp implements [help.KeyMap].
2133func (m *UI) FullHelp() [][]key.Binding {
2134	var binds [][]key.Binding
2135	k := &m.keyMap
2136	help := k.Help
2137	help.SetHelp("ctrl+g", "less")
2138	hasAttachments := len(m.attachments.List()) > 0
2139	hasSession := m.hasSession()
2140	commands := k.Commands
2141	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2142		commands.SetHelp("/ or ctrl+p", "commands")
2143	}
2144
2145	switch m.state {
2146	case uiInitialize:
2147		binds = append(binds,
2148			[]key.Binding{
2149				k.Quit,
2150			})
2151	case uiChat:
2152		// Show cancel binding if agent is busy.
2153		if m.isAgentBusy() {
2154			cancelBinding := k.Chat.Cancel
2155			if m.isCanceling {
2156				cancelBinding.SetHelp("esc", "press again to cancel")
2157			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2158				cancelBinding.SetHelp("esc", "clear queue")
2159			}
2160			binds = append(binds, []key.Binding{cancelBinding})
2161		}
2162
2163		mainBinds := []key.Binding{}
2164		tab := k.Tab
2165		if m.focus == uiFocusEditor {
2166			tab.SetHelp("tab", "focus chat")
2167		} else {
2168			tab.SetHelp("tab", "focus editor")
2169		}
2170
2171		mainBinds = append(mainBinds,
2172			tab,
2173			commands,
2174			k.Models,
2175			k.Sessions,
2176		)
2177		if hasSession {
2178			mainBinds = append(mainBinds, k.Chat.NewSession)
2179		}
2180
2181		binds = append(binds, mainBinds)
2182
2183		switch m.focus {
2184		case uiFocusEditor:
2185			binds = append(binds,
2186				[]key.Binding{
2187					k.Editor.Newline,
2188					k.Editor.AddImage,
2189					k.Editor.PasteImage,
2190					k.Editor.MentionFile,
2191					k.Editor.OpenEditor,
2192				},
2193			)
2194			if hasAttachments {
2195				binds = append(binds,
2196					[]key.Binding{
2197						k.Editor.AttachmentDeleteMode,
2198						k.Editor.DeleteAllAttachments,
2199						k.Editor.Escape,
2200					},
2201				)
2202			}
2203		case uiFocusMain:
2204			binds = append(binds,
2205				[]key.Binding{
2206					k.Chat.UpDown,
2207					k.Chat.UpDownOneItem,
2208					k.Chat.PageUp,
2209					k.Chat.PageDown,
2210				},
2211				[]key.Binding{
2212					k.Chat.HalfPageUp,
2213					k.Chat.HalfPageDown,
2214					k.Chat.Home,
2215					k.Chat.End,
2216				},
2217				[]key.Binding{
2218					k.Chat.Copy,
2219					k.Chat.ClearHighlight,
2220				},
2221			)
2222			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2223				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2224			}
2225		}
2226	default:
2227		if m.session == nil {
2228			// no session selected
2229			binds = append(binds,
2230				[]key.Binding{
2231					commands,
2232					k.Models,
2233					k.Sessions,
2234				},
2235				[]key.Binding{
2236					k.Editor.Newline,
2237					k.Editor.AddImage,
2238					k.Editor.PasteImage,
2239					k.Editor.MentionFile,
2240					k.Editor.OpenEditor,
2241				},
2242			)
2243			if hasAttachments {
2244				binds = append(binds,
2245					[]key.Binding{
2246						k.Editor.AttachmentDeleteMode,
2247						k.Editor.DeleteAllAttachments,
2248						k.Editor.Escape,
2249					},
2250				)
2251			}
2252			binds = append(binds,
2253				[]key.Binding{
2254					help,
2255				},
2256			)
2257		}
2258	}
2259
2260	binds = append(binds,
2261		[]key.Binding{
2262			help,
2263			k.Quit,
2264		},
2265	)
2266
2267	return binds
2268}
2269
2270// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2271func (m *UI) toggleCompactMode() tea.Cmd {
2272	m.forceCompactMode = !m.forceCompactMode
2273
2274	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2275	if err != nil {
2276		return util.ReportError(err)
2277	}
2278
2279	m.updateLayoutAndSize()
2280
2281	return nil
2282}
2283
2284// updateLayoutAndSize updates the layout and sizes of UI components.
2285func (m *UI) updateLayoutAndSize() {
2286	// Determine if we should be in compact mode
2287	if m.state == uiChat {
2288		if m.forceCompactMode {
2289			m.isCompact = true
2290			return
2291		}
2292		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2293			m.isCompact = true
2294		} else {
2295			m.isCompact = false
2296		}
2297	}
2298
2299	m.layout = m.generateLayout(m.width, m.height)
2300	m.updateSize()
2301}
2302
2303// updateSize updates the sizes of UI components based on the current layout.
2304func (m *UI) updateSize() {
2305	// Set status width
2306	m.status.SetWidth(m.layout.status.Dx())
2307
2308	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2309	m.textarea.SetWidth(m.layout.editor.Dx())
2310	// TODO: Abstract the textarea and attachments into a single editor
2311	// component so we don't have to manually account for the attachments
2312	// height here.
2313	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2314	m.renderPills()
2315
2316	// Handle different app states
2317	switch m.state {
2318	case uiChat:
2319		if !m.isCompact {
2320			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2321		}
2322	}
2323}
2324
2325// generateLayout calculates the layout rectangles for all UI components based
2326// on the current UI state and terminal dimensions.
2327func (m *UI) generateLayout(w, h int) uiLayout {
2328	// The screen area we're working with
2329	area := image.Rect(0, 0, w, h)
2330
2331	// The help height
2332	helpHeight := 1
2333	// The editor height
2334	editorHeight := 5
2335	// The sidebar width
2336	sidebarWidth := 30
2337	// The header height
2338	const landingHeaderHeight = 4
2339
2340	var helpKeyMap help.KeyMap = m
2341	if m.status != nil && m.status.ShowingAll() {
2342		for _, row := range helpKeyMap.FullHelp() {
2343			helpHeight = max(helpHeight, len(row))
2344		}
2345	}
2346
2347	// Add app margins
2348	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2349	appRect.Min.Y += 1
2350	appRect.Max.Y -= 1
2351	helpRect.Min.Y -= 1
2352	appRect.Min.X += 1
2353	appRect.Max.X -= 1
2354
2355	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2356		// extra padding on left and right for these states
2357		appRect.Min.X += 1
2358		appRect.Max.X -= 1
2359	}
2360
2361	uiLayout := uiLayout{
2362		area:   area,
2363		status: helpRect,
2364	}
2365
2366	// Handle different app states
2367	switch m.state {
2368	case uiOnboarding, uiInitialize:
2369		// Layout
2370		//
2371		// header
2372		// ------
2373		// main
2374		// ------
2375		// help
2376
2377		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2378		uiLayout.header = headerRect
2379		uiLayout.main = mainRect
2380
2381	case uiLanding:
2382		// Layout
2383		//
2384		// header
2385		// ------
2386		// main
2387		// ------
2388		// editor
2389		// ------
2390		// help
2391		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2392		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2393		// Remove extra padding from editor (but keep it for header and main)
2394		editorRect.Min.X -= 1
2395		editorRect.Max.X += 1
2396		uiLayout.header = headerRect
2397		uiLayout.main = mainRect
2398		uiLayout.editor = editorRect
2399
2400	case uiChat:
2401		if m.isCompact {
2402			// Layout
2403			//
2404			// compact-header
2405			// ------
2406			// main
2407			// ------
2408			// editor
2409			// ------
2410			// help
2411			const compactHeaderHeight = 1
2412			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2413			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2414			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2415			uiLayout.sessionDetails = sessionDetailsArea
2416			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2417			// Add one line gap between header and main content
2418			mainRect.Min.Y += 1
2419			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2420			mainRect.Max.X -= 1 // Add padding right
2421			uiLayout.header = headerRect
2422			pillsHeight := m.pillsAreaHeight()
2423			if pillsHeight > 0 {
2424				pillsHeight = min(pillsHeight, mainRect.Dy())
2425				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2426				uiLayout.main = chatRect
2427				uiLayout.pills = pillsRect
2428			} else {
2429				uiLayout.main = mainRect
2430			}
2431			// Add bottom margin to main
2432			uiLayout.main.Max.Y -= 1
2433			uiLayout.editor = editorRect
2434		} else {
2435			// Layout
2436			//
2437			// ------|---
2438			// main  |
2439			// ------| side
2440			// editor|
2441			// ----------
2442			// help
2443
2444			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2445			// Add padding left
2446			sideRect.Min.X += 1
2447			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2448			mainRect.Max.X -= 1 // Add padding right
2449			uiLayout.sidebar = sideRect
2450			pillsHeight := m.pillsAreaHeight()
2451			if pillsHeight > 0 {
2452				pillsHeight = min(pillsHeight, mainRect.Dy())
2453				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2454				uiLayout.main = chatRect
2455				uiLayout.pills = pillsRect
2456			} else {
2457				uiLayout.main = mainRect
2458			}
2459			// Add bottom margin to main
2460			uiLayout.main.Max.Y -= 1
2461			uiLayout.editor = editorRect
2462		}
2463	}
2464
2465	return uiLayout
2466}
2467
2468// uiLayout defines the positioning of UI elements.
2469type uiLayout struct {
2470	// area is the overall available area.
2471	area uv.Rectangle
2472
2473	// header is the header shown in special cases
2474	// e.x when the sidebar is collapsed
2475	// or when in the landing page
2476	// or in init/config
2477	header uv.Rectangle
2478
2479	// main is the area for the main pane. (e.x chat, configure, landing)
2480	main uv.Rectangle
2481
2482	// pills is the area for the pills panel.
2483	pills uv.Rectangle
2484
2485	// editor is the area for the editor pane.
2486	editor uv.Rectangle
2487
2488	// sidebar is the area for the sidebar.
2489	sidebar uv.Rectangle
2490
2491	// status is the area for the status view.
2492	status uv.Rectangle
2493
2494	// session details is the area for the session details overlay in compact mode.
2495	sessionDetails uv.Rectangle
2496}
2497
2498func (m *UI) openEditor(value string) tea.Cmd {
2499	tmpfile, err := os.CreateTemp("", "msg_*.md")
2500	if err != nil {
2501		return util.ReportError(err)
2502	}
2503	defer tmpfile.Close() //nolint:errcheck
2504	if _, err := tmpfile.WriteString(value); err != nil {
2505		return util.ReportError(err)
2506	}
2507	cmd, err := editor.Command(
2508		"crush",
2509		tmpfile.Name(),
2510		editor.AtPosition(
2511			m.textarea.Line()+1,
2512			m.textarea.Column()+1,
2513		),
2514	)
2515	if err != nil {
2516		return util.ReportError(err)
2517	}
2518	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2519		if err != nil {
2520			return util.ReportError(err)
2521		}
2522		content, err := os.ReadFile(tmpfile.Name())
2523		if err != nil {
2524			return util.ReportError(err)
2525		}
2526		if len(content) == 0 {
2527			return util.ReportWarn("Message is empty")
2528		}
2529		os.Remove(tmpfile.Name())
2530		return openEditorMsg{
2531			Text: strings.TrimSpace(string(content)),
2532		}
2533	})
2534}
2535
2536// setEditorPrompt configures the textarea prompt function based on whether
2537// yolo mode is enabled.
2538func (m *UI) setEditorPrompt(yolo bool) {
2539	if yolo {
2540		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2541		return
2542	}
2543	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2544}
2545
2546// normalPromptFunc returns the normal editor prompt style ("  > " on first
2547// line, "::: " on subsequent lines).
2548func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2549	t := m.com.Styles
2550	if info.LineNumber == 0 {
2551		if info.Focused {
2552			return "  > "
2553		}
2554		return "::: "
2555	}
2556	if info.Focused {
2557		return t.EditorPromptNormalFocused.Render()
2558	}
2559	return t.EditorPromptNormalBlurred.Render()
2560}
2561
2562// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2563// and colored dots.
2564func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2565	t := m.com.Styles
2566	if info.LineNumber == 0 {
2567		if info.Focused {
2568			return t.EditorPromptYoloIconFocused.Render()
2569		} else {
2570			return t.EditorPromptYoloIconBlurred.Render()
2571		}
2572	}
2573	if info.Focused {
2574		return t.EditorPromptYoloDotsFocused.Render()
2575	}
2576	return t.EditorPromptYoloDotsBlurred.Render()
2577}
2578
2579// closeCompletions closes the completions popup and resets state.
2580func (m *UI) closeCompletions() {
2581	m.completionsOpen = false
2582	m.completionsQuery = ""
2583	m.completionsStartIndex = 0
2584	m.completions.Close()
2585}
2586
2587// insertCompletionText replaces the @query in the textarea with the given text.
2588// Returns false if the replacement cannot be performed.
2589func (m *UI) insertCompletionText(text string) bool {
2590	value := m.textarea.Value()
2591	if m.completionsStartIndex > len(value) {
2592		return false
2593	}
2594
2595	word := m.textareaWord()
2596	endIdx := min(m.completionsStartIndex+len(word), len(value))
2597	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2598	m.textarea.SetValue(newValue)
2599	m.textarea.MoveToEnd()
2600	m.textarea.InsertRune(' ')
2601	return true
2602}
2603
2604// insertFileCompletion inserts the selected file path into the textarea,
2605// replacing the @query, and adds the file as an attachment.
2606func (m *UI) insertFileCompletion(path string) tea.Cmd {
2607	if !m.insertCompletionText(path) {
2608		return nil
2609	}
2610
2611	return func() tea.Msg {
2612		absPath, _ := filepath.Abs(path)
2613
2614		if m.hasSession() {
2615			// Skip attachment if file was already read and hasn't been modified.
2616			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2617			if !lastRead.IsZero() {
2618				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2619					return nil
2620				}
2621			}
2622		} else if slices.Contains(m.sessionFileReads, absPath) {
2623			return nil
2624		}
2625
2626		m.sessionFileReads = append(m.sessionFileReads, absPath)
2627
2628		// Add file as attachment.
2629		content, err := os.ReadFile(path)
2630		if err != nil {
2631			// If it fails, let the LLM handle it later.
2632			return nil
2633		}
2634
2635		return message.Attachment{
2636			FilePath: path,
2637			FileName: filepath.Base(path),
2638			MimeType: mimeOf(content),
2639			Content:  content,
2640		}
2641	}
2642}
2643
2644// insertMCPResourceCompletion inserts the selected resource into the textarea,
2645// replacing the @query, and adds the resource as an attachment.
2646func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2647	displayText := cmp.Or(item.Title, item.URI)
2648
2649	if !m.insertCompletionText(displayText) {
2650		return nil
2651	}
2652
2653	return func() tea.Msg {
2654		contents, err := mcp.ReadResource(
2655			context.Background(),
2656			m.com.Store(),
2657			item.MCPName,
2658			item.URI,
2659		)
2660		if err != nil {
2661			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2662			return nil
2663		}
2664		if len(contents) == 0 {
2665			return nil
2666		}
2667
2668		content := contents[0]
2669		var data []byte
2670		if content.Text != "" {
2671			data = []byte(content.Text)
2672		} else if len(content.Blob) > 0 {
2673			data = content.Blob
2674		}
2675		if len(data) == 0 {
2676			return nil
2677		}
2678
2679		mimeType := item.MIMEType
2680		if mimeType == "" && content.MIMEType != "" {
2681			mimeType = content.MIMEType
2682		}
2683		if mimeType == "" {
2684			mimeType = "text/plain"
2685		}
2686
2687		return message.Attachment{
2688			FilePath: item.URI,
2689			FileName: displayText,
2690			MimeType: mimeType,
2691			Content:  data,
2692		}
2693	}
2694}
2695
2696// completionsPosition returns the X and Y position for the completions popup.
2697func (m *UI) completionsPosition() image.Point {
2698	cur := m.textarea.Cursor()
2699	if cur == nil {
2700		return image.Point{
2701			X: m.layout.editor.Min.X,
2702			Y: m.layout.editor.Min.Y,
2703		}
2704	}
2705	return image.Point{
2706		X: cur.X + m.layout.editor.Min.X,
2707		Y: m.layout.editor.Min.Y + cur.Y,
2708	}
2709}
2710
2711// textareaWord returns the current word at the cursor position.
2712func (m *UI) textareaWord() string {
2713	return m.textarea.Word()
2714}
2715
2716// isWhitespace returns true if the byte is a whitespace character.
2717func isWhitespace(b byte) bool {
2718	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2719}
2720
2721// isAgentBusy returns true if the agent coordinator exists and is currently
2722// busy processing a request.
2723func (m *UI) isAgentBusy() bool {
2724	return m.com.App != nil &&
2725		m.com.App.AgentCoordinator != nil &&
2726		m.com.App.AgentCoordinator.IsBusy()
2727}
2728
2729// hasSession returns true if there is an active session with a valid ID.
2730func (m *UI) hasSession() bool {
2731	return m.session != nil && m.session.ID != ""
2732}
2733
2734// mimeOf detects the MIME type of the given content.
2735func mimeOf(content []byte) string {
2736	mimeBufferSize := min(512, len(content))
2737	return http.DetectContentType(content[:mimeBufferSize])
2738}
2739
2740var readyPlaceholders = [...]string{
2741	"Ready!",
2742	"Ready...",
2743	"Ready?",
2744	"Ready for instructions",
2745}
2746
2747var workingPlaceholders = [...]string{
2748	"Working!",
2749	"Working...",
2750	"Brrrrr...",
2751	"Prrrrrrrr...",
2752	"Processing...",
2753	"Thinking...",
2754}
2755
2756// randomizePlaceholders selects random placeholder text for the textarea's
2757// ready and working states.
2758func (m *UI) randomizePlaceholders() {
2759	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2760	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2761}
2762
2763// renderEditorView renders the editor view with attachments if any.
2764func (m *UI) renderEditorView(width int) string {
2765	var attachmentsView string
2766	if len(m.attachments.List()) > 0 {
2767		attachmentsView = m.attachments.Render(width)
2768	}
2769	return strings.Join([]string{
2770		attachmentsView,
2771		m.textarea.View(),
2772		"", // margin at bottom of editor
2773	}, "\n")
2774}
2775
2776// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2777func (m *UI) cacheSidebarLogo(width int) {
2778	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2779}
2780
2781// sendMessage sends a message with the given content and attachments.
2782func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2783	if m.com.App.AgentCoordinator == nil {
2784		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2785	}
2786
2787	var cmds []tea.Cmd
2788	if !m.hasSession() {
2789		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2790		if err != nil {
2791			return util.ReportError(err)
2792		}
2793		if m.forceCompactMode {
2794			m.isCompact = true
2795		}
2796		if newSession.ID != "" {
2797			m.session = &newSession
2798			cmds = append(cmds, m.loadSession(newSession.ID))
2799		}
2800		m.setState(uiChat, m.focus)
2801	}
2802
2803	ctx := context.Background()
2804	cmds = append(cmds, func() tea.Msg {
2805		for _, path := range m.sessionFileReads {
2806			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2807			m.com.App.LSPManager.Start(ctx, path)
2808		}
2809		return nil
2810	})
2811
2812	// Capture session ID to avoid race with main goroutine updating m.session.
2813	sessionID := m.session.ID
2814	cmds = append(cmds, func() tea.Msg {
2815		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2816		if err != nil {
2817			isCancelErr := errors.Is(err, context.Canceled)
2818			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2819			if isCancelErr || isPermissionErr {
2820				return nil
2821			}
2822			return util.InfoMsg{
2823				Type: util.InfoTypeError,
2824				Msg:  err.Error(),
2825			}
2826		}
2827		return nil
2828	})
2829	return tea.Batch(cmds...)
2830}
2831
2832const cancelTimerDuration = 2 * time.Second
2833
2834// cancelTimerCmd creates a command that expires the cancel timer.
2835func cancelTimerCmd() tea.Cmd {
2836	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2837		return cancelTimerExpiredMsg{}
2838	})
2839}
2840
2841// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2842// and starts a timer. The second press (before the timer expires) actually
2843// cancels the agent.
2844func (m *UI) cancelAgent() tea.Cmd {
2845	if !m.hasSession() {
2846		return nil
2847	}
2848
2849	coordinator := m.com.App.AgentCoordinator
2850	if coordinator == nil {
2851		return nil
2852	}
2853
2854	if m.isCanceling {
2855		// Second escape press - actually cancel the agent.
2856		m.isCanceling = false
2857		coordinator.Cancel(m.session.ID)
2858		// Stop the spinning todo indicator.
2859		m.todoIsSpinning = false
2860		m.renderPills()
2861		return nil
2862	}
2863
2864	// Check if there are queued prompts - if so, clear the queue.
2865	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2866		coordinator.ClearQueue(m.session.ID)
2867		return nil
2868	}
2869
2870	// First escape press - set canceling state and start timer.
2871	m.isCanceling = true
2872	return cancelTimerCmd()
2873}
2874
2875// openDialog opens a dialog by its ID.
2876func (m *UI) openDialog(id string) tea.Cmd {
2877	var cmds []tea.Cmd
2878	switch id {
2879	case dialog.SessionsID:
2880		if cmd := m.openSessionsDialog(); cmd != nil {
2881			cmds = append(cmds, cmd)
2882		}
2883	case dialog.ModelsID:
2884		if cmd := m.openModelsDialog(); cmd != nil {
2885			cmds = append(cmds, cmd)
2886		}
2887	case dialog.CommandsID:
2888		if cmd := m.openCommandsDialog(); cmd != nil {
2889			cmds = append(cmds, cmd)
2890		}
2891	case dialog.ReasoningID:
2892		if cmd := m.openReasoningDialog(); cmd != nil {
2893			cmds = append(cmds, cmd)
2894		}
2895	case dialog.QuitID:
2896		if cmd := m.openQuitDialog(); cmd != nil {
2897			cmds = append(cmds, cmd)
2898		}
2899	default:
2900		// Unknown dialog
2901		break
2902	}
2903	return tea.Batch(cmds...)
2904}
2905
2906// openQuitDialog opens the quit confirmation dialog.
2907func (m *UI) openQuitDialog() tea.Cmd {
2908	if m.dialog.ContainsDialog(dialog.QuitID) {
2909		// Bring to front
2910		m.dialog.BringToFront(dialog.QuitID)
2911		return nil
2912	}
2913
2914	quitDialog := dialog.NewQuit(m.com)
2915	m.dialog.OpenDialog(quitDialog)
2916	return nil
2917}
2918
2919// openModelsDialog opens the models dialog.
2920func (m *UI) openModelsDialog() tea.Cmd {
2921	if m.dialog.ContainsDialog(dialog.ModelsID) {
2922		// Bring to front
2923		m.dialog.BringToFront(dialog.ModelsID)
2924		return nil
2925	}
2926
2927	isOnboarding := m.state == uiOnboarding
2928	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2929	if err != nil {
2930		return util.ReportError(err)
2931	}
2932
2933	m.dialog.OpenDialog(modelsDialog)
2934
2935	return nil
2936}
2937
2938// openCommandsDialog opens the commands dialog.
2939func (m *UI) openCommandsDialog() tea.Cmd {
2940	if m.dialog.ContainsDialog(dialog.CommandsID) {
2941		// Bring to front
2942		m.dialog.BringToFront(dialog.CommandsID)
2943		return nil
2944	}
2945
2946	var sessionID string
2947	hasSession := m.session != nil
2948	if hasSession {
2949		sessionID = m.session.ID
2950	}
2951	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2952	hasQueue := m.promptQueue > 0
2953
2954	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2955	if err != nil {
2956		return util.ReportError(err)
2957	}
2958
2959	m.dialog.OpenDialog(commands)
2960
2961	return nil
2962}
2963
2964// openReasoningDialog opens the reasoning effort dialog.
2965func (m *UI) openReasoningDialog() tea.Cmd {
2966	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2967		m.dialog.BringToFront(dialog.ReasoningID)
2968		return nil
2969	}
2970
2971	reasoningDialog, err := dialog.NewReasoning(m.com)
2972	if err != nil {
2973		return util.ReportError(err)
2974	}
2975
2976	m.dialog.OpenDialog(reasoningDialog)
2977	return nil
2978}
2979
2980// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2981// it brings it to the front. Otherwise, it will list all the sessions and open
2982// the dialog.
2983func (m *UI) openSessionsDialog() tea.Cmd {
2984	if m.dialog.ContainsDialog(dialog.SessionsID) {
2985		// Bring to front
2986		m.dialog.BringToFront(dialog.SessionsID)
2987		return nil
2988	}
2989
2990	selectedSessionID := ""
2991	if m.session != nil {
2992		selectedSessionID = m.session.ID
2993	}
2994
2995	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2996	if err != nil {
2997		return util.ReportError(err)
2998	}
2999
3000	m.dialog.OpenDialog(dialog)
3001	return nil
3002}
3003
3004// openFilesDialog opens the file picker dialog.
3005func (m *UI) openFilesDialog() tea.Cmd {
3006	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3007		// Bring to front
3008		m.dialog.BringToFront(dialog.FilePickerID)
3009		return nil
3010	}
3011
3012	filePicker, cmd := dialog.NewFilePicker(m.com)
3013	filePicker.SetImageCapabilities(&m.caps)
3014	m.dialog.OpenDialog(filePicker)
3015
3016	return cmd
3017}
3018
3019// openPermissionsDialog opens the permissions dialog for a permission request.
3020func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3021	// Close any existing permissions dialog first.
3022	m.dialog.CloseDialog(dialog.PermissionsID)
3023
3024	// Get diff mode from config.
3025	var opts []dialog.PermissionsOption
3026	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3027		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3028	}
3029
3030	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3031	m.dialog.OpenDialog(permDialog)
3032	return nil
3033}
3034
3035// handlePermissionNotification updates tool items when permission state changes.
3036func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3037	toolItem := m.chat.MessageItem(notification.ToolCallID)
3038	if toolItem == nil {
3039		return
3040	}
3041
3042	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3043		if notification.Granted {
3044			permItem.SetStatus(chat.ToolStatusRunning)
3045		} else {
3046			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3047		}
3048	}
3049}
3050
3051// handleAgentNotification translates domain agent events into desktop
3052// notifications using the UI notification backend.
3053func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3054	switch n.Type {
3055	case notify.TypeAgentFinished:
3056		return m.sendNotification(notification.Notification{
3057			Title:   "Crush is waiting...",
3058			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3059		})
3060	default:
3061		return nil
3062	}
3063}
3064
3065// newSession clears the current session state and prepares for a new session.
3066// The actual session creation happens when the user sends their first message.
3067// Returns a command to reload prompt history.
3068func (m *UI) newSession() tea.Cmd {
3069	if !m.hasSession() {
3070		return nil
3071	}
3072
3073	m.session = nil
3074	m.sessionFiles = nil
3075	m.sessionFileReads = nil
3076	m.setState(uiLanding, uiFocusEditor)
3077	m.textarea.Focus()
3078	m.chat.Blur()
3079	m.chat.ClearMessages()
3080	m.pillsExpanded = false
3081	m.promptQueue = 0
3082	m.pillsView = ""
3083	m.historyReset()
3084	agenttools.ResetCache()
3085	return tea.Batch(
3086		func() tea.Msg {
3087			m.com.App.LSPManager.StopAll(context.Background())
3088			return nil
3089		},
3090		m.loadPromptHistory(),
3091	)
3092}
3093
3094// handlePasteMsg handles a paste message.
3095func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3096	if m.dialog.HasDialogs() {
3097		return m.handleDialogMsg(msg)
3098	}
3099
3100	if m.focus != uiFocusEditor {
3101		return nil
3102	}
3103
3104	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3105		return func() tea.Msg {
3106			content := []byte(msg.Content)
3107			if int64(len(content)) > common.MaxAttachmentSize {
3108				return util.ReportWarn("Paste is too big (>5mb)")
3109			}
3110			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3111			mimeBufferSize := min(512, len(content))
3112			mimeType := http.DetectContentType(content[:mimeBufferSize])
3113			return message.Attachment{
3114				FileName: name,
3115				FilePath: name,
3116				MimeType: mimeType,
3117				Content:  content,
3118			}
3119		}
3120	}
3121
3122	// Attempt to parse pasted content as file paths. If possible to parse,
3123	// all files exist and are valid, add as attachments.
3124	// Otherwise, paste as text.
3125	paths := fsext.ParsePastedFiles(msg.Content)
3126	allExistsAndValid := func() bool {
3127		if len(paths) == 0 {
3128			return false
3129		}
3130		for _, path := range paths {
3131			if _, err := os.Stat(path); os.IsNotExist(err) {
3132				return false
3133			}
3134
3135			lowerPath := strings.ToLower(path)
3136			isValid := false
3137			for _, ext := range common.AllowedImageTypes {
3138				if strings.HasSuffix(lowerPath, ext) {
3139					isValid = true
3140					break
3141				}
3142			}
3143			if !isValid {
3144				return false
3145			}
3146		}
3147		return true
3148	}
3149	if !allExistsAndValid() {
3150		var cmd tea.Cmd
3151		m.textarea, cmd = m.textarea.Update(msg)
3152		return cmd
3153	}
3154
3155	var cmds []tea.Cmd
3156	for _, path := range paths {
3157		cmds = append(cmds, m.handleFilePathPaste(path))
3158	}
3159	return tea.Batch(cmds...)
3160}
3161
3162// handleFilePathPaste handles a pasted file path.
3163func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3164	return func() tea.Msg {
3165		fileInfo, err := os.Stat(path)
3166		if err != nil {
3167			return util.ReportError(err)
3168		}
3169		if fileInfo.IsDir() {
3170			return util.ReportWarn("Cannot attach a directory")
3171		}
3172		if fileInfo.Size() > common.MaxAttachmentSize {
3173			return util.ReportWarn("File is too big (>5mb)")
3174		}
3175
3176		content, err := os.ReadFile(path)
3177		if err != nil {
3178			return util.ReportError(err)
3179		}
3180
3181		mimeBufferSize := min(512, len(content))
3182		mimeType := http.DetectContentType(content[:mimeBufferSize])
3183		fileName := filepath.Base(path)
3184		return message.Attachment{
3185			FilePath: path,
3186			FileName: fileName,
3187			MimeType: mimeType,
3188			Content:  content,
3189		}
3190	}
3191}
3192
3193// pasteImageFromClipboard reads image data from the system clipboard and
3194// creates an attachment. If no image data is found, it falls back to
3195// interpreting clipboard text as a file path.
3196func (m *UI) pasteImageFromClipboard() tea.Msg {
3197	imageData, err := readClipboard(clipboardFormatImage)
3198	if int64(len(imageData)) > common.MaxAttachmentSize {
3199		return util.InfoMsg{
3200			Type: util.InfoTypeError,
3201			Msg:  "File too large, max 5MB",
3202		}
3203	}
3204	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3205	if err == nil {
3206		return message.Attachment{
3207			FilePath: name,
3208			FileName: name,
3209			MimeType: mimeOf(imageData),
3210			Content:  imageData,
3211		}
3212	}
3213
3214	textData, textErr := readClipboard(clipboardFormatText)
3215	if textErr != nil || len(textData) == 0 {
3216		return nil // Clipboard is empty or does not contain an image
3217	}
3218
3219	path := strings.TrimSpace(string(textData))
3220	path = strings.ReplaceAll(path, "\\ ", " ")
3221	if _, statErr := os.Stat(path); statErr != nil {
3222		return nil // Clipboard does not contain an image or valid file path
3223	}
3224
3225	lowerPath := strings.ToLower(path)
3226	isAllowed := false
3227	for _, ext := range common.AllowedImageTypes {
3228		if strings.HasSuffix(lowerPath, ext) {
3229			isAllowed = true
3230			break
3231		}
3232	}
3233	if !isAllowed {
3234		return util.NewInfoMsg("File type is not a supported image format")
3235	}
3236
3237	fileInfo, statErr := os.Stat(path)
3238	if statErr != nil {
3239		return util.InfoMsg{
3240			Type: util.InfoTypeError,
3241			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3242		}
3243	}
3244	if fileInfo.Size() > common.MaxAttachmentSize {
3245		return util.InfoMsg{
3246			Type: util.InfoTypeError,
3247			Msg:  "File too large, max 5MB",
3248		}
3249	}
3250
3251	content, readErr := os.ReadFile(path)
3252	if readErr != nil {
3253		return util.InfoMsg{
3254			Type: util.InfoTypeError,
3255			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3256		}
3257	}
3258
3259	return message.Attachment{
3260		FilePath: path,
3261		FileName: filepath.Base(path),
3262		MimeType: mimeOf(content),
3263		Content:  content,
3264	}
3265}
3266
3267var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3268
3269func (m *UI) pasteIdx() int {
3270	result := 0
3271	for _, at := range m.attachments.List() {
3272		found := pasteRE.FindStringSubmatch(at.FileName)
3273		if len(found) == 0 {
3274			continue
3275		}
3276		idx, err := strconv.Atoi(found[1])
3277		if err == nil {
3278			result = max(result, idx)
3279		}
3280	}
3281	return result + 1
3282}
3283
3284// drawSessionDetails draws the session details in compact mode.
3285func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3286	if m.session == nil {
3287		return
3288	}
3289
3290	s := m.com.Styles
3291
3292	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3293	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3294
3295	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3296	blocks := []string{
3297		title,
3298		"",
3299		m.modelInfo(width),
3300		"",
3301	}
3302
3303	detailsHeader := lipgloss.JoinVertical(
3304		lipgloss.Left,
3305		blocks...,
3306	)
3307
3308	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3309
3310	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3311
3312	const maxSectionWidth = 50
3313	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3314	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3315
3316	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3317	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3318	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3319	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3320	uv.NewStyledString(
3321		s.CompactDetails.View.
3322			Width(area.Dx()).
3323			Render(
3324				lipgloss.JoinVertical(
3325					lipgloss.Left,
3326					detailsHeader,
3327					sections,
3328					version,
3329				),
3330			),
3331	).Draw(scr, area)
3332}
3333
3334func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3335	load := func() tea.Msg {
3336		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3337		if err != nil {
3338			// TODO: make this better
3339			return util.ReportError(err)()
3340		}
3341
3342		if prompt == "" {
3343			return nil
3344		}
3345		return sendMessageMsg{
3346			Content: prompt,
3347		}
3348	}
3349
3350	var cmds []tea.Cmd
3351	if cmd := m.dialog.StartLoading(); cmd != nil {
3352		cmds = append(cmds, cmd)
3353	}
3354	cmds = append(cmds, load, func() tea.Msg {
3355		return closeDialogMsg{}
3356	})
3357
3358	return tea.Sequence(cmds...)
3359}
3360
3361func (m *UI) handleStateChanged() tea.Cmd {
3362	return func() tea.Msg {
3363		m.com.App.UpdateAgentModel(context.Background())
3364		return mcpStateChangedMsg{
3365			states: mcp.GetStates(),
3366		}
3367	}
3368}
3369
3370func handleMCPPromptsEvent(name string) tea.Cmd {
3371	return func() tea.Msg {
3372		mcp.RefreshPrompts(context.Background(), name)
3373		return nil
3374	}
3375}
3376
3377func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3378	return func() tea.Msg {
3379		mcp.RefreshTools(
3380			context.Background(),
3381			cfg,
3382			name,
3383		)
3384		return nil
3385	}
3386}
3387
3388func handleMCPResourcesEvent(name string) tea.Cmd {
3389	return func() tea.Msg {
3390		mcp.RefreshResources(context.Background(), name)
3391		return nil
3392	}
3393}
3394
3395func (m *UI) copyChatHighlight() tea.Cmd {
3396	text := m.chat.HighlightContent()
3397	return common.CopyToClipboardWithCallback(
3398		text,
3399		"Selected text copied to clipboard",
3400		func() tea.Msg {
3401			m.chat.ClearMouse()
3402			return nil
3403		},
3404	)
3405}
3406
3407// renderLogo renders the Crush logo with the given styles and dimensions.
3408func renderLogo(t *styles.Styles, compact bool, width int) string {
3409	return logo.Render(t, version.Version, compact, logo.Opts{
3410		FieldColor:   t.LogoFieldColor,
3411		TitleColorA:  t.LogoTitleColorA,
3412		TitleColorB:  t.LogoTitleColorB,
3413		CharmColor:   t.LogoCharmColor,
3414		VersionColor: t.LogoVersionColor,
3415		Width:        width,
3416	})
3417}