ui.go

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