ui.go

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