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		}
2315	}
2316
2317	binds = append(binds,
2318		[]key.Binding{
2319			help,
2320			k.Quit,
2321		},
2322	)
2323
2324	return binds
2325}
2326
2327// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2328func (m *UI) toggleCompactMode() tea.Cmd {
2329	m.forceCompactMode = !m.forceCompactMode
2330
2331	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2332	if err != nil {
2333		return util.ReportError(err)
2334	}
2335
2336	m.updateLayoutAndSize()
2337
2338	return nil
2339}
2340
2341// updateLayoutAndSize updates the layout and sizes of UI components.
2342func (m *UI) updateLayoutAndSize() {
2343	// Determine if we should be in compact mode
2344	if m.state == uiChat {
2345		if m.forceCompactMode {
2346			m.isCompact = true
2347			return
2348		}
2349		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2350			m.isCompact = true
2351		} else {
2352			m.isCompact = false
2353		}
2354	}
2355
2356	m.layout = m.generateLayout(m.width, m.height)
2357	m.updateSize()
2358}
2359
2360// updateSize updates the sizes of UI components based on the current layout.
2361func (m *UI) updateSize() {
2362	// Set status width
2363	m.status.SetWidth(m.layout.status.Dx())
2364
2365	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2366	m.textarea.SetWidth(m.layout.editor.Dx())
2367	// TODO: Abstract the textarea and attachments into a single editor
2368	// component so we don't have to manually account for the attachments
2369	// height here.
2370	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2371	m.renderPills()
2372
2373	// Handle different app states
2374	switch m.state {
2375	case uiChat:
2376		if !m.isCompact {
2377			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2378		}
2379	}
2380}
2381
2382// generateLayout calculates the layout rectangles for all UI components based
2383// on the current UI state and terminal dimensions.
2384func (m *UI) generateLayout(w, h int) uiLayout {
2385	// The screen area we're working with
2386	area := image.Rect(0, 0, w, h)
2387
2388	// The help height
2389	helpHeight := 1
2390	// The editor height
2391	editorHeight := 5
2392	// The sidebar width
2393	sidebarWidth := 30
2394	// The header height
2395	const landingHeaderHeight = 4
2396
2397	var helpKeyMap help.KeyMap = m
2398	if m.status != nil && m.status.ShowingAll() {
2399		for _, row := range helpKeyMap.FullHelp() {
2400			helpHeight = max(helpHeight, len(row))
2401		}
2402	}
2403
2404	// Add app margins
2405	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2406	appRect.Min.Y += 1
2407	appRect.Max.Y -= 1
2408	helpRect.Min.Y -= 1
2409	appRect.Min.X += 1
2410	appRect.Max.X -= 1
2411
2412	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2413		// extra padding on left and right for these states
2414		appRect.Min.X += 1
2415		appRect.Max.X -= 1
2416	}
2417
2418	uiLayout := uiLayout{
2419		area:   area,
2420		status: helpRect,
2421	}
2422
2423	// Handle different app states
2424	switch m.state {
2425	case uiOnboarding, uiInitialize:
2426		// Layout
2427		//
2428		// header
2429		// ------
2430		// main
2431		// ------
2432		// help
2433
2434		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2435		uiLayout.header = headerRect
2436		uiLayout.main = mainRect
2437
2438	case uiLanding:
2439		// Layout
2440		//
2441		// header
2442		// ------
2443		// main
2444		// ------
2445		// editor
2446		// ------
2447		// help
2448		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2449		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2450		// Remove extra padding from editor (but keep it for header and main)
2451		editorRect.Min.X -= 1
2452		editorRect.Max.X += 1
2453		uiLayout.header = headerRect
2454		uiLayout.main = mainRect
2455		uiLayout.editor = editorRect
2456
2457	case uiChat:
2458		if m.isCompact {
2459			// Layout
2460			//
2461			// compact-header
2462			// ------
2463			// main
2464			// ------
2465			// editor
2466			// ------
2467			// help
2468			const compactHeaderHeight = 1
2469			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2470			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2471			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2472			uiLayout.sessionDetails = sessionDetailsArea
2473			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2474			// Add one line gap between header and main content
2475			mainRect.Min.Y += 1
2476			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2477			mainRect.Max.X -= 1 // Add padding right
2478			uiLayout.header = headerRect
2479			pillsHeight := m.pillsAreaHeight()
2480			if pillsHeight > 0 {
2481				pillsHeight = min(pillsHeight, mainRect.Dy())
2482				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2483				uiLayout.main = chatRect
2484				uiLayout.pills = pillsRect
2485			} else {
2486				uiLayout.main = mainRect
2487			}
2488			// Add bottom margin to main
2489			uiLayout.main.Max.Y -= 1
2490			uiLayout.editor = editorRect
2491		} else {
2492			// Layout
2493			//
2494			// ------|---
2495			// main  |
2496			// ------| side
2497			// editor|
2498			// ----------
2499			// help
2500
2501			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2502			// Add padding left
2503			sideRect.Min.X += 1
2504			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2505			mainRect.Max.X -= 1 // Add padding right
2506			uiLayout.sidebar = sideRect
2507			pillsHeight := m.pillsAreaHeight()
2508			if pillsHeight > 0 {
2509				pillsHeight = min(pillsHeight, mainRect.Dy())
2510				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2511				uiLayout.main = chatRect
2512				uiLayout.pills = pillsRect
2513			} else {
2514				uiLayout.main = mainRect
2515			}
2516			// Add bottom margin to main
2517			uiLayout.main.Max.Y -= 1
2518			uiLayout.editor = editorRect
2519		}
2520	}
2521
2522	return uiLayout
2523}
2524
2525// uiLayout defines the positioning of UI elements.
2526type uiLayout struct {
2527	// area is the overall available area.
2528	area uv.Rectangle
2529
2530	// header is the header shown in special cases
2531	// e.x when the sidebar is collapsed
2532	// or when in the landing page
2533	// or in init/config
2534	header uv.Rectangle
2535
2536	// main is the area for the main pane. (e.x chat, configure, landing)
2537	main uv.Rectangle
2538
2539	// pills is the area for the pills panel.
2540	pills uv.Rectangle
2541
2542	// editor is the area for the editor pane.
2543	editor uv.Rectangle
2544
2545	// sidebar is the area for the sidebar.
2546	sidebar uv.Rectangle
2547
2548	// status is the area for the status view.
2549	status uv.Rectangle
2550
2551	// session details is the area for the session details overlay in compact mode.
2552	sessionDetails uv.Rectangle
2553}
2554
2555func (m *UI) openEditor(value string) tea.Cmd {
2556	tmpfile, err := os.CreateTemp("", "msg_*.md")
2557	if err != nil {
2558		return util.ReportError(err)
2559	}
2560	defer tmpfile.Close() //nolint:errcheck
2561	if _, err := tmpfile.WriteString(value); err != nil {
2562		return util.ReportError(err)
2563	}
2564	cmd, err := editor.Command(
2565		"crush",
2566		tmpfile.Name(),
2567		editor.AtPosition(
2568			m.textarea.Line()+1,
2569			m.textarea.Column()+1,
2570		),
2571	)
2572	if err != nil {
2573		return util.ReportError(err)
2574	}
2575	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2576		if err != nil {
2577			return util.ReportError(err)
2578		}
2579		content, err := os.ReadFile(tmpfile.Name())
2580		if err != nil {
2581			return util.ReportError(err)
2582		}
2583		if len(content) == 0 {
2584			return util.ReportWarn("Message is empty")
2585		}
2586		os.Remove(tmpfile.Name())
2587		return openEditorMsg{
2588			Text: strings.TrimSpace(string(content)),
2589		}
2590	})
2591}
2592
2593// setEditorPrompt configures the textarea prompt function based on whether
2594// yolo mode is enabled.
2595func (m *UI) setEditorPrompt(yolo bool) {
2596	if yolo {
2597		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2598		return
2599	}
2600	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2601}
2602
2603// normalPromptFunc returns the normal editor prompt style ("  > " on first
2604// line, "::: " on subsequent lines).
2605func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2606	t := m.com.Styles
2607	if info.LineNumber == 0 {
2608		if info.Focused {
2609			return "  > "
2610		}
2611		return "::: "
2612	}
2613	if info.Focused {
2614		return t.EditorPromptNormalFocused.Render()
2615	}
2616	return t.EditorPromptNormalBlurred.Render()
2617}
2618
2619// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2620// and colored dots.
2621func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2622	t := m.com.Styles
2623	if info.LineNumber == 0 {
2624		if info.Focused {
2625			return t.EditorPromptYoloIconFocused.Render()
2626		} else {
2627			return t.EditorPromptYoloIconBlurred.Render()
2628		}
2629	}
2630	if info.Focused {
2631		return t.EditorPromptYoloDotsFocused.Render()
2632	}
2633	return t.EditorPromptYoloDotsBlurred.Render()
2634}
2635
2636// closeCompletions closes the completions popup and resets state.
2637func (m *UI) closeCompletions() {
2638	m.completionsOpen = false
2639	m.completionsQuery = ""
2640	m.completionsStartIndex = 0
2641	m.completions.Close()
2642}
2643
2644// insertCompletionText replaces the @query in the textarea with the given text.
2645// Returns false if the replacement cannot be performed.
2646func (m *UI) insertCompletionText(text string) bool {
2647	value := m.textarea.Value()
2648	if m.completionsStartIndex > len(value) {
2649		return false
2650	}
2651
2652	word := m.textareaWord()
2653	endIdx := min(m.completionsStartIndex+len(word), len(value))
2654	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2655	m.textarea.SetValue(newValue)
2656	m.textarea.MoveToEnd()
2657	m.textarea.InsertRune(' ')
2658	return true
2659}
2660
2661// insertFileCompletion inserts the selected file path into the textarea,
2662// replacing the @query, and adds the file as an attachment.
2663func (m *UI) insertFileCompletion(path string) tea.Cmd {
2664	if !m.insertCompletionText(path) {
2665		return nil
2666	}
2667
2668	return func() tea.Msg {
2669		absPath, _ := filepath.Abs(path)
2670
2671		if m.hasSession() {
2672			// Skip attachment if file was already read and hasn't been modified.
2673			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2674			if !lastRead.IsZero() {
2675				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2676					return nil
2677				}
2678			}
2679		} else if slices.Contains(m.sessionFileReads, absPath) {
2680			return nil
2681		}
2682
2683		m.sessionFileReads = append(m.sessionFileReads, absPath)
2684
2685		// Add file as attachment.
2686		content, err := os.ReadFile(path)
2687		if err != nil {
2688			// If it fails, let the LLM handle it later.
2689			return nil
2690		}
2691
2692		return message.Attachment{
2693			FilePath: path,
2694			FileName: filepath.Base(path),
2695			MimeType: mimeOf(content),
2696			Content:  content,
2697		}
2698	}
2699}
2700
2701// insertMCPResourceCompletion inserts the selected resource into the textarea,
2702// replacing the @query, and adds the resource as an attachment.
2703func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2704	displayText := cmp.Or(item.Title, item.URI)
2705
2706	if !m.insertCompletionText(displayText) {
2707		return nil
2708	}
2709
2710	return func() tea.Msg {
2711		contents, err := mcp.ReadResource(
2712			context.Background(),
2713			m.com.Store(),
2714			item.MCPName,
2715			item.URI,
2716		)
2717		if err != nil {
2718			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2719			return nil
2720		}
2721		if len(contents) == 0 {
2722			return nil
2723		}
2724
2725		content := contents[0]
2726		var data []byte
2727		if content.Text != "" {
2728			data = []byte(content.Text)
2729		} else if len(content.Blob) > 0 {
2730			data = content.Blob
2731		}
2732		if len(data) == 0 {
2733			return nil
2734		}
2735
2736		mimeType := item.MIMEType
2737		if mimeType == "" && content.MIMEType != "" {
2738			mimeType = content.MIMEType
2739		}
2740		if mimeType == "" {
2741			mimeType = "text/plain"
2742		}
2743
2744		return message.Attachment{
2745			FilePath: item.URI,
2746			FileName: displayText,
2747			MimeType: mimeType,
2748			Content:  data,
2749		}
2750	}
2751}
2752
2753// completionsPosition returns the X and Y position for the completions popup.
2754func (m *UI) completionsPosition() image.Point {
2755	cur := m.textarea.Cursor()
2756	if cur == nil {
2757		return image.Point{
2758			X: m.layout.editor.Min.X,
2759			Y: m.layout.editor.Min.Y,
2760		}
2761	}
2762	return image.Point{
2763		X: cur.X + m.layout.editor.Min.X,
2764		Y: m.layout.editor.Min.Y + cur.Y,
2765	}
2766}
2767
2768// textareaWord returns the current word at the cursor position.
2769func (m *UI) textareaWord() string {
2770	return m.textarea.Word()
2771}
2772
2773// isWhitespace returns true if the byte is a whitespace character.
2774func isWhitespace(b byte) bool {
2775	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2776}
2777
2778// isAgentBusy returns true if the agent coordinator exists and is currently
2779// busy processing a request.
2780func (m *UI) isAgentBusy() bool {
2781	return m.com.App != nil &&
2782		m.com.App.AgentCoordinator != nil &&
2783		m.com.App.AgentCoordinator.IsBusy()
2784}
2785
2786// hasSession returns true if there is an active session with a valid ID.
2787func (m *UI) hasSession() bool {
2788	return m.session != nil && m.session.ID != ""
2789}
2790
2791// mimeOf detects the MIME type of the given content.
2792func mimeOf(content []byte) string {
2793	mimeBufferSize := min(512, len(content))
2794	return http.DetectContentType(content[:mimeBufferSize])
2795}
2796
2797var readyPlaceholders = [...]string{
2798	"Ready!",
2799	"Ready...",
2800	"Ready?",
2801	"Ready for instructions",
2802}
2803
2804var workingPlaceholders = [...]string{
2805	"Working!",
2806	"Working...",
2807	"Brrrrr...",
2808	"Prrrrrrrr...",
2809	"Processing...",
2810	"Thinking...",
2811}
2812
2813// randomizePlaceholders selects random placeholder text for the textarea's
2814// ready and working states.
2815func (m *UI) randomizePlaceholders() {
2816	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2817	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2818}
2819
2820// renderEditorView renders the editor view with attachments if any.
2821func (m *UI) renderEditorView(width int) string {
2822	var attachmentsView string
2823	if len(m.attachments.List()) > 0 {
2824		attachmentsView = m.attachments.Render(width)
2825	}
2826	return strings.Join([]string{
2827		attachmentsView,
2828		m.textarea.View(),
2829		"", // margin at bottom of editor
2830	}, "\n")
2831}
2832
2833// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2834func (m *UI) cacheSidebarLogo(width int) {
2835	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2836}
2837
2838// sendMessage sends a message with the given content and attachments.
2839func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2840	if m.com.App.AgentCoordinator == nil {
2841		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2842	}
2843
2844	var cmds []tea.Cmd
2845	if !m.hasSession() {
2846		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2847		if err != nil {
2848			return util.ReportError(err)
2849		}
2850		if m.forceCompactMode {
2851			m.isCompact = true
2852		}
2853		if newSession.ID != "" {
2854			m.session = &newSession
2855			cmds = append(cmds, m.loadSession(newSession.ID))
2856		}
2857		m.setState(uiChat, m.focus)
2858	}
2859
2860	ctx := context.Background()
2861	cmds = append(cmds, func() tea.Msg {
2862		for _, path := range m.sessionFileReads {
2863			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2864			m.com.App.LSPManager.Start(ctx, path)
2865		}
2866		return nil
2867	})
2868
2869	// Capture session ID to avoid race with main goroutine updating m.session.
2870	sessionID := m.session.ID
2871	cmds = append(cmds, func() tea.Msg {
2872		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2873		if err != nil {
2874			isCancelErr := errors.Is(err, context.Canceled)
2875			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2876			if isCancelErr || isPermissionErr {
2877				return nil
2878			}
2879			return util.InfoMsg{
2880				Type: util.InfoTypeError,
2881				Msg:  err.Error(),
2882			}
2883		}
2884		return nil
2885	})
2886	return tea.Batch(cmds...)
2887}
2888
2889const cancelTimerDuration = 2 * time.Second
2890
2891// cancelTimerCmd creates a command that expires the cancel timer.
2892func cancelTimerCmd() tea.Cmd {
2893	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2894		return cancelTimerExpiredMsg{}
2895	})
2896}
2897
2898// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2899// and starts a timer. The second press (before the timer expires) actually
2900// cancels the agent.
2901func (m *UI) cancelAgent() tea.Cmd {
2902	if !m.hasSession() {
2903		return nil
2904	}
2905
2906	coordinator := m.com.App.AgentCoordinator
2907	if coordinator == nil {
2908		return nil
2909	}
2910
2911	if m.isCanceling {
2912		// Second escape press - actually cancel the agent.
2913		m.isCanceling = false
2914		coordinator.Cancel(m.session.ID)
2915		// Stop the spinning todo indicator.
2916		m.todoIsSpinning = false
2917		m.renderPills()
2918		return nil
2919	}
2920
2921	// Check if there are queued prompts - if so, clear the queue.
2922	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2923		coordinator.ClearQueue(m.session.ID)
2924		return nil
2925	}
2926
2927	// First escape press - set canceling state and start timer.
2928	m.isCanceling = true
2929	return cancelTimerCmd()
2930}
2931
2932// openDialog opens a dialog by its ID.
2933func (m *UI) openDialog(id string) tea.Cmd {
2934	var cmds []tea.Cmd
2935	switch id {
2936	case dialog.SessionsID:
2937		if cmd := m.openSessionsDialog(); cmd != nil {
2938			cmds = append(cmds, cmd)
2939		}
2940	case dialog.ModelsID:
2941		if cmd := m.openModelsDialog(); cmd != nil {
2942			cmds = append(cmds, cmd)
2943		}
2944	case dialog.CommandsID:
2945		if cmd := m.openCommandsDialog(); cmd != nil {
2946			cmds = append(cmds, cmd)
2947		}
2948	case dialog.ReasoningID:
2949		if cmd := m.openReasoningDialog(); cmd != nil {
2950			cmds = append(cmds, cmd)
2951		}
2952	case dialog.QuitID:
2953		if cmd := m.openQuitDialog(); cmd != nil {
2954			cmds = append(cmds, cmd)
2955		}
2956	default:
2957		// Unknown dialog
2958		break
2959	}
2960	return tea.Batch(cmds...)
2961}
2962
2963// openQuitDialog opens the quit confirmation dialog.
2964func (m *UI) openQuitDialog() tea.Cmd {
2965	if m.dialog.ContainsDialog(dialog.QuitID) {
2966		// Bring to front
2967		m.dialog.BringToFront(dialog.QuitID)
2968		return nil
2969	}
2970
2971	quitDialog := dialog.NewQuit(m.com)
2972	m.dialog.OpenDialog(quitDialog)
2973	return nil
2974}
2975
2976// openModelsDialog opens the models dialog.
2977func (m *UI) openModelsDialog() tea.Cmd {
2978	if m.dialog.ContainsDialog(dialog.ModelsID) {
2979		// Bring to front
2980		m.dialog.BringToFront(dialog.ModelsID)
2981		return nil
2982	}
2983
2984	isOnboarding := m.state == uiOnboarding
2985	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2986	if err != nil {
2987		return util.ReportError(err)
2988	}
2989
2990	m.dialog.OpenDialog(modelsDialog)
2991
2992	return nil
2993}
2994
2995// openCommandsDialog opens the commands dialog.
2996func (m *UI) openCommandsDialog() tea.Cmd {
2997	if m.dialog.ContainsDialog(dialog.CommandsID) {
2998		// Bring to front
2999		m.dialog.BringToFront(dialog.CommandsID)
3000		return nil
3001	}
3002
3003	var sessionID string
3004	hasSession := m.session != nil
3005	if hasSession {
3006		sessionID = m.session.ID
3007	}
3008	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3009	hasQueue := m.promptQueue > 0
3010
3011	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3012	if err != nil {
3013		return util.ReportError(err)
3014	}
3015
3016	m.dialog.OpenDialog(commands)
3017
3018	return commands.InitialCmd()
3019}
3020
3021// openReasoningDialog opens the reasoning effort dialog.
3022func (m *UI) openReasoningDialog() tea.Cmd {
3023	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3024		m.dialog.BringToFront(dialog.ReasoningID)
3025		return nil
3026	}
3027
3028	reasoningDialog, err := dialog.NewReasoning(m.com)
3029	if err != nil {
3030		return util.ReportError(err)
3031	}
3032
3033	m.dialog.OpenDialog(reasoningDialog)
3034	return nil
3035}
3036
3037// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3038// it brings it to the front. Otherwise, it will list all the sessions and open
3039// the dialog.
3040func (m *UI) openSessionsDialog() tea.Cmd {
3041	if m.dialog.ContainsDialog(dialog.SessionsID) {
3042		// Bring to front
3043		m.dialog.BringToFront(dialog.SessionsID)
3044		return nil
3045	}
3046
3047	selectedSessionID := ""
3048	if m.session != nil {
3049		selectedSessionID = m.session.ID
3050	}
3051
3052	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3053	if err != nil {
3054		return util.ReportError(err)
3055	}
3056
3057	m.dialog.OpenDialog(dialog)
3058	return nil
3059}
3060
3061// openFilesDialog opens the file picker dialog.
3062func (m *UI) openFilesDialog() tea.Cmd {
3063	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3064		// Bring to front
3065		m.dialog.BringToFront(dialog.FilePickerID)
3066		return nil
3067	}
3068
3069	filePicker, cmd := dialog.NewFilePicker(m.com)
3070	filePicker.SetImageCapabilities(&m.caps)
3071	m.dialog.OpenDialog(filePicker)
3072
3073	return cmd
3074}
3075
3076// openPermissionsDialog opens the permissions dialog for a permission request.
3077func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3078	// Close any existing permissions dialog first.
3079	m.dialog.CloseDialog(dialog.PermissionsID)
3080
3081	// Get diff mode from config.
3082	var opts []dialog.PermissionsOption
3083	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3084		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3085	}
3086
3087	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3088	m.dialog.OpenDialog(permDialog)
3089	return nil
3090}
3091
3092// handlePermissionNotification updates tool items when permission state changes.
3093func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3094	toolItem := m.chat.MessageItem(notification.ToolCallID)
3095	if toolItem == nil {
3096		return
3097	}
3098
3099	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3100		if notification.Granted {
3101			permItem.SetStatus(chat.ToolStatusRunning)
3102		} else {
3103			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3104		}
3105	}
3106}
3107
3108// handleAgentNotification translates domain agent events into desktop
3109// notifications using the UI notification backend.
3110func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3111	switch n.Type {
3112	case notify.TypeAgentFinished:
3113		return m.sendNotification(notification.Notification{
3114			Title:   "Crush is waiting...",
3115			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3116		})
3117	default:
3118		return nil
3119	}
3120}
3121
3122// newSession clears the current session state and prepares for a new session.
3123// The actual session creation happens when the user sends their first message.
3124// Returns a command to reload prompt history.
3125func (m *UI) newSession() tea.Cmd {
3126	if !m.hasSession() {
3127		return nil
3128	}
3129
3130	m.session = nil
3131	m.sessionFiles = nil
3132	m.sessionFileReads = nil
3133	m.setState(uiLanding, uiFocusEditor)
3134	m.textarea.Focus()
3135	m.chat.Blur()
3136	m.chat.ClearMessages()
3137	m.pillsExpanded = false
3138	m.promptQueue = 0
3139	m.pillsView = ""
3140	m.historyReset()
3141	agenttools.ResetCache()
3142	return tea.Batch(
3143		func() tea.Msg {
3144			m.com.App.LSPManager.StopAll(context.Background())
3145			return nil
3146		},
3147		m.loadPromptHistory(),
3148	)
3149}
3150
3151// handlePasteMsg handles a paste message.
3152func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3153	if m.dialog.HasDialogs() {
3154		return m.handleDialogMsg(msg)
3155	}
3156
3157	if m.focus != uiFocusEditor {
3158		return nil
3159	}
3160
3161	if hasPasteExceededThreshold(msg) {
3162		return func() tea.Msg {
3163			content := []byte(msg.Content)
3164			if int64(len(content)) > common.MaxAttachmentSize {
3165				return util.ReportWarn("Paste is too big (>5mb)")
3166			}
3167			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3168			mimeBufferSize := min(512, len(content))
3169			mimeType := http.DetectContentType(content[:mimeBufferSize])
3170			return message.Attachment{
3171				FileName: name,
3172				FilePath: name,
3173				MimeType: mimeType,
3174				Content:  content,
3175			}
3176		}
3177	}
3178
3179	// Attempt to parse pasted content as file paths. If possible to parse,
3180	// all files exist and are valid, add as attachments.
3181	// Otherwise, paste as text.
3182	paths := fsext.ParsePastedFiles(msg.Content)
3183	allExistsAndValid := func() bool {
3184		if len(paths) == 0 {
3185			return false
3186		}
3187		for _, path := range paths {
3188			if _, err := os.Stat(path); os.IsNotExist(err) {
3189				return false
3190			}
3191
3192			lowerPath := strings.ToLower(path)
3193			isValid := false
3194			for _, ext := range common.AllowedImageTypes {
3195				if strings.HasSuffix(lowerPath, ext) {
3196					isValid = true
3197					break
3198				}
3199			}
3200			if !isValid {
3201				return false
3202			}
3203		}
3204		return true
3205	}
3206	if !allExistsAndValid() {
3207		var cmd tea.Cmd
3208		m.textarea, cmd = m.textarea.Update(msg)
3209		return cmd
3210	}
3211
3212	var cmds []tea.Cmd
3213	for _, path := range paths {
3214		cmds = append(cmds, m.handleFilePathPaste(path))
3215	}
3216	return tea.Batch(cmds...)
3217}
3218
3219func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3220	var (
3221		lineCount = 0
3222		colCount  = 0
3223	)
3224	for line := range strings.SplitSeq(msg.Content, "\n") {
3225		lineCount++
3226		colCount = max(colCount, len(line))
3227
3228		if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3229			return true
3230		}
3231	}
3232	return false
3233}
3234
3235// handleFilePathPaste handles a pasted file path.
3236func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3237	return func() tea.Msg {
3238		fileInfo, err := os.Stat(path)
3239		if err != nil {
3240			return util.ReportError(err)
3241		}
3242		if fileInfo.IsDir() {
3243			return util.ReportWarn("Cannot attach a directory")
3244		}
3245		if fileInfo.Size() > common.MaxAttachmentSize {
3246			return util.ReportWarn("File is too big (>5mb)")
3247		}
3248
3249		content, err := os.ReadFile(path)
3250		if err != nil {
3251			return util.ReportError(err)
3252		}
3253
3254		mimeBufferSize := min(512, len(content))
3255		mimeType := http.DetectContentType(content[:mimeBufferSize])
3256		fileName := filepath.Base(path)
3257		return message.Attachment{
3258			FilePath: path,
3259			FileName: fileName,
3260			MimeType: mimeType,
3261			Content:  content,
3262		}
3263	}
3264}
3265
3266// pasteImageFromClipboard reads image data from the system clipboard and
3267// creates an attachment. If no image data is found, it falls back to
3268// interpreting clipboard text as a file path.
3269func (m *UI) pasteImageFromClipboard() tea.Msg {
3270	imageData, err := readClipboard(clipboardFormatImage)
3271	if int64(len(imageData)) > common.MaxAttachmentSize {
3272		return util.InfoMsg{
3273			Type: util.InfoTypeError,
3274			Msg:  "File too large, max 5MB",
3275		}
3276	}
3277	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3278	if err == nil {
3279		return message.Attachment{
3280			FilePath: name,
3281			FileName: name,
3282			MimeType: mimeOf(imageData),
3283			Content:  imageData,
3284		}
3285	}
3286
3287	textData, textErr := readClipboard(clipboardFormatText)
3288	if textErr != nil || len(textData) == 0 {
3289		return nil // Clipboard is empty or does not contain an image
3290	}
3291
3292	path := strings.TrimSpace(string(textData))
3293	path = strings.ReplaceAll(path, "\\ ", " ")
3294	if _, statErr := os.Stat(path); statErr != nil {
3295		return nil // Clipboard does not contain an image or valid file path
3296	}
3297
3298	lowerPath := strings.ToLower(path)
3299	isAllowed := false
3300	for _, ext := range common.AllowedImageTypes {
3301		if strings.HasSuffix(lowerPath, ext) {
3302			isAllowed = true
3303			break
3304		}
3305	}
3306	if !isAllowed {
3307		return util.NewInfoMsg("File type is not a supported image format")
3308	}
3309
3310	fileInfo, statErr := os.Stat(path)
3311	if statErr != nil {
3312		return util.InfoMsg{
3313			Type: util.InfoTypeError,
3314			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3315		}
3316	}
3317	if fileInfo.Size() > common.MaxAttachmentSize {
3318		return util.InfoMsg{
3319			Type: util.InfoTypeError,
3320			Msg:  "File too large, max 5MB",
3321		}
3322	}
3323
3324	content, readErr := os.ReadFile(path)
3325	if readErr != nil {
3326		return util.InfoMsg{
3327			Type: util.InfoTypeError,
3328			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3329		}
3330	}
3331
3332	return message.Attachment{
3333		FilePath: path,
3334		FileName: filepath.Base(path),
3335		MimeType: mimeOf(content),
3336		Content:  content,
3337	}
3338}
3339
3340var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3341
3342func (m *UI) pasteIdx() int {
3343	result := 0
3344	for _, at := range m.attachments.List() {
3345		found := pasteRE.FindStringSubmatch(at.FileName)
3346		if len(found) == 0 {
3347			continue
3348		}
3349		idx, err := strconv.Atoi(found[1])
3350		if err == nil {
3351			result = max(result, idx)
3352		}
3353	}
3354	return result + 1
3355}
3356
3357// drawSessionDetails draws the session details in compact mode.
3358func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3359	if m.session == nil {
3360		return
3361	}
3362
3363	s := m.com.Styles
3364
3365	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3366	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3367
3368	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3369	blocks := []string{
3370		title,
3371		"",
3372		m.modelInfo(width),
3373		"",
3374	}
3375
3376	detailsHeader := lipgloss.JoinVertical(
3377		lipgloss.Left,
3378		blocks...,
3379	)
3380
3381	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3382
3383	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3384
3385	const maxSectionWidth = 50
3386	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3387	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3388
3389	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3390	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3391	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3392	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3393	uv.NewStyledString(
3394		s.CompactDetails.View.
3395			Width(area.Dx()).
3396			Render(
3397				lipgloss.JoinVertical(
3398					lipgloss.Left,
3399					detailsHeader,
3400					sections,
3401					version,
3402				),
3403			),
3404	).Draw(scr, area)
3405}
3406
3407func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3408	load := func() tea.Msg {
3409		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3410		if err != nil {
3411			// TODO: make this better
3412			return util.ReportError(err)()
3413		}
3414
3415		if prompt == "" {
3416			return nil
3417		}
3418		return sendMessageMsg{
3419			Content: prompt,
3420		}
3421	}
3422
3423	var cmds []tea.Cmd
3424	if cmd := m.dialog.StartLoading(); cmd != nil {
3425		cmds = append(cmds, cmd)
3426	}
3427	cmds = append(cmds, load, func() tea.Msg {
3428		return closeDialogMsg{}
3429	})
3430
3431	return tea.Sequence(cmds...)
3432}
3433
3434func (m *UI) handleStateChanged() tea.Cmd {
3435	return func() tea.Msg {
3436		m.com.App.UpdateAgentModel(context.Background())
3437		return mcpStateChangedMsg{
3438			states: mcp.GetStates(),
3439		}
3440	}
3441}
3442
3443func handleMCPPromptsEvent(name string) tea.Cmd {
3444	return func() tea.Msg {
3445		mcp.RefreshPrompts(context.Background(), name)
3446		return nil
3447	}
3448}
3449
3450func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3451	return func() tea.Msg {
3452		mcp.RefreshTools(
3453			context.Background(),
3454			cfg,
3455			name,
3456		)
3457		return nil
3458	}
3459}
3460
3461func handleMCPResourcesEvent(name string) tea.Cmd {
3462	return func() tea.Msg {
3463		mcp.RefreshResources(context.Background(), name)
3464		return nil
3465	}
3466}
3467
3468func (m *UI) copyChatHighlight() tea.Cmd {
3469	text := m.chat.HighlightContent()
3470	return common.CopyToClipboardWithCallback(
3471		text,
3472		"Selected text copied to clipboard",
3473		func() tea.Msg {
3474			m.chat.ClearMouse()
3475			return nil
3476		},
3477	)
3478}
3479
3480func (m *UI) enableDockerMCP() tea.Msg {
3481	store := m.com.Store()
3482	// Stage Docker MCP in memory first so startup and persistence can be atomic.
3483	mcpConfig, err := store.PrepareDockerMCPConfig()
3484	if err != nil {
3485		return util.ReportError(err)()
3486	}
3487
3488	ctx := context.Background()
3489	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3490		// Roll back runtime and in-memory state when startup fails.
3491		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3492		delete(store.Config().MCP, config.DockerMCPName)
3493		return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3494	}
3495
3496	if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3497		// Roll back runtime and in-memory state if persistence fails.
3498		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3499		delete(store.Config().MCP, config.DockerMCPName)
3500		return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3501	}
3502
3503	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3504}
3505
3506func (m *UI) disableDockerMCP() tea.Msg {
3507	store := m.com.Store()
3508	// Close the Docker MCP client.
3509	if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3510		return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3511	}
3512
3513	// Remove from config and persist.
3514	if err := store.DisableDockerMCP(); err != nil {
3515		return util.ReportError(err)()
3516	}
3517
3518	return util.NewInfoMsg("Docker MCP disabled successfully")
3519}
3520
3521// renderLogo renders the Crush logo with the given styles and dimensions.
3522func renderLogo(t *styles.Styles, compact bool, width int) string {
3523	return logo.Render(t, version.Version, compact, logo.Opts{
3524		FieldColor:   t.LogoFieldColor,
3525		TitleColorA:  t.LogoTitleColorA,
3526		TitleColorB:  t.LogoTitleColorB,
3527		CharmColor:   t.LogoCharmColor,
3528		VersionColor: t.LogoVersionColor,
3529		Width:        width,
3530	})
3531}