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