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/commands"
  32	"github.com/charmbracelet/crush/internal/config"
  33	"github.com/charmbracelet/crush/internal/fsext"
  34	"github.com/charmbracelet/crush/internal/history"
  35	"github.com/charmbracelet/crush/internal/home"
  36	"github.com/charmbracelet/crush/internal/message"
  37	"github.com/charmbracelet/crush/internal/permission"
  38	"github.com/charmbracelet/crush/internal/pubsub"
  39	"github.com/charmbracelet/crush/internal/session"
  40	"github.com/charmbracelet/crush/internal/ui/anim"
  41	"github.com/charmbracelet/crush/internal/ui/attachments"
  42	"github.com/charmbracelet/crush/internal/ui/chat"
  43	"github.com/charmbracelet/crush/internal/ui/common"
  44	"github.com/charmbracelet/crush/internal/ui/completions"
  45	"github.com/charmbracelet/crush/internal/ui/dialog"
  46	fimage "github.com/charmbracelet/crush/internal/ui/image"
  47	"github.com/charmbracelet/crush/internal/ui/logo"
  48	"github.com/charmbracelet/crush/internal/ui/notification"
  49	"github.com/charmbracelet/crush/internal/ui/styles"
  50	"github.com/charmbracelet/crush/internal/ui/util"
  51	"github.com/charmbracelet/crush/internal/version"
  52	"github.com/charmbracelet/crush/internal/workspace"
  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]workspace.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]workspace.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, _ := com.Workspace.ProjectNeedsInitialization(); 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.Workspace.AgentQueuedPrompts(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.Workspace.ListMessages(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[workspace.LSPEvent]:
 571		m.lspStates = m.com.Workspace.LSPGetStates()
 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(m.com.Workspace, msg.Payload.Name)
 581		case mcp.EventToolsListChanged:
 582			return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
 583		case mcp.EventResourcesListChanged:
 584			return m, handleMCPResourcesEvent(m.com.Workspace, 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		if msg.Type == util.InfoTypeError {
 795			slog.Error("Error reported", "error", msg.Msg)
 796		}
 797		m.status.SetInfoMsg(msg)
 798		ttl := msg.TTL
 799		if ttl <= 0 {
 800			ttl = DefaultStatusTTL
 801		}
 802		cmds = append(cmds, clearInfoMsgCmd(ttl))
 803	case util.ClearStatusMsg:
 804		m.status.ClearInfoMsg()
 805	case completions.CompletionItemsLoadedMsg:
 806		if m.completionsOpen {
 807			m.completions.SetItems(msg.Files, msg.Resources)
 808		}
 809	case uv.KittyGraphicsEvent:
 810		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 811			slog.Warn("Unexpected Kitty graphics response",
 812				"response", string(msg.Payload),
 813				"options", msg.Options)
 814		}
 815	default:
 816		if m.dialog.HasDialogs() {
 817			if cmd := m.handleDialogMsg(msg); cmd != nil {
 818				cmds = append(cmds, cmd)
 819			}
 820		}
 821	}
 822
 823	// This logic gets triggered on any message type, but should it?
 824	switch m.focus {
 825	case uiFocusMain:
 826	case uiFocusEditor:
 827		// Textarea placeholder logic
 828		if m.isAgentBusy() {
 829			m.textarea.Placeholder = m.workingPlaceholder
 830		} else {
 831			m.textarea.Placeholder = m.readyPlaceholder
 832		}
 833		if m.com.Workspace.PermissionSkipRequests() {
 834			m.textarea.Placeholder = "Yolo mode!"
 835		}
 836	}
 837
 838	// at this point this can only handle [message.Attachment] message, and we
 839	// should return all cmds anyway.
 840	_ = m.attachments.Update(msg)
 841	return m, tea.Batch(cmds...)
 842}
 843
 844// setSessionMessages sets the messages for the current session in the chat
 845func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 846	var cmds []tea.Cmd
 847	// Build tool result map to link tool calls with their results
 848	msgPtrs := make([]*message.Message, len(msgs))
 849	for i := range msgs {
 850		msgPtrs[i] = &msgs[i]
 851	}
 852	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 853	if len(msgPtrs) > 0 {
 854		m.lastUserMessageTime = msgPtrs[0].CreatedAt
 855	}
 856
 857	// Add messages to chat with linked tool results
 858	items := make([]chat.MessageItem, 0, len(msgs)*2)
 859	for _, msg := range msgPtrs {
 860		switch msg.Role {
 861		case message.User:
 862			m.lastUserMessageTime = msg.CreatedAt
 863			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 864		case message.Assistant:
 865			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 866			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 867				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 868				items = append(items, infoItem)
 869			}
 870		default:
 871			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 872		}
 873	}
 874
 875	// Load nested tool calls for agent/agentic_fetch tools.
 876	m.loadNestedToolCalls(items)
 877
 878	// If the user switches between sessions while the agent is working we want
 879	// to make sure the animations are shown.
 880	for _, item := range items {
 881		if animatable, ok := item.(chat.Animatable); ok {
 882			if cmd := animatable.StartAnimation(); cmd != nil {
 883				cmds = append(cmds, cmd)
 884			}
 885		}
 886	}
 887
 888	m.chat.SetMessages(items...)
 889	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 890		cmds = append(cmds, cmd)
 891	}
 892	m.chat.SelectLast()
 893	return tea.Sequence(cmds...)
 894}
 895
 896// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 897func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 898	for _, item := range items {
 899		nestedContainer, ok := item.(chat.NestedToolContainer)
 900		if !ok {
 901			continue
 902		}
 903		toolItem, ok := item.(chat.ToolMessageItem)
 904		if !ok {
 905			continue
 906		}
 907
 908		tc := toolItem.ToolCall()
 909		messageID := toolItem.MessageID()
 910
 911		// Get the agent tool session ID.
 912		agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
 913
 914		// Fetch nested messages.
 915		nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
 916		if err != nil || len(nestedMsgs) == 0 {
 917			continue
 918		}
 919
 920		// Build tool result map for nested messages.
 921		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 922		for i := range nestedMsgs {
 923			nestedMsgPtrs[i] = &nestedMsgs[i]
 924		}
 925		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 926
 927		// Extract nested tool items.
 928		var nestedTools []chat.ToolMessageItem
 929		for _, nestedMsg := range nestedMsgPtrs {
 930			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 931			for _, nestedItem := range nestedItems {
 932				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 933					// Mark nested tools as simple (compact) rendering.
 934					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 935						simplifiable.SetCompact(true)
 936					}
 937					nestedTools = append(nestedTools, nestedToolItem)
 938				}
 939			}
 940		}
 941
 942		// Recursively load nested tool calls for any agent tools within.
 943		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 944		for i, nt := range nestedTools {
 945			nestedMessageItems[i] = nt
 946		}
 947		m.loadNestedToolCalls(nestedMessageItems)
 948
 949		// Set nested tools on the parent.
 950		nestedContainer.SetNestedTools(nestedTools)
 951	}
 952}
 953
 954// appendSessionMessage appends a new message to the current session in the chat
 955// if the message is a tool result it will update the corresponding tool call message
 956func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 957	var cmds []tea.Cmd
 958
 959	existing := m.chat.MessageItem(msg.ID)
 960	if existing != nil {
 961		// message already exists, skip
 962		return nil
 963	}
 964
 965	switch msg.Role {
 966	case message.User:
 967		m.lastUserMessageTime = msg.CreatedAt
 968		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 969		for _, item := range items {
 970			if animatable, ok := item.(chat.Animatable); ok {
 971				if cmd := animatable.StartAnimation(); cmd != nil {
 972					cmds = append(cmds, cmd)
 973				}
 974			}
 975		}
 976		m.chat.AppendMessages(items...)
 977		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 978			cmds = append(cmds, cmd)
 979		}
 980	case message.Assistant:
 981		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 982		for _, item := range items {
 983			if animatable, ok := item.(chat.Animatable); ok {
 984				if cmd := animatable.StartAnimation(); cmd != nil {
 985					cmds = append(cmds, cmd)
 986				}
 987			}
 988		}
 989		m.chat.AppendMessages(items...)
 990		if m.chat.Follow() {
 991			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 992				cmds = append(cmds, cmd)
 993			}
 994		}
 995		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 996			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 997			m.chat.AppendMessages(infoItem)
 998			if m.chat.Follow() {
 999				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1000					cmds = append(cmds, cmd)
1001				}
1002			}
1003		}
1004	case message.Tool:
1005		for _, tr := range msg.ToolResults() {
1006			toolItem := m.chat.MessageItem(tr.ToolCallID)
1007			if toolItem == nil {
1008				// we should have an item!
1009				continue
1010			}
1011			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1012				toolMsgItem.SetResult(&tr)
1013				if m.chat.Follow() {
1014					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1015						cmds = append(cmds, cmd)
1016					}
1017				}
1018			}
1019		}
1020	}
1021	return tea.Sequence(cmds...)
1022}
1023
1024func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1025	switch {
1026	case m.state != uiChat:
1027		return nil
1028	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1029		return nil
1030	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1031		m.focus = uiFocusEditor
1032		cmd = m.textarea.Focus()
1033		m.chat.Blur()
1034	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1035		m.focus = uiFocusMain
1036		m.textarea.Blur()
1037		m.chat.Focus()
1038	}
1039	return cmd
1040}
1041
1042// updateSessionMessage updates an existing message in the current session in the chat
1043// when an assistant message is updated it may include updated tool calls as well
1044// that is why we need to handle creating/updating each tool call message too
1045func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1046	var cmds []tea.Cmd
1047	existingItem := m.chat.MessageItem(msg.ID)
1048
1049	if existingItem != nil {
1050		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1051			assistantItem.SetMessage(&msg)
1052		}
1053	}
1054
1055	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1056	// if the message of the assistant does not have any  response just tool calls we need to remove it
1057	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1058		m.chat.RemoveMessage(msg.ID)
1059		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1060			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1061		}
1062	}
1063
1064	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1065		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1066			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1067			m.chat.AppendMessages(newInfoItem)
1068		}
1069	}
1070
1071	var items []chat.MessageItem
1072	for _, tc := range msg.ToolCalls() {
1073		existingToolItem := m.chat.MessageItem(tc.ID)
1074		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1075			existingToolCall := toolItem.ToolCall()
1076			// only update if finished state changed or input changed
1077			// to avoid clearing the cache
1078			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1079				toolItem.SetToolCall(tc)
1080			}
1081		}
1082		if existingToolItem == nil {
1083			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1084		}
1085	}
1086
1087	for _, item := range items {
1088		if animatable, ok := item.(chat.Animatable); ok {
1089			if cmd := animatable.StartAnimation(); cmd != nil {
1090				cmds = append(cmds, cmd)
1091			}
1092		}
1093	}
1094
1095	m.chat.AppendMessages(items...)
1096	if m.chat.Follow() {
1097		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1098			cmds = append(cmds, cmd)
1099		}
1100		m.chat.SelectLast()
1101	}
1102
1103	return tea.Sequence(cmds...)
1104}
1105
1106// handleChildSessionMessage handles messages from child sessions (agent tools).
1107func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1108	var cmds []tea.Cmd
1109
1110	// Only process messages with tool calls or results.
1111	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1112		return nil
1113	}
1114
1115	// Check if this is an agent tool session and parse it.
1116	childSessionID := event.Payload.SessionID
1117	_, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1118	if !ok {
1119		return nil
1120	}
1121
1122	// Find the parent agent tool item.
1123	var agentItem chat.NestedToolContainer
1124	for i := 0; i < m.chat.Len(); i++ {
1125		item := m.chat.MessageItem(toolCallID)
1126		if item == nil {
1127			continue
1128		}
1129		if agent, ok := item.(chat.NestedToolContainer); ok {
1130			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1131				if toolMessageItem.ToolCall().ID == toolCallID {
1132					// Verify this agent belongs to the correct parent message.
1133					// We can't directly check parentMessageID on the item, so we trust the session parsing.
1134					agentItem = agent
1135					break
1136				}
1137			}
1138		}
1139	}
1140
1141	if agentItem == nil {
1142		return nil
1143	}
1144
1145	// Get existing nested tools.
1146	nestedTools := agentItem.NestedTools()
1147
1148	// Update or create nested tool calls.
1149	for _, tc := range event.Payload.ToolCalls() {
1150		found := false
1151		for _, existingTool := range nestedTools {
1152			if existingTool.ToolCall().ID == tc.ID {
1153				existingTool.SetToolCall(tc)
1154				found = true
1155				break
1156			}
1157		}
1158		if !found {
1159			// Create a new nested tool item.
1160			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1161			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1162				simplifiable.SetCompact(true)
1163			}
1164			if animatable, ok := nestedItem.(chat.Animatable); ok {
1165				if cmd := animatable.StartAnimation(); cmd != nil {
1166					cmds = append(cmds, cmd)
1167				}
1168			}
1169			nestedTools = append(nestedTools, nestedItem)
1170		}
1171	}
1172
1173	// Update nested tool results.
1174	for _, tr := range event.Payload.ToolResults() {
1175		for _, nestedTool := range nestedTools {
1176			if nestedTool.ToolCall().ID == tr.ToolCallID {
1177				nestedTool.SetResult(&tr)
1178				break
1179			}
1180		}
1181	}
1182
1183	// Update the agent item with the new nested tools.
1184	agentItem.SetNestedTools(nestedTools)
1185
1186	// Update the chat so it updates the index map for animations to work as expected
1187	m.chat.UpdateNestedToolIDs(toolCallID)
1188
1189	if m.chat.Follow() {
1190		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1191			cmds = append(cmds, cmd)
1192		}
1193		m.chat.SelectLast()
1194	}
1195
1196	return tea.Sequence(cmds...)
1197}
1198
1199func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1200	var cmds []tea.Cmd
1201	action := m.dialog.Update(msg)
1202	if action == nil {
1203		return tea.Batch(cmds...)
1204	}
1205
1206	isOnboarding := m.state == uiOnboarding
1207
1208	switch msg := action.(type) {
1209	// Generic dialog messages
1210	case dialog.ActionClose:
1211		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1212			break
1213		}
1214
1215		if m.dialog.ContainsDialog(dialog.FilePickerID) {
1216			defer fimage.ResetCache()
1217		}
1218
1219		m.dialog.CloseFrontDialog()
1220
1221		if isOnboarding {
1222			if cmd := m.openModelsDialog(); cmd != nil {
1223				cmds = append(cmds, cmd)
1224			}
1225		}
1226
1227		if m.focus == uiFocusEditor {
1228			cmds = append(cmds, m.textarea.Focus())
1229		}
1230	case dialog.ActionCmd:
1231		if msg.Cmd != nil {
1232			cmds = append(cmds, msg.Cmd)
1233		}
1234
1235	// Session dialog messages
1236	case dialog.ActionSelectSession:
1237		m.dialog.CloseDialog(dialog.SessionsID)
1238		cmds = append(cmds, m.loadSession(msg.Session.ID))
1239
1240	// Open dialog message
1241	case dialog.ActionOpenDialog:
1242		m.dialog.CloseDialog(dialog.CommandsID)
1243		if cmd := m.openDialog(msg.DialogID); cmd != nil {
1244			cmds = append(cmds, cmd)
1245		}
1246
1247	// Command dialog messages
1248	case dialog.ActionToggleYoloMode:
1249		yolo := !m.com.Workspace.PermissionSkipRequests()
1250		m.com.Workspace.PermissionSetSkipRequests(yolo)
1251		m.setEditorPrompt(yolo)
1252		m.dialog.CloseDialog(dialog.CommandsID)
1253	case dialog.ActionNewSession:
1254		if m.isAgentBusy() {
1255			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1256			break
1257		}
1258		if cmd := m.newSession(); cmd != nil {
1259			cmds = append(cmds, cmd)
1260		}
1261		m.dialog.CloseDialog(dialog.CommandsID)
1262	case dialog.ActionSummarize:
1263		if m.isAgentBusy() {
1264			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1265			break
1266		}
1267		cmds = append(cmds, func() tea.Msg {
1268			err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1269			if err != nil {
1270				return util.ReportError(err)()
1271			}
1272			return nil
1273		})
1274		m.dialog.CloseDialog(dialog.CommandsID)
1275	case dialog.ActionToggleHelp:
1276		m.status.ToggleHelp()
1277		m.dialog.CloseDialog(dialog.CommandsID)
1278	case dialog.ActionExternalEditor:
1279		if m.isAgentBusy() {
1280			cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1281			break
1282		}
1283		cmds = append(cmds, m.openEditor(m.textarea.Value()))
1284		m.dialog.CloseDialog(dialog.CommandsID)
1285	case dialog.ActionToggleCompactMode:
1286		cmds = append(cmds, m.toggleCompactMode())
1287		m.dialog.CloseDialog(dialog.CommandsID)
1288	case dialog.ActionTogglePills:
1289		if cmd := m.togglePillsExpanded(); cmd != nil {
1290			cmds = append(cmds, cmd)
1291		}
1292		m.dialog.CloseDialog(dialog.CommandsID)
1293	case dialog.ActionToggleThinking:
1294		cmds = append(cmds, func() tea.Msg {
1295			cfg := m.com.Config()
1296			if cfg == nil {
1297				return util.ReportError(errors.New("configuration not found"))()
1298			}
1299
1300			agentCfg, ok := cfg.Agents[config.AgentCoder]
1301			if !ok {
1302				return util.ReportError(errors.New("agent configuration not found"))()
1303			}
1304
1305			currentModel := cfg.Models[agentCfg.Model]
1306			currentModel.Think = !currentModel.Think
1307			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1308				return util.ReportError(err)()
1309			}
1310			m.com.Workspace.UpdateAgentModel(context.TODO())
1311			status := "disabled"
1312			if currentModel.Think {
1313				status = "enabled"
1314			}
1315			return util.NewInfoMsg("Thinking mode " + status)
1316		})
1317		m.dialog.CloseDialog(dialog.CommandsID)
1318	case dialog.ActionQuit:
1319		cmds = append(cmds, tea.Quit)
1320	case dialog.ActionInitializeProject:
1321		if m.isAgentBusy() {
1322			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1323			break
1324		}
1325		cmds = append(cmds, m.initializeProject())
1326		m.dialog.CloseDialog(dialog.CommandsID)
1327
1328	case dialog.ActionSelectModel:
1329		if m.isAgentBusy() {
1330			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1331			break
1332		}
1333
1334		cfg := m.com.Config()
1335		if cfg == nil {
1336			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1337			break
1338		}
1339
1340		var (
1341			providerID   = msg.Model.Provider
1342			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1343			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1344		)
1345
1346		// Attempt to import GitHub Copilot tokens from VSCode if available.
1347		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1348			m.com.Workspace.ImportCopilot()
1349		}
1350
1351		if !isConfigured() || msg.ReAuthenticate {
1352			m.dialog.CloseDialog(dialog.ModelsID)
1353			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1354				cmds = append(cmds, cmd)
1355			}
1356			break
1357		}
1358
1359		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1360			cmds = append(cmds, util.ReportError(err))
1361		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1362			// Ensure small model is set is unset.
1363			smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1364			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1365				cmds = append(cmds, util.ReportError(err))
1366			}
1367		}
1368
1369		cmds = append(cmds, func() tea.Msg {
1370			if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1371				return util.ReportError(err)
1372			}
1373
1374			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1375
1376			return util.NewInfoMsg(modelMsg)
1377		})
1378
1379		m.dialog.CloseDialog(dialog.APIKeyInputID)
1380		m.dialog.CloseDialog(dialog.OAuthID)
1381		m.dialog.CloseDialog(dialog.ModelsID)
1382
1383		if isOnboarding {
1384			m.setState(uiLanding, uiFocusEditor)
1385			m.com.Config().SetupAgents()
1386			if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1387				cmds = append(cmds, util.ReportError(err))
1388			}
1389		}
1390	case dialog.ActionSelectReasoningEffort:
1391		if m.isAgentBusy() {
1392			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1393			break
1394		}
1395
1396		cfg := m.com.Config()
1397		if cfg == nil {
1398			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1399			break
1400		}
1401
1402		agentCfg, ok := cfg.Agents[config.AgentCoder]
1403		if !ok {
1404			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1405			break
1406		}
1407
1408		currentModel := cfg.Models[agentCfg.Model]
1409		currentModel.ReasoningEffort = msg.Effort
1410		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1411			cmds = append(cmds, util.ReportError(err))
1412			break
1413		}
1414
1415		cmds = append(cmds, func() tea.Msg {
1416			m.com.Workspace.UpdateAgentModel(context.TODO())
1417			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1418		})
1419		m.dialog.CloseDialog(dialog.ReasoningID)
1420	case dialog.ActionPermissionResponse:
1421		m.dialog.CloseDialog(dialog.PermissionsID)
1422		switch msg.Action {
1423		case dialog.PermissionAllow:
1424			m.com.Workspace.PermissionGrant(msg.Permission)
1425		case dialog.PermissionAllowForSession:
1426			m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1427		case dialog.PermissionDeny:
1428			m.com.Workspace.PermissionDeny(msg.Permission)
1429		}
1430
1431	case dialog.ActionFilePickerSelected:
1432		cmds = append(cmds, tea.Sequence(
1433			msg.Cmd(),
1434			func() tea.Msg {
1435				m.dialog.CloseDialog(dialog.FilePickerID)
1436				return nil
1437			},
1438			func() tea.Msg {
1439				fimage.ResetCache()
1440				return nil
1441			},
1442		))
1443
1444	case dialog.ActionRunCustomCommand:
1445		if len(msg.Arguments) > 0 && msg.Args == nil {
1446			m.dialog.CloseFrontDialog()
1447			argsDialog := dialog.NewArguments(
1448				m.com,
1449				"Custom Command Arguments",
1450				"",
1451				msg.Arguments,
1452				msg, // Pass the action as the result
1453			)
1454			m.dialog.OpenDialog(argsDialog)
1455			break
1456		}
1457		content := msg.Content
1458		if msg.Args != nil {
1459			content = substituteArgs(content, msg.Args)
1460		}
1461		cmds = append(cmds, m.sendMessage(content))
1462		m.dialog.CloseFrontDialog()
1463	case dialog.ActionRunMCPPrompt:
1464		if len(msg.Arguments) > 0 && msg.Args == nil {
1465			m.dialog.CloseFrontDialog()
1466			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1467			argsDialog := dialog.NewArguments(
1468				m.com,
1469				title,
1470				msg.Description,
1471				msg.Arguments,
1472				msg, // Pass the action as the result
1473			)
1474			m.dialog.OpenDialog(argsDialog)
1475			break
1476		}
1477		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1478	default:
1479		cmds = append(cmds, util.CmdHandler(msg))
1480	}
1481
1482	return tea.Batch(cmds...)
1483}
1484
1485// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1486func substituteArgs(content string, args map[string]string) string {
1487	for name, value := range args {
1488		placeholder := "$" + name
1489		content = strings.ReplaceAll(content, placeholder, value)
1490	}
1491	return content
1492}
1493
1494func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1495	var (
1496		dlg dialog.Dialog
1497		cmd tea.Cmd
1498
1499		isOnboarding = m.state == uiOnboarding
1500	)
1501
1502	switch provider.ID {
1503	case "hyper":
1504		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1505	case catwalk.InferenceProviderCopilot:
1506		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1507	default:
1508		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1509	}
1510
1511	if m.dialog.ContainsDialog(dlg.ID()) {
1512		m.dialog.BringToFront(dlg.ID())
1513		return nil
1514	}
1515
1516	m.dialog.OpenDialog(dlg)
1517	return cmd
1518}
1519
1520func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1521	var cmds []tea.Cmd
1522
1523	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1524		switch {
1525		case key.Matches(msg, m.keyMap.Help):
1526			m.status.ToggleHelp()
1527			m.updateLayoutAndSize()
1528			return true
1529		case key.Matches(msg, m.keyMap.Commands):
1530			if cmd := m.openCommandsDialog(); cmd != nil {
1531				cmds = append(cmds, cmd)
1532			}
1533			return true
1534		case key.Matches(msg, m.keyMap.Models):
1535			if cmd := m.openModelsDialog(); cmd != nil {
1536				cmds = append(cmds, cmd)
1537			}
1538			return true
1539		case key.Matches(msg, m.keyMap.Sessions):
1540			if cmd := m.openSessionsDialog(); cmd != nil {
1541				cmds = append(cmds, cmd)
1542			}
1543			return true
1544		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1545			m.detailsOpen = !m.detailsOpen
1546			m.updateLayoutAndSize()
1547			return true
1548		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1549			if m.state == uiChat && m.hasSession() {
1550				if cmd := m.togglePillsExpanded(); cmd != nil {
1551					cmds = append(cmds, cmd)
1552				}
1553				return true
1554			}
1555		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1556			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1557				if cmd := m.switchPillSection(-1); cmd != nil {
1558					cmds = append(cmds, cmd)
1559				}
1560				return true
1561			}
1562		case key.Matches(msg, m.keyMap.Chat.PillRight):
1563			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1564				if cmd := m.switchPillSection(1); cmd != nil {
1565					cmds = append(cmds, cmd)
1566				}
1567				return true
1568			}
1569		case key.Matches(msg, m.keyMap.Suspend):
1570			if m.isAgentBusy() {
1571				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1572				return true
1573			}
1574			cmds = append(cmds, tea.Suspend)
1575			return true
1576		}
1577		return false
1578	}
1579
1580	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1581		// Always handle quit keys first
1582		if cmd := m.openQuitDialog(); cmd != nil {
1583			cmds = append(cmds, cmd)
1584		}
1585
1586		return tea.Batch(cmds...)
1587	}
1588
1589	// Route all messages to dialog if one is open.
1590	if m.dialog.HasDialogs() {
1591		return m.handleDialogMsg(msg)
1592	}
1593
1594	// Handle cancel key when agent is busy.
1595	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1596		if m.isAgentBusy() {
1597			if cmd := m.cancelAgent(); cmd != nil {
1598				cmds = append(cmds, cmd)
1599			}
1600			return tea.Batch(cmds...)
1601		}
1602	}
1603
1604	switch m.state {
1605	case uiOnboarding:
1606		return tea.Batch(cmds...)
1607	case uiInitialize:
1608		cmds = append(cmds, m.updateInitializeView(msg)...)
1609		return tea.Batch(cmds...)
1610	case uiChat, uiLanding:
1611		switch m.focus {
1612		case uiFocusEditor:
1613			// Handle completions if open.
1614			if m.completionsOpen {
1615				if msg, ok := m.completions.Update(msg); ok {
1616					switch msg := msg.(type) {
1617					case completions.SelectionMsg[completions.FileCompletionValue]:
1618						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1619						if !msg.KeepOpen {
1620							m.closeCompletions()
1621						}
1622					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1623						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1624						if !msg.KeepOpen {
1625							m.closeCompletions()
1626						}
1627					case completions.ClosedMsg:
1628						m.completionsOpen = false
1629					}
1630					return tea.Batch(cmds...)
1631				}
1632			}
1633
1634			if ok := m.attachments.Update(msg); ok {
1635				return tea.Batch(cmds...)
1636			}
1637
1638			switch {
1639			case key.Matches(msg, m.keyMap.Editor.AddImage):
1640				if cmd := m.openFilesDialog(); cmd != nil {
1641					cmds = append(cmds, cmd)
1642				}
1643
1644			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1645				cmds = append(cmds, m.pasteImageFromClipboard)
1646
1647			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1648				value := m.textarea.Value()
1649				if before, ok := strings.CutSuffix(value, "\\"); ok {
1650					// If the last character is a backslash, remove it and add a newline.
1651					m.textarea.SetValue(before)
1652					break
1653				}
1654
1655				// Otherwise, send the message
1656				m.textarea.Reset()
1657
1658				value = strings.TrimSpace(value)
1659				if value == "exit" || value == "quit" {
1660					return m.openQuitDialog()
1661				}
1662
1663				attachments := m.attachments.List()
1664				m.attachments.Reset()
1665				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1666					return nil
1667				}
1668
1669				m.randomizePlaceholders()
1670				m.historyReset()
1671
1672				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1673			case key.Matches(msg, m.keyMap.Chat.NewSession):
1674				if !m.hasSession() {
1675					break
1676				}
1677				if m.isAgentBusy() {
1678					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1679					break
1680				}
1681				if cmd := m.newSession(); cmd != nil {
1682					cmds = append(cmds, cmd)
1683				}
1684			case key.Matches(msg, m.keyMap.Tab):
1685				if m.state != uiLanding {
1686					m.setState(m.state, uiFocusMain)
1687					m.textarea.Blur()
1688					m.chat.Focus()
1689					m.chat.SetSelected(m.chat.Len() - 1)
1690				}
1691			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1692				if m.isAgentBusy() {
1693					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1694					break
1695				}
1696				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1697			case key.Matches(msg, m.keyMap.Editor.Newline):
1698				m.textarea.InsertRune('\n')
1699				m.closeCompletions()
1700				ta, cmd := m.textarea.Update(msg)
1701				m.textarea = ta
1702				cmds = append(cmds, cmd)
1703			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1704				cmd := m.handleHistoryUp(msg)
1705				if cmd != nil {
1706					cmds = append(cmds, cmd)
1707				}
1708			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1709				cmd := m.handleHistoryDown(msg)
1710				if cmd != nil {
1711					cmds = append(cmds, cmd)
1712				}
1713			case key.Matches(msg, m.keyMap.Editor.Escape):
1714				cmd := m.handleHistoryEscape(msg)
1715				if cmd != nil {
1716					cmds = append(cmds, cmd)
1717				}
1718			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1719				if cmd := m.openCommandsDialog(); cmd != nil {
1720					cmds = append(cmds, cmd)
1721				}
1722			default:
1723				if handleGlobalKeys(msg) {
1724					// Handle global keys first before passing to textarea.
1725					break
1726				}
1727
1728				// Check for @ trigger before passing to textarea.
1729				curValue := m.textarea.Value()
1730				curIdx := len(curValue)
1731
1732				// Trigger completions on @.
1733				if msg.String() == "@" && !m.completionsOpen {
1734					// Only show if beginning of prompt or after whitespace.
1735					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1736						m.completionsOpen = true
1737						m.completionsQuery = ""
1738						m.completionsStartIndex = curIdx
1739						m.completionsPositionStart = m.completionsPosition()
1740						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1741						cmds = append(cmds, m.completions.Open(depth, limit))
1742					}
1743				}
1744
1745				// remove the details if they are open when user starts typing
1746				if m.detailsOpen {
1747					m.detailsOpen = false
1748					m.updateLayoutAndSize()
1749				}
1750
1751				ta, cmd := m.textarea.Update(msg)
1752				m.textarea = ta
1753				cmds = append(cmds, cmd)
1754
1755				// Any text modification becomes the current draft.
1756				m.updateHistoryDraft(curValue)
1757
1758				// After updating textarea, check if we need to filter completions.
1759				// Skip filtering on the initial @ keystroke since items are loading async.
1760				if m.completionsOpen && msg.String() != "@" {
1761					newValue := m.textarea.Value()
1762					newIdx := len(newValue)
1763
1764					// Close completions if cursor moved before start.
1765					if newIdx <= m.completionsStartIndex {
1766						m.closeCompletions()
1767					} else if msg.String() == "space" {
1768						// Close on space.
1769						m.closeCompletions()
1770					} else {
1771						// Extract current word and filter.
1772						word := m.textareaWord()
1773						if strings.HasPrefix(word, "@") {
1774							m.completionsQuery = word[1:]
1775							m.completions.Filter(m.completionsQuery)
1776						} else if m.completionsOpen {
1777							m.closeCompletions()
1778						}
1779					}
1780				}
1781			}
1782		case uiFocusMain:
1783			switch {
1784			case key.Matches(msg, m.keyMap.Tab):
1785				m.focus = uiFocusEditor
1786				cmds = append(cmds, m.textarea.Focus())
1787				m.chat.Blur()
1788			case key.Matches(msg, m.keyMap.Chat.NewSession):
1789				if !m.hasSession() {
1790					break
1791				}
1792				if m.isAgentBusy() {
1793					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1794					break
1795				}
1796				m.focus = uiFocusEditor
1797				if cmd := m.newSession(); cmd != nil {
1798					cmds = append(cmds, cmd)
1799				}
1800			case key.Matches(msg, m.keyMap.Chat.Expand):
1801				m.chat.ToggleExpandedSelectedItem()
1802			case key.Matches(msg, m.keyMap.Chat.Up):
1803				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1804					cmds = append(cmds, cmd)
1805				}
1806				if !m.chat.SelectedItemInView() {
1807					m.chat.SelectPrev()
1808					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1809						cmds = append(cmds, cmd)
1810					}
1811				}
1812			case key.Matches(msg, m.keyMap.Chat.Down):
1813				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1814					cmds = append(cmds, cmd)
1815				}
1816				if !m.chat.SelectedItemInView() {
1817					m.chat.SelectNext()
1818					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1819						cmds = append(cmds, cmd)
1820					}
1821				}
1822			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1823				m.chat.SelectPrev()
1824				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1825					cmds = append(cmds, cmd)
1826				}
1827			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1828				m.chat.SelectNext()
1829				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1830					cmds = append(cmds, cmd)
1831				}
1832			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1833				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1834					cmds = append(cmds, cmd)
1835				}
1836				m.chat.SelectFirstInView()
1837			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1838				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1839					cmds = append(cmds, cmd)
1840				}
1841				m.chat.SelectLastInView()
1842			case key.Matches(msg, m.keyMap.Chat.PageUp):
1843				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1844					cmds = append(cmds, cmd)
1845				}
1846				m.chat.SelectFirstInView()
1847			case key.Matches(msg, m.keyMap.Chat.PageDown):
1848				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1849					cmds = append(cmds, cmd)
1850				}
1851				m.chat.SelectLastInView()
1852			case key.Matches(msg, m.keyMap.Chat.Home):
1853				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1854					cmds = append(cmds, cmd)
1855				}
1856				m.chat.SelectFirst()
1857			case key.Matches(msg, m.keyMap.Chat.End):
1858				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1859					cmds = append(cmds, cmd)
1860				}
1861				m.chat.SelectLast()
1862			default:
1863				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1864					cmds = append(cmds, cmd)
1865				} else {
1866					handleGlobalKeys(msg)
1867				}
1868			}
1869		default:
1870			handleGlobalKeys(msg)
1871		}
1872	default:
1873		handleGlobalKeys(msg)
1874	}
1875
1876	return tea.Sequence(cmds...)
1877}
1878
1879// drawHeader draws the header section of the UI.
1880func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1881	m.header.drawHeader(
1882		scr,
1883		area,
1884		m.session,
1885		m.isCompact,
1886		m.detailsOpen,
1887		area.Dx(),
1888	)
1889}
1890
1891// Draw implements [uv.Drawable] and draws the UI model.
1892func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1893	layout := m.generateLayout(area.Dx(), area.Dy())
1894
1895	if m.layout != layout {
1896		m.layout = layout
1897		m.updateSize()
1898	}
1899
1900	// Clear the screen first
1901	screen.Clear(scr)
1902
1903	switch m.state {
1904	case uiOnboarding:
1905		m.drawHeader(scr, layout.header)
1906
1907		// NOTE: Onboarding flow will be rendered as dialogs below, but
1908		// positioned at the bottom left of the screen.
1909
1910	case uiInitialize:
1911		m.drawHeader(scr, layout.header)
1912
1913		main := uv.NewStyledString(m.initializeView())
1914		main.Draw(scr, layout.main)
1915
1916	case uiLanding:
1917		m.drawHeader(scr, layout.header)
1918		main := uv.NewStyledString(m.landingView())
1919		main.Draw(scr, layout.main)
1920
1921		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1922		editor.Draw(scr, layout.editor)
1923
1924	case uiChat:
1925		if m.isCompact {
1926			m.drawHeader(scr, layout.header)
1927		} else {
1928			m.drawSidebar(scr, layout.sidebar)
1929		}
1930
1931		m.chat.Draw(scr, layout.main)
1932		if layout.pills.Dy() > 0 && m.pillsView != "" {
1933			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1934		}
1935
1936		editorWidth := scr.Bounds().Dx()
1937		if !m.isCompact {
1938			editorWidth -= layout.sidebar.Dx()
1939		}
1940		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1941		editor.Draw(scr, layout.editor)
1942
1943		// Draw details overlay in compact mode when open
1944		if m.isCompact && m.detailsOpen {
1945			m.drawSessionDetails(scr, layout.sessionDetails)
1946		}
1947	}
1948
1949	isOnboarding := m.state == uiOnboarding
1950
1951	// Add status and help layer
1952	m.status.SetHideHelp(isOnboarding)
1953	m.status.Draw(scr, layout.status)
1954
1955	// Draw completions popup if open
1956	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1957		w, h := m.completions.Size()
1958		x := m.completionsPositionStart.X
1959		y := m.completionsPositionStart.Y - h
1960
1961		screenW := area.Dx()
1962		if x+w > screenW {
1963			x = screenW - w
1964		}
1965		x = max(0, x)
1966		y = max(0, y+1) // Offset for attachments row
1967
1968		completionsView := uv.NewStyledString(m.completions.Render())
1969		completionsView.Draw(scr, image.Rectangle{
1970			Min: image.Pt(x, y),
1971			Max: image.Pt(x+w, y+h),
1972		})
1973	}
1974
1975	// Debugging rendering (visually see when the tui rerenders)
1976	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1977		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1978		debug := uv.NewStyledString(debugView.String())
1979		debug.Draw(scr, image.Rectangle{
1980			Min: image.Pt(4, 1),
1981			Max: image.Pt(8, 3),
1982		})
1983	}
1984
1985	// This needs to come last to overlay on top of everything. We always pass
1986	// the full screen bounds because the dialogs will position themselves
1987	// accordingly.
1988	if m.dialog.HasDialogs() {
1989		return m.dialog.Draw(scr, scr.Bounds())
1990	}
1991
1992	switch m.focus {
1993	case uiFocusEditor:
1994		if m.layout.editor.Dy() <= 0 {
1995			// Don't show cursor if editor is not visible
1996			return nil
1997		}
1998		if m.detailsOpen && m.isCompact {
1999			// Don't show cursor if details overlay is open
2000			return nil
2001		}
2002
2003		if m.textarea.Focused() {
2004			cur := m.textarea.Cursor()
2005			cur.X++                            // Adjust for app margins
2006			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2007			return cur
2008		}
2009	}
2010	return nil
2011}
2012
2013// View renders the UI model's view.
2014func (m *UI) View() tea.View {
2015	var v tea.View
2016	v.AltScreen = true
2017	if !m.isTransparent {
2018		v.BackgroundColor = m.com.Styles.Background
2019	}
2020	v.MouseMode = tea.MouseModeCellMotion
2021	v.ReportFocus = m.caps.ReportFocusEvents
2022	v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2023
2024	canvas := uv.NewScreenBuffer(m.width, m.height)
2025	v.Cursor = m.Draw(canvas, canvas.Bounds())
2026
2027	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2028	contentLines := strings.Split(content, "\n")
2029	for i, line := range contentLines {
2030		// Trim trailing spaces for concise rendering
2031		contentLines[i] = strings.TrimRight(line, " ")
2032	}
2033
2034	content = strings.Join(contentLines, "\n")
2035
2036	v.Content = content
2037	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2038		// HACK: use a random percentage to prevent ghostty from hiding it
2039		// after a timeout.
2040		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2041	}
2042
2043	return v
2044}
2045
2046// ShortHelp implements [help.KeyMap].
2047func (m *UI) ShortHelp() []key.Binding {
2048	var binds []key.Binding
2049	k := &m.keyMap
2050	tab := k.Tab
2051	commands := k.Commands
2052	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2053		commands.SetHelp("/ or ctrl+p", "commands")
2054	}
2055
2056	switch m.state {
2057	case uiInitialize:
2058		binds = append(binds, k.Quit)
2059	case uiChat:
2060		// Show cancel binding if agent is busy.
2061		if m.isAgentBusy() {
2062			cancelBinding := k.Chat.Cancel
2063			if m.isCanceling {
2064				cancelBinding.SetHelp("esc", "press again to cancel")
2065			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2066				cancelBinding.SetHelp("esc", "clear queue")
2067			}
2068			binds = append(binds, cancelBinding)
2069		}
2070
2071		if m.focus == uiFocusEditor {
2072			tab.SetHelp("tab", "focus chat")
2073		} else {
2074			tab.SetHelp("tab", "focus editor")
2075		}
2076
2077		binds = append(binds,
2078			tab,
2079			commands,
2080			k.Models,
2081		)
2082
2083		switch m.focus {
2084		case uiFocusEditor:
2085			binds = append(binds,
2086				k.Editor.Newline,
2087			)
2088		case uiFocusMain:
2089			binds = append(binds,
2090				k.Chat.UpDown,
2091				k.Chat.UpDownOneItem,
2092				k.Chat.PageUp,
2093				k.Chat.PageDown,
2094				k.Chat.Copy,
2095			)
2096			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2097				binds = append(binds, k.Chat.PillLeft)
2098			}
2099		}
2100	default:
2101		// TODO: other states
2102		// if m.session == nil {
2103		// no session selected
2104		binds = append(binds,
2105			commands,
2106			k.Models,
2107			k.Editor.Newline,
2108		)
2109	}
2110
2111	binds = append(binds,
2112		k.Quit,
2113		k.Help,
2114	)
2115
2116	return binds
2117}
2118
2119// FullHelp implements [help.KeyMap].
2120func (m *UI) FullHelp() [][]key.Binding {
2121	var binds [][]key.Binding
2122	k := &m.keyMap
2123	help := k.Help
2124	help.SetHelp("ctrl+g", "less")
2125	hasAttachments := len(m.attachments.List()) > 0
2126	hasSession := m.hasSession()
2127	commands := k.Commands
2128	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2129		commands.SetHelp("/ or ctrl+p", "commands")
2130	}
2131
2132	switch m.state {
2133	case uiInitialize:
2134		binds = append(binds,
2135			[]key.Binding{
2136				k.Quit,
2137			})
2138	case uiChat:
2139		// Show cancel binding if agent is busy.
2140		if m.isAgentBusy() {
2141			cancelBinding := k.Chat.Cancel
2142			if m.isCanceling {
2143				cancelBinding.SetHelp("esc", "press again to cancel")
2144			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2145				cancelBinding.SetHelp("esc", "clear queue")
2146			}
2147			binds = append(binds, []key.Binding{cancelBinding})
2148		}
2149
2150		mainBinds := []key.Binding{}
2151		tab := k.Tab
2152		if m.focus == uiFocusEditor {
2153			tab.SetHelp("tab", "focus chat")
2154		} else {
2155			tab.SetHelp("tab", "focus editor")
2156		}
2157
2158		mainBinds = append(mainBinds,
2159			tab,
2160			commands,
2161			k.Models,
2162			k.Sessions,
2163		)
2164		if hasSession {
2165			mainBinds = append(mainBinds, k.Chat.NewSession)
2166		}
2167
2168		binds = append(binds, mainBinds)
2169
2170		switch m.focus {
2171		case uiFocusEditor:
2172			binds = append(binds,
2173				[]key.Binding{
2174					k.Editor.Newline,
2175					k.Editor.AddImage,
2176					k.Editor.PasteImage,
2177					k.Editor.MentionFile,
2178					k.Editor.OpenEditor,
2179				},
2180			)
2181			if hasAttachments {
2182				binds = append(binds,
2183					[]key.Binding{
2184						k.Editor.AttachmentDeleteMode,
2185						k.Editor.DeleteAllAttachments,
2186						k.Editor.Escape,
2187					},
2188				)
2189			}
2190		case uiFocusMain:
2191			binds = append(binds,
2192				[]key.Binding{
2193					k.Chat.UpDown,
2194					k.Chat.UpDownOneItem,
2195					k.Chat.PageUp,
2196					k.Chat.PageDown,
2197				},
2198				[]key.Binding{
2199					k.Chat.HalfPageUp,
2200					k.Chat.HalfPageDown,
2201					k.Chat.Home,
2202					k.Chat.End,
2203				},
2204				[]key.Binding{
2205					k.Chat.Copy,
2206					k.Chat.ClearHighlight,
2207				},
2208			)
2209			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2210				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2211			}
2212		}
2213	default:
2214		if m.session == nil {
2215			// no session selected
2216			binds = append(binds,
2217				[]key.Binding{
2218					commands,
2219					k.Models,
2220					k.Sessions,
2221				},
2222				[]key.Binding{
2223					k.Editor.Newline,
2224					k.Editor.AddImage,
2225					k.Editor.PasteImage,
2226					k.Editor.MentionFile,
2227					k.Editor.OpenEditor,
2228				},
2229			)
2230			if hasAttachments {
2231				binds = append(binds,
2232					[]key.Binding{
2233						k.Editor.AttachmentDeleteMode,
2234						k.Editor.DeleteAllAttachments,
2235						k.Editor.Escape,
2236					},
2237				)
2238			}
2239			binds = append(binds,
2240				[]key.Binding{
2241					help,
2242				},
2243			)
2244		}
2245	}
2246
2247	binds = append(binds,
2248		[]key.Binding{
2249			help,
2250			k.Quit,
2251		},
2252	)
2253
2254	return binds
2255}
2256
2257// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2258func (m *UI) toggleCompactMode() tea.Cmd {
2259	m.forceCompactMode = !m.forceCompactMode
2260
2261	err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2262	if err != nil {
2263		return util.ReportError(err)
2264	}
2265
2266	m.updateLayoutAndSize()
2267
2268	return nil
2269}
2270
2271// updateLayoutAndSize updates the layout and sizes of UI components.
2272func (m *UI) updateLayoutAndSize() {
2273	// Determine if we should be in compact mode
2274	if m.state == uiChat {
2275		if m.forceCompactMode {
2276			m.isCompact = true
2277			return
2278		}
2279		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2280			m.isCompact = true
2281		} else {
2282			m.isCompact = false
2283		}
2284	}
2285
2286	m.layout = m.generateLayout(m.width, m.height)
2287	m.updateSize()
2288}
2289
2290// updateSize updates the sizes of UI components based on the current layout.
2291func (m *UI) updateSize() {
2292	// Set status width
2293	m.status.SetWidth(m.layout.status.Dx())
2294
2295	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2296	m.textarea.SetWidth(m.layout.editor.Dx())
2297	// TODO: Abstract the textarea and attachments into a single editor
2298	// component so we don't have to manually account for the attachments
2299	// height here.
2300	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2301	m.renderPills()
2302
2303	// Handle different app states
2304	switch m.state {
2305	case uiChat:
2306		if !m.isCompact {
2307			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2308		}
2309	}
2310}
2311
2312// generateLayout calculates the layout rectangles for all UI components based
2313// on the current UI state and terminal dimensions.
2314func (m *UI) generateLayout(w, h int) uiLayout {
2315	// The screen area we're working with
2316	area := image.Rect(0, 0, w, h)
2317
2318	// The help height
2319	helpHeight := 1
2320	// The editor height
2321	editorHeight := 5
2322	// The sidebar width
2323	sidebarWidth := 30
2324	// The header height
2325	const landingHeaderHeight = 4
2326
2327	var helpKeyMap help.KeyMap = m
2328	if m.status != nil && m.status.ShowingAll() {
2329		for _, row := range helpKeyMap.FullHelp() {
2330			helpHeight = max(helpHeight, len(row))
2331		}
2332	}
2333
2334	// Add app margins
2335	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2336	appRect.Min.Y += 1
2337	appRect.Max.Y -= 1
2338	helpRect.Min.Y -= 1
2339	appRect.Min.X += 1
2340	appRect.Max.X -= 1
2341
2342	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2343		// extra padding on left and right for these states
2344		appRect.Min.X += 1
2345		appRect.Max.X -= 1
2346	}
2347
2348	uiLayout := uiLayout{
2349		area:   area,
2350		status: helpRect,
2351	}
2352
2353	// Handle different app states
2354	switch m.state {
2355	case uiOnboarding, uiInitialize:
2356		// Layout
2357		//
2358		// header
2359		// ------
2360		// main
2361		// ------
2362		// help
2363
2364		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2365		uiLayout.header = headerRect
2366		uiLayout.main = mainRect
2367
2368	case uiLanding:
2369		// Layout
2370		//
2371		// header
2372		// ------
2373		// main
2374		// ------
2375		// editor
2376		// ------
2377		// help
2378		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2379		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2380		// Remove extra padding from editor (but keep it for header and main)
2381		editorRect.Min.X -= 1
2382		editorRect.Max.X += 1
2383		uiLayout.header = headerRect
2384		uiLayout.main = mainRect
2385		uiLayout.editor = editorRect
2386
2387	case uiChat:
2388		if m.isCompact {
2389			// Layout
2390			//
2391			// compact-header
2392			// ------
2393			// main
2394			// ------
2395			// editor
2396			// ------
2397			// help
2398			const compactHeaderHeight = 1
2399			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2400			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2401			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2402			uiLayout.sessionDetails = sessionDetailsArea
2403			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2404			// Add one line gap between header and main content
2405			mainRect.Min.Y += 1
2406			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2407			mainRect.Max.X -= 1 // Add padding right
2408			uiLayout.header = headerRect
2409			pillsHeight := m.pillsAreaHeight()
2410			if pillsHeight > 0 {
2411				pillsHeight = min(pillsHeight, mainRect.Dy())
2412				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2413				uiLayout.main = chatRect
2414				uiLayout.pills = pillsRect
2415			} else {
2416				uiLayout.main = mainRect
2417			}
2418			// Add bottom margin to main
2419			uiLayout.main.Max.Y -= 1
2420			uiLayout.editor = editorRect
2421		} else {
2422			// Layout
2423			//
2424			// ------|---
2425			// main  |
2426			// ------| side
2427			// editor|
2428			// ----------
2429			// help
2430
2431			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2432			// Add padding left
2433			sideRect.Min.X += 1
2434			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2435			mainRect.Max.X -= 1 // Add padding right
2436			uiLayout.sidebar = sideRect
2437			pillsHeight := m.pillsAreaHeight()
2438			if pillsHeight > 0 {
2439				pillsHeight = min(pillsHeight, mainRect.Dy())
2440				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2441				uiLayout.main = chatRect
2442				uiLayout.pills = pillsRect
2443			} else {
2444				uiLayout.main = mainRect
2445			}
2446			// Add bottom margin to main
2447			uiLayout.main.Max.Y -= 1
2448			uiLayout.editor = editorRect
2449		}
2450	}
2451
2452	return uiLayout
2453}
2454
2455// uiLayout defines the positioning of UI elements.
2456type uiLayout struct {
2457	// area is the overall available area.
2458	area uv.Rectangle
2459
2460	// header is the header shown in special cases
2461	// e.x when the sidebar is collapsed
2462	// or when in the landing page
2463	// or in init/config
2464	header uv.Rectangle
2465
2466	// main is the area for the main pane. (e.x chat, configure, landing)
2467	main uv.Rectangle
2468
2469	// pills is the area for the pills panel.
2470	pills uv.Rectangle
2471
2472	// editor is the area for the editor pane.
2473	editor uv.Rectangle
2474
2475	// sidebar is the area for the sidebar.
2476	sidebar uv.Rectangle
2477
2478	// status is the area for the status view.
2479	status uv.Rectangle
2480
2481	// session details is the area for the session details overlay in compact mode.
2482	sessionDetails uv.Rectangle
2483}
2484
2485func (m *UI) openEditor(value string) tea.Cmd {
2486	tmpfile, err := os.CreateTemp("", "msg_*.md")
2487	if err != nil {
2488		return util.ReportError(err)
2489	}
2490	defer tmpfile.Close() //nolint:errcheck
2491	if _, err := tmpfile.WriteString(value); err != nil {
2492		return util.ReportError(err)
2493	}
2494	cmd, err := editor.Command(
2495		"crush",
2496		tmpfile.Name(),
2497		editor.AtPosition(
2498			m.textarea.Line()+1,
2499			m.textarea.Column()+1,
2500		),
2501	)
2502	if err != nil {
2503		return util.ReportError(err)
2504	}
2505	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2506		if err != nil {
2507			return util.ReportError(err)
2508		}
2509		content, err := os.ReadFile(tmpfile.Name())
2510		if err != nil {
2511			return util.ReportError(err)
2512		}
2513		if len(content) == 0 {
2514			return util.ReportWarn("Message is empty")
2515		}
2516		os.Remove(tmpfile.Name())
2517		return openEditorMsg{
2518			Text: strings.TrimSpace(string(content)),
2519		}
2520	})
2521}
2522
2523// setEditorPrompt configures the textarea prompt function based on whether
2524// yolo mode is enabled.
2525func (m *UI) setEditorPrompt(yolo bool) {
2526	if yolo {
2527		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2528		return
2529	}
2530	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2531}
2532
2533// normalPromptFunc returns the normal editor prompt style ("  > " on first
2534// line, "::: " on subsequent lines).
2535func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2536	t := m.com.Styles
2537	if info.LineNumber == 0 {
2538		if info.Focused {
2539			return "  > "
2540		}
2541		return "::: "
2542	}
2543	if info.Focused {
2544		return t.EditorPromptNormalFocused.Render()
2545	}
2546	return t.EditorPromptNormalBlurred.Render()
2547}
2548
2549// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2550// and colored dots.
2551func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2552	t := m.com.Styles
2553	if info.LineNumber == 0 {
2554		if info.Focused {
2555			return t.EditorPromptYoloIconFocused.Render()
2556		} else {
2557			return t.EditorPromptYoloIconBlurred.Render()
2558		}
2559	}
2560	if info.Focused {
2561		return t.EditorPromptYoloDotsFocused.Render()
2562	}
2563	return t.EditorPromptYoloDotsBlurred.Render()
2564}
2565
2566// closeCompletions closes the completions popup and resets state.
2567func (m *UI) closeCompletions() {
2568	m.completionsOpen = false
2569	m.completionsQuery = ""
2570	m.completionsStartIndex = 0
2571	m.completions.Close()
2572}
2573
2574// insertCompletionText replaces the @query in the textarea with the given text.
2575// Returns false if the replacement cannot be performed.
2576func (m *UI) insertCompletionText(text string) bool {
2577	value := m.textarea.Value()
2578	if m.completionsStartIndex > len(value) {
2579		return false
2580	}
2581
2582	word := m.textareaWord()
2583	endIdx := min(m.completionsStartIndex+len(word), len(value))
2584	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2585	m.textarea.SetValue(newValue)
2586	m.textarea.MoveToEnd()
2587	m.textarea.InsertRune(' ')
2588	return true
2589}
2590
2591// insertFileCompletion inserts the selected file path into the textarea,
2592// replacing the @query, and adds the file as an attachment.
2593func (m *UI) insertFileCompletion(path string) tea.Cmd {
2594	if !m.insertCompletionText(path) {
2595		return nil
2596	}
2597
2598	return func() tea.Msg {
2599		absPath, _ := filepath.Abs(path)
2600
2601		if m.hasSession() {
2602			// Skip attachment if file was already read and hasn't been modified.
2603			lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2604			if !lastRead.IsZero() {
2605				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2606					return nil
2607				}
2608			}
2609		} else if slices.Contains(m.sessionFileReads, absPath) {
2610			return nil
2611		}
2612
2613		m.sessionFileReads = append(m.sessionFileReads, absPath)
2614
2615		// Add file as attachment.
2616		content, err := os.ReadFile(path)
2617		if err != nil {
2618			// If it fails, let the LLM handle it later.
2619			return nil
2620		}
2621
2622		return message.Attachment{
2623			FilePath: path,
2624			FileName: filepath.Base(path),
2625			MimeType: mimeOf(content),
2626			Content:  content,
2627		}
2628	}
2629}
2630
2631// insertMCPResourceCompletion inserts the selected resource into the textarea,
2632// replacing the @query, and adds the resource as an attachment.
2633func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2634	displayText := cmp.Or(item.Title, item.URI)
2635
2636	if !m.insertCompletionText(displayText) {
2637		return nil
2638	}
2639
2640	return func() tea.Msg {
2641		contents, err := m.com.Workspace.ReadMCPResource(
2642			context.Background(),
2643			item.MCPName,
2644			item.URI,
2645		)
2646		if err != nil {
2647			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2648			return nil
2649		}
2650		if len(contents) == 0 {
2651			return nil
2652		}
2653
2654		content := contents[0]
2655		var data []byte
2656		if content.Text != "" {
2657			data = []byte(content.Text)
2658		} else if len(content.Blob) > 0 {
2659			data = content.Blob
2660		}
2661		if len(data) == 0 {
2662			return nil
2663		}
2664
2665		mimeType := item.MIMEType
2666		if mimeType == "" && content.MIMEType != "" {
2667			mimeType = content.MIMEType
2668		}
2669		if mimeType == "" {
2670			mimeType = "text/plain"
2671		}
2672
2673		return message.Attachment{
2674			FilePath: item.URI,
2675			FileName: displayText,
2676			MimeType: mimeType,
2677			Content:  data,
2678		}
2679	}
2680}
2681
2682// completionsPosition returns the X and Y position for the completions popup.
2683func (m *UI) completionsPosition() image.Point {
2684	cur := m.textarea.Cursor()
2685	if cur == nil {
2686		return image.Point{
2687			X: m.layout.editor.Min.X,
2688			Y: m.layout.editor.Min.Y,
2689		}
2690	}
2691	return image.Point{
2692		X: cur.X + m.layout.editor.Min.X,
2693		Y: m.layout.editor.Min.Y + cur.Y,
2694	}
2695}
2696
2697// textareaWord returns the current word at the cursor position.
2698func (m *UI) textareaWord() string {
2699	return m.textarea.Word()
2700}
2701
2702// isWhitespace returns true if the byte is a whitespace character.
2703func isWhitespace(b byte) bool {
2704	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2705}
2706
2707// isAgentBusy returns true if the agent coordinator exists and is currently
2708// busy processing a request.
2709func (m *UI) isAgentBusy() bool {
2710	return m.com.Workspace.AgentIsReady() &&
2711		m.com.Workspace.AgentIsBusy()
2712}
2713
2714// hasSession returns true if there is an active session with a valid ID.
2715func (m *UI) hasSession() bool {
2716	return m.session != nil && m.session.ID != ""
2717}
2718
2719// mimeOf detects the MIME type of the given content.
2720func mimeOf(content []byte) string {
2721	mimeBufferSize := min(512, len(content))
2722	return http.DetectContentType(content[:mimeBufferSize])
2723}
2724
2725var readyPlaceholders = [...]string{
2726	"Ready!",
2727	"Ready...",
2728	"Ready?",
2729	"Ready for instructions",
2730}
2731
2732var workingPlaceholders = [...]string{
2733	"Working!",
2734	"Working...",
2735	"Brrrrr...",
2736	"Prrrrrrrr...",
2737	"Processing...",
2738	"Thinking...",
2739}
2740
2741// randomizePlaceholders selects random placeholder text for the textarea's
2742// ready and working states.
2743func (m *UI) randomizePlaceholders() {
2744	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2745	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2746}
2747
2748// renderEditorView renders the editor view with attachments if any.
2749func (m *UI) renderEditorView(width int) string {
2750	var attachmentsView string
2751	if len(m.attachments.List()) > 0 {
2752		attachmentsView = m.attachments.Render(width)
2753	}
2754	return strings.Join([]string{
2755		attachmentsView,
2756		m.textarea.View(),
2757		"", // margin at bottom of editor
2758	}, "\n")
2759}
2760
2761// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2762func (m *UI) cacheSidebarLogo(width int) {
2763	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2764}
2765
2766// sendMessage sends a message with the given content and attachments.
2767func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2768	if !m.com.Workspace.AgentIsReady() {
2769		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2770	}
2771
2772	var cmds []tea.Cmd
2773	if !m.hasSession() {
2774		newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2775		if err != nil {
2776			return util.ReportError(err)
2777		}
2778		if m.forceCompactMode {
2779			m.isCompact = true
2780		}
2781		if newSession.ID != "" {
2782			m.session = &newSession
2783			cmds = append(cmds, m.loadSession(newSession.ID))
2784		}
2785		m.setState(uiChat, m.focus)
2786	}
2787
2788	ctx := context.Background()
2789	cmds = append(cmds, func() tea.Msg {
2790		for _, path := range m.sessionFileReads {
2791			m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2792			m.com.Workspace.LSPStart(ctx, path)
2793		}
2794		return nil
2795	})
2796
2797	// Capture session ID to avoid race with main goroutine updating m.session.
2798	sessionID := m.session.ID
2799	cmds = append(cmds, func() tea.Msg {
2800		err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2801		if err != nil {
2802			isCancelErr := errors.Is(err, context.Canceled)
2803			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2804			if isCancelErr || isPermissionErr {
2805				return nil
2806			}
2807			return util.InfoMsg{
2808				Type: util.InfoTypeError,
2809				Msg:  fmt.Sprintf("Failed to run agent: %v", err),
2810			}
2811		}
2812		return nil
2813	})
2814	return tea.Batch(cmds...)
2815}
2816
2817const cancelTimerDuration = 2 * time.Second
2818
2819// cancelTimerCmd creates a command that expires the cancel timer.
2820func cancelTimerCmd() tea.Cmd {
2821	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2822		return cancelTimerExpiredMsg{}
2823	})
2824}
2825
2826// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2827// and starts a timer. The second press (before the timer expires) actually
2828// cancels the agent.
2829func (m *UI) cancelAgent() tea.Cmd {
2830	if !m.hasSession() {
2831		return nil
2832	}
2833
2834	if !m.com.Workspace.AgentIsReady() {
2835		return nil
2836	}
2837
2838	if m.isCanceling {
2839		// Second escape press - actually cancel the agent.
2840		m.isCanceling = false
2841		m.com.Workspace.AgentCancel(m.session.ID)
2842		// Stop the spinning todo indicator.
2843		m.todoIsSpinning = false
2844		m.renderPills()
2845		return nil
2846	}
2847
2848	// Check if there are queued prompts - if so, clear the queue.
2849	if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2850		m.com.Workspace.AgentClearQueue(m.session.ID)
2851		return nil
2852	}
2853
2854	// First escape press - set canceling state and start timer.
2855	m.isCanceling = true
2856	return cancelTimerCmd()
2857}
2858
2859// openDialog opens a dialog by its ID.
2860func (m *UI) openDialog(id string) tea.Cmd {
2861	var cmds []tea.Cmd
2862	switch id {
2863	case dialog.SessionsID:
2864		if cmd := m.openSessionsDialog(); cmd != nil {
2865			cmds = append(cmds, cmd)
2866		}
2867	case dialog.ModelsID:
2868		if cmd := m.openModelsDialog(); cmd != nil {
2869			cmds = append(cmds, cmd)
2870		}
2871	case dialog.CommandsID:
2872		if cmd := m.openCommandsDialog(); cmd != nil {
2873			cmds = append(cmds, cmd)
2874		}
2875	case dialog.ReasoningID:
2876		if cmd := m.openReasoningDialog(); cmd != nil {
2877			cmds = append(cmds, cmd)
2878		}
2879	case dialog.QuitID:
2880		if cmd := m.openQuitDialog(); cmd != nil {
2881			cmds = append(cmds, cmd)
2882		}
2883	default:
2884		// Unknown dialog
2885		break
2886	}
2887	return tea.Batch(cmds...)
2888}
2889
2890// openQuitDialog opens the quit confirmation dialog.
2891func (m *UI) openQuitDialog() tea.Cmd {
2892	if m.dialog.ContainsDialog(dialog.QuitID) {
2893		// Bring to front
2894		m.dialog.BringToFront(dialog.QuitID)
2895		return nil
2896	}
2897
2898	quitDialog := dialog.NewQuit(m.com)
2899	m.dialog.OpenDialog(quitDialog)
2900	return nil
2901}
2902
2903// openModelsDialog opens the models dialog.
2904func (m *UI) openModelsDialog() tea.Cmd {
2905	if m.dialog.ContainsDialog(dialog.ModelsID) {
2906		// Bring to front
2907		m.dialog.BringToFront(dialog.ModelsID)
2908		return nil
2909	}
2910
2911	isOnboarding := m.state == uiOnboarding
2912	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2913	if err != nil {
2914		return util.ReportError(err)
2915	}
2916
2917	m.dialog.OpenDialog(modelsDialog)
2918
2919	return nil
2920}
2921
2922// openCommandsDialog opens the commands dialog.
2923func (m *UI) openCommandsDialog() tea.Cmd {
2924	if m.dialog.ContainsDialog(dialog.CommandsID) {
2925		// Bring to front
2926		m.dialog.BringToFront(dialog.CommandsID)
2927		return nil
2928	}
2929
2930	var sessionID string
2931	hasSession := m.session != nil
2932	if hasSession {
2933		sessionID = m.session.ID
2934	}
2935	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2936	hasQueue := m.promptQueue > 0
2937
2938	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2939	if err != nil {
2940		return util.ReportError(err)
2941	}
2942
2943	m.dialog.OpenDialog(commands)
2944
2945	return nil
2946}
2947
2948// openReasoningDialog opens the reasoning effort dialog.
2949func (m *UI) openReasoningDialog() tea.Cmd {
2950	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2951		m.dialog.BringToFront(dialog.ReasoningID)
2952		return nil
2953	}
2954
2955	reasoningDialog, err := dialog.NewReasoning(m.com)
2956	if err != nil {
2957		return util.ReportError(err)
2958	}
2959
2960	m.dialog.OpenDialog(reasoningDialog)
2961	return nil
2962}
2963
2964// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2965// it brings it to the front. Otherwise, it will list all the sessions and open
2966// the dialog.
2967func (m *UI) openSessionsDialog() tea.Cmd {
2968	if m.dialog.ContainsDialog(dialog.SessionsID) {
2969		// Bring to front
2970		m.dialog.BringToFront(dialog.SessionsID)
2971		return nil
2972	}
2973
2974	selectedSessionID := ""
2975	if m.session != nil {
2976		selectedSessionID = m.session.ID
2977	}
2978
2979	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2980	if err != nil {
2981		return util.ReportError(err)
2982	}
2983
2984	m.dialog.OpenDialog(dialog)
2985	return nil
2986}
2987
2988// openFilesDialog opens the file picker dialog.
2989func (m *UI) openFilesDialog() tea.Cmd {
2990	if m.dialog.ContainsDialog(dialog.FilePickerID) {
2991		// Bring to front
2992		m.dialog.BringToFront(dialog.FilePickerID)
2993		return nil
2994	}
2995
2996	filePicker, cmd := dialog.NewFilePicker(m.com)
2997	filePicker.SetImageCapabilities(&m.caps)
2998	m.dialog.OpenDialog(filePicker)
2999
3000	return cmd
3001}
3002
3003// openPermissionsDialog opens the permissions dialog for a permission request.
3004func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3005	// Close any existing permissions dialog first.
3006	m.dialog.CloseDialog(dialog.PermissionsID)
3007
3008	// Get diff mode from config.
3009	var opts []dialog.PermissionsOption
3010	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3011		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3012	}
3013
3014	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3015	m.dialog.OpenDialog(permDialog)
3016	return nil
3017}
3018
3019// handlePermissionNotification updates tool items when permission state changes.
3020func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3021	toolItem := m.chat.MessageItem(notification.ToolCallID)
3022	if toolItem == nil {
3023		return
3024	}
3025
3026	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3027		if notification.Granted {
3028			permItem.SetStatus(chat.ToolStatusRunning)
3029		} else {
3030			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3031		}
3032	}
3033}
3034
3035// handleAgentNotification translates domain agent events into desktop
3036// notifications using the UI notification backend.
3037func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3038	switch n.Type {
3039	case notify.TypeAgentFinished:
3040		return m.sendNotification(notification.Notification{
3041			Title:   "Crush is waiting...",
3042			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3043		})
3044	default:
3045		return nil
3046	}
3047}
3048
3049// newSession clears the current session state and prepares for a new session.
3050// The actual session creation happens when the user sends their first message.
3051// Returns a command to reload prompt history.
3052func (m *UI) newSession() tea.Cmd {
3053	if !m.hasSession() {
3054		return nil
3055	}
3056
3057	m.session = nil
3058	m.sessionFiles = nil
3059	m.sessionFileReads = nil
3060	m.setState(uiLanding, uiFocusEditor)
3061	m.textarea.Focus()
3062	m.chat.Blur()
3063	m.chat.ClearMessages()
3064	m.pillsExpanded = false
3065	m.promptQueue = 0
3066	m.pillsView = ""
3067	m.historyReset()
3068	agenttools.ResetCache()
3069	return tea.Batch(
3070		func() tea.Msg {
3071			m.com.Workspace.LSPStopAll(context.Background())
3072			return nil
3073		},
3074		m.loadPromptHistory(),
3075	)
3076}
3077
3078// handlePasteMsg handles a paste message.
3079func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3080	if m.dialog.HasDialogs() {
3081		return m.handleDialogMsg(msg)
3082	}
3083
3084	if m.focus != uiFocusEditor {
3085		return nil
3086	}
3087
3088	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3089		return func() tea.Msg {
3090			content := []byte(msg.Content)
3091			if int64(len(content)) > common.MaxAttachmentSize {
3092				return util.ReportWarn("Paste is too big (>5mb)")
3093			}
3094			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3095			mimeBufferSize := min(512, len(content))
3096			mimeType := http.DetectContentType(content[:mimeBufferSize])
3097			return message.Attachment{
3098				FileName: name,
3099				FilePath: name,
3100				MimeType: mimeType,
3101				Content:  content,
3102			}
3103		}
3104	}
3105
3106	// Attempt to parse pasted content as file paths. If possible to parse,
3107	// all files exist and are valid, add as attachments.
3108	// Otherwise, paste as text.
3109	paths := fsext.ParsePastedFiles(msg.Content)
3110	allExistsAndValid := func() bool {
3111		if len(paths) == 0 {
3112			return false
3113		}
3114		for _, path := range paths {
3115			if _, err := os.Stat(path); os.IsNotExist(err) {
3116				return false
3117			}
3118
3119			lowerPath := strings.ToLower(path)
3120			isValid := false
3121			for _, ext := range common.AllowedImageTypes {
3122				if strings.HasSuffix(lowerPath, ext) {
3123					isValid = true
3124					break
3125				}
3126			}
3127			if !isValid {
3128				return false
3129			}
3130		}
3131		return true
3132	}
3133	if !allExistsAndValid() {
3134		var cmd tea.Cmd
3135		m.textarea, cmd = m.textarea.Update(msg)
3136		return cmd
3137	}
3138
3139	var cmds []tea.Cmd
3140	for _, path := range paths {
3141		cmds = append(cmds, m.handleFilePathPaste(path))
3142	}
3143	return tea.Batch(cmds...)
3144}
3145
3146// handleFilePathPaste handles a pasted file path.
3147func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3148	return func() tea.Msg {
3149		fileInfo, err := os.Stat(path)
3150		if err != nil {
3151			return util.ReportError(err)
3152		}
3153		if fileInfo.IsDir() {
3154			return util.ReportWarn("Cannot attach a directory")
3155		}
3156		if fileInfo.Size() > common.MaxAttachmentSize {
3157			return util.ReportWarn("File is too big (>5mb)")
3158		}
3159
3160		content, err := os.ReadFile(path)
3161		if err != nil {
3162			return util.ReportError(err)
3163		}
3164
3165		mimeBufferSize := min(512, len(content))
3166		mimeType := http.DetectContentType(content[:mimeBufferSize])
3167		fileName := filepath.Base(path)
3168		return message.Attachment{
3169			FilePath: path,
3170			FileName: fileName,
3171			MimeType: mimeType,
3172			Content:  content,
3173		}
3174	}
3175}
3176
3177// pasteImageFromClipboard reads image data from the system clipboard and
3178// creates an attachment. If no image data is found, it falls back to
3179// interpreting clipboard text as a file path.
3180func (m *UI) pasteImageFromClipboard() tea.Msg {
3181	imageData, err := readClipboard(clipboardFormatImage)
3182	if int64(len(imageData)) > common.MaxAttachmentSize {
3183		return util.InfoMsg{
3184			Type: util.InfoTypeError,
3185			Msg:  "File too large, max 5MB",
3186		}
3187	}
3188	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3189	if err == nil {
3190		return message.Attachment{
3191			FilePath: name,
3192			FileName: name,
3193			MimeType: mimeOf(imageData),
3194			Content:  imageData,
3195		}
3196	}
3197
3198	textData, textErr := readClipboard(clipboardFormatText)
3199	if textErr != nil || len(textData) == 0 {
3200		return nil // Clipboard is empty or does not contain an image
3201	}
3202
3203	path := strings.TrimSpace(string(textData))
3204	path = strings.ReplaceAll(path, "\\ ", " ")
3205	if _, statErr := os.Stat(path); statErr != nil {
3206		return nil // Clipboard does not contain an image or valid file path
3207	}
3208
3209	lowerPath := strings.ToLower(path)
3210	isAllowed := false
3211	for _, ext := range common.AllowedImageTypes {
3212		if strings.HasSuffix(lowerPath, ext) {
3213			isAllowed = true
3214			break
3215		}
3216	}
3217	if !isAllowed {
3218		return util.NewInfoMsg("File type is not a supported image format")
3219	}
3220
3221	fileInfo, statErr := os.Stat(path)
3222	if statErr != nil {
3223		return util.InfoMsg{
3224			Type: util.InfoTypeError,
3225			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3226		}
3227	}
3228	if fileInfo.Size() > common.MaxAttachmentSize {
3229		return util.InfoMsg{
3230			Type: util.InfoTypeError,
3231			Msg:  "File too large, max 5MB",
3232		}
3233	}
3234
3235	content, readErr := os.ReadFile(path)
3236	if readErr != nil {
3237		return util.InfoMsg{
3238			Type: util.InfoTypeError,
3239			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3240		}
3241	}
3242
3243	return message.Attachment{
3244		FilePath: path,
3245		FileName: filepath.Base(path),
3246		MimeType: mimeOf(content),
3247		Content:  content,
3248	}
3249}
3250
3251var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3252
3253func (m *UI) pasteIdx() int {
3254	result := 0
3255	for _, at := range m.attachments.List() {
3256		found := pasteRE.FindStringSubmatch(at.FileName)
3257		if len(found) == 0 {
3258			continue
3259		}
3260		idx, err := strconv.Atoi(found[1])
3261		if err == nil {
3262			result = max(result, idx)
3263		}
3264	}
3265	return result + 1
3266}
3267
3268// drawSessionDetails draws the session details in compact mode.
3269func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3270	if m.session == nil {
3271		return
3272	}
3273
3274	s := m.com.Styles
3275
3276	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3277	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3278
3279	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3280	blocks := []string{
3281		title,
3282		"",
3283		m.modelInfo(width),
3284		"",
3285	}
3286
3287	detailsHeader := lipgloss.JoinVertical(
3288		lipgloss.Left,
3289		blocks...,
3290	)
3291
3292	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3293
3294	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3295
3296	const maxSectionWidth = 50
3297	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3298	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3299
3300	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3301	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3302	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3303	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3304	uv.NewStyledString(
3305		s.CompactDetails.View.
3306			Width(area.Dx()).
3307			Render(
3308				lipgloss.JoinVertical(
3309					lipgloss.Left,
3310					detailsHeader,
3311					sections,
3312					version,
3313				),
3314			),
3315	).Draw(scr, area)
3316}
3317
3318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3319	load := func() tea.Msg {
3320		prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3321		if err != nil {
3322			// TODO: make this better
3323			return util.ReportError(err)()
3324		}
3325
3326		if prompt == "" {
3327			return nil
3328		}
3329		return sendMessageMsg{
3330			Content: prompt,
3331		}
3332	}
3333
3334	var cmds []tea.Cmd
3335	if cmd := m.dialog.StartLoading(); cmd != nil {
3336		cmds = append(cmds, cmd)
3337	}
3338	cmds = append(cmds, load, func() tea.Msg {
3339		return closeDialogMsg{}
3340	})
3341
3342	return tea.Sequence(cmds...)
3343}
3344
3345func (m *UI) handleStateChanged() tea.Cmd {
3346	return func() tea.Msg {
3347		m.com.Workspace.UpdateAgentModel(context.Background())
3348		return mcpStateChangedMsg{
3349			states: m.com.Workspace.MCPGetStates(),
3350		}
3351	}
3352}
3353
3354func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3355	return func() tea.Msg {
3356		ws.MCPRefreshPrompts(context.Background(), name)
3357		return nil
3358	}
3359}
3360
3361func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3362	return func() tea.Msg {
3363		ws.RefreshMCPTools(context.Background(), name)
3364		return nil
3365	}
3366}
3367
3368func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3369	return func() tea.Msg {
3370		ws.MCPRefreshResources(context.Background(), name)
3371		return nil
3372	}
3373}
3374
3375func (m *UI) copyChatHighlight() tea.Cmd {
3376	text := m.chat.HighlightContent()
3377	return common.CopyToClipboardWithCallback(
3378		text,
3379		"Selected text copied to clipboard",
3380		func() tea.Msg {
3381			m.chat.ClearMouse()
3382			return nil
3383		},
3384	)
3385}
3386
3387// renderLogo renders the Crush logo with the given styles and dimensions.
3388func renderLogo(t *styles.Styles, compact bool, width int) string {
3389	return logo.Render(t, version.Version, compact, logo.Opts{
3390		FieldColor:   t.LogoFieldColor,
3391		TitleColorA:  t.LogoTitleColorA,
3392		TitleColorB:  t.LogoTitleColorB,
3393		CharmColor:   t.LogoCharmColor,
3394		VersionColor: t.LogoVersionColor,
3395		Width:        width,
3396	})
3397}