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 !m.currentModelSupportsImages() {
1726					break
1727				}
1728				if cmd := m.openFilesDialog(); cmd != nil {
1729					cmds = append(cmds, cmd)
1730				}
1731
1732			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1733				if !m.currentModelSupportsImages() {
1734					break
1735				}
1736				cmds = append(cmds, m.pasteImageFromClipboard)
1737
1738			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1739				prevHeight := m.textarea.Height()
1740				value := m.textarea.Value()
1741				if before, ok := strings.CutSuffix(value, "\\"); ok {
1742					// If the last character is a backslash, remove it and add a newline.
1743					m.textarea.SetValue(before)
1744					if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1745						cmds = append(cmds, cmd)
1746					}
1747					break
1748				}
1749
1750				// Otherwise, send the message
1751				m.textarea.Reset()
1752				if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1753					cmds = append(cmds, cmd)
1754				}
1755
1756				value = strings.TrimSpace(value)
1757				if value == "exit" || value == "quit" {
1758					return m.openQuitDialog()
1759				}
1760
1761				attachments := m.attachments.List()
1762				m.attachments.Reset()
1763				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1764					return nil
1765				}
1766
1767				m.randomizePlaceholders()
1768				m.historyReset()
1769
1770				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1771			case key.Matches(msg, m.keyMap.Chat.NewSession):
1772				if !m.hasSession() {
1773					break
1774				}
1775				if m.isAgentBusy() {
1776					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1777					break
1778				}
1779				if cmd := m.newSession(); cmd != nil {
1780					cmds = append(cmds, cmd)
1781				}
1782			case key.Matches(msg, m.keyMap.Tab):
1783				if m.state != uiLanding {
1784					m.setState(m.state, uiFocusMain)
1785					m.textarea.Blur()
1786					m.chat.Focus()
1787					m.chat.SetSelected(m.chat.Len() - 1)
1788				}
1789			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1790				if m.isAgentBusy() {
1791					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1792					break
1793				}
1794				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1795			case key.Matches(msg, m.keyMap.Editor.Newline):
1796				prevHeight := m.textarea.Height()
1797				m.textarea.InsertRune('\n')
1798				m.closeCompletions()
1799				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1800			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1801				cmd := m.handleHistoryUp(msg)
1802				if cmd != nil {
1803					cmds = append(cmds, cmd)
1804				}
1805			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1806				cmd := m.handleHistoryDown(msg)
1807				if cmd != nil {
1808					cmds = append(cmds, cmd)
1809				}
1810			case key.Matches(msg, m.keyMap.Editor.Escape):
1811				cmd := m.handleHistoryEscape(msg)
1812				if cmd != nil {
1813					cmds = append(cmds, cmd)
1814				}
1815			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1816				if cmd := m.openCommandsDialog(); cmd != nil {
1817					cmds = append(cmds, cmd)
1818				}
1819			default:
1820				if handleGlobalKeys(msg) {
1821					// Handle global keys first before passing to textarea.
1822					break
1823				}
1824
1825				// Check for @ trigger before passing to textarea.
1826				curValue := m.textarea.Value()
1827				curIdx := len(curValue)
1828
1829				// Trigger completions on @.
1830				if msg.String() == "@" && !m.completionsOpen {
1831					// Only show if beginning of prompt or after whitespace.
1832					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1833						m.completionsOpen = true
1834						m.completionsQuery = ""
1835						m.completionsStartIndex = curIdx
1836						m.completionsPositionStart = m.completionsPosition()
1837						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1838						cmds = append(cmds, m.completions.Open(depth, limit))
1839					}
1840				}
1841
1842				// remove the details if they are open when user starts typing
1843				if m.detailsOpen {
1844					m.detailsOpen = false
1845					m.updateLayoutAndSize()
1846				}
1847
1848				prevHeight := m.textarea.Height()
1849				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1850
1851				// Any text modification becomes the current draft.
1852				m.updateHistoryDraft(curValue)
1853
1854				// After updating textarea, check if we need to filter completions.
1855				// Skip filtering on the initial @ keystroke since items are loading async.
1856				if m.completionsOpen && msg.String() != "@" {
1857					newValue := m.textarea.Value()
1858					newIdx := len(newValue)
1859
1860					// Close completions if cursor moved before start.
1861					if newIdx <= m.completionsStartIndex {
1862						m.closeCompletions()
1863					} else if msg.String() == "space" {
1864						// Close on space.
1865						m.closeCompletions()
1866					} else {
1867						// Extract current word and filter.
1868						word := m.textareaWord()
1869						if strings.HasPrefix(word, "@") {
1870							m.completionsQuery = word[1:]
1871							m.completions.Filter(m.completionsQuery)
1872						} else if m.completionsOpen {
1873							m.closeCompletions()
1874						}
1875					}
1876				}
1877			}
1878		case uiFocusMain:
1879			switch {
1880			case key.Matches(msg, m.keyMap.Tab):
1881				m.focus = uiFocusEditor
1882				cmds = append(cmds, m.textarea.Focus())
1883				m.chat.Blur()
1884			case key.Matches(msg, m.keyMap.Chat.NewSession):
1885				if !m.hasSession() {
1886					break
1887				}
1888				if m.isAgentBusy() {
1889					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1890					break
1891				}
1892				m.focus = uiFocusEditor
1893				if cmd := m.newSession(); cmd != nil {
1894					cmds = append(cmds, cmd)
1895				}
1896			case key.Matches(msg, m.keyMap.Chat.Expand):
1897				m.chat.ToggleExpandedSelectedItem()
1898			case key.Matches(msg, m.keyMap.Chat.Up):
1899				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1900					cmds = append(cmds, cmd)
1901				}
1902				if !m.chat.SelectedItemInView() {
1903					m.chat.SelectPrev()
1904					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1905						cmds = append(cmds, cmd)
1906					}
1907				}
1908			case key.Matches(msg, m.keyMap.Chat.Down):
1909				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1910					cmds = append(cmds, cmd)
1911				}
1912				if !m.chat.SelectedItemInView() {
1913					m.chat.SelectNext()
1914					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1915						cmds = append(cmds, cmd)
1916					}
1917				}
1918			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1919				m.chat.SelectPrev()
1920				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1921					cmds = append(cmds, cmd)
1922				}
1923			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1924				m.chat.SelectNext()
1925				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1926					cmds = append(cmds, cmd)
1927				}
1928			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1929				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1930					cmds = append(cmds, cmd)
1931				}
1932				m.chat.SelectFirstInView()
1933			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1934				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1935					cmds = append(cmds, cmd)
1936				}
1937				m.chat.SelectLastInView()
1938			case key.Matches(msg, m.keyMap.Chat.PageUp):
1939				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1940					cmds = append(cmds, cmd)
1941				}
1942				m.chat.SelectFirstInView()
1943			case key.Matches(msg, m.keyMap.Chat.PageDown):
1944				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1945					cmds = append(cmds, cmd)
1946				}
1947				m.chat.SelectLastInView()
1948			case key.Matches(msg, m.keyMap.Chat.Home):
1949				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1950					cmds = append(cmds, cmd)
1951				}
1952				m.chat.SelectFirst()
1953			case key.Matches(msg, m.keyMap.Chat.End):
1954				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1955					cmds = append(cmds, cmd)
1956				}
1957				m.chat.SelectLast()
1958			default:
1959				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1960					cmds = append(cmds, cmd)
1961				} else {
1962					handleGlobalKeys(msg)
1963				}
1964			}
1965		default:
1966			handleGlobalKeys(msg)
1967		}
1968	default:
1969		handleGlobalKeys(msg)
1970	}
1971
1972	return tea.Sequence(cmds...)
1973}
1974
1975// drawHeader draws the header section of the UI.
1976func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1977	m.header.drawHeader(
1978		scr,
1979		area,
1980		m.session,
1981		m.isCompact,
1982		m.detailsOpen,
1983		area.Dx(),
1984	)
1985}
1986
1987// Draw implements [uv.Drawable] and draws the UI model.
1988func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1989	layout := m.generateLayout(area.Dx(), area.Dy())
1990
1991	if m.layout != layout {
1992		m.layout = layout
1993		m.updateSize()
1994	}
1995
1996	// Clear the screen first
1997	screen.Clear(scr)
1998
1999	switch m.state {
2000	case uiOnboarding:
2001		m.drawHeader(scr, layout.header)
2002
2003		// NOTE: Onboarding flow will be rendered as dialogs below, but
2004		// positioned at the bottom left of the screen.
2005
2006	case uiInitialize:
2007		m.drawHeader(scr, layout.header)
2008
2009		main := uv.NewStyledString(m.initializeView())
2010		main.Draw(scr, layout.main)
2011
2012	case uiLanding:
2013		m.drawHeader(scr, layout.header)
2014		main := uv.NewStyledString(m.landingView())
2015		main.Draw(scr, layout.main)
2016
2017		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
2018		editor.Draw(scr, layout.editor)
2019
2020	case uiChat:
2021		if m.isCompact {
2022			m.drawHeader(scr, layout.header)
2023		} else {
2024			m.drawSidebar(scr, layout.sidebar)
2025		}
2026
2027		m.chat.Draw(scr, layout.main)
2028		if layout.pills.Dy() > 0 && m.pillsView != "" {
2029			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2030		}
2031
2032		editorWidth := scr.Bounds().Dx()
2033		if !m.isCompact {
2034			editorWidth -= layout.sidebar.Dx()
2035		}
2036		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2037		editor.Draw(scr, layout.editor)
2038
2039		// Draw details overlay in compact mode when open
2040		if m.isCompact && m.detailsOpen {
2041			m.drawSessionDetails(scr, layout.sessionDetails)
2042		}
2043	}
2044
2045	isOnboarding := m.state == uiOnboarding
2046
2047	// Add status and help layer
2048	m.status.SetHideHelp(isOnboarding)
2049	m.status.Draw(scr, layout.status)
2050
2051	// Draw completions popup if open
2052	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2053		w, h := m.completions.Size()
2054		x := m.completionsPositionStart.X
2055		y := m.completionsPositionStart.Y - h
2056
2057		screenW := area.Dx()
2058		if x+w > screenW {
2059			x = screenW - w
2060		}
2061		x = max(0, x)
2062		y = max(0, y+1) // Offset for attachments row
2063
2064		completionsView := uv.NewStyledString(m.completions.Render())
2065		completionsView.Draw(scr, image.Rectangle{
2066			Min: image.Pt(x, y),
2067			Max: image.Pt(x+w, y+h),
2068		})
2069	}
2070
2071	// Debugging rendering (visually see when the tui rerenders)
2072	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2073		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2074		debug := uv.NewStyledString(debugView.String())
2075		debug.Draw(scr, image.Rectangle{
2076			Min: image.Pt(4, 1),
2077			Max: image.Pt(8, 3),
2078		})
2079	}
2080
2081	// This needs to come last to overlay on top of everything. We always pass
2082	// the full screen bounds because the dialogs will position themselves
2083	// accordingly.
2084	if m.dialog.HasDialogs() {
2085		return m.dialog.Draw(scr, scr.Bounds())
2086	}
2087
2088	switch m.focus {
2089	case uiFocusEditor:
2090		if m.layout.editor.Dy() <= 0 {
2091			// Don't show cursor if editor is not visible
2092			return nil
2093		}
2094		if m.detailsOpen && m.isCompact {
2095			// Don't show cursor if details overlay is open
2096			return nil
2097		}
2098
2099		if m.textarea.Focused() {
2100			cur := m.textarea.Cursor()
2101			cur.X++                            // Adjust for app margins
2102			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2103			return cur
2104		}
2105	}
2106	return nil
2107}
2108
2109// View renders the UI model's view.
2110func (m *UI) View() tea.View {
2111	var v tea.View
2112	v.AltScreen = true
2113	if !m.isTransparent {
2114		v.BackgroundColor = m.com.Styles.Background
2115	}
2116	v.MouseMode = tea.MouseModeCellMotion
2117	v.ReportFocus = m.caps.ReportFocusEvents
2118	v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2119
2120	canvas := uv.NewScreenBuffer(m.width, m.height)
2121	v.Cursor = m.Draw(canvas, canvas.Bounds())
2122
2123	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2124	contentLines := strings.Split(content, "\n")
2125	for i, line := range contentLines {
2126		// Trim trailing spaces for concise rendering
2127		contentLines[i] = strings.TrimRight(line, " ")
2128	}
2129
2130	content = strings.Join(contentLines, "\n")
2131
2132	v.Content = content
2133	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2134		// HACK: use a random percentage to prevent ghostty from hiding it
2135		// after a timeout.
2136		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2137	}
2138
2139	return v
2140}
2141
2142// ShortHelp implements [help.KeyMap].
2143func (m *UI) ShortHelp() []key.Binding {
2144	var binds []key.Binding
2145	k := &m.keyMap
2146	tab := k.Tab
2147	commands := k.Commands
2148	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2149		commands.SetHelp("/ or ctrl+p", "commands")
2150	}
2151
2152	switch m.state {
2153	case uiInitialize:
2154		binds = append(binds, k.Quit)
2155	case uiChat:
2156		// Show cancel binding if agent is busy.
2157		if m.isAgentBusy() {
2158			cancelBinding := k.Chat.Cancel
2159			if m.isCanceling {
2160				cancelBinding.SetHelp("esc", "press again to cancel")
2161			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2162				cancelBinding.SetHelp("esc", "clear queue")
2163			}
2164			binds = append(binds, cancelBinding)
2165		}
2166
2167		if m.focus == uiFocusEditor {
2168			tab.SetHelp("tab", "focus chat")
2169		} else {
2170			tab.SetHelp("tab", "focus editor")
2171		}
2172
2173		binds = append(binds,
2174			tab,
2175			commands,
2176			k.Models,
2177		)
2178
2179		switch m.focus {
2180		case uiFocusEditor:
2181			binds = append(binds,
2182				k.Editor.Newline,
2183			)
2184		case uiFocusMain:
2185			binds = append(binds,
2186				k.Chat.UpDown,
2187				k.Chat.UpDownOneItem,
2188				k.Chat.PageUp,
2189				k.Chat.PageDown,
2190				k.Chat.Copy,
2191			)
2192			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2193				binds = append(binds, k.Chat.PillLeft)
2194			}
2195		}
2196	default:
2197		// TODO: other states
2198		// if m.session == nil {
2199		// no session selected
2200		binds = append(binds,
2201			commands,
2202			k.Models,
2203			k.Editor.Newline,
2204		)
2205	}
2206
2207	binds = append(binds,
2208		k.Quit,
2209		k.Help,
2210	)
2211
2212	return binds
2213}
2214
2215// FullHelp implements [help.KeyMap].
2216func (m *UI) FullHelp() [][]key.Binding {
2217	var binds [][]key.Binding
2218	k := &m.keyMap
2219	help := k.Help
2220	help.SetHelp("ctrl+g", "less")
2221	hasAttachments := len(m.attachments.List()) > 0
2222	hasSession := m.hasSession()
2223	commands := k.Commands
2224	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2225		commands.SetHelp("/ or ctrl+p", "commands")
2226	}
2227
2228	switch m.state {
2229	case uiInitialize:
2230		binds = append(binds,
2231			[]key.Binding{
2232				k.Quit,
2233			})
2234	case uiChat:
2235		// Show cancel binding if agent is busy.
2236		if m.isAgentBusy() {
2237			cancelBinding := k.Chat.Cancel
2238			if m.isCanceling {
2239				cancelBinding.SetHelp("esc", "press again to cancel")
2240			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2241				cancelBinding.SetHelp("esc", "clear queue")
2242			}
2243			binds = append(binds, []key.Binding{cancelBinding})
2244		}
2245
2246		mainBinds := []key.Binding{}
2247		tab := k.Tab
2248		if m.focus == uiFocusEditor {
2249			tab.SetHelp("tab", "focus chat")
2250		} else {
2251			tab.SetHelp("tab", "focus editor")
2252		}
2253
2254		mainBinds = append(mainBinds,
2255			tab,
2256			commands,
2257			k.Models,
2258			k.Sessions,
2259		)
2260		if hasSession {
2261			mainBinds = append(mainBinds, k.Chat.NewSession)
2262		}
2263
2264		binds = append(binds, mainBinds)
2265
2266		switch m.focus {
2267		case uiFocusEditor:
2268			editorBinds := []key.Binding{
2269				k.Editor.Newline,
2270				k.Editor.MentionFile,
2271				k.Editor.OpenEditor,
2272			}
2273			if m.currentModelSupportsImages() {
2274				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2275			}
2276			binds = append(binds, editorBinds)
2277			if hasAttachments {
2278				binds = append(binds,
2279					[]key.Binding{
2280						k.Editor.AttachmentDeleteMode,
2281						k.Editor.DeleteAllAttachments,
2282						k.Editor.Escape,
2283					},
2284				)
2285			}
2286		case uiFocusMain:
2287			binds = append(binds,
2288				[]key.Binding{
2289					k.Chat.UpDown,
2290					k.Chat.UpDownOneItem,
2291					k.Chat.PageUp,
2292					k.Chat.PageDown,
2293				},
2294				[]key.Binding{
2295					k.Chat.HalfPageUp,
2296					k.Chat.HalfPageDown,
2297					k.Chat.Home,
2298					k.Chat.End,
2299				},
2300				[]key.Binding{
2301					k.Chat.Copy,
2302					k.Chat.ClearHighlight,
2303				},
2304			)
2305			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2306				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2307			}
2308		}
2309	default:
2310		if m.session == nil {
2311			// no session selected
2312			binds = append(binds,
2313				[]key.Binding{
2314					commands,
2315					k.Models,
2316					k.Sessions,
2317				},
2318			)
2319			editorBinds := []key.Binding{
2320				k.Editor.Newline,
2321				k.Editor.MentionFile,
2322				k.Editor.OpenEditor,
2323			}
2324			if m.currentModelSupportsImages() {
2325				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2326			}
2327			binds = append(binds, editorBinds)
2328			if hasAttachments {
2329				binds = append(binds,
2330					[]key.Binding{
2331						k.Editor.AttachmentDeleteMode,
2332						k.Editor.DeleteAllAttachments,
2333						k.Editor.Escape,
2334					},
2335				)
2336			}
2337		}
2338	}
2339
2340	binds = append(binds,
2341		[]key.Binding{
2342			help,
2343			k.Quit,
2344		},
2345	)
2346
2347	return binds
2348}
2349
2350func (m *UI) currentModelSupportsImages() bool {
2351	cfg := m.com.Config()
2352	if cfg == nil {
2353		return false
2354	}
2355	agentCfg, ok := cfg.Agents[config.AgentCoder]
2356	if !ok {
2357		return false
2358	}
2359	model := cfg.GetModelByType(agentCfg.Model)
2360	return model != nil && model.SupportsImages
2361}
2362
2363// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2364func (m *UI) toggleCompactMode() tea.Cmd {
2365	m.forceCompactMode = !m.forceCompactMode
2366
2367	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2368	if err != nil {
2369		return util.ReportError(err)
2370	}
2371
2372	m.updateLayoutAndSize()
2373
2374	return nil
2375}
2376
2377// updateLayoutAndSize updates the layout and sizes of UI components.
2378func (m *UI) updateLayoutAndSize() {
2379	// Determine if we should be in compact mode
2380	if m.state == uiChat {
2381		if m.forceCompactMode {
2382			m.isCompact = true
2383		} else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2384			m.isCompact = true
2385		} else {
2386			m.isCompact = false
2387		}
2388	}
2389
2390	// First pass sizes components from the current textarea height.
2391	m.layout = m.generateLayout(m.width, m.height)
2392	prevHeight := m.textarea.Height()
2393	m.updateSize()
2394
2395	// SetWidth can change textarea height due to soft-wrap recalculation.
2396	// If that happens, run one reconciliation pass with the new height.
2397	if m.textarea.Height() != prevHeight {
2398		m.layout = m.generateLayout(m.width, m.height)
2399		m.updateSize()
2400	}
2401}
2402
2403// handleTextareaHeightChange checks whether the textarea height changed and,
2404// if so, recalculates the layout. When the chat is in follow mode it keeps
2405// the view scrolled to the bottom. The returned command, if non-nil, must be
2406// batched by the caller.
2407func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2408	if m.textarea.Height() == prevHeight {
2409		return nil
2410	}
2411	m.updateLayoutAndSize()
2412	if m.state == uiChat && m.chat.Follow() {
2413		return m.chat.ScrollToBottomAndAnimate()
2414	}
2415	return nil
2416}
2417
2418// updateTextarea updates the textarea for msg and then reconciles layout if
2419// the textarea height changed as a result.
2420func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2421	return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2422}
2423
2424// updateTextareaWithPrevHeight is for cases when the height of the layout may
2425// have changed.
2426//
2427// Particularly, it's for cases where the textarea changes before
2428// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2429// pass the height from before those changes took place so we can compare
2430// "before" vs "after" sizing and recalculate the layout if the textarea grew
2431// or shrank.
2432func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2433	ta, cmd := m.textarea.Update(msg)
2434	m.textarea = ta
2435	return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2436}
2437
2438// updateSize updates the sizes of UI components based on the current layout.
2439func (m *UI) updateSize() {
2440	// Set status width
2441	m.status.SetWidth(m.layout.status.Dx())
2442
2443	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2444	m.textarea.MaxHeight = TextareaMaxHeight
2445	m.textarea.SetWidth(m.layout.editor.Dx())
2446	m.renderPills()
2447
2448	// Handle different app states
2449	switch m.state {
2450	case uiChat:
2451		if !m.isCompact {
2452			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2453		}
2454	}
2455}
2456
2457// generateLayout calculates the layout rectangles for all UI components based
2458// on the current UI state and terminal dimensions.
2459func (m *UI) generateLayout(w, h int) uiLayout {
2460	// The screen area we're working with
2461	area := image.Rect(0, 0, w, h)
2462
2463	// The help height
2464	helpHeight := 1
2465	// The editor height: textarea height + margin for attachments and bottom spacing.
2466	editorHeight := m.textarea.Height() + editorHeightMargin
2467	// The sidebar width
2468	sidebarWidth := 30
2469	// The header height
2470	const landingHeaderHeight = 4
2471
2472	var helpKeyMap help.KeyMap = m
2473	if m.status != nil && m.status.ShowingAll() {
2474		for _, row := range helpKeyMap.FullHelp() {
2475			helpHeight = max(helpHeight, len(row))
2476		}
2477	}
2478
2479	// Add app margins
2480	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2481	appRect.Min.Y += 1
2482	appRect.Max.Y -= 1
2483	helpRect.Min.Y -= 1
2484	appRect.Min.X += 1
2485	appRect.Max.X -= 1
2486
2487	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2488		// extra padding on left and right for these states
2489		appRect.Min.X += 1
2490		appRect.Max.X -= 1
2491	}
2492
2493	uiLayout := uiLayout{
2494		area:   area,
2495		status: helpRect,
2496	}
2497
2498	// Handle different app states
2499	switch m.state {
2500	case uiOnboarding, uiInitialize:
2501		// Layout
2502		//
2503		// header
2504		// ------
2505		// main
2506		// ------
2507		// help
2508
2509		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2510		uiLayout.header = headerRect
2511		uiLayout.main = mainRect
2512
2513	case uiLanding:
2514		// Layout
2515		//
2516		// header
2517		// ------
2518		// main
2519		// ------
2520		// editor
2521		// ------
2522		// help
2523		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2524		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2525		// Remove extra padding from editor (but keep it for header and main)
2526		editorRect.Min.X -= 1
2527		editorRect.Max.X += 1
2528		uiLayout.header = headerRect
2529		uiLayout.main = mainRect
2530		uiLayout.editor = editorRect
2531
2532	case uiChat:
2533		if m.isCompact {
2534			// Layout
2535			//
2536			// compact-header
2537			// ------
2538			// main
2539			// ------
2540			// editor
2541			// ------
2542			// help
2543			const compactHeaderHeight = 1
2544			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2545			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2546			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2547			uiLayout.sessionDetails = sessionDetailsArea
2548			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2549			// Add one line gap between header and main content
2550			mainRect.Min.Y += 1
2551			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2552			mainRect.Max.X -= 1 // Add padding right
2553			uiLayout.header = headerRect
2554			pillsHeight := m.pillsAreaHeight()
2555			if pillsHeight > 0 {
2556				pillsHeight = min(pillsHeight, mainRect.Dy())
2557				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2558				uiLayout.main = chatRect
2559				uiLayout.pills = pillsRect
2560			} else {
2561				uiLayout.main = mainRect
2562			}
2563			// Add bottom margin to main
2564			uiLayout.main.Max.Y -= 1
2565			uiLayout.editor = editorRect
2566		} else {
2567			// Layout
2568			//
2569			// ------|---
2570			// main  |
2571			// ------| side
2572			// editor|
2573			// ----------
2574			// help
2575
2576			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2577			// Add padding left
2578			sideRect.Min.X += 1
2579			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2580			mainRect.Max.X -= 1 // Add padding right
2581			uiLayout.sidebar = sideRect
2582			pillsHeight := m.pillsAreaHeight()
2583			if pillsHeight > 0 {
2584				pillsHeight = min(pillsHeight, mainRect.Dy())
2585				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2586				uiLayout.main = chatRect
2587				uiLayout.pills = pillsRect
2588			} else {
2589				uiLayout.main = mainRect
2590			}
2591			// Add bottom margin to main
2592			uiLayout.main.Max.Y -= 1
2593			uiLayout.editor = editorRect
2594		}
2595	}
2596
2597	return uiLayout
2598}
2599
2600// uiLayout defines the positioning of UI elements.
2601type uiLayout struct {
2602	// area is the overall available area.
2603	area uv.Rectangle
2604
2605	// header is the header shown in special cases
2606	// e.x when the sidebar is collapsed
2607	// or when in the landing page
2608	// or in init/config
2609	header uv.Rectangle
2610
2611	// main is the area for the main pane. (e.x chat, configure, landing)
2612	main uv.Rectangle
2613
2614	// pills is the area for the pills panel.
2615	pills uv.Rectangle
2616
2617	// editor is the area for the editor pane.
2618	editor uv.Rectangle
2619
2620	// sidebar is the area for the sidebar.
2621	sidebar uv.Rectangle
2622
2623	// status is the area for the status view.
2624	status uv.Rectangle
2625
2626	// session details is the area for the session details overlay in compact mode.
2627	sessionDetails uv.Rectangle
2628}
2629
2630func (m *UI) openEditor(value string) tea.Cmd {
2631	tmpfile, err := os.CreateTemp("", "msg_*.md")
2632	if err != nil {
2633		return util.ReportError(err)
2634	}
2635	tmpPath := tmpfile.Name()
2636	defer tmpfile.Close() //nolint:errcheck
2637	if _, err := tmpfile.WriteString(value); err != nil {
2638		return util.ReportError(err)
2639	}
2640	cmd, err := editor.Command(
2641		"crush",
2642		tmpPath,
2643		editor.AtPosition(
2644			m.textarea.Line()+1,
2645			m.textarea.Column()+1,
2646		),
2647	)
2648	if err != nil {
2649		return util.ReportError(err)
2650	}
2651	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2652		defer func() {
2653			_ = os.Remove(tmpPath)
2654		}()
2655
2656		if err != nil {
2657			return util.ReportError(err)
2658		}
2659		content, err := os.ReadFile(tmpPath)
2660		if err != nil {
2661			return util.ReportError(err)
2662		}
2663		if len(content) == 0 {
2664			return util.ReportWarn("Message is empty")
2665		}
2666		return openEditorMsg{
2667			Text: strings.TrimSpace(string(content)),
2668		}
2669	})
2670}
2671
2672// setEditorPrompt configures the textarea prompt function based on whether
2673// yolo mode is enabled.
2674func (m *UI) setEditorPrompt(yolo bool) {
2675	if yolo {
2676		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2677		return
2678	}
2679	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2680}
2681
2682// normalPromptFunc returns the normal editor prompt style ("  > " on first
2683// line, "::: " on subsequent lines).
2684func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2685	t := m.com.Styles
2686	if info.LineNumber == 0 {
2687		if info.Focused {
2688			return "  > "
2689		}
2690		return "::: "
2691	}
2692	if info.Focused {
2693		return t.EditorPromptNormalFocused.Render()
2694	}
2695	return t.EditorPromptNormalBlurred.Render()
2696}
2697
2698// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2699// and colored dots.
2700func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2701	t := m.com.Styles
2702	if info.LineNumber == 0 {
2703		if info.Focused {
2704			return t.EditorPromptYoloIconFocused.Render()
2705		} else {
2706			return t.EditorPromptYoloIconBlurred.Render()
2707		}
2708	}
2709	if info.Focused {
2710		return t.EditorPromptYoloDotsFocused.Render()
2711	}
2712	return t.EditorPromptYoloDotsBlurred.Render()
2713}
2714
2715// closeCompletions closes the completions popup and resets state.
2716func (m *UI) closeCompletions() {
2717	m.completionsOpen = false
2718	m.completionsQuery = ""
2719	m.completionsStartIndex = 0
2720	m.completions.Close()
2721}
2722
2723// insertCompletionText replaces the @query in the textarea with the given text.
2724// Returns false if the replacement cannot be performed.
2725func (m *UI) insertCompletionText(text string) bool {
2726	value := m.textarea.Value()
2727	if m.completionsStartIndex > len(value) {
2728		return false
2729	}
2730
2731	word := m.textareaWord()
2732	endIdx := min(m.completionsStartIndex+len(word), len(value))
2733	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2734	m.textarea.SetValue(newValue)
2735	m.textarea.MoveToEnd()
2736	m.textarea.InsertRune(' ')
2737	return true
2738}
2739
2740// insertFileCompletion inserts the selected file path into the textarea,
2741// replacing the @query, and adds the file as an attachment.
2742func (m *UI) insertFileCompletion(path string) tea.Cmd {
2743	prevHeight := m.textarea.Height()
2744	if !m.insertCompletionText(path) {
2745		return nil
2746	}
2747	heightCmd := m.handleTextareaHeightChange(prevHeight)
2748
2749	fileCmd := func() tea.Msg {
2750		absPath, _ := filepath.Abs(path)
2751
2752		if m.hasSession() {
2753			// Skip attachment if file was already read and hasn't been modified.
2754			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2755			if !lastRead.IsZero() {
2756				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2757					return nil
2758				}
2759			}
2760		} else if slices.Contains(m.sessionFileReads, absPath) {
2761			return nil
2762		}
2763
2764		m.sessionFileReads = append(m.sessionFileReads, absPath)
2765
2766		// Add file as attachment.
2767		content, err := os.ReadFile(path)
2768		if err != nil {
2769			// If it fails, let the LLM handle it later.
2770			return nil
2771		}
2772
2773		return message.Attachment{
2774			FilePath: path,
2775			FileName: filepath.Base(path),
2776			MimeType: mimeOf(content),
2777			Content:  content,
2778		}
2779	}
2780	return tea.Batch(heightCmd, fileCmd)
2781}
2782
2783// insertMCPResourceCompletion inserts the selected resource into the textarea,
2784// replacing the @query, and adds the resource as an attachment.
2785func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2786	displayText := cmp.Or(item.Title, item.URI)
2787
2788	prevHeight := m.textarea.Height()
2789	if !m.insertCompletionText(displayText) {
2790		return nil
2791	}
2792	heightCmd := m.handleTextareaHeightChange(prevHeight)
2793
2794	resourceCmd := func() tea.Msg {
2795		contents, err := mcp.ReadResource(
2796			context.Background(),
2797			m.com.Store(),
2798			item.MCPName,
2799			item.URI,
2800		)
2801		if err != nil {
2802			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2803			return nil
2804		}
2805		if len(contents) == 0 {
2806			return nil
2807		}
2808
2809		content := contents[0]
2810		var data []byte
2811		if content.Text != "" {
2812			data = []byte(content.Text)
2813		} else if len(content.Blob) > 0 {
2814			data = content.Blob
2815		}
2816		if len(data) == 0 {
2817			return nil
2818		}
2819
2820		mimeType := item.MIMEType
2821		if mimeType == "" && content.MIMEType != "" {
2822			mimeType = content.MIMEType
2823		}
2824		if mimeType == "" {
2825			mimeType = "text/plain"
2826		}
2827
2828		return message.Attachment{
2829			FilePath: item.URI,
2830			FileName: displayText,
2831			MimeType: mimeType,
2832			Content:  data,
2833		}
2834	}
2835	return tea.Batch(heightCmd, resourceCmd)
2836}
2837
2838// completionsPosition returns the X and Y position for the completions popup.
2839func (m *UI) completionsPosition() image.Point {
2840	cur := m.textarea.Cursor()
2841	if cur == nil {
2842		return image.Point{
2843			X: m.layout.editor.Min.X,
2844			Y: m.layout.editor.Min.Y,
2845		}
2846	}
2847	return image.Point{
2848		X: cur.X + m.layout.editor.Min.X,
2849		Y: m.layout.editor.Min.Y + cur.Y,
2850	}
2851}
2852
2853// textareaWord returns the current word at the cursor position.
2854func (m *UI) textareaWord() string {
2855	return m.textarea.Word()
2856}
2857
2858// isWhitespace returns true if the byte is a whitespace character.
2859func isWhitespace(b byte) bool {
2860	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2861}
2862
2863// isAgentBusy returns true if the agent coordinator exists and is currently
2864// busy processing a request.
2865func (m *UI) isAgentBusy() bool {
2866	return m.com.App != nil &&
2867		m.com.App.AgentCoordinator != nil &&
2868		m.com.App.AgentCoordinator.IsBusy()
2869}
2870
2871// hasSession returns true if there is an active session with a valid ID.
2872func (m *UI) hasSession() bool {
2873	return m.session != nil && m.session.ID != ""
2874}
2875
2876// mimeOf detects the MIME type of the given content.
2877func mimeOf(content []byte) string {
2878	mimeBufferSize := min(512, len(content))
2879	return http.DetectContentType(content[:mimeBufferSize])
2880}
2881
2882var readyPlaceholders = [...]string{
2883	"Ready!",
2884	"Ready...",
2885	"Ready?",
2886	"Ready for instructions",
2887}
2888
2889var workingPlaceholders = [...]string{
2890	"Working!",
2891	"Working...",
2892	"Brrrrr...",
2893	"Prrrrrrrr...",
2894	"Processing...",
2895	"Thinking...",
2896}
2897
2898// randomizePlaceholders selects random placeholder text for the textarea's
2899// ready and working states.
2900func (m *UI) randomizePlaceholders() {
2901	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2902	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2903}
2904
2905// renderEditorView renders the editor view with attachments if any.
2906func (m *UI) renderEditorView(width int) string {
2907	var attachmentsView string
2908	if len(m.attachments.List()) > 0 {
2909		attachmentsView = m.attachments.Render(width)
2910	}
2911	return strings.Join([]string{
2912		attachmentsView,
2913		m.textarea.View(),
2914		"", // margin at bottom of editor
2915	}, "\n")
2916}
2917
2918// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2919func (m *UI) cacheSidebarLogo(width int) {
2920	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2921}
2922
2923// sendMessage sends a message with the given content and attachments.
2924func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2925	if m.com.App.AgentCoordinator == nil {
2926		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2927	}
2928
2929	var cmds []tea.Cmd
2930	if !m.hasSession() {
2931		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2932		if err != nil {
2933			return util.ReportError(err)
2934		}
2935		if m.forceCompactMode {
2936			m.isCompact = true
2937		}
2938		if newSession.ID != "" {
2939			m.session = &newSession
2940			cmds = append(cmds, m.loadSession(newSession.ID))
2941		}
2942		m.setState(uiChat, m.focus)
2943	}
2944
2945	ctx := context.Background()
2946	cmds = append(cmds, func() tea.Msg {
2947		for _, path := range m.sessionFileReads {
2948			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2949			m.com.App.LSPManager.Start(ctx, path)
2950		}
2951		return nil
2952	})
2953
2954	// Capture session ID to avoid race with main goroutine updating m.session.
2955	sessionID := m.session.ID
2956	cmds = append(cmds, func() tea.Msg {
2957		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2958		if err != nil {
2959			isCancelErr := errors.Is(err, context.Canceled)
2960			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2961			if isCancelErr || isPermissionErr {
2962				return nil
2963			}
2964			return util.InfoMsg{
2965				Type: util.InfoTypeError,
2966				Msg:  err.Error(),
2967			}
2968		}
2969		return nil
2970	})
2971	return tea.Batch(cmds...)
2972}
2973
2974const cancelTimerDuration = 2 * time.Second
2975
2976// cancelTimerCmd creates a command that expires the cancel timer.
2977func cancelTimerCmd() tea.Cmd {
2978	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2979		return cancelTimerExpiredMsg{}
2980	})
2981}
2982
2983// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2984// and starts a timer. The second press (before the timer expires) actually
2985// cancels the agent.
2986func (m *UI) cancelAgent() tea.Cmd {
2987	if !m.hasSession() {
2988		return nil
2989	}
2990
2991	coordinator := m.com.App.AgentCoordinator
2992	if coordinator == nil {
2993		return nil
2994	}
2995
2996	if m.isCanceling {
2997		// Second escape press - actually cancel the agent.
2998		m.isCanceling = false
2999		coordinator.Cancel(m.session.ID)
3000		// Stop the spinning todo indicator.
3001		m.todoIsSpinning = false
3002		m.renderPills()
3003		return nil
3004	}
3005
3006	// Check if there are queued prompts - if so, clear the queue.
3007	if coordinator.QueuedPrompts(m.session.ID) > 0 {
3008		coordinator.ClearQueue(m.session.ID)
3009		return nil
3010	}
3011
3012	// First escape press - set canceling state and start timer.
3013	m.isCanceling = true
3014	return cancelTimerCmd()
3015}
3016
3017// openDialog opens a dialog by its ID.
3018func (m *UI) openDialog(id string) tea.Cmd {
3019	var cmds []tea.Cmd
3020	switch id {
3021	case dialog.SessionsID:
3022		if cmd := m.openSessionsDialog(); cmd != nil {
3023			cmds = append(cmds, cmd)
3024		}
3025	case dialog.ModelsID:
3026		if cmd := m.openModelsDialog(); cmd != nil {
3027			cmds = append(cmds, cmd)
3028		}
3029	case dialog.CommandsID:
3030		if cmd := m.openCommandsDialog(); cmd != nil {
3031			cmds = append(cmds, cmd)
3032		}
3033	case dialog.ReasoningID:
3034		if cmd := m.openReasoningDialog(); cmd != nil {
3035			cmds = append(cmds, cmd)
3036		}
3037	case dialog.FilePickerID:
3038		if cmd := m.openFilesDialog(); cmd != nil {
3039			cmds = append(cmds, cmd)
3040		}
3041	case dialog.QuitID:
3042		if cmd := m.openQuitDialog(); cmd != nil {
3043			cmds = append(cmds, cmd)
3044		}
3045	default:
3046		// Unknown dialog
3047		break
3048	}
3049	return tea.Batch(cmds...)
3050}
3051
3052// openQuitDialog opens the quit confirmation dialog.
3053func (m *UI) openQuitDialog() tea.Cmd {
3054	if m.dialog.ContainsDialog(dialog.QuitID) {
3055		// Bring to front
3056		m.dialog.BringToFront(dialog.QuitID)
3057		return nil
3058	}
3059
3060	quitDialog := dialog.NewQuit(m.com)
3061	m.dialog.OpenDialog(quitDialog)
3062	return nil
3063}
3064
3065// openModelsDialog opens the models dialog.
3066func (m *UI) openModelsDialog() tea.Cmd {
3067	if m.dialog.ContainsDialog(dialog.ModelsID) {
3068		// Bring to front
3069		m.dialog.BringToFront(dialog.ModelsID)
3070		return nil
3071	}
3072
3073	isOnboarding := m.state == uiOnboarding
3074	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3075	if err != nil {
3076		return util.ReportError(err)
3077	}
3078
3079	m.dialog.OpenDialog(modelsDialog)
3080
3081	return nil
3082}
3083
3084// openCommandsDialog opens the commands dialog.
3085func (m *UI) openCommandsDialog() tea.Cmd {
3086	if m.dialog.ContainsDialog(dialog.CommandsID) {
3087		// Bring to front
3088		m.dialog.BringToFront(dialog.CommandsID)
3089		return nil
3090	}
3091
3092	var sessionID string
3093	hasSession := m.session != nil
3094	if hasSession {
3095		sessionID = m.session.ID
3096	}
3097	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3098	hasQueue := m.promptQueue > 0
3099
3100	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3101	if err != nil {
3102		return util.ReportError(err)
3103	}
3104
3105	m.dialog.OpenDialog(commands)
3106
3107	return commands.InitialCmd()
3108}
3109
3110// openReasoningDialog opens the reasoning effort dialog.
3111func (m *UI) openReasoningDialog() tea.Cmd {
3112	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3113		m.dialog.BringToFront(dialog.ReasoningID)
3114		return nil
3115	}
3116
3117	reasoningDialog, err := dialog.NewReasoning(m.com)
3118	if err != nil {
3119		return util.ReportError(err)
3120	}
3121
3122	m.dialog.OpenDialog(reasoningDialog)
3123	return nil
3124}
3125
3126// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3127// it brings it to the front. Otherwise, it will list all the sessions and open
3128// the dialog.
3129func (m *UI) openSessionsDialog() tea.Cmd {
3130	if m.dialog.ContainsDialog(dialog.SessionsID) {
3131		// Bring to front
3132		m.dialog.BringToFront(dialog.SessionsID)
3133		return nil
3134	}
3135
3136	selectedSessionID := ""
3137	if m.session != nil {
3138		selectedSessionID = m.session.ID
3139	}
3140
3141	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3142	if err != nil {
3143		return util.ReportError(err)
3144	}
3145
3146	m.dialog.OpenDialog(dialog)
3147	return nil
3148}
3149
3150// openFilesDialog opens the file picker dialog.
3151func (m *UI) openFilesDialog() tea.Cmd {
3152	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3153		// Bring to front
3154		m.dialog.BringToFront(dialog.FilePickerID)
3155		return nil
3156	}
3157
3158	filePicker, cmd := dialog.NewFilePicker(m.com)
3159	filePicker.SetImageCapabilities(&m.caps)
3160	m.dialog.OpenDialog(filePicker)
3161
3162	return cmd
3163}
3164
3165// openPermissionsDialog opens the permissions dialog for a permission request.
3166func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3167	// Close any existing permissions dialog first.
3168	m.dialog.CloseDialog(dialog.PermissionsID)
3169
3170	// Get diff mode from config.
3171	var opts []dialog.PermissionsOption
3172	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3173		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3174	}
3175
3176	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3177	m.dialog.OpenDialog(permDialog)
3178	return nil
3179}
3180
3181// handlePermissionNotification updates tool items when permission state changes.
3182func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3183	toolItem := m.chat.MessageItem(notification.ToolCallID)
3184	if toolItem == nil {
3185		return
3186	}
3187
3188	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3189		if notification.Granted {
3190			permItem.SetStatus(chat.ToolStatusRunning)
3191		} else {
3192			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3193		}
3194	}
3195}
3196
3197// handleAgentNotification translates domain agent events into desktop
3198// notifications using the UI notification backend.
3199func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3200	switch n.Type {
3201	case notify.TypeAgentFinished:
3202		return m.sendNotification(notification.Notification{
3203			Title:   "Crush is waiting...",
3204			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3205		})
3206	default:
3207		return nil
3208	}
3209}
3210
3211// newSession clears the current session state and prepares for a new session.
3212// The actual session creation happens when the user sends their first message.
3213// Returns a command to reload prompt history.
3214func (m *UI) newSession() tea.Cmd {
3215	if !m.hasSession() {
3216		return nil
3217	}
3218
3219	m.session = nil
3220	m.sessionFiles = nil
3221	m.sessionFileReads = nil
3222	m.setState(uiLanding, uiFocusEditor)
3223	m.textarea.Focus()
3224	m.chat.Blur()
3225	m.chat.ClearMessages()
3226	m.pillsExpanded = false
3227	m.promptQueue = 0
3228	m.pillsView = ""
3229	m.historyReset()
3230	agenttools.ResetCache()
3231	return tea.Batch(
3232		func() tea.Msg {
3233			m.com.App.LSPManager.StopAll(context.Background())
3234			return nil
3235		},
3236		m.loadPromptHistory(),
3237	)
3238}
3239
3240// handlePasteMsg handles a paste message.
3241func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3242	if m.dialog.HasDialogs() {
3243		return m.handleDialogMsg(msg)
3244	}
3245
3246	if m.focus != uiFocusEditor {
3247		return nil
3248	}
3249
3250	if hasPasteExceededThreshold(msg) {
3251		return func() tea.Msg {
3252			content := []byte(msg.Content)
3253			if int64(len(content)) > common.MaxAttachmentSize {
3254				return util.ReportWarn("Paste is too big (>5mb)")
3255			}
3256			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3257			mimeBufferSize := min(512, len(content))
3258			mimeType := http.DetectContentType(content[:mimeBufferSize])
3259			return message.Attachment{
3260				FileName: name,
3261				FilePath: name,
3262				MimeType: mimeType,
3263				Content:  content,
3264			}
3265		}
3266	}
3267
3268	// Attempt to parse pasted content as file paths. If possible to parse,
3269	// all files exist and are valid, add as attachments.
3270	// Otherwise, paste as text.
3271	paths := fsext.ParsePastedFiles(msg.Content)
3272	allExistsAndValid := func() bool {
3273		if len(paths) == 0 {
3274			return false
3275		}
3276		for _, path := range paths {
3277			if _, err := os.Stat(path); os.IsNotExist(err) {
3278				return false
3279			}
3280
3281			lowerPath := strings.ToLower(path)
3282			isValid := false
3283			for _, ext := range common.AllowedImageTypes {
3284				if strings.HasSuffix(lowerPath, ext) {
3285					isValid = true
3286					break
3287				}
3288			}
3289			if !isValid {
3290				return false
3291			}
3292		}
3293		return true
3294	}
3295	if !allExistsAndValid() {
3296		prevHeight := m.textarea.Height()
3297		return m.updateTextareaWithPrevHeight(msg, prevHeight)
3298	}
3299
3300	var cmds []tea.Cmd
3301	for _, path := range paths {
3302		cmds = append(cmds, m.handleFilePathPaste(path))
3303	}
3304	return tea.Batch(cmds...)
3305}
3306
3307func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3308	var (
3309		lineCount = 0
3310		colCount  = 0
3311	)
3312	for line := range strings.SplitSeq(msg.Content, "\n") {
3313		lineCount++
3314		colCount = max(colCount, len(line))
3315
3316		if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3317			return true
3318		}
3319	}
3320	return false
3321}
3322
3323// handleFilePathPaste handles a pasted file path.
3324func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3325	return func() tea.Msg {
3326		fileInfo, err := os.Stat(path)
3327		if err != nil {
3328			return util.ReportError(err)
3329		}
3330		if fileInfo.IsDir() {
3331			return util.ReportWarn("Cannot attach a directory")
3332		}
3333		if fileInfo.Size() > common.MaxAttachmentSize {
3334			return util.ReportWarn("File is too big (>5mb)")
3335		}
3336
3337		content, err := os.ReadFile(path)
3338		if err != nil {
3339			return util.ReportError(err)
3340		}
3341
3342		mimeBufferSize := min(512, len(content))
3343		mimeType := http.DetectContentType(content[:mimeBufferSize])
3344		fileName := filepath.Base(path)
3345		return message.Attachment{
3346			FilePath: path,
3347			FileName: fileName,
3348			MimeType: mimeType,
3349			Content:  content,
3350		}
3351	}
3352}
3353
3354// pasteImageFromClipboard reads image data from the system clipboard and
3355// creates an attachment. If no image data is found, it falls back to
3356// interpreting clipboard text as a file path.
3357func (m *UI) pasteImageFromClipboard() tea.Msg {
3358	imageData, err := readClipboard(clipboardFormatImage)
3359	if int64(len(imageData)) > common.MaxAttachmentSize {
3360		return util.InfoMsg{
3361			Type: util.InfoTypeError,
3362			Msg:  "File too large, max 5MB",
3363		}
3364	}
3365	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3366	if err == nil {
3367		return message.Attachment{
3368			FilePath: name,
3369			FileName: name,
3370			MimeType: mimeOf(imageData),
3371			Content:  imageData,
3372		}
3373	}
3374
3375	textData, textErr := readClipboard(clipboardFormatText)
3376	if textErr != nil || len(textData) == 0 {
3377		return nil // Clipboard is empty or does not contain an image
3378	}
3379
3380	path := strings.TrimSpace(string(textData))
3381	path = strings.ReplaceAll(path, "\\ ", " ")
3382	if _, statErr := os.Stat(path); statErr != nil {
3383		return nil // Clipboard does not contain an image or valid file path
3384	}
3385
3386	lowerPath := strings.ToLower(path)
3387	isAllowed := false
3388	for _, ext := range common.AllowedImageTypes {
3389		if strings.HasSuffix(lowerPath, ext) {
3390			isAllowed = true
3391			break
3392		}
3393	}
3394	if !isAllowed {
3395		return util.NewInfoMsg("File type is not a supported image format")
3396	}
3397
3398	fileInfo, statErr := os.Stat(path)
3399	if statErr != nil {
3400		return util.InfoMsg{
3401			Type: util.InfoTypeError,
3402			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3403		}
3404	}
3405	if fileInfo.Size() > common.MaxAttachmentSize {
3406		return util.InfoMsg{
3407			Type: util.InfoTypeError,
3408			Msg:  "File too large, max 5MB",
3409		}
3410	}
3411
3412	content, readErr := os.ReadFile(path)
3413	if readErr != nil {
3414		return util.InfoMsg{
3415			Type: util.InfoTypeError,
3416			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3417		}
3418	}
3419
3420	return message.Attachment{
3421		FilePath: path,
3422		FileName: filepath.Base(path),
3423		MimeType: mimeOf(content),
3424		Content:  content,
3425	}
3426}
3427
3428var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3429
3430func (m *UI) pasteIdx() int {
3431	result := 0
3432	for _, at := range m.attachments.List() {
3433		found := pasteRE.FindStringSubmatch(at.FileName)
3434		if len(found) == 0 {
3435			continue
3436		}
3437		idx, err := strconv.Atoi(found[1])
3438		if err == nil {
3439			result = max(result, idx)
3440		}
3441	}
3442	return result + 1
3443}
3444
3445// drawSessionDetails draws the session details in compact mode.
3446func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3447	if m.session == nil {
3448		return
3449	}
3450
3451	s := m.com.Styles
3452
3453	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3454	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3455
3456	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3457	blocks := []string{
3458		title,
3459		"",
3460		m.modelInfo(width),
3461		"",
3462	}
3463
3464	detailsHeader := lipgloss.JoinVertical(
3465		lipgloss.Left,
3466		blocks...,
3467	)
3468
3469	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3470
3471	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3472
3473	const maxSectionWidth = 50
3474	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3475	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3476
3477	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3478	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3479	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3480	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3481	uv.NewStyledString(
3482		s.CompactDetails.View.
3483			Width(area.Dx()).
3484			Render(
3485				lipgloss.JoinVertical(
3486					lipgloss.Left,
3487					detailsHeader,
3488					sections,
3489					version,
3490				),
3491			),
3492	).Draw(scr, area)
3493}
3494
3495func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3496	load := func() tea.Msg {
3497		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3498		if err != nil {
3499			// TODO: make this better
3500			return util.ReportError(err)()
3501		}
3502
3503		if prompt == "" {
3504			return nil
3505		}
3506		return sendMessageMsg{
3507			Content: prompt,
3508		}
3509	}
3510
3511	var cmds []tea.Cmd
3512	if cmd := m.dialog.StartLoading(); cmd != nil {
3513		cmds = append(cmds, cmd)
3514	}
3515	cmds = append(cmds, load, func() tea.Msg {
3516		return closeDialogMsg{}
3517	})
3518
3519	return tea.Sequence(cmds...)
3520}
3521
3522func (m *UI) handleStateChanged() tea.Cmd {
3523	return func() tea.Msg {
3524		m.com.App.UpdateAgentModel(context.Background())
3525		return mcpStateChangedMsg{
3526			states: mcp.GetStates(),
3527		}
3528	}
3529}
3530
3531func handleMCPPromptsEvent(name string) tea.Cmd {
3532	return func() tea.Msg {
3533		mcp.RefreshPrompts(context.Background(), name)
3534		return nil
3535	}
3536}
3537
3538func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3539	return func() tea.Msg {
3540		mcp.RefreshTools(
3541			context.Background(),
3542			cfg,
3543			name,
3544		)
3545		return nil
3546	}
3547}
3548
3549func handleMCPResourcesEvent(name string) tea.Cmd {
3550	return func() tea.Msg {
3551		mcp.RefreshResources(context.Background(), name)
3552		return nil
3553	}
3554}
3555
3556func (m *UI) copyChatHighlight() tea.Cmd {
3557	text := m.chat.HighlightContent()
3558	return common.CopyToClipboardWithCallback(
3559		text,
3560		"Selected text copied to clipboard",
3561		func() tea.Msg {
3562			m.chat.ClearMouse()
3563			return nil
3564		},
3565	)
3566}
3567
3568func (m *UI) enableDockerMCP() tea.Msg {
3569	store := m.com.Store()
3570	// Stage Docker MCP in memory first so startup and persistence can be atomic.
3571	mcpConfig, err := store.PrepareDockerMCPConfig()
3572	if err != nil {
3573		return util.ReportError(err)()
3574	}
3575
3576	ctx := context.Background()
3577	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3578		// Roll back runtime and in-memory state when startup fails.
3579		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3580		delete(store.Config().MCP, config.DockerMCPName)
3581		return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3582	}
3583
3584	if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3585		// Roll back runtime and in-memory state if persistence fails.
3586		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3587		delete(store.Config().MCP, config.DockerMCPName)
3588		return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3589	}
3590
3591	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3592}
3593
3594func (m *UI) disableDockerMCP() tea.Msg {
3595	store := m.com.Store()
3596	// Close the Docker MCP client.
3597	if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3598		return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3599	}
3600
3601	// Remove from config and persist.
3602	if err := store.DisableDockerMCP(); err != nil {
3603		return util.ReportError(err)()
3604	}
3605
3606	return util.NewInfoMsg("Docker MCP disabled successfully")
3607}
3608
3609// renderLogo renders the Crush logo with the given styles and dimensions.
3610func renderLogo(t *styles.Styles, compact bool, width int) string {
3611	return logo.Render(t, version.Version, compact, logo.Opts{
3612		FieldColor:   t.LogoFieldColor,
3613		TitleColorA:  t.LogoTitleColorA,
3614		TitleColorB:  t.LogoTitleColorB,
3615		CharmColor:   t.LogoCharmColor,
3616		VersionColor: t.LogoVersionColor,
3617		Width:        width,
3618	})
3619}