ui.go

   1package model
   2
   3import (
   4	"bytes"
   5	"cmp"
   6	"context"
   7	"errors"
   8	"fmt"
   9	"image"
  10	"log/slog"
  11	"math/rand"
  12	"net/http"
  13	"os"
  14	"path/filepath"
  15	"regexp"
  16	"slices"
  17	"strconv"
  18	"strings"
  19	"time"
  20
  21	"charm.land/bubbles/v2/help"
  22	"charm.land/bubbles/v2/key"
  23	"charm.land/bubbles/v2/spinner"
  24	"charm.land/bubbles/v2/textarea"
  25	tea "charm.land/bubbletea/v2"
  26	"charm.land/catwalk/pkg/catwalk"
  27	"charm.land/lipgloss/v2"
  28	"github.com/charmbracelet/crush/internal/agent/notify"
  29	agenttools "github.com/charmbracelet/crush/internal/agent/tools"
  30	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  31	"github.com/charmbracelet/crush/internal/app"
  32	"github.com/charmbracelet/crush/internal/commands"
  33	"github.com/charmbracelet/crush/internal/config"
  34	"github.com/charmbracelet/crush/internal/fsext"
  35	"github.com/charmbracelet/crush/internal/history"
  36	"github.com/charmbracelet/crush/internal/home"
  37	"github.com/charmbracelet/crush/internal/message"
  38	"github.com/charmbracelet/crush/internal/permission"
  39	"github.com/charmbracelet/crush/internal/pubsub"
  40	"github.com/charmbracelet/crush/internal/session"
  41	"github.com/charmbracelet/crush/internal/ui/anim"
  42	"github.com/charmbracelet/crush/internal/ui/attachments"
  43	"github.com/charmbracelet/crush/internal/ui/chat"
  44	"github.com/charmbracelet/crush/internal/ui/common"
  45	"github.com/charmbracelet/crush/internal/ui/completions"
  46	"github.com/charmbracelet/crush/internal/ui/dialog"
  47	fimage "github.com/charmbracelet/crush/internal/ui/image"
  48	"github.com/charmbracelet/crush/internal/ui/logo"
  49	"github.com/charmbracelet/crush/internal/ui/notification"
  50	"github.com/charmbracelet/crush/internal/ui/styles"
  51	"github.com/charmbracelet/crush/internal/ui/util"
  52	"github.com/charmbracelet/crush/internal/version"
  53	uv "github.com/charmbracelet/ultraviolet"
  54	"github.com/charmbracelet/ultraviolet/layout"
  55	"github.com/charmbracelet/ultraviolet/screen"
  56	"github.com/charmbracelet/x/editor"
  57)
  58
  59// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
  60// wheel event occurs.
  61const MouseScrollThreshold = 5
  62
  63// Compact mode breakpoints.
  64const (
  65	compactModeWidthBreakpoint  = 120
  66	compactModeHeightBreakpoint = 30
  67)
  68
  69// If pasted text has more than 2 newlines, treat it as a file attachment.
  70const pasteLinesThreshold = 10
  71
  72// Session details panel max height.
  73const sessionDetailsMaxHeight = 20
  74
  75// uiFocusState represents the current focus state of the UI.
  76type uiFocusState uint8
  77
  78// Possible uiFocusState values.
  79const (
  80	uiFocusNone uiFocusState = iota
  81	uiFocusEditor
  82	uiFocusMain
  83)
  84
  85type uiState uint8
  86
  87// Possible uiState values.
  88const (
  89	uiOnboarding uiState = iota
  90	uiInitialize
  91	uiLanding
  92	uiChat
  93)
  94
  95type openEditorMsg struct {
  96	Text string
  97}
  98
  99type (
 100	// cancelTimerExpiredMsg is sent when the cancel timer expires.
 101	cancelTimerExpiredMsg struct{}
 102	// userCommandsLoadedMsg is sent when user commands are loaded.
 103	userCommandsLoadedMsg struct {
 104		Commands []commands.CustomCommand
 105	}
 106	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
 107	mcpPromptsLoadedMsg struct {
 108		Prompts []commands.MCPPrompt
 109	}
 110	// mcpStateChangedMsg is sent when there is a change in MCP client states.
 111	mcpStateChangedMsg struct {
 112		states map[string]mcp.ClientInfo
 113	}
 114	// sendMessageMsg is sent to send a message.
 115	// currently only used for mcp prompts.
 116	sendMessageMsg struct {
 117		Content     string
 118		Attachments []message.Attachment
 119	}
 120
 121	// closeDialogMsg is sent to close the current dialog.
 122	closeDialogMsg struct{}
 123
 124	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
 125	copyChatHighlightMsg struct{}
 126
 127	// sessionFilesUpdatesMsg is sent when the files for this session have been updated
 128	sessionFilesUpdatesMsg struct {
 129		sessionFiles []SessionFile
 130	}
 131)
 132
 133// UI represents the main user interface model.
 134type UI struct {
 135	com          *common.Common
 136	session      *session.Session
 137	sessionFiles []SessionFile
 138
 139	// keeps track of read files while we don't have a session id
 140	sessionFileReads []string
 141
 142	lastUserMessageTime int64
 143
 144	// The width and height of the terminal in cells.
 145	width  int
 146	height int
 147	layout uiLayout
 148
 149	isTransparent bool
 150
 151	focus uiFocusState
 152	state uiState
 153
 154	keyMap KeyMap
 155	keyenh tea.KeyboardEnhancementsMsg
 156
 157	dialog *dialog.Overlay
 158	status *Status
 159
 160	// isCanceling tracks whether the user has pressed escape once to cancel.
 161	isCanceling bool
 162
 163	header *header
 164
 165	// sendProgressBar instructs the TUI to send progress bar updates to the
 166	// terminal.
 167	sendProgressBar    bool
 168	progressBarEnabled bool
 169
 170	// caps hold different terminal capabilities that we query for.
 171	caps common.Capabilities
 172
 173	// Editor components
 174	textarea textarea.Model
 175
 176	// Attachment list
 177	attachments *attachments.Attachments
 178
 179	readyPlaceholder   string
 180	workingPlaceholder string
 181
 182	// Completions state
 183	completions              *completions.Completions
 184	completionsOpen          bool
 185	completionsStartIndex    int
 186	completionsQuery         string
 187	completionsPositionStart image.Point // x,y where user typed '@'
 188
 189	// Chat components
 190	chat *Chat
 191
 192	// onboarding state
 193	onboarding struct {
 194		yesInitializeSelected bool
 195	}
 196
 197	// lsp
 198	lspStates map[string]app.LSPClientInfo
 199
 200	// mcp
 201	mcpStates map[string]mcp.ClientInfo
 202
 203	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 204	sidebarLogo string
 205
 206	// Notification state
 207	notifyBackend       notification.Backend
 208	notifyWindowFocused bool
 209	// custom commands & mcp commands
 210	customCommands []commands.CustomCommand
 211	mcpPrompts     []commands.MCPPrompt
 212
 213	// forceCompactMode tracks whether compact mode is forced by user toggle
 214	forceCompactMode bool
 215
 216	// isCompact tracks whether we're currently in compact layout mode (either
 217	// by user toggle or auto-switch based on window size)
 218	isCompact bool
 219
 220	// detailsOpen tracks whether the details panel is open (in compact mode)
 221	detailsOpen bool
 222
 223	// pills state
 224	pillsExpanded      bool
 225	focusedPillSection pillSection
 226	promptQueue        int
 227	pillsView          string
 228
 229	// Todo spinner
 230	todoSpinner    spinner.Model
 231	todoIsSpinning bool
 232
 233	// mouse highlighting related state
 234	lastClickTime time.Time
 235
 236	// Prompt history for up/down navigation through previous messages.
 237	promptHistory struct {
 238		messages []string
 239		index    int
 240		draft    string
 241	}
 242}
 243
 244// New creates a new instance of the [UI] model.
 245func New(com *common.Common) *UI {
 246	// Editor components
 247	ta := textarea.New()
 248	ta.SetStyles(com.Styles.TextArea)
 249	ta.ShowLineNumbers = false
 250	ta.CharLimit = -1
 251	ta.SetVirtualCursor(false)
 252	ta.Focus()
 253
 254	ch := NewChat(com)
 255
 256	keyMap := DefaultKeyMap()
 257
 258	// Completions component
 259	comp := completions.New(
 260		com.Styles.Completions.Normal,
 261		com.Styles.Completions.Focused,
 262		com.Styles.Completions.Match,
 263	)
 264
 265	todoSpinner := spinner.New(
 266		spinner.WithSpinner(spinner.MiniDot),
 267		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
 268	)
 269
 270	// Attachments component
 271	attachments := attachments.New(
 272		attachments.NewRenderer(
 273			com.Styles.Attachments.Normal,
 274			com.Styles.Attachments.Deleting,
 275			com.Styles.Attachments.Image,
 276			com.Styles.Attachments.Text,
 277		),
 278		attachments.Keymap{
 279			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 280			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 281			Escape:     keyMap.Editor.Escape,
 282		},
 283	)
 284
 285	header := newHeader(com)
 286
 287	ui := &UI{
 288		com:                 com,
 289		dialog:              dialog.NewOverlay(),
 290		keyMap:              keyMap,
 291		textarea:            ta,
 292		chat:                ch,
 293		header:              header,
 294		completions:         comp,
 295		attachments:         attachments,
 296		todoSpinner:         todoSpinner,
 297		lspStates:           make(map[string]app.LSPClientInfo),
 298		mcpStates:           make(map[string]mcp.ClientInfo),
 299		notifyBackend:       notification.NoopBackend{},
 300		notifyWindowFocused: true,
 301	}
 302
 303	status := NewStatus(com, ui)
 304
 305	ui.setEditorPrompt(false)
 306	ui.randomizePlaceholders()
 307	ui.textarea.Placeholder = ui.readyPlaceholder
 308	ui.status = status
 309
 310	// Initialize compact mode from config
 311	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
 312
 313	// set onboarding state defaults
 314	ui.onboarding.yesInitializeSelected = true
 315
 316	desiredState := uiLanding
 317	desiredFocus := uiFocusEditor
 318	if !com.Config().IsConfigured() {
 319		desiredState = uiOnboarding
 320	} else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
 321		desiredState = uiInitialize
 322	}
 323
 324	// set initial state
 325	ui.setState(desiredState, desiredFocus)
 326
 327	opts := com.Config().Options
 328
 329	// disable indeterminate progress bar
 330	ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
 331	// enable transparent mode
 332	ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
 333
 334	return ui
 335}
 336
 337// Init initializes the UI model.
 338func (m *UI) Init() tea.Cmd {
 339	var cmds []tea.Cmd
 340	if m.state == uiOnboarding {
 341		if cmd := m.openModelsDialog(); cmd != nil {
 342			cmds = append(cmds, cmd)
 343		}
 344	}
 345	// load the user commands async
 346	cmds = append(cmds, m.loadCustomCommands())
 347	// load prompt history async
 348	cmds = append(cmds, m.loadPromptHistory())
 349	return tea.Batch(cmds...)
 350}
 351
 352// sendNotification returns a command that sends a notification if allowed by policy.
 353func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
 354	if !m.shouldSendNotification() {
 355		return nil
 356	}
 357
 358	backend := m.notifyBackend
 359	return func() tea.Msg {
 360		if err := backend.Send(n); err != nil {
 361			slog.Error("Failed to send notification", "error", err)
 362		}
 363		return nil
 364	}
 365}
 366
 367// shouldSendNotification returns true if notifications should be sent based on
 368// current state. Focus reporting must be supported, window must not focused,
 369// and notifications must not be disabled in config.
 370func (m *UI) shouldSendNotification() bool {
 371	cfg := m.com.Config()
 372	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
 373		return false
 374	}
 375	return m.caps.ReportFocusEvents && !m.notifyWindowFocused
 376}
 377
 378// setState changes the UI state and focus.
 379func (m *UI) setState(state uiState, focus uiFocusState) {
 380	if state == uiLanding {
 381		// Always turn off compact mode when going to landing
 382		m.isCompact = false
 383	}
 384	m.state = state
 385	m.focus = focus
 386	// Changing the state may change layout, so update it.
 387	m.updateLayoutAndSize()
 388}
 389
 390// loadCustomCommands loads the custom commands asynchronously.
 391func (m *UI) loadCustomCommands() tea.Cmd {
 392	return func() tea.Msg {
 393		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 394		if err != nil {
 395			slog.Error("Failed to load custom commands", "error", err)
 396		}
 397		return userCommandsLoadedMsg{Commands: customCommands}
 398	}
 399}
 400
 401// loadMCPrompts loads the MCP prompts asynchronously.
 402func (m *UI) loadMCPrompts() tea.Msg {
 403	prompts, err := commands.LoadMCPPrompts()
 404	if err != nil {
 405		slog.Error("Failed to load MCP prompts", "error", err)
 406	}
 407	if prompts == nil {
 408		// flag them as loaded even if there is none or an error
 409		prompts = []commands.MCPPrompt{}
 410	}
 411	return mcpPromptsLoadedMsg{Prompts: prompts}
 412}
 413
 414// Update handles updates to the UI model.
 415func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 416	var cmds []tea.Cmd
 417	if m.hasSession() && m.isAgentBusy() {
 418		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
 419		if queueSize != m.promptQueue {
 420			m.promptQueue = queueSize
 421			m.updateLayoutAndSize()
 422		}
 423	}
 424	// Update terminal capabilities
 425	m.caps.Update(msg)
 426	switch msg := msg.(type) {
 427	case tea.EnvMsg:
 428		// Is this Windows Terminal?
 429		if !m.sendProgressBar {
 430			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 431		}
 432		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 433	case tea.ModeReportMsg:
 434		if m.caps.ReportFocusEvents {
 435			m.notifyBackend = notification.NewNativeBackend(notification.Icon)
 436		}
 437	case tea.FocusMsg:
 438		m.notifyWindowFocused = true
 439	case tea.BlurMsg:
 440		m.notifyWindowFocused = false
 441	case pubsub.Event[notify.Notification]:
 442		if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
 443			cmds = append(cmds, cmd)
 444		}
 445	case loadSessionMsg:
 446		if m.forceCompactMode {
 447			m.isCompact = true
 448		}
 449		m.setState(uiChat, m.focus)
 450		m.session = msg.session
 451		m.sessionFiles = msg.files
 452		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
 453		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 454		if err != nil {
 455			cmds = append(cmds, util.ReportError(err))
 456			break
 457		}
 458		if cmd := m.setSessionMessages(msgs); cmd != nil {
 459			cmds = append(cmds, cmd)
 460		}
 461		if hasInProgressTodo(m.session.Todos) {
 462			// only start spinner if there is an in-progress todo
 463			if m.isAgentBusy() {
 464				m.todoIsSpinning = true
 465				cmds = append(cmds, m.todoSpinner.Tick)
 466			}
 467			m.updateLayoutAndSize()
 468		}
 469		// Reload prompt history for the new session.
 470		m.historyReset()
 471		cmds = append(cmds, m.loadPromptHistory())
 472		m.updateLayoutAndSize()
 473
 474	case sessionFilesUpdatesMsg:
 475		m.sessionFiles = msg.sessionFiles
 476		var paths []string
 477		for _, f := range msg.sessionFiles {
 478			paths = append(paths, f.LatestVersion.Path)
 479		}
 480		cmds = append(cmds, m.startLSPs(paths))
 481
 482	case sendMessageMsg:
 483		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 484
 485	case userCommandsLoadedMsg:
 486		m.customCommands = msg.Commands
 487		dia := m.dialog.Dialog(dialog.CommandsID)
 488		if dia == nil {
 489			break
 490		}
 491
 492		commands, ok := dia.(*dialog.Commands)
 493		if ok {
 494			commands.SetCustomCommands(m.customCommands)
 495		}
 496
 497	case mcpStateChangedMsg:
 498		m.mcpStates = msg.states
 499	case mcpPromptsLoadedMsg:
 500		m.mcpPrompts = msg.Prompts
 501		dia := m.dialog.Dialog(dialog.CommandsID)
 502		if dia == nil {
 503			break
 504		}
 505
 506		commands, ok := dia.(*dialog.Commands)
 507		if ok {
 508			commands.SetMCPPrompts(m.mcpPrompts)
 509		}
 510
 511	case promptHistoryLoadedMsg:
 512		m.promptHistory.messages = msg.messages
 513		m.promptHistory.index = -1
 514		m.promptHistory.draft = ""
 515
 516	case closeDialogMsg:
 517		m.dialog.CloseFrontDialog()
 518
 519	case pubsub.Event[session.Session]:
 520		if msg.Type == pubsub.DeletedEvent {
 521			if m.session != nil && m.session.ID == msg.Payload.ID {
 522				if cmd := m.newSession(); cmd != nil {
 523					cmds = append(cmds, cmd)
 524				}
 525			}
 526			break
 527		}
 528		if m.session != nil && msg.Payload.ID == m.session.ID {
 529			prevHasInProgress := hasInProgressTodo(m.session.Todos)
 530			m.session = &msg.Payload
 531			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
 532				m.todoIsSpinning = true
 533				cmds = append(cmds, m.todoSpinner.Tick)
 534				m.updateLayoutAndSize()
 535			}
 536		}
 537	case pubsub.Event[message.Message]:
 538		// Check if this is a child session message for an agent tool.
 539		if m.session == nil {
 540			break
 541		}
 542		if msg.Payload.SessionID != m.session.ID {
 543			// This might be a child session message from an agent tool.
 544			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 545				cmds = append(cmds, cmd)
 546			}
 547			break
 548		}
 549		switch msg.Type {
 550		case pubsub.CreatedEvent:
 551			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 552		case pubsub.UpdatedEvent:
 553			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 554		case pubsub.DeletedEvent:
 555			m.chat.RemoveMessage(msg.Payload.ID)
 556		}
 557		// start the spinner if there is a new message
 558		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
 559			m.todoIsSpinning = true
 560			cmds = append(cmds, m.todoSpinner.Tick)
 561		}
 562		// stop the spinner if the agent is not busy anymore
 563		if m.todoIsSpinning && !m.isAgentBusy() {
 564			m.todoIsSpinning = false
 565		}
 566		// there is a number of things that could change the pills here so we want to re-render
 567		m.renderPills()
 568	case pubsub.Event[history.File]:
 569		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 570	case pubsub.Event[app.LSPEvent]:
 571		m.lspStates = app.GetLSPStates()
 572	case pubsub.Event[mcp.Event]:
 573		switch msg.Payload.Type {
 574		case mcp.EventStateChanged:
 575			return m, tea.Batch(
 576				m.handleStateChanged(),
 577				m.loadMCPrompts,
 578			)
 579		case mcp.EventPromptsListChanged:
 580			return m, handleMCPPromptsEvent(msg.Payload.Name)
 581		case mcp.EventToolsListChanged:
 582			return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
 583		case mcp.EventResourcesListChanged:
 584			return m, handleMCPResourcesEvent(msg.Payload.Name)
 585		}
 586	case pubsub.Event[permission.PermissionRequest]:
 587		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
 588			cmds = append(cmds, cmd)
 589		}
 590		if cmd := m.sendNotification(notification.Notification{
 591			Title:   "Crush is waiting...",
 592			Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
 593		}); cmd != nil {
 594			cmds = append(cmds, cmd)
 595		}
 596	case pubsub.Event[permission.PermissionNotification]:
 597		m.handlePermissionNotification(msg.Payload)
 598	case cancelTimerExpiredMsg:
 599		m.isCanceling = false
 600	case tea.TerminalVersionMsg:
 601		termVersion := strings.ToLower(msg.Name)
 602		// Only enable progress bar for the following terminals.
 603		if !m.sendProgressBar {
 604			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 605		}
 606		return m, nil
 607	case tea.WindowSizeMsg:
 608		m.width, m.height = msg.Width, msg.Height
 609		m.updateLayoutAndSize()
 610		if m.state == uiChat && m.chat.Follow() {
 611			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 612				cmds = append(cmds, cmd)
 613			}
 614		}
 615	case tea.KeyboardEnhancementsMsg:
 616		m.keyenh = msg
 617		if msg.SupportsKeyDisambiguation() {
 618			m.keyMap.Models.SetHelp("ctrl+m", "models")
 619			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 620		}
 621	case copyChatHighlightMsg:
 622		cmds = append(cmds, m.copyChatHighlight())
 623	case DelayedClickMsg:
 624		// Handle delayed single-click action (e.g., expansion).
 625		m.chat.HandleDelayedClick(msg)
 626	case tea.MouseClickMsg:
 627		// Pass mouse events to dialogs first if any are open.
 628		if m.dialog.HasDialogs() {
 629			m.dialog.Update(msg)
 630			return m, tea.Batch(cmds...)
 631		}
 632
 633		if cmd := m.handleClickFocus(msg); cmd != nil {
 634			cmds = append(cmds, cmd)
 635		}
 636
 637		switch m.state {
 638		case uiChat:
 639			x, y := msg.X, msg.Y
 640			// Adjust for chat area position
 641			x -= m.layout.main.Min.X
 642			y -= m.layout.main.Min.Y
 643			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
 644				if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
 645					m.lastClickTime = time.Now()
 646					if cmd != nil {
 647						cmds = append(cmds, cmd)
 648					}
 649				}
 650			}
 651		}
 652
 653	case tea.MouseMotionMsg:
 654		// Pass mouse events to dialogs first if any are open.
 655		if m.dialog.HasDialogs() {
 656			m.dialog.Update(msg)
 657			return m, tea.Batch(cmds...)
 658		}
 659
 660		switch m.state {
 661		case uiChat:
 662			if msg.Y <= 0 {
 663				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 664					cmds = append(cmds, cmd)
 665				}
 666				if !m.chat.SelectedItemInView() {
 667					m.chat.SelectPrev()
 668					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 669						cmds = append(cmds, cmd)
 670					}
 671				}
 672			} else if msg.Y >= m.chat.Height()-1 {
 673				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 674					cmds = append(cmds, cmd)
 675				}
 676				if !m.chat.SelectedItemInView() {
 677					m.chat.SelectNext()
 678					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 679						cmds = append(cmds, cmd)
 680					}
 681				}
 682			}
 683
 684			x, y := msg.X, msg.Y
 685			// Adjust for chat area position
 686			x -= m.layout.main.Min.X
 687			y -= m.layout.main.Min.Y
 688			m.chat.HandleMouseDrag(x, y)
 689		}
 690
 691	case tea.MouseReleaseMsg:
 692		// Pass mouse events to dialogs first if any are open.
 693		if m.dialog.HasDialogs() {
 694			m.dialog.Update(msg)
 695			return m, tea.Batch(cmds...)
 696		}
 697
 698		switch m.state {
 699		case uiChat:
 700			x, y := msg.X, msg.Y
 701			// Adjust for chat area position
 702			x -= m.layout.main.Min.X
 703			y -= m.layout.main.Min.Y
 704			if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
 705				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
 706					if time.Since(m.lastClickTime) >= doubleClickThreshold {
 707						return copyChatHighlightMsg{}
 708					}
 709					return nil
 710				}))
 711			}
 712		}
 713	case tea.MouseWheelMsg:
 714		// Pass mouse events to dialogs first if any are open.
 715		if m.dialog.HasDialogs() {
 716			m.dialog.Update(msg)
 717			return m, tea.Batch(cmds...)
 718		}
 719
 720		// Otherwise handle mouse wheel for chat.
 721		switch m.state {
 722		case uiChat:
 723			switch msg.Button {
 724			case tea.MouseWheelUp:
 725				if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
 726					cmds = append(cmds, cmd)
 727				}
 728				if !m.chat.SelectedItemInView() {
 729					m.chat.SelectPrev()
 730					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 731						cmds = append(cmds, cmd)
 732					}
 733				}
 734			case tea.MouseWheelDown:
 735				if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
 736					cmds = append(cmds, cmd)
 737				}
 738				if !m.chat.SelectedItemInView() {
 739					if m.chat.AtBottom() {
 740						m.chat.SelectLast()
 741					} else {
 742						m.chat.SelectNext()
 743					}
 744					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 745						cmds = append(cmds, cmd)
 746					}
 747				}
 748			}
 749		}
 750	case anim.StepMsg:
 751		if m.state == uiChat {
 752			if cmd := m.chat.Animate(msg); cmd != nil {
 753				cmds = append(cmds, cmd)
 754			}
 755			if m.chat.Follow() {
 756				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 757					cmds = append(cmds, cmd)
 758				}
 759			}
 760		}
 761	case spinner.TickMsg:
 762		if m.dialog.HasDialogs() {
 763			// route to dialog
 764			if cmd := m.handleDialogMsg(msg); cmd != nil {
 765				cmds = append(cmds, cmd)
 766			}
 767		}
 768		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
 769			var cmd tea.Cmd
 770			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
 771			if cmd != nil {
 772				m.renderPills()
 773				cmds = append(cmds, cmd)
 774			}
 775		}
 776
 777	case tea.KeyPressMsg:
 778		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 779			cmds = append(cmds, cmd)
 780		}
 781	case tea.PasteMsg:
 782		if cmd := m.handlePasteMsg(msg); cmd != nil {
 783			cmds = append(cmds, cmd)
 784		}
 785	case openEditorMsg:
 786		var cmd tea.Cmd
 787		m.textarea.SetValue(msg.Text)
 788		m.textarea.MoveToEnd()
 789		m.textarea, cmd = m.textarea.Update(msg)
 790		if cmd != nil {
 791			cmds = append(cmds, cmd)
 792		}
 793	case util.InfoMsg:
 794		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.App.Permissions.SkipRequests() {
 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.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 913
 914		// Fetch nested messages.
 915		nestedMsgs, err := m.com.App.Messages.List(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.App.Sessions.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.App.Permissions.SkipRequests()
1250		m.com.App.Permissions.SetSkipRequests(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.App.AgentCoordinator.Summarize(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.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1308				return util.ReportError(err)()
1309			}
1310			m.com.App.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.Store().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.Store().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.App.GetDefaultSmallModel(providerID)
1364			if err := m.com.Store().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.App.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.App.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.Store().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.App.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.App.Permissions.Grant(msg.Permission)
1425		case dialog.PermissionAllowForSession:
1426			m.com.App.Permissions.GrantPersistent(msg.Permission)
1427		case dialog.PermissionDeny:
1428			m.com.App.Permissions.Deny(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.Store().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.App.AgentCoordinator.QueuedPrompts(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.App.AgentCoordinator.QueuedPrompts(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.Store().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.App.FileTracker.LastReadTime(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 := mcp.ReadResource(
2642			context.Background(),
2643			m.com.Store(),
2644			item.MCPName,
2645			item.URI,
2646		)
2647		if err != nil {
2648			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2649			return nil
2650		}
2651		if len(contents) == 0 {
2652			return nil
2653		}
2654
2655		content := contents[0]
2656		var data []byte
2657		if content.Text != "" {
2658			data = []byte(content.Text)
2659		} else if len(content.Blob) > 0 {
2660			data = content.Blob
2661		}
2662		if len(data) == 0 {
2663			return nil
2664		}
2665
2666		mimeType := item.MIMEType
2667		if mimeType == "" && content.MIMEType != "" {
2668			mimeType = content.MIMEType
2669		}
2670		if mimeType == "" {
2671			mimeType = "text/plain"
2672		}
2673
2674		return message.Attachment{
2675			FilePath: item.URI,
2676			FileName: displayText,
2677			MimeType: mimeType,
2678			Content:  data,
2679		}
2680	}
2681}
2682
2683// completionsPosition returns the X and Y position for the completions popup.
2684func (m *UI) completionsPosition() image.Point {
2685	cur := m.textarea.Cursor()
2686	if cur == nil {
2687		return image.Point{
2688			X: m.layout.editor.Min.X,
2689			Y: m.layout.editor.Min.Y,
2690		}
2691	}
2692	return image.Point{
2693		X: cur.X + m.layout.editor.Min.X,
2694		Y: m.layout.editor.Min.Y + cur.Y,
2695	}
2696}
2697
2698// textareaWord returns the current word at the cursor position.
2699func (m *UI) textareaWord() string {
2700	return m.textarea.Word()
2701}
2702
2703// isWhitespace returns true if the byte is a whitespace character.
2704func isWhitespace(b byte) bool {
2705	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2706}
2707
2708// isAgentBusy returns true if the agent coordinator exists and is currently
2709// busy processing a request.
2710func (m *UI) isAgentBusy() bool {
2711	return m.com.App != nil &&
2712		m.com.App.AgentCoordinator != nil &&
2713		m.com.App.AgentCoordinator.IsBusy()
2714}
2715
2716// hasSession returns true if there is an active session with a valid ID.
2717func (m *UI) hasSession() bool {
2718	return m.session != nil && m.session.ID != ""
2719}
2720
2721// mimeOf detects the MIME type of the given content.
2722func mimeOf(content []byte) string {
2723	mimeBufferSize := min(512, len(content))
2724	return http.DetectContentType(content[:mimeBufferSize])
2725}
2726
2727var readyPlaceholders = [...]string{
2728	"Ready!",
2729	"Ready...",
2730	"Ready?",
2731	"Ready for instructions",
2732}
2733
2734var workingPlaceholders = [...]string{
2735	"Working!",
2736	"Working...",
2737	"Brrrrr...",
2738	"Prrrrrrrr...",
2739	"Processing...",
2740	"Thinking...",
2741}
2742
2743// randomizePlaceholders selects random placeholder text for the textarea's
2744// ready and working states.
2745func (m *UI) randomizePlaceholders() {
2746	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2747	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2748}
2749
2750// renderEditorView renders the editor view with attachments if any.
2751func (m *UI) renderEditorView(width int) string {
2752	var attachmentsView string
2753	if len(m.attachments.List()) > 0 {
2754		attachmentsView = m.attachments.Render(width)
2755	}
2756	return strings.Join([]string{
2757		attachmentsView,
2758		m.textarea.View(),
2759		"", // margin at bottom of editor
2760	}, "\n")
2761}
2762
2763// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2764func (m *UI) cacheSidebarLogo(width int) {
2765	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2766}
2767
2768// sendMessage sends a message with the given content and attachments.
2769func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2770	if m.com.App.AgentCoordinator == nil {
2771		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2772	}
2773
2774	var cmds []tea.Cmd
2775	if !m.hasSession() {
2776		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2777		if err != nil {
2778			return util.ReportError(err)
2779		}
2780		if m.forceCompactMode {
2781			m.isCompact = true
2782		}
2783		if newSession.ID != "" {
2784			m.session = &newSession
2785			cmds = append(cmds, m.loadSession(newSession.ID))
2786		}
2787		m.setState(uiChat, m.focus)
2788	}
2789
2790	ctx := context.Background()
2791	cmds = append(cmds, func() tea.Msg {
2792		for _, path := range m.sessionFileReads {
2793			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2794			m.com.App.LSPManager.Start(ctx, path)
2795		}
2796		return nil
2797	})
2798
2799	// Capture session ID to avoid race with main goroutine updating m.session.
2800	sessionID := m.session.ID
2801	cmds = append(cmds, func() tea.Msg {
2802		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2803		if err != nil {
2804			isCancelErr := errors.Is(err, context.Canceled)
2805			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2806			if isCancelErr || isPermissionErr {
2807				return nil
2808			}
2809			return util.InfoMsg{
2810				Type: util.InfoTypeError,
2811				Msg:  fmt.Sprintf("Failed to run agent: %v", err),
2812			}
2813		}
2814		return nil
2815	})
2816	return tea.Batch(cmds...)
2817}
2818
2819const cancelTimerDuration = 2 * time.Second
2820
2821// cancelTimerCmd creates a command that expires the cancel timer.
2822func cancelTimerCmd() tea.Cmd {
2823	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2824		return cancelTimerExpiredMsg{}
2825	})
2826}
2827
2828// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2829// and starts a timer. The second press (before the timer expires) actually
2830// cancels the agent.
2831func (m *UI) cancelAgent() tea.Cmd {
2832	if !m.hasSession() {
2833		return nil
2834	}
2835
2836	coordinator := m.com.App.AgentCoordinator
2837	if coordinator == nil {
2838		return nil
2839	}
2840
2841	if m.isCanceling {
2842		// Second escape press - actually cancel the agent.
2843		m.isCanceling = false
2844		coordinator.Cancel(m.session.ID)
2845		// Stop the spinning todo indicator.
2846		m.todoIsSpinning = false
2847		m.renderPills()
2848		return nil
2849	}
2850
2851	// Check if there are queued prompts - if so, clear the queue.
2852	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2853		coordinator.ClearQueue(m.session.ID)
2854		return nil
2855	}
2856
2857	// First escape press - set canceling state and start timer.
2858	m.isCanceling = true
2859	return cancelTimerCmd()
2860}
2861
2862// openDialog opens a dialog by its ID.
2863func (m *UI) openDialog(id string) tea.Cmd {
2864	var cmds []tea.Cmd
2865	switch id {
2866	case dialog.SessionsID:
2867		if cmd := m.openSessionsDialog(); cmd != nil {
2868			cmds = append(cmds, cmd)
2869		}
2870	case dialog.ModelsID:
2871		if cmd := m.openModelsDialog(); cmd != nil {
2872			cmds = append(cmds, cmd)
2873		}
2874	case dialog.CommandsID:
2875		if cmd := m.openCommandsDialog(); cmd != nil {
2876			cmds = append(cmds, cmd)
2877		}
2878	case dialog.ReasoningID:
2879		if cmd := m.openReasoningDialog(); cmd != nil {
2880			cmds = append(cmds, cmd)
2881		}
2882	case dialog.QuitID:
2883		if cmd := m.openQuitDialog(); cmd != nil {
2884			cmds = append(cmds, cmd)
2885		}
2886	default:
2887		// Unknown dialog
2888		break
2889	}
2890	return tea.Batch(cmds...)
2891}
2892
2893// openQuitDialog opens the quit confirmation dialog.
2894func (m *UI) openQuitDialog() tea.Cmd {
2895	if m.dialog.ContainsDialog(dialog.QuitID) {
2896		// Bring to front
2897		m.dialog.BringToFront(dialog.QuitID)
2898		return nil
2899	}
2900
2901	quitDialog := dialog.NewQuit(m.com)
2902	m.dialog.OpenDialog(quitDialog)
2903	return nil
2904}
2905
2906// openModelsDialog opens the models dialog.
2907func (m *UI) openModelsDialog() tea.Cmd {
2908	if m.dialog.ContainsDialog(dialog.ModelsID) {
2909		// Bring to front
2910		m.dialog.BringToFront(dialog.ModelsID)
2911		return nil
2912	}
2913
2914	isOnboarding := m.state == uiOnboarding
2915	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2916	if err != nil {
2917		return util.ReportError(err)
2918	}
2919
2920	m.dialog.OpenDialog(modelsDialog)
2921
2922	return nil
2923}
2924
2925// openCommandsDialog opens the commands dialog.
2926func (m *UI) openCommandsDialog() tea.Cmd {
2927	if m.dialog.ContainsDialog(dialog.CommandsID) {
2928		// Bring to front
2929		m.dialog.BringToFront(dialog.CommandsID)
2930		return nil
2931	}
2932
2933	var sessionID string
2934	hasSession := m.session != nil
2935	if hasSession {
2936		sessionID = m.session.ID
2937	}
2938	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2939	hasQueue := m.promptQueue > 0
2940
2941	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2942	if err != nil {
2943		return util.ReportError(err)
2944	}
2945
2946	m.dialog.OpenDialog(commands)
2947
2948	return nil
2949}
2950
2951// openReasoningDialog opens the reasoning effort dialog.
2952func (m *UI) openReasoningDialog() tea.Cmd {
2953	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2954		m.dialog.BringToFront(dialog.ReasoningID)
2955		return nil
2956	}
2957
2958	reasoningDialog, err := dialog.NewReasoning(m.com)
2959	if err != nil {
2960		return util.ReportError(err)
2961	}
2962
2963	m.dialog.OpenDialog(reasoningDialog)
2964	return nil
2965}
2966
2967// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2968// it brings it to the front. Otherwise, it will list all the sessions and open
2969// the dialog.
2970func (m *UI) openSessionsDialog() tea.Cmd {
2971	if m.dialog.ContainsDialog(dialog.SessionsID) {
2972		// Bring to front
2973		m.dialog.BringToFront(dialog.SessionsID)
2974		return nil
2975	}
2976
2977	selectedSessionID := ""
2978	if m.session != nil {
2979		selectedSessionID = m.session.ID
2980	}
2981
2982	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2983	if err != nil {
2984		return util.ReportError(err)
2985	}
2986
2987	m.dialog.OpenDialog(dialog)
2988	return nil
2989}
2990
2991// openFilesDialog opens the file picker dialog.
2992func (m *UI) openFilesDialog() tea.Cmd {
2993	if m.dialog.ContainsDialog(dialog.FilePickerID) {
2994		// Bring to front
2995		m.dialog.BringToFront(dialog.FilePickerID)
2996		return nil
2997	}
2998
2999	filePicker, cmd := dialog.NewFilePicker(m.com)
3000	filePicker.SetImageCapabilities(&m.caps)
3001	m.dialog.OpenDialog(filePicker)
3002
3003	return cmd
3004}
3005
3006// openPermissionsDialog opens the permissions dialog for a permission request.
3007func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3008	// Close any existing permissions dialog first.
3009	m.dialog.CloseDialog(dialog.PermissionsID)
3010
3011	// Get diff mode from config.
3012	var opts []dialog.PermissionsOption
3013	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3014		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3015	}
3016
3017	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3018	m.dialog.OpenDialog(permDialog)
3019	return nil
3020}
3021
3022// handlePermissionNotification updates tool items when permission state changes.
3023func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3024	toolItem := m.chat.MessageItem(notification.ToolCallID)
3025	if toolItem == nil {
3026		return
3027	}
3028
3029	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3030		if notification.Granted {
3031			permItem.SetStatus(chat.ToolStatusRunning)
3032		} else {
3033			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3034		}
3035	}
3036}
3037
3038// handleAgentNotification translates domain agent events into desktop
3039// notifications using the UI notification backend.
3040func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3041	switch n.Type {
3042	case notify.TypeAgentFinished:
3043		return m.sendNotification(notification.Notification{
3044			Title:   "Crush is waiting...",
3045			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3046		})
3047	default:
3048		return nil
3049	}
3050}
3051
3052// newSession clears the current session state and prepares for a new session.
3053// The actual session creation happens when the user sends their first message.
3054// Returns a command to reload prompt history.
3055func (m *UI) newSession() tea.Cmd {
3056	if !m.hasSession() {
3057		return nil
3058	}
3059
3060	m.session = nil
3061	m.sessionFiles = nil
3062	m.sessionFileReads = nil
3063	m.setState(uiLanding, uiFocusEditor)
3064	m.textarea.Focus()
3065	m.chat.Blur()
3066	m.chat.ClearMessages()
3067	m.pillsExpanded = false
3068	m.promptQueue = 0
3069	m.pillsView = ""
3070	m.historyReset()
3071	agenttools.ResetCache()
3072	return tea.Batch(
3073		func() tea.Msg {
3074			m.com.App.LSPManager.StopAll(context.Background())
3075			return nil
3076		},
3077		m.loadPromptHistory(),
3078	)
3079}
3080
3081// handlePasteMsg handles a paste message.
3082func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3083	if m.dialog.HasDialogs() {
3084		return m.handleDialogMsg(msg)
3085	}
3086
3087	if m.focus != uiFocusEditor {
3088		return nil
3089	}
3090
3091	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3092		return func() tea.Msg {
3093			content := []byte(msg.Content)
3094			if int64(len(content)) > common.MaxAttachmentSize {
3095				return util.ReportWarn("Paste is too big (>5mb)")
3096			}
3097			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3098			mimeBufferSize := min(512, len(content))
3099			mimeType := http.DetectContentType(content[:mimeBufferSize])
3100			return message.Attachment{
3101				FileName: name,
3102				FilePath: name,
3103				MimeType: mimeType,
3104				Content:  content,
3105			}
3106		}
3107	}
3108
3109	// Attempt to parse pasted content as file paths. If possible to parse,
3110	// all files exist and are valid, add as attachments.
3111	// Otherwise, paste as text.
3112	paths := fsext.ParsePastedFiles(msg.Content)
3113	allExistsAndValid := func() bool {
3114		if len(paths) == 0 {
3115			return false
3116		}
3117		for _, path := range paths {
3118			if _, err := os.Stat(path); os.IsNotExist(err) {
3119				return false
3120			}
3121
3122			lowerPath := strings.ToLower(path)
3123			isValid := false
3124			for _, ext := range common.AllowedImageTypes {
3125				if strings.HasSuffix(lowerPath, ext) {
3126					isValid = true
3127					break
3128				}
3129			}
3130			if !isValid {
3131				return false
3132			}
3133		}
3134		return true
3135	}
3136	if !allExistsAndValid() {
3137		var cmd tea.Cmd
3138		m.textarea, cmd = m.textarea.Update(msg)
3139		return cmd
3140	}
3141
3142	var cmds []tea.Cmd
3143	for _, path := range paths {
3144		cmds = append(cmds, m.handleFilePathPaste(path))
3145	}
3146	return tea.Batch(cmds...)
3147}
3148
3149// handleFilePathPaste handles a pasted file path.
3150func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3151	return func() tea.Msg {
3152		fileInfo, err := os.Stat(path)
3153		if err != nil {
3154			return util.ReportError(err)
3155		}
3156		if fileInfo.IsDir() {
3157			return util.ReportWarn("Cannot attach a directory")
3158		}
3159		if fileInfo.Size() > common.MaxAttachmentSize {
3160			return util.ReportWarn("File is too big (>5mb)")
3161		}
3162
3163		content, err := os.ReadFile(path)
3164		if err != nil {
3165			return util.ReportError(err)
3166		}
3167
3168		mimeBufferSize := min(512, len(content))
3169		mimeType := http.DetectContentType(content[:mimeBufferSize])
3170		fileName := filepath.Base(path)
3171		return message.Attachment{
3172			FilePath: path,
3173			FileName: fileName,
3174			MimeType: mimeType,
3175			Content:  content,
3176		}
3177	}
3178}
3179
3180// pasteImageFromClipboard reads image data from the system clipboard and
3181// creates an attachment. If no image data is found, it falls back to
3182// interpreting clipboard text as a file path.
3183func (m *UI) pasteImageFromClipboard() tea.Msg {
3184	imageData, err := readClipboard(clipboardFormatImage)
3185	if int64(len(imageData)) > common.MaxAttachmentSize {
3186		return util.InfoMsg{
3187			Type: util.InfoTypeError,
3188			Msg:  "File too large, max 5MB",
3189		}
3190	}
3191	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3192	if err == nil {
3193		return message.Attachment{
3194			FilePath: name,
3195			FileName: name,
3196			MimeType: mimeOf(imageData),
3197			Content:  imageData,
3198		}
3199	}
3200
3201	textData, textErr := readClipboard(clipboardFormatText)
3202	if textErr != nil || len(textData) == 0 {
3203		return nil // Clipboard is empty or does not contain an image
3204	}
3205
3206	path := strings.TrimSpace(string(textData))
3207	path = strings.ReplaceAll(path, "\\ ", " ")
3208	if _, statErr := os.Stat(path); statErr != nil {
3209		return nil // Clipboard does not contain an image or valid file path
3210	}
3211
3212	lowerPath := strings.ToLower(path)
3213	isAllowed := false
3214	for _, ext := range common.AllowedImageTypes {
3215		if strings.HasSuffix(lowerPath, ext) {
3216			isAllowed = true
3217			break
3218		}
3219	}
3220	if !isAllowed {
3221		return util.NewInfoMsg("File type is not a supported image format")
3222	}
3223
3224	fileInfo, statErr := os.Stat(path)
3225	if statErr != nil {
3226		return util.InfoMsg{
3227			Type: util.InfoTypeError,
3228			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3229		}
3230	}
3231	if fileInfo.Size() > common.MaxAttachmentSize {
3232		return util.InfoMsg{
3233			Type: util.InfoTypeError,
3234			Msg:  "File too large, max 5MB",
3235		}
3236	}
3237
3238	content, readErr := os.ReadFile(path)
3239	if readErr != nil {
3240		return util.InfoMsg{
3241			Type: util.InfoTypeError,
3242			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3243		}
3244	}
3245
3246	return message.Attachment{
3247		FilePath: path,
3248		FileName: filepath.Base(path),
3249		MimeType: mimeOf(content),
3250		Content:  content,
3251	}
3252}
3253
3254var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3255
3256func (m *UI) pasteIdx() int {
3257	result := 0
3258	for _, at := range m.attachments.List() {
3259		found := pasteRE.FindStringSubmatch(at.FileName)
3260		if len(found) == 0 {
3261			continue
3262		}
3263		idx, err := strconv.Atoi(found[1])
3264		if err == nil {
3265			result = max(result, idx)
3266		}
3267	}
3268	return result + 1
3269}
3270
3271// drawSessionDetails draws the session details in compact mode.
3272func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3273	if m.session == nil {
3274		return
3275	}
3276
3277	s := m.com.Styles
3278
3279	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3280	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3281
3282	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3283	blocks := []string{
3284		title,
3285		"",
3286		m.modelInfo(width),
3287		"",
3288	}
3289
3290	detailsHeader := lipgloss.JoinVertical(
3291		lipgloss.Left,
3292		blocks...,
3293	)
3294
3295	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3296
3297	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3298
3299	const maxSectionWidth = 50
3300	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3301	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3302
3303	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3304	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3305	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3306	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3307	uv.NewStyledString(
3308		s.CompactDetails.View.
3309			Width(area.Dx()).
3310			Render(
3311				lipgloss.JoinVertical(
3312					lipgloss.Left,
3313					detailsHeader,
3314					sections,
3315					version,
3316				),
3317			),
3318	).Draw(scr, area)
3319}
3320
3321func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3322	load := func() tea.Msg {
3323		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3324		if err != nil {
3325			// TODO: make this better
3326			return util.ReportError(err)()
3327		}
3328
3329		if prompt == "" {
3330			return nil
3331		}
3332		return sendMessageMsg{
3333			Content: prompt,
3334		}
3335	}
3336
3337	var cmds []tea.Cmd
3338	if cmd := m.dialog.StartLoading(); cmd != nil {
3339		cmds = append(cmds, cmd)
3340	}
3341	cmds = append(cmds, load, func() tea.Msg {
3342		return closeDialogMsg{}
3343	})
3344
3345	return tea.Sequence(cmds...)
3346}
3347
3348func (m *UI) handleStateChanged() tea.Cmd {
3349	return func() tea.Msg {
3350		m.com.App.UpdateAgentModel(context.Background())
3351		return mcpStateChangedMsg{
3352			states: mcp.GetStates(),
3353		}
3354	}
3355}
3356
3357func handleMCPPromptsEvent(name string) tea.Cmd {
3358	return func() tea.Msg {
3359		mcp.RefreshPrompts(context.Background(), name)
3360		return nil
3361	}
3362}
3363
3364func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3365	return func() tea.Msg {
3366		mcp.RefreshTools(
3367			context.Background(),
3368			cfg,
3369			name,
3370		)
3371		return nil
3372	}
3373}
3374
3375func handleMCPResourcesEvent(name string) tea.Cmd {
3376	return func() tea.Msg {
3377		mcp.RefreshResources(context.Background(), name)
3378		return nil
3379	}
3380}
3381
3382func (m *UI) copyChatHighlight() tea.Cmd {
3383	text := m.chat.HighlightContent()
3384	return common.CopyToClipboardWithCallback(
3385		text,
3386		"Selected text copied to clipboard",
3387		func() tea.Msg {
3388			m.chat.ClearMouse()
3389			return nil
3390		},
3391	)
3392}
3393
3394// renderLogo renders the Crush logo with the given styles and dimensions.
3395func renderLogo(t *styles.Styles, compact bool, width int) string {
3396	return logo.Render(t, version.Version, compact, logo.Opts{
3397		FieldColor:   t.LogoFieldColor,
3398		TitleColorA:  t.LogoTitleColorA,
3399		TitleColorB:  t.LogoTitleColorB,
3400		CharmColor:   t.LogoCharmColor,
3401		VersionColor: t.LogoVersionColor,
3402		Width:        width,
3403	})
3404}