ui.go

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