ui.go

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