ui.go

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