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