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/commands"
  32	"github.com/charmbracelet/crush/internal/config"
  33	"github.com/charmbracelet/crush/internal/fsext"
  34	"github.com/charmbracelet/crush/internal/history"
  35	"github.com/charmbracelet/crush/internal/home"
  36	"github.com/charmbracelet/crush/internal/message"
  37	"github.com/charmbracelet/crush/internal/permission"
  38	"github.com/charmbracelet/crush/internal/pubsub"
  39	"github.com/charmbracelet/crush/internal/session"
  40	"github.com/charmbracelet/crush/internal/ui/anim"
  41	"github.com/charmbracelet/crush/internal/ui/attachments"
  42	"github.com/charmbracelet/crush/internal/ui/chat"
  43	"github.com/charmbracelet/crush/internal/ui/common"
  44	"github.com/charmbracelet/crush/internal/ui/completions"
  45	"github.com/charmbracelet/crush/internal/ui/dialog"
  46	fimage "github.com/charmbracelet/crush/internal/ui/image"
  47	"github.com/charmbracelet/crush/internal/ui/logo"
  48	"github.com/charmbracelet/crush/internal/ui/notification"
  49	"github.com/charmbracelet/crush/internal/ui/styles"
  50	"github.com/charmbracelet/crush/internal/ui/util"
  51	"github.com/charmbracelet/crush/internal/version"
  52	"github.com/charmbracelet/crush/internal/workspace"
  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]workspace.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]workspace.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, _ := com.Workspace.ProjectNeedsInitialization(); 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			sessions, err := m.com.Workspace.ListSessions(context.Background())
 390			if err != nil || len(sessions) == 0 {
 391				return nil
 392			}
 393			return m.loadSession(sessions[0].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.Workspace.AgentQueuedPrompts(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.Workspace.ListMessages(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[workspace.LSPEvent]:
 619		m.lspStates = m.com.Workspace.LSPGetStates()
 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(m.com.Workspace, msg.Payload.Name)
 629		case mcp.EventToolsListChanged:
 630			return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
 631		case mcp.EventResourcesListChanged:
 632			return m, handleMCPResourcesEvent(m.com.Workspace, 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		if msg.Type == util.InfoTypeError {
 840			slog.Error("Error reported", "error", msg.Msg)
 841		}
 842		m.status.SetInfoMsg(msg)
 843		ttl := msg.TTL
 844		if ttl <= 0 {
 845			ttl = DefaultStatusTTL
 846		}
 847		cmds = append(cmds, clearInfoMsgCmd(ttl))
 848	case util.ClearStatusMsg:
 849		m.status.ClearInfoMsg()
 850	case completions.CompletionItemsLoadedMsg:
 851		if m.completionsOpen {
 852			m.completions.SetItems(msg.Files, msg.Resources)
 853		}
 854	case uv.KittyGraphicsEvent:
 855		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 856			slog.Warn("Unexpected Kitty graphics response",
 857				"response", string(msg.Payload),
 858				"options", msg.Options)
 859		}
 860	default:
 861		if m.dialog.HasDialogs() {
 862			if cmd := m.handleDialogMsg(msg); cmd != nil {
 863				cmds = append(cmds, cmd)
 864			}
 865		}
 866	}
 867
 868	// This logic gets triggered on any message type, but should it?
 869	switch m.focus {
 870	case uiFocusMain:
 871	case uiFocusEditor:
 872		// Textarea placeholder logic
 873		if m.isAgentBusy() {
 874			m.textarea.Placeholder = m.workingPlaceholder
 875		} else {
 876			m.textarea.Placeholder = m.readyPlaceholder
 877		}
 878		if m.com.Workspace.PermissionSkipRequests() {
 879			m.textarea.Placeholder = "Yolo mode!"
 880		}
 881	}
 882
 883	// at this point this can only handle [message.Attachment] message, and we
 884	// should return all cmds anyway.
 885	_ = m.attachments.Update(msg)
 886	return m, tea.Batch(cmds...)
 887}
 888
 889// setSessionMessages sets the messages for the current session in the chat
 890func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 891	var cmds []tea.Cmd
 892	// Build tool result map to link tool calls with their results
 893	msgPtrs := make([]*message.Message, len(msgs))
 894	for i := range msgs {
 895		msgPtrs[i] = &msgs[i]
 896	}
 897	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 898	if len(msgPtrs) > 0 {
 899		m.lastUserMessageTime = msgPtrs[0].CreatedAt
 900	}
 901
 902	// Add messages to chat with linked tool results
 903	items := make([]chat.MessageItem, 0, len(msgs)*2)
 904	for _, msg := range msgPtrs {
 905		switch msg.Role {
 906		case message.User:
 907			m.lastUserMessageTime = msg.CreatedAt
 908			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 909		case message.Assistant:
 910			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 911			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 912				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 913				items = append(items, infoItem)
 914			}
 915		default:
 916			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 917		}
 918	}
 919
 920	// Load nested tool calls for agent/agentic_fetch tools.
 921	m.loadNestedToolCalls(items)
 922
 923	// If the user switches between sessions while the agent is working we want
 924	// to make sure the animations are shown.
 925	for _, item := range items {
 926		if animatable, ok := item.(chat.Animatable); ok {
 927			if cmd := animatable.StartAnimation(); cmd != nil {
 928				cmds = append(cmds, cmd)
 929			}
 930		}
 931	}
 932
 933	m.chat.SetMessages(items...)
 934	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 935		cmds = append(cmds, cmd)
 936	}
 937	m.chat.SelectLast()
 938	return tea.Sequence(cmds...)
 939}
 940
 941// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 942func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 943	for _, item := range items {
 944		nestedContainer, ok := item.(chat.NestedToolContainer)
 945		if !ok {
 946			continue
 947		}
 948		toolItem, ok := item.(chat.ToolMessageItem)
 949		if !ok {
 950			continue
 951		}
 952
 953		tc := toolItem.ToolCall()
 954		messageID := toolItem.MessageID()
 955
 956		// Get the agent tool session ID.
 957		agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
 958
 959		// Fetch nested messages.
 960		nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
 961		if err != nil || len(nestedMsgs) == 0 {
 962			continue
 963		}
 964
 965		// Build tool result map for nested messages.
 966		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 967		for i := range nestedMsgs {
 968			nestedMsgPtrs[i] = &nestedMsgs[i]
 969		}
 970		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 971
 972		// Extract nested tool items.
 973		var nestedTools []chat.ToolMessageItem
 974		for _, nestedMsg := range nestedMsgPtrs {
 975			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 976			for _, nestedItem := range nestedItems {
 977				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 978					// Mark nested tools as simple (compact) rendering.
 979					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 980						simplifiable.SetCompact(true)
 981					}
 982					nestedTools = append(nestedTools, nestedToolItem)
 983				}
 984			}
 985		}
 986
 987		// Recursively load nested tool calls for any agent tools within.
 988		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 989		for i, nt := range nestedTools {
 990			nestedMessageItems[i] = nt
 991		}
 992		m.loadNestedToolCalls(nestedMessageItems)
 993
 994		// Set nested tools on the parent.
 995		nestedContainer.SetNestedTools(nestedTools)
 996	}
 997}
 998
 999// appendSessionMessage appends a new message to the current session in the chat
1000// if the message is a tool result it will update the corresponding tool call message
1001func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
1002	var cmds []tea.Cmd
1003
1004	existing := m.chat.MessageItem(msg.ID)
1005	if existing != nil {
1006		// message already exists, skip
1007		return nil
1008	}
1009
1010	switch msg.Role {
1011	case message.User:
1012		m.lastUserMessageTime = msg.CreatedAt
1013		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1014		for _, item := range items {
1015			if animatable, ok := item.(chat.Animatable); ok {
1016				if cmd := animatable.StartAnimation(); cmd != nil {
1017					cmds = append(cmds, cmd)
1018				}
1019			}
1020		}
1021		m.chat.AppendMessages(items...)
1022		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1023			cmds = append(cmds, cmd)
1024		}
1025	case message.Assistant:
1026		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1027		for _, item := range items {
1028			if animatable, ok := item.(chat.Animatable); ok {
1029				if cmd := animatable.StartAnimation(); cmd != nil {
1030					cmds = append(cmds, cmd)
1031				}
1032			}
1033		}
1034		m.chat.AppendMessages(items...)
1035		if m.chat.Follow() {
1036			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1037				cmds = append(cmds, cmd)
1038			}
1039		}
1040		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1041			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1042			m.chat.AppendMessages(infoItem)
1043			if m.chat.Follow() {
1044				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1045					cmds = append(cmds, cmd)
1046				}
1047			}
1048		}
1049	case message.Tool:
1050		for _, tr := range msg.ToolResults() {
1051			toolItem := m.chat.MessageItem(tr.ToolCallID)
1052			if toolItem == nil {
1053				// we should have an item!
1054				continue
1055			}
1056			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1057				toolMsgItem.SetResult(&tr)
1058				if m.chat.Follow() {
1059					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1060						cmds = append(cmds, cmd)
1061					}
1062				}
1063			}
1064		}
1065	}
1066	return tea.Sequence(cmds...)
1067}
1068
1069func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1070	switch {
1071	case m.state != uiChat:
1072		return nil
1073	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1074		return nil
1075	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1076		m.focus = uiFocusEditor
1077		cmd = m.textarea.Focus()
1078		m.chat.Blur()
1079	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1080		m.focus = uiFocusMain
1081		m.textarea.Blur()
1082		m.chat.Focus()
1083	}
1084	return cmd
1085}
1086
1087// updateSessionMessage updates an existing message in the current session in the chat
1088// when an assistant message is updated it may include updated tool calls as well
1089// that is why we need to handle creating/updating each tool call message too
1090func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1091	var cmds []tea.Cmd
1092	existingItem := m.chat.MessageItem(msg.ID)
1093
1094	if existingItem != nil {
1095		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1096			assistantItem.SetMessage(&msg)
1097		}
1098	}
1099
1100	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1101	// if the message of the assistant does not have any  response just tool calls we need to remove it
1102	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1103		m.chat.RemoveMessage(msg.ID)
1104		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1105			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1106		}
1107	}
1108
1109	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1110		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1111			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1112			m.chat.AppendMessages(newInfoItem)
1113		}
1114	}
1115
1116	var items []chat.MessageItem
1117	for _, tc := range msg.ToolCalls() {
1118		existingToolItem := m.chat.MessageItem(tc.ID)
1119		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1120			existingToolCall := toolItem.ToolCall()
1121			// only update if finished state changed or input changed
1122			// to avoid clearing the cache
1123			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1124				toolItem.SetToolCall(tc)
1125			}
1126		}
1127		if existingToolItem == nil {
1128			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1129		}
1130	}
1131
1132	for _, item := range items {
1133		if animatable, ok := item.(chat.Animatable); ok {
1134			if cmd := animatable.StartAnimation(); cmd != nil {
1135				cmds = append(cmds, cmd)
1136			}
1137		}
1138	}
1139
1140	m.chat.AppendMessages(items...)
1141	if m.chat.Follow() {
1142		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1143			cmds = append(cmds, cmd)
1144		}
1145		m.chat.SelectLast()
1146	}
1147
1148	return tea.Sequence(cmds...)
1149}
1150
1151// handleChildSessionMessage handles messages from child sessions (agent tools).
1152func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1153	var cmds []tea.Cmd
1154
1155	// Only process messages with tool calls or results.
1156	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1157		return nil
1158	}
1159
1160	// Check if this is an agent tool session and parse it.
1161	childSessionID := event.Payload.SessionID
1162	_, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1163	if !ok {
1164		return nil
1165	}
1166
1167	// Find the parent agent tool item.
1168	var agentItem chat.NestedToolContainer
1169	for i := 0; i < m.chat.Len(); i++ {
1170		item := m.chat.MessageItem(toolCallID)
1171		if item == nil {
1172			continue
1173		}
1174		if agent, ok := item.(chat.NestedToolContainer); ok {
1175			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1176				if toolMessageItem.ToolCall().ID == toolCallID {
1177					// Verify this agent belongs to the correct parent message.
1178					// We can't directly check parentMessageID on the item, so we trust the session parsing.
1179					agentItem = agent
1180					break
1181				}
1182			}
1183		}
1184	}
1185
1186	if agentItem == nil {
1187		return nil
1188	}
1189
1190	// Get existing nested tools.
1191	nestedTools := agentItem.NestedTools()
1192
1193	// Update or create nested tool calls.
1194	for _, tc := range event.Payload.ToolCalls() {
1195		found := false
1196		for _, existingTool := range nestedTools {
1197			if existingTool.ToolCall().ID == tc.ID {
1198				existingTool.SetToolCall(tc)
1199				found = true
1200				break
1201			}
1202		}
1203		if !found {
1204			// Create a new nested tool item.
1205			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1206			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1207				simplifiable.SetCompact(true)
1208			}
1209			if animatable, ok := nestedItem.(chat.Animatable); ok {
1210				if cmd := animatable.StartAnimation(); cmd != nil {
1211					cmds = append(cmds, cmd)
1212				}
1213			}
1214			nestedTools = append(nestedTools, nestedItem)
1215		}
1216	}
1217
1218	// Update nested tool results.
1219	for _, tr := range event.Payload.ToolResults() {
1220		for _, nestedTool := range nestedTools {
1221			if nestedTool.ToolCall().ID == tr.ToolCallID {
1222				nestedTool.SetResult(&tr)
1223				break
1224			}
1225		}
1226	}
1227
1228	// Update the agent item with the new nested tools.
1229	agentItem.SetNestedTools(nestedTools)
1230
1231	// Update the chat so it updates the index map for animations to work as expected
1232	m.chat.UpdateNestedToolIDs(toolCallID)
1233
1234	if m.chat.Follow() {
1235		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1236			cmds = append(cmds, cmd)
1237		}
1238		m.chat.SelectLast()
1239	}
1240
1241	return tea.Sequence(cmds...)
1242}
1243
1244func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1245	var cmds []tea.Cmd
1246	action := m.dialog.Update(msg)
1247	if action == nil {
1248		return tea.Batch(cmds...)
1249	}
1250
1251	isOnboarding := m.state == uiOnboarding
1252
1253	switch msg := action.(type) {
1254	// Generic dialog messages
1255	case dialog.ActionClose:
1256		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1257			break
1258		}
1259
1260		if m.dialog.ContainsDialog(dialog.FilePickerID) {
1261			defer fimage.ResetCache()
1262		}
1263
1264		m.dialog.CloseFrontDialog()
1265
1266		if isOnboarding {
1267			if cmd := m.openModelsDialog(); cmd != nil {
1268				cmds = append(cmds, cmd)
1269			}
1270		}
1271
1272		if m.focus == uiFocusEditor {
1273			cmds = append(cmds, m.textarea.Focus())
1274		}
1275	case dialog.ActionCmd:
1276		if msg.Cmd != nil {
1277			cmds = append(cmds, msg.Cmd)
1278		}
1279
1280	// Session dialog messages.
1281	case dialog.ActionSelectSession:
1282		m.dialog.CloseDialog(dialog.SessionsID)
1283		cmds = append(cmds, m.loadSession(msg.Session.ID))
1284
1285	// Open dialog message.
1286	case dialog.ActionOpenDialog:
1287		m.dialog.CloseDialog(dialog.CommandsID)
1288		if cmd := m.openDialog(msg.DialogID); cmd != nil {
1289			cmds = append(cmds, cmd)
1290		}
1291
1292	// Command dialog messages.
1293	case dialog.ActionToggleYoloMode:
1294		yolo := !m.com.Workspace.PermissionSkipRequests()
1295		m.com.Workspace.PermissionSetSkipRequests(yolo)
1296		m.setEditorPrompt(yolo)
1297		m.dialog.CloseDialog(dialog.CommandsID)
1298	case dialog.ActionToggleNotifications:
1299		cfg := m.com.Config()
1300		if cfg != nil && cfg.Options != nil {
1301			disabled := !cfg.Options.DisableNotifications
1302			cfg.Options.DisableNotifications = disabled
1303			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1304				cmds = append(cmds, util.ReportError(err))
1305			} else {
1306				status := "enabled"
1307				if disabled {
1308					status = "disabled"
1309				}
1310				cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1311			}
1312		}
1313		m.dialog.CloseDialog(dialog.CommandsID)
1314	case dialog.ActionNewSession:
1315		if m.isAgentBusy() {
1316			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1317			break
1318		}
1319		if cmd := m.newSession(); cmd != nil {
1320			cmds = append(cmds, cmd)
1321		}
1322		m.dialog.CloseDialog(dialog.CommandsID)
1323	case dialog.ActionSummarize:
1324		if m.isAgentBusy() {
1325			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1326			break
1327		}
1328		cmds = append(cmds, func() tea.Msg {
1329			err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1330			if err != nil {
1331				return util.ReportError(err)()
1332			}
1333			return nil
1334		})
1335		m.dialog.CloseDialog(dialog.CommandsID)
1336	case dialog.ActionToggleHelp:
1337		m.status.ToggleHelp()
1338		m.dialog.CloseDialog(dialog.CommandsID)
1339	case dialog.ActionExternalEditor:
1340		if m.isAgentBusy() {
1341			cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1342			break
1343		}
1344		cmds = append(cmds, m.openEditor(m.textarea.Value()))
1345		m.dialog.CloseDialog(dialog.CommandsID)
1346	case dialog.ActionToggleCompactMode:
1347		cmds = append(cmds, m.toggleCompactMode())
1348		m.dialog.CloseDialog(dialog.CommandsID)
1349	case dialog.ActionTogglePills:
1350		if cmd := m.togglePillsExpanded(); cmd != nil {
1351			cmds = append(cmds, cmd)
1352		}
1353		m.dialog.CloseDialog(dialog.CommandsID)
1354	case dialog.ActionToggleThinking:
1355		cmds = append(cmds, func() tea.Msg {
1356			cfg := m.com.Config()
1357			if cfg == nil {
1358				return util.ReportError(errors.New("configuration not found"))()
1359			}
1360
1361			agentCfg, ok := cfg.Agents[config.AgentCoder]
1362			if !ok {
1363				return util.ReportError(errors.New("agent configuration not found"))()
1364			}
1365
1366			currentModel := cfg.Models[agentCfg.Model]
1367			currentModel.Think = !currentModel.Think
1368			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1369				return util.ReportError(err)()
1370			}
1371			m.com.Workspace.UpdateAgentModel(context.TODO())
1372			status := "disabled"
1373			if currentModel.Think {
1374				status = "enabled"
1375			}
1376			return util.NewInfoMsg("Thinking mode " + status)
1377		})
1378		m.dialog.CloseDialog(dialog.CommandsID)
1379	case dialog.ActionToggleTransparentBackground:
1380		cmds = append(cmds, func() tea.Msg {
1381			cfg := m.com.Config()
1382			if cfg == nil {
1383				return util.ReportError(errors.New("configuration not found"))()
1384			}
1385
1386			isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1387			newValue := !isTransparent
1388			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
1389				return util.ReportError(err)()
1390			}
1391			m.isTransparent = newValue
1392
1393			status := "disabled"
1394			if newValue {
1395				status = "enabled"
1396			}
1397			return util.NewInfoMsg("Transparent background " + status)
1398		})
1399		m.dialog.CloseDialog(dialog.CommandsID)
1400	case dialog.ActionQuit:
1401		cmds = append(cmds, tea.Quit)
1402	case dialog.ActionEnableDockerMCP:
1403		m.dialog.CloseDialog(dialog.CommandsID)
1404		cmds = append(cmds, m.enableDockerMCP)
1405	case dialog.ActionDisableDockerMCP:
1406		m.dialog.CloseDialog(dialog.CommandsID)
1407		cmds = append(cmds, m.disableDockerMCP)
1408	case dialog.ActionInitializeProject:
1409		if m.isAgentBusy() {
1410			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1411			break
1412		}
1413		cmds = append(cmds, m.initializeProject())
1414		m.dialog.CloseDialog(dialog.CommandsID)
1415
1416	case dialog.ActionSelectModel:
1417		if m.isAgentBusy() {
1418			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1419			break
1420		}
1421
1422		cfg := m.com.Config()
1423		if cfg == nil {
1424			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1425			break
1426		}
1427
1428		var (
1429			providerID   = msg.Model.Provider
1430			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1431			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1432		)
1433
1434		// Attempt to import GitHub Copilot tokens from VSCode if available.
1435		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1436			m.com.Workspace.ImportCopilot()
1437		}
1438
1439		if !isConfigured() || msg.ReAuthenticate {
1440			m.dialog.CloseDialog(dialog.ModelsID)
1441			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1442				cmds = append(cmds, cmd)
1443			}
1444			break
1445		}
1446
1447		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1448			cmds = append(cmds, util.ReportError(err))
1449		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1450			// Ensure small model is set is unset.
1451			smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1452			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1453				cmds = append(cmds, util.ReportError(err))
1454			}
1455		}
1456
1457		cmds = append(cmds, func() tea.Msg {
1458			if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1459				return util.ReportError(err)
1460			}
1461
1462			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1463
1464			return util.NewInfoMsg(modelMsg)
1465		})
1466
1467		m.dialog.CloseDialog(dialog.APIKeyInputID)
1468		m.dialog.CloseDialog(dialog.OAuthID)
1469		m.dialog.CloseDialog(dialog.ModelsID)
1470
1471		if isOnboarding {
1472			m.setState(uiLanding, uiFocusEditor)
1473			m.com.Config().SetupAgents()
1474			if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1475				cmds = append(cmds, util.ReportError(err))
1476			}
1477		}
1478	case dialog.ActionSelectReasoningEffort:
1479		if m.isAgentBusy() {
1480			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1481			break
1482		}
1483
1484		cfg := m.com.Config()
1485		if cfg == nil {
1486			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1487			break
1488		}
1489
1490		agentCfg, ok := cfg.Agents[config.AgentCoder]
1491		if !ok {
1492			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1493			break
1494		}
1495
1496		currentModel := cfg.Models[agentCfg.Model]
1497		currentModel.ReasoningEffort = msg.Effort
1498		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1499			cmds = append(cmds, util.ReportError(err))
1500			break
1501		}
1502
1503		cmds = append(cmds, func() tea.Msg {
1504			m.com.Workspace.UpdateAgentModel(context.TODO())
1505			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1506		})
1507		m.dialog.CloseDialog(dialog.ReasoningID)
1508	case dialog.ActionPermissionResponse:
1509		m.dialog.CloseDialog(dialog.PermissionsID)
1510		switch msg.Action {
1511		case dialog.PermissionAllow:
1512			m.com.Workspace.PermissionGrant(msg.Permission)
1513		case dialog.PermissionAllowForSession:
1514			m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1515		case dialog.PermissionDeny:
1516			m.com.Workspace.PermissionDeny(msg.Permission)
1517		}
1518
1519	case dialog.ActionFilePickerSelected:
1520		cmds = append(cmds, tea.Sequence(
1521			msg.Cmd(),
1522			func() tea.Msg {
1523				m.dialog.CloseDialog(dialog.FilePickerID)
1524				return nil
1525			},
1526			func() tea.Msg {
1527				fimage.ResetCache()
1528				return nil
1529			},
1530		))
1531
1532	case dialog.ActionRunCustomCommand:
1533		if len(msg.Arguments) > 0 && msg.Args == nil {
1534			m.dialog.CloseFrontDialog()
1535			argsDialog := dialog.NewArguments(
1536				m.com,
1537				"Custom Command Arguments",
1538				"",
1539				msg.Arguments,
1540				msg, // Pass the action as the result
1541			)
1542			m.dialog.OpenDialog(argsDialog)
1543			break
1544		}
1545		content := msg.Content
1546		if msg.Args != nil {
1547			content = substituteArgs(content, msg.Args)
1548		}
1549		cmds = append(cmds, m.sendMessage(content))
1550		m.dialog.CloseFrontDialog()
1551	case dialog.ActionRunMCPPrompt:
1552		if len(msg.Arguments) > 0 && msg.Args == nil {
1553			m.dialog.CloseFrontDialog()
1554			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1555			argsDialog := dialog.NewArguments(
1556				m.com,
1557				title,
1558				msg.Description,
1559				msg.Arguments,
1560				msg, // Pass the action as the result
1561			)
1562			m.dialog.OpenDialog(argsDialog)
1563			break
1564		}
1565		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1566	default:
1567		cmds = append(cmds, util.CmdHandler(msg))
1568	}
1569
1570	return tea.Batch(cmds...)
1571}
1572
1573// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1574func substituteArgs(content string, args map[string]string) string {
1575	for name, value := range args {
1576		placeholder := "$" + name
1577		content = strings.ReplaceAll(content, placeholder, value)
1578	}
1579	return content
1580}
1581
1582func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1583	var (
1584		dlg dialog.Dialog
1585		cmd tea.Cmd
1586
1587		isOnboarding = m.state == uiOnboarding
1588	)
1589
1590	switch provider.ID {
1591	case "hyper":
1592		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1593	case catwalk.InferenceProviderCopilot:
1594		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1595	default:
1596		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1597	}
1598
1599	if m.dialog.ContainsDialog(dlg.ID()) {
1600		m.dialog.BringToFront(dlg.ID())
1601		return nil
1602	}
1603
1604	m.dialog.OpenDialog(dlg)
1605	return cmd
1606}
1607
1608func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1609	var cmds []tea.Cmd
1610
1611	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1612		switch {
1613		case key.Matches(msg, m.keyMap.Help):
1614			m.status.ToggleHelp()
1615			m.updateLayoutAndSize()
1616			return true
1617		case key.Matches(msg, m.keyMap.Commands):
1618			if cmd := m.openCommandsDialog(); cmd != nil {
1619				cmds = append(cmds, cmd)
1620			}
1621			return true
1622		case key.Matches(msg, m.keyMap.Models):
1623			if cmd := m.openModelsDialog(); cmd != nil {
1624				cmds = append(cmds, cmd)
1625			}
1626			return true
1627		case key.Matches(msg, m.keyMap.Sessions):
1628			if cmd := m.openSessionsDialog(); cmd != nil {
1629				cmds = append(cmds, cmd)
1630			}
1631			return true
1632		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1633			m.detailsOpen = !m.detailsOpen
1634			m.updateLayoutAndSize()
1635			return true
1636		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1637			if m.state == uiChat && m.hasSession() {
1638				if cmd := m.togglePillsExpanded(); cmd != nil {
1639					cmds = append(cmds, cmd)
1640				}
1641				return true
1642			}
1643		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1644			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1645				if cmd := m.switchPillSection(-1); cmd != nil {
1646					cmds = append(cmds, cmd)
1647				}
1648				return true
1649			}
1650		case key.Matches(msg, m.keyMap.Chat.PillRight):
1651			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1652				if cmd := m.switchPillSection(1); cmd != nil {
1653					cmds = append(cmds, cmd)
1654				}
1655				return true
1656			}
1657		case key.Matches(msg, m.keyMap.Suspend):
1658			if m.isAgentBusy() {
1659				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1660				return true
1661			}
1662			cmds = append(cmds, tea.Suspend)
1663			return true
1664		}
1665		return false
1666	}
1667
1668	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1669		// Always handle quit keys first
1670		if cmd := m.openQuitDialog(); cmd != nil {
1671			cmds = append(cmds, cmd)
1672		}
1673
1674		return tea.Batch(cmds...)
1675	}
1676
1677	// Route all messages to dialog if one is open.
1678	if m.dialog.HasDialogs() {
1679		return m.handleDialogMsg(msg)
1680	}
1681
1682	// Handle cancel key when agent is busy.
1683	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1684		if m.isAgentBusy() {
1685			if cmd := m.cancelAgent(); cmd != nil {
1686				cmds = append(cmds, cmd)
1687			}
1688			return tea.Batch(cmds...)
1689		}
1690	}
1691
1692	switch m.state {
1693	case uiOnboarding:
1694		return tea.Batch(cmds...)
1695	case uiInitialize:
1696		cmds = append(cmds, m.updateInitializeView(msg)...)
1697		return tea.Batch(cmds...)
1698	case uiChat, uiLanding:
1699		switch m.focus {
1700		case uiFocusEditor:
1701			// Handle completions if open.
1702			if m.completionsOpen {
1703				if msg, ok := m.completions.Update(msg); ok {
1704					switch msg := msg.(type) {
1705					case completions.SelectionMsg[completions.FileCompletionValue]:
1706						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1707						if !msg.KeepOpen {
1708							m.closeCompletions()
1709						}
1710					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1711						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1712						if !msg.KeepOpen {
1713							m.closeCompletions()
1714						}
1715					case completions.ClosedMsg:
1716						m.completionsOpen = false
1717					}
1718					return tea.Batch(cmds...)
1719				}
1720			}
1721
1722			if ok := m.attachments.Update(msg); ok {
1723				return tea.Batch(cmds...)
1724			}
1725
1726			switch {
1727			case key.Matches(msg, m.keyMap.Editor.AddImage):
1728				if !m.currentModelSupportsImages() {
1729					break
1730				}
1731				if cmd := m.openFilesDialog(); cmd != nil {
1732					cmds = append(cmds, cmd)
1733				}
1734
1735			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1736				if !m.currentModelSupportsImages() {
1737					break
1738				}
1739				cmds = append(cmds, m.pasteImageFromClipboard)
1740
1741			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1742				prevHeight := m.textarea.Height()
1743				value := m.textarea.Value()
1744				if before, ok := strings.CutSuffix(value, "\\"); ok {
1745					// If the last character is a backslash, remove it and add a newline.
1746					m.textarea.SetValue(before)
1747					if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1748						cmds = append(cmds, cmd)
1749					}
1750					break
1751				}
1752
1753				// Otherwise, send the message
1754				m.textarea.Reset()
1755				if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1756					cmds = append(cmds, cmd)
1757				}
1758
1759				value = strings.TrimSpace(value)
1760				if value == "exit" || value == "quit" {
1761					return m.openQuitDialog()
1762				}
1763
1764				attachments := m.attachments.List()
1765				m.attachments.Reset()
1766				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1767					return nil
1768				}
1769
1770				m.randomizePlaceholders()
1771				m.historyReset()
1772
1773				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1774			case key.Matches(msg, m.keyMap.Chat.NewSession):
1775				if !m.hasSession() {
1776					break
1777				}
1778				if m.isAgentBusy() {
1779					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1780					break
1781				}
1782				if cmd := m.newSession(); cmd != nil {
1783					cmds = append(cmds, cmd)
1784				}
1785			case key.Matches(msg, m.keyMap.Tab):
1786				if m.state != uiLanding {
1787					m.setState(m.state, uiFocusMain)
1788					m.textarea.Blur()
1789					m.chat.Focus()
1790					m.chat.SetSelected(m.chat.Len() - 1)
1791				}
1792			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1793				if m.isAgentBusy() {
1794					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1795					break
1796				}
1797				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1798			case key.Matches(msg, m.keyMap.Editor.Newline):
1799				prevHeight := m.textarea.Height()
1800				m.textarea.InsertRune('\n')
1801				m.closeCompletions()
1802				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1803			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1804				cmd := m.handleHistoryUp(msg)
1805				if cmd != nil {
1806					cmds = append(cmds, cmd)
1807				}
1808			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1809				cmd := m.handleHistoryDown(msg)
1810				if cmd != nil {
1811					cmds = append(cmds, cmd)
1812				}
1813			case key.Matches(msg, m.keyMap.Editor.Escape):
1814				cmd := m.handleHistoryEscape(msg)
1815				if cmd != nil {
1816					cmds = append(cmds, cmd)
1817				}
1818			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1819				if cmd := m.openCommandsDialog(); cmd != nil {
1820					cmds = append(cmds, cmd)
1821				}
1822			default:
1823				if handleGlobalKeys(msg) {
1824					// Handle global keys first before passing to textarea.
1825					break
1826				}
1827
1828				// Check for @ trigger before passing to textarea.
1829				curValue := m.textarea.Value()
1830				curIdx := len(curValue)
1831
1832				// Trigger completions on @.
1833				if msg.String() == "@" && !m.completionsOpen {
1834					// Only show if beginning of prompt or after whitespace.
1835					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1836						m.completionsOpen = true
1837						m.completionsQuery = ""
1838						m.completionsStartIndex = curIdx
1839						m.completionsPositionStart = m.completionsPosition()
1840						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1841						cmds = append(cmds, m.completions.Open(depth, limit))
1842					}
1843				}
1844
1845				// remove the details if they are open when user starts typing
1846				if m.detailsOpen {
1847					m.detailsOpen = false
1848					m.updateLayoutAndSize()
1849				}
1850
1851				prevHeight := m.textarea.Height()
1852				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1853
1854				// Any text modification becomes the current draft.
1855				m.updateHistoryDraft(curValue)
1856
1857				// After updating textarea, check if we need to filter completions.
1858				// Skip filtering on the initial @ keystroke since items are loading async.
1859				if m.completionsOpen && msg.String() != "@" {
1860					newValue := m.textarea.Value()
1861					newIdx := len(newValue)
1862
1863					// Close completions if cursor moved before start.
1864					if newIdx <= m.completionsStartIndex {
1865						m.closeCompletions()
1866					} else if msg.String() == "space" {
1867						// Close on space.
1868						m.closeCompletions()
1869					} else {
1870						// Extract current word and filter.
1871						word := m.textareaWord()
1872						if strings.HasPrefix(word, "@") {
1873							m.completionsQuery = word[1:]
1874							m.completions.Filter(m.completionsQuery)
1875						} else if m.completionsOpen {
1876							m.closeCompletions()
1877						}
1878					}
1879				}
1880			}
1881		case uiFocusMain:
1882			switch {
1883			case key.Matches(msg, m.keyMap.Tab):
1884				m.focus = uiFocusEditor
1885				cmds = append(cmds, m.textarea.Focus())
1886				m.chat.Blur()
1887			case key.Matches(msg, m.keyMap.Chat.NewSession):
1888				if !m.hasSession() {
1889					break
1890				}
1891				if m.isAgentBusy() {
1892					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1893					break
1894				}
1895				m.focus = uiFocusEditor
1896				if cmd := m.newSession(); cmd != nil {
1897					cmds = append(cmds, cmd)
1898				}
1899			case key.Matches(msg, m.keyMap.Chat.Expand):
1900				m.chat.ToggleExpandedSelectedItem()
1901			case key.Matches(msg, m.keyMap.Chat.Up):
1902				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1903					cmds = append(cmds, cmd)
1904				}
1905				if !m.chat.SelectedItemInView() {
1906					m.chat.SelectPrev()
1907					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1908						cmds = append(cmds, cmd)
1909					}
1910				}
1911			case key.Matches(msg, m.keyMap.Chat.Down):
1912				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1913					cmds = append(cmds, cmd)
1914				}
1915				if !m.chat.SelectedItemInView() {
1916					m.chat.SelectNext()
1917					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1918						cmds = append(cmds, cmd)
1919					}
1920				}
1921			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1922				m.chat.SelectPrev()
1923				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1924					cmds = append(cmds, cmd)
1925				}
1926			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1927				m.chat.SelectNext()
1928				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1929					cmds = append(cmds, cmd)
1930				}
1931			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1932				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1933					cmds = append(cmds, cmd)
1934				}
1935				m.chat.SelectFirstInView()
1936			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1937				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1938					cmds = append(cmds, cmd)
1939				}
1940				m.chat.SelectLastInView()
1941			case key.Matches(msg, m.keyMap.Chat.PageUp):
1942				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1943					cmds = append(cmds, cmd)
1944				}
1945				m.chat.SelectFirstInView()
1946			case key.Matches(msg, m.keyMap.Chat.PageDown):
1947				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1948					cmds = append(cmds, cmd)
1949				}
1950				m.chat.SelectLastInView()
1951			case key.Matches(msg, m.keyMap.Chat.Home):
1952				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1953					cmds = append(cmds, cmd)
1954				}
1955				m.chat.SelectFirst()
1956			case key.Matches(msg, m.keyMap.Chat.End):
1957				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1958					cmds = append(cmds, cmd)
1959				}
1960				m.chat.SelectLast()
1961			default:
1962				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1963					cmds = append(cmds, cmd)
1964				} else {
1965					handleGlobalKeys(msg)
1966				}
1967			}
1968		default:
1969			handleGlobalKeys(msg)
1970		}
1971	default:
1972		handleGlobalKeys(msg)
1973	}
1974
1975	return tea.Sequence(cmds...)
1976}
1977
1978// drawHeader draws the header section of the UI.
1979func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1980	m.header.drawHeader(
1981		scr,
1982		area,
1983		m.session,
1984		m.isCompact,
1985		m.detailsOpen,
1986		area.Dx(),
1987	)
1988}
1989
1990// Draw implements [uv.Drawable] and draws the UI model.
1991func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1992	layout := m.generateLayout(area.Dx(), area.Dy())
1993
1994	if m.layout != layout {
1995		m.layout = layout
1996		m.updateSize()
1997	}
1998
1999	// Clear the screen first
2000	screen.Clear(scr)
2001
2002	switch m.state {
2003	case uiOnboarding:
2004		m.drawHeader(scr, layout.header)
2005
2006		// NOTE: Onboarding flow will be rendered as dialogs below, but
2007		// positioned at the bottom left of the screen.
2008
2009	case uiInitialize:
2010		m.drawHeader(scr, layout.header)
2011
2012		main := uv.NewStyledString(m.initializeView())
2013		main.Draw(scr, layout.main)
2014
2015	case uiLanding:
2016		m.drawHeader(scr, layout.header)
2017		main := uv.NewStyledString(m.landingView())
2018		main.Draw(scr, layout.main)
2019
2020		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
2021		editor.Draw(scr, layout.editor)
2022
2023	case uiChat:
2024		if m.isCompact {
2025			m.drawHeader(scr, layout.header)
2026		} else {
2027			m.drawSidebar(scr, layout.sidebar)
2028		}
2029
2030		m.chat.Draw(scr, layout.main)
2031		if layout.pills.Dy() > 0 && m.pillsView != "" {
2032			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2033		}
2034
2035		editorWidth := scr.Bounds().Dx()
2036		if !m.isCompact {
2037			editorWidth -= layout.sidebar.Dx()
2038		}
2039		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2040		editor.Draw(scr, layout.editor)
2041
2042		// Draw details overlay in compact mode when open
2043		if m.isCompact && m.detailsOpen {
2044			m.drawSessionDetails(scr, layout.sessionDetails)
2045		}
2046	}
2047
2048	isOnboarding := m.state == uiOnboarding
2049
2050	// Add status and help layer
2051	m.status.SetHideHelp(isOnboarding)
2052	m.status.Draw(scr, layout.status)
2053
2054	// Draw completions popup if open
2055	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2056		w, h := m.completions.Size()
2057		x := m.completionsPositionStart.X
2058		y := m.completionsPositionStart.Y - h
2059
2060		screenW := area.Dx()
2061		if x+w > screenW {
2062			x = screenW - w
2063		}
2064		x = max(0, x)
2065		y = max(0, y+1) // Offset for attachments row
2066
2067		completionsView := uv.NewStyledString(m.completions.Render())
2068		completionsView.Draw(scr, image.Rectangle{
2069			Min: image.Pt(x, y),
2070			Max: image.Pt(x+w, y+h),
2071		})
2072	}
2073
2074	// Debugging rendering (visually see when the tui rerenders)
2075	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2076		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2077		debug := uv.NewStyledString(debugView.String())
2078		debug.Draw(scr, image.Rectangle{
2079			Min: image.Pt(4, 1),
2080			Max: image.Pt(8, 3),
2081		})
2082	}
2083
2084	// This needs to come last to overlay on top of everything. We always pass
2085	// the full screen bounds because the dialogs will position themselves
2086	// accordingly.
2087	if m.dialog.HasDialogs() {
2088		return m.dialog.Draw(scr, scr.Bounds())
2089	}
2090
2091	switch m.focus {
2092	case uiFocusEditor:
2093		if m.layout.editor.Dy() <= 0 {
2094			// Don't show cursor if editor is not visible
2095			return nil
2096		}
2097		if m.detailsOpen && m.isCompact {
2098			// Don't show cursor if details overlay is open
2099			return nil
2100		}
2101
2102		if m.textarea.Focused() {
2103			cur := m.textarea.Cursor()
2104			cur.X++                            // Adjust for app margins
2105			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2106			return cur
2107		}
2108	}
2109	return nil
2110}
2111
2112// View renders the UI model's view.
2113func (m *UI) View() tea.View {
2114	var v tea.View
2115	v.AltScreen = true
2116	if !m.isTransparent {
2117		v.BackgroundColor = m.com.Styles.Background
2118	}
2119	v.MouseMode = tea.MouseModeCellMotion
2120	v.ReportFocus = m.caps.ReportFocusEvents
2121	v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2122
2123	canvas := uv.NewScreenBuffer(m.width, m.height)
2124	v.Cursor = m.Draw(canvas, canvas.Bounds())
2125
2126	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2127	contentLines := strings.Split(content, "\n")
2128	for i, line := range contentLines {
2129		// Trim trailing spaces for concise rendering
2130		contentLines[i] = strings.TrimRight(line, " ")
2131	}
2132
2133	content = strings.Join(contentLines, "\n")
2134
2135	v.Content = content
2136	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2137		// HACK: use a random percentage to prevent ghostty from hiding it
2138		// after a timeout.
2139		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2140	}
2141
2142	return v
2143}
2144
2145// ShortHelp implements [help.KeyMap].
2146func (m *UI) ShortHelp() []key.Binding {
2147	var binds []key.Binding
2148	k := &m.keyMap
2149	tab := k.Tab
2150	commands := k.Commands
2151	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2152		commands.SetHelp("/ or ctrl+p", "commands")
2153	}
2154
2155	switch m.state {
2156	case uiInitialize:
2157		binds = append(binds, k.Quit)
2158	case uiChat:
2159		// Show cancel binding if agent is busy.
2160		if m.isAgentBusy() {
2161			cancelBinding := k.Chat.Cancel
2162			if m.isCanceling {
2163				cancelBinding.SetHelp("esc", "press again to cancel")
2164			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2165				cancelBinding.SetHelp("esc", "clear queue")
2166			}
2167			binds = append(binds, cancelBinding)
2168		}
2169
2170		if m.focus == uiFocusEditor {
2171			tab.SetHelp("tab", "focus chat")
2172		} else {
2173			tab.SetHelp("tab", "focus editor")
2174		}
2175
2176		binds = append(binds,
2177			tab,
2178			commands,
2179			k.Models,
2180		)
2181
2182		switch m.focus {
2183		case uiFocusEditor:
2184			binds = append(binds,
2185				k.Editor.Newline,
2186			)
2187		case uiFocusMain:
2188			binds = append(binds,
2189				k.Chat.UpDown,
2190				k.Chat.UpDownOneItem,
2191				k.Chat.PageUp,
2192				k.Chat.PageDown,
2193				k.Chat.Copy,
2194			)
2195			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2196				binds = append(binds, k.Chat.PillLeft)
2197			}
2198		}
2199	default:
2200		// TODO: other states
2201		// if m.session == nil {
2202		// no session selected
2203		binds = append(binds,
2204			commands,
2205			k.Models,
2206			k.Editor.Newline,
2207		)
2208	}
2209
2210	binds = append(binds,
2211		k.Quit,
2212		k.Help,
2213	)
2214
2215	return binds
2216}
2217
2218// FullHelp implements [help.KeyMap].
2219func (m *UI) FullHelp() [][]key.Binding {
2220	var binds [][]key.Binding
2221	k := &m.keyMap
2222	help := k.Help
2223	help.SetHelp("ctrl+g", "less")
2224	hasAttachments := len(m.attachments.List()) > 0
2225	hasSession := m.hasSession()
2226	commands := k.Commands
2227	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2228		commands.SetHelp("/ or ctrl+p", "commands")
2229	}
2230
2231	switch m.state {
2232	case uiInitialize:
2233		binds = append(binds,
2234			[]key.Binding{
2235				k.Quit,
2236			})
2237	case uiChat:
2238		// Show cancel binding if agent is busy.
2239		if m.isAgentBusy() {
2240			cancelBinding := k.Chat.Cancel
2241			if m.isCanceling {
2242				cancelBinding.SetHelp("esc", "press again to cancel")
2243			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2244				cancelBinding.SetHelp("esc", "clear queue")
2245			}
2246			binds = append(binds, []key.Binding{cancelBinding})
2247		}
2248
2249		mainBinds := []key.Binding{}
2250		tab := k.Tab
2251		if m.focus == uiFocusEditor {
2252			tab.SetHelp("tab", "focus chat")
2253		} else {
2254			tab.SetHelp("tab", "focus editor")
2255		}
2256
2257		mainBinds = append(mainBinds,
2258			tab,
2259			commands,
2260			k.Models,
2261			k.Sessions,
2262		)
2263		if hasSession {
2264			mainBinds = append(mainBinds, k.Chat.NewSession)
2265		}
2266
2267		binds = append(binds, mainBinds)
2268
2269		switch m.focus {
2270		case uiFocusEditor:
2271			editorBinds := []key.Binding{
2272				k.Editor.Newline,
2273				k.Editor.MentionFile,
2274				k.Editor.OpenEditor,
2275			}
2276			if m.currentModelSupportsImages() {
2277				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2278			}
2279			binds = append(binds, editorBinds)
2280			if hasAttachments {
2281				binds = append(binds,
2282					[]key.Binding{
2283						k.Editor.AttachmentDeleteMode,
2284						k.Editor.DeleteAllAttachments,
2285						k.Editor.Escape,
2286					},
2287				)
2288			}
2289		case uiFocusMain:
2290			binds = append(binds,
2291				[]key.Binding{
2292					k.Chat.UpDown,
2293					k.Chat.UpDownOneItem,
2294					k.Chat.PageUp,
2295					k.Chat.PageDown,
2296				},
2297				[]key.Binding{
2298					k.Chat.HalfPageUp,
2299					k.Chat.HalfPageDown,
2300					k.Chat.Home,
2301					k.Chat.End,
2302				},
2303				[]key.Binding{
2304					k.Chat.Copy,
2305					k.Chat.ClearHighlight,
2306				},
2307			)
2308			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2309				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2310			}
2311		}
2312	default:
2313		if m.session == nil {
2314			// no session selected
2315			binds = append(binds,
2316				[]key.Binding{
2317					commands,
2318					k.Models,
2319					k.Sessions,
2320				},
2321			)
2322			editorBinds := []key.Binding{
2323				k.Editor.Newline,
2324				k.Editor.MentionFile,
2325				k.Editor.OpenEditor,
2326			}
2327			if m.currentModelSupportsImages() {
2328				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2329			}
2330			binds = append(binds, editorBinds)
2331			if hasAttachments {
2332				binds = append(binds,
2333					[]key.Binding{
2334						k.Editor.AttachmentDeleteMode,
2335						k.Editor.DeleteAllAttachments,
2336						k.Editor.Escape,
2337					},
2338				)
2339			}
2340		}
2341	}
2342
2343	binds = append(binds,
2344		[]key.Binding{
2345			help,
2346			k.Quit,
2347		},
2348	)
2349
2350	return binds
2351}
2352
2353func (m *UI) currentModelSupportsImages() bool {
2354	cfg := m.com.Config()
2355	if cfg == nil {
2356		return false
2357	}
2358	agentCfg, ok := cfg.Agents[config.AgentCoder]
2359	if !ok {
2360		return false
2361	}
2362	model := cfg.GetModelByType(agentCfg.Model)
2363	return model != nil && model.SupportsImages
2364}
2365
2366// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2367func (m *UI) toggleCompactMode() tea.Cmd {
2368	m.forceCompactMode = !m.forceCompactMode
2369
2370	err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2371	if err != nil {
2372		return util.ReportError(err)
2373	}
2374
2375	m.updateLayoutAndSize()
2376
2377	return nil
2378}
2379
2380// updateLayoutAndSize updates the layout and sizes of UI components.
2381func (m *UI) updateLayoutAndSize() {
2382	// Determine if we should be in compact mode
2383	if m.state == uiChat {
2384		if m.forceCompactMode {
2385			m.isCompact = true
2386		} else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2387			m.isCompact = true
2388		} else {
2389			m.isCompact = false
2390		}
2391	}
2392
2393	// First pass sizes components from the current textarea height.
2394	m.layout = m.generateLayout(m.width, m.height)
2395	prevHeight := m.textarea.Height()
2396	m.updateSize()
2397
2398	// SetWidth can change textarea height due to soft-wrap recalculation.
2399	// If that happens, run one reconciliation pass with the new height.
2400	if m.textarea.Height() != prevHeight {
2401		m.layout = m.generateLayout(m.width, m.height)
2402		m.updateSize()
2403	}
2404}
2405
2406// handleTextareaHeightChange checks whether the textarea height changed and,
2407// if so, recalculates the layout. When the chat is in follow mode it keeps
2408// the view scrolled to the bottom. The returned command, if non-nil, must be
2409// batched by the caller.
2410func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2411	if m.textarea.Height() == prevHeight {
2412		return nil
2413	}
2414	m.updateLayoutAndSize()
2415	if m.state == uiChat && m.chat.Follow() {
2416		return m.chat.ScrollToBottomAndAnimate()
2417	}
2418	return nil
2419}
2420
2421// updateTextarea updates the textarea for msg and then reconciles layout if
2422// the textarea height changed as a result.
2423func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2424	return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2425}
2426
2427// updateTextareaWithPrevHeight is for cases when the height of the layout may
2428// have changed.
2429//
2430// Particularly, it's for cases where the textarea changes before
2431// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2432// pass the height from before those changes took place so we can compare
2433// "before" vs "after" sizing and recalculate the layout if the textarea grew
2434// or shrank.
2435func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2436	ta, cmd := m.textarea.Update(msg)
2437	m.textarea = ta
2438	return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2439}
2440
2441// updateSize updates the sizes of UI components based on the current layout.
2442func (m *UI) updateSize() {
2443	// Set status width
2444	m.status.SetWidth(m.layout.status.Dx())
2445
2446	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2447	m.textarea.MaxHeight = TextareaMaxHeight
2448	m.textarea.SetWidth(m.layout.editor.Dx())
2449	m.renderPills()
2450
2451	// Handle different app states
2452	switch m.state {
2453	case uiChat:
2454		if !m.isCompact {
2455			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2456		}
2457	}
2458}
2459
2460// generateLayout calculates the layout rectangles for all UI components based
2461// on the current UI state and terminal dimensions.
2462func (m *UI) generateLayout(w, h int) uiLayout {
2463	// The screen area we're working with
2464	area := image.Rect(0, 0, w, h)
2465
2466	// The help height
2467	helpHeight := 1
2468	// The editor height: textarea height + margin for attachments and bottom spacing.
2469	editorHeight := m.textarea.Height() + editorHeightMargin
2470	// The sidebar width
2471	sidebarWidth := 30
2472	// The header height
2473	const landingHeaderHeight = 4
2474
2475	var helpKeyMap help.KeyMap = m
2476	if m.status != nil && m.status.ShowingAll() {
2477		for _, row := range helpKeyMap.FullHelp() {
2478			helpHeight = max(helpHeight, len(row))
2479		}
2480	}
2481
2482	// Add app margins
2483	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2484	appRect.Min.Y += 1
2485	appRect.Max.Y -= 1
2486	helpRect.Min.Y -= 1
2487	appRect.Min.X += 1
2488	appRect.Max.X -= 1
2489
2490	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2491		// extra padding on left and right for these states
2492		appRect.Min.X += 1
2493		appRect.Max.X -= 1
2494	}
2495
2496	uiLayout := uiLayout{
2497		area:   area,
2498		status: helpRect,
2499	}
2500
2501	// Handle different app states
2502	switch m.state {
2503	case uiOnboarding, uiInitialize:
2504		// Layout
2505		//
2506		// header
2507		// ------
2508		// main
2509		// ------
2510		// help
2511
2512		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2513		uiLayout.header = headerRect
2514		uiLayout.main = mainRect
2515
2516	case uiLanding:
2517		// Layout
2518		//
2519		// header
2520		// ------
2521		// main
2522		// ------
2523		// editor
2524		// ------
2525		// help
2526		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2527		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2528		// Remove extra padding from editor (but keep it for header and main)
2529		editorRect.Min.X -= 1
2530		editorRect.Max.X += 1
2531		uiLayout.header = headerRect
2532		uiLayout.main = mainRect
2533		uiLayout.editor = editorRect
2534
2535	case uiChat:
2536		if m.isCompact {
2537			// Layout
2538			//
2539			// compact-header
2540			// ------
2541			// main
2542			// ------
2543			// editor
2544			// ------
2545			// help
2546			const compactHeaderHeight = 1
2547			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2548			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2549			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2550			uiLayout.sessionDetails = sessionDetailsArea
2551			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2552			// Add one line gap between header and main content
2553			mainRect.Min.Y += 1
2554			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2555			mainRect.Max.X -= 1 // Add padding right
2556			uiLayout.header = headerRect
2557			pillsHeight := m.pillsAreaHeight()
2558			if pillsHeight > 0 {
2559				pillsHeight = min(pillsHeight, mainRect.Dy())
2560				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2561				uiLayout.main = chatRect
2562				uiLayout.pills = pillsRect
2563			} else {
2564				uiLayout.main = mainRect
2565			}
2566			// Add bottom margin to main
2567			uiLayout.main.Max.Y -= 1
2568			uiLayout.editor = editorRect
2569		} else {
2570			// Layout
2571			//
2572			// ------|---
2573			// main  |
2574			// ------| side
2575			// editor|
2576			// ----------
2577			// help
2578
2579			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2580			// Add padding left
2581			sideRect.Min.X += 1
2582			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2583			mainRect.Max.X -= 1 // Add padding right
2584			uiLayout.sidebar = sideRect
2585			pillsHeight := m.pillsAreaHeight()
2586			if pillsHeight > 0 {
2587				pillsHeight = min(pillsHeight, mainRect.Dy())
2588				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2589				uiLayout.main = chatRect
2590				uiLayout.pills = pillsRect
2591			} else {
2592				uiLayout.main = mainRect
2593			}
2594			// Add bottom margin to main
2595			uiLayout.main.Max.Y -= 1
2596			uiLayout.editor = editorRect
2597		}
2598	}
2599
2600	return uiLayout
2601}
2602
2603// uiLayout defines the positioning of UI elements.
2604type uiLayout struct {
2605	// area is the overall available area.
2606	area uv.Rectangle
2607
2608	// header is the header shown in special cases
2609	// e.x when the sidebar is collapsed
2610	// or when in the landing page
2611	// or in init/config
2612	header uv.Rectangle
2613
2614	// main is the area for the main pane. (e.x chat, configure, landing)
2615	main uv.Rectangle
2616
2617	// pills is the area for the pills panel.
2618	pills uv.Rectangle
2619
2620	// editor is the area for the editor pane.
2621	editor uv.Rectangle
2622
2623	// sidebar is the area for the sidebar.
2624	sidebar uv.Rectangle
2625
2626	// status is the area for the status view.
2627	status uv.Rectangle
2628
2629	// session details is the area for the session details overlay in compact mode.
2630	sessionDetails uv.Rectangle
2631}
2632
2633func (m *UI) openEditor(value string) tea.Cmd {
2634	tmpfile, err := os.CreateTemp("", "msg_*.md")
2635	if err != nil {
2636		return util.ReportError(err)
2637	}
2638	tmpPath := tmpfile.Name()
2639	defer tmpfile.Close() //nolint:errcheck
2640	if _, err := tmpfile.WriteString(value); err != nil {
2641		return util.ReportError(err)
2642	}
2643	cmd, err := editor.Command(
2644		"crush",
2645		tmpPath,
2646		editor.AtPosition(
2647			m.textarea.Line()+1,
2648			m.textarea.Column()+1,
2649		),
2650	)
2651	if err != nil {
2652		return util.ReportError(err)
2653	}
2654	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2655		defer func() {
2656			_ = os.Remove(tmpPath)
2657		}()
2658
2659		if err != nil {
2660			return util.ReportError(err)
2661		}
2662		content, err := os.ReadFile(tmpPath)
2663		if err != nil {
2664			return util.ReportError(err)
2665		}
2666		if len(content) == 0 {
2667			return util.ReportWarn("Message is empty")
2668		}
2669		return openEditorMsg{
2670			Text: strings.TrimSpace(string(content)),
2671		}
2672	})
2673}
2674
2675// setEditorPrompt configures the textarea prompt function based on whether
2676// yolo mode is enabled.
2677func (m *UI) setEditorPrompt(yolo bool) {
2678	if yolo {
2679		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2680		return
2681	}
2682	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2683}
2684
2685// normalPromptFunc returns the normal editor prompt style ("  > " on first
2686// line, "::: " on subsequent lines).
2687func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2688	t := m.com.Styles
2689	if info.LineNumber == 0 {
2690		if info.Focused {
2691			return "  > "
2692		}
2693		return "::: "
2694	}
2695	if info.Focused {
2696		return t.EditorPromptNormalFocused.Render()
2697	}
2698	return t.EditorPromptNormalBlurred.Render()
2699}
2700
2701// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2702// and colored dots.
2703func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2704	t := m.com.Styles
2705	if info.LineNumber == 0 {
2706		if info.Focused {
2707			return t.EditorPromptYoloIconFocused.Render()
2708		} else {
2709			return t.EditorPromptYoloIconBlurred.Render()
2710		}
2711	}
2712	if info.Focused {
2713		return t.EditorPromptYoloDotsFocused.Render()
2714	}
2715	return t.EditorPromptYoloDotsBlurred.Render()
2716}
2717
2718// closeCompletions closes the completions popup and resets state.
2719func (m *UI) closeCompletions() {
2720	m.completionsOpen = false
2721	m.completionsQuery = ""
2722	m.completionsStartIndex = 0
2723	m.completions.Close()
2724}
2725
2726// insertCompletionText replaces the @query in the textarea with the given text.
2727// Returns false if the replacement cannot be performed.
2728func (m *UI) insertCompletionText(text string) bool {
2729	value := m.textarea.Value()
2730	if m.completionsStartIndex > len(value) {
2731		return false
2732	}
2733
2734	word := m.textareaWord()
2735	endIdx := min(m.completionsStartIndex+len(word), len(value))
2736	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2737	m.textarea.SetValue(newValue)
2738	m.textarea.MoveToEnd()
2739	m.textarea.InsertRune(' ')
2740	return true
2741}
2742
2743// insertFileCompletion inserts the selected file path into the textarea,
2744// replacing the @query, and adds the file as an attachment.
2745func (m *UI) insertFileCompletion(path string) tea.Cmd {
2746	prevHeight := m.textarea.Height()
2747	if !m.insertCompletionText(path) {
2748		return nil
2749	}
2750	heightCmd := m.handleTextareaHeightChange(prevHeight)
2751
2752	fileCmd := func() tea.Msg {
2753		absPath, _ := filepath.Abs(path)
2754
2755		if m.hasSession() {
2756			// Skip attachment if file was already read and hasn't been modified.
2757			lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2758			if !lastRead.IsZero() {
2759				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2760					return nil
2761				}
2762			}
2763		} else if slices.Contains(m.sessionFileReads, absPath) {
2764			return nil
2765		}
2766
2767		m.sessionFileReads = append(m.sessionFileReads, absPath)
2768
2769		// Add file as attachment.
2770		content, err := os.ReadFile(path)
2771		if err != nil {
2772			// If it fails, let the LLM handle it later.
2773			return nil
2774		}
2775
2776		return message.Attachment{
2777			FilePath: path,
2778			FileName: filepath.Base(path),
2779			MimeType: mimeOf(content),
2780			Content:  content,
2781		}
2782	}
2783	return tea.Batch(heightCmd, fileCmd)
2784}
2785
2786// insertMCPResourceCompletion inserts the selected resource into the textarea,
2787// replacing the @query, and adds the resource as an attachment.
2788func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2789	displayText := cmp.Or(item.Title, item.URI)
2790
2791	prevHeight := m.textarea.Height()
2792	if !m.insertCompletionText(displayText) {
2793		return nil
2794	}
2795	heightCmd := m.handleTextareaHeightChange(prevHeight)
2796
2797	resourceCmd := func() tea.Msg {
2798		contents, err := m.com.Workspace.ReadMCPResource(
2799			context.Background(),
2800			item.MCPName,
2801			item.URI,
2802		)
2803		if err != nil {
2804			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2805			return nil
2806		}
2807		if len(contents) == 0 {
2808			return nil
2809		}
2810
2811		content := contents[0]
2812		var data []byte
2813		if content.Text != "" {
2814			data = []byte(content.Text)
2815		} else if len(content.Blob) > 0 {
2816			data = content.Blob
2817		}
2818		if len(data) == 0 {
2819			return nil
2820		}
2821
2822		mimeType := item.MIMEType
2823		if mimeType == "" && content.MIMEType != "" {
2824			mimeType = content.MIMEType
2825		}
2826		if mimeType == "" {
2827			mimeType = "text/plain"
2828		}
2829
2830		return message.Attachment{
2831			FilePath: item.URI,
2832			FileName: displayText,
2833			MimeType: mimeType,
2834			Content:  data,
2835		}
2836	}
2837	return tea.Batch(heightCmd, resourceCmd)
2838}
2839
2840// completionsPosition returns the X and Y position for the completions popup.
2841func (m *UI) completionsPosition() image.Point {
2842	cur := m.textarea.Cursor()
2843	if cur == nil {
2844		return image.Point{
2845			X: m.layout.editor.Min.X,
2846			Y: m.layout.editor.Min.Y,
2847		}
2848	}
2849	return image.Point{
2850		X: cur.X + m.layout.editor.Min.X,
2851		Y: m.layout.editor.Min.Y + cur.Y,
2852	}
2853}
2854
2855// textareaWord returns the current word at the cursor position.
2856func (m *UI) textareaWord() string {
2857	return m.textarea.Word()
2858}
2859
2860// isWhitespace returns true if the byte is a whitespace character.
2861func isWhitespace(b byte) bool {
2862	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2863}
2864
2865// isAgentBusy returns true if the agent coordinator exists and is currently
2866// busy processing a request.
2867func (m *UI) isAgentBusy() bool {
2868	return m.com.Workspace.AgentIsReady() &&
2869		m.com.Workspace.AgentIsBusy()
2870}
2871
2872// hasSession returns true if there is an active session with a valid ID.
2873func (m *UI) hasSession() bool {
2874	return m.session != nil && m.session.ID != ""
2875}
2876
2877// mimeOf detects the MIME type of the given content.
2878func mimeOf(content []byte) string {
2879	mimeBufferSize := min(512, len(content))
2880	return http.DetectContentType(content[:mimeBufferSize])
2881}
2882
2883var readyPlaceholders = [...]string{
2884	"Ready!",
2885	"Ready...",
2886	"Ready?",
2887	"Ready for instructions",
2888}
2889
2890var workingPlaceholders = [...]string{
2891	"Working!",
2892	"Working...",
2893	"Brrrrr...",
2894	"Prrrrrrrr...",
2895	"Processing...",
2896	"Thinking...",
2897}
2898
2899// randomizePlaceholders selects random placeholder text for the textarea's
2900// ready and working states.
2901func (m *UI) randomizePlaceholders() {
2902	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2903	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2904}
2905
2906// renderEditorView renders the editor view with attachments if any.
2907func (m *UI) renderEditorView(width int) string {
2908	var attachmentsView string
2909	if len(m.attachments.List()) > 0 {
2910		attachmentsView = m.attachments.Render(width)
2911	}
2912	return strings.Join([]string{
2913		attachmentsView,
2914		m.textarea.View(),
2915		"", // margin at bottom of editor
2916	}, "\n")
2917}
2918
2919// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2920func (m *UI) cacheSidebarLogo(width int) {
2921	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2922}
2923
2924// sendMessage sends a message with the given content and attachments.
2925func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2926	if !m.com.Workspace.AgentIsReady() {
2927		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2928	}
2929
2930	var cmds []tea.Cmd
2931	if !m.hasSession() {
2932		newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2933		if err != nil {
2934			return util.ReportError(err)
2935		}
2936		if m.forceCompactMode {
2937			m.isCompact = true
2938		}
2939		if newSession.ID != "" {
2940			m.session = &newSession
2941			cmds = append(cmds, m.loadSession(newSession.ID))
2942		}
2943		m.setState(uiChat, m.focus)
2944	}
2945
2946	ctx := context.Background()
2947	cmds = append(cmds, func() tea.Msg {
2948		for _, path := range m.sessionFileReads {
2949			m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2950			m.com.Workspace.LSPStart(ctx, path)
2951		}
2952		return nil
2953	})
2954
2955	// Capture session ID to avoid race with main goroutine updating m.session.
2956	sessionID := m.session.ID
2957	cmds = append(cmds, func() tea.Msg {
2958		err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2959		if err != nil {
2960			isCancelErr := errors.Is(err, context.Canceled)
2961			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2962			if isCancelErr || isPermissionErr {
2963				return nil
2964			}
2965			return util.InfoMsg{
2966				Type: util.InfoTypeError,
2967				Msg:  fmt.Sprintf("Failed to run agent: %v", err),
2968			}
2969		}
2970		return nil
2971	})
2972	return tea.Batch(cmds...)
2973}
2974
2975const cancelTimerDuration = 2 * time.Second
2976
2977// cancelTimerCmd creates a command that expires the cancel timer.
2978func cancelTimerCmd() tea.Cmd {
2979	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2980		return cancelTimerExpiredMsg{}
2981	})
2982}
2983
2984// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2985// and starts a timer. The second press (before the timer expires) actually
2986// cancels the agent.
2987func (m *UI) cancelAgent() tea.Cmd {
2988	if !m.hasSession() {
2989		return nil
2990	}
2991
2992	if !m.com.Workspace.AgentIsReady() {
2993		return nil
2994	}
2995
2996	if m.isCanceling {
2997		// Second escape press - actually cancel the agent.
2998		m.isCanceling = false
2999		m.com.Workspace.AgentCancel(m.session.ID)
3000		// Stop the spinning todo indicator.
3001		m.todoIsSpinning = false
3002		m.renderPills()
3003		return nil
3004	}
3005
3006	// Check if there are queued prompts - if so, clear the queue.
3007	if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3008		m.com.Workspace.AgentClearQueue(m.session.ID)
3009		return nil
3010	}
3011
3012	// First escape press - set canceling state and start timer.
3013	m.isCanceling = true
3014	return cancelTimerCmd()
3015}
3016
3017// openDialog opens a dialog by its ID.
3018func (m *UI) openDialog(id string) tea.Cmd {
3019	var cmds []tea.Cmd
3020	switch id {
3021	case dialog.SessionsID:
3022		if cmd := m.openSessionsDialog(); cmd != nil {
3023			cmds = append(cmds, cmd)
3024		}
3025	case dialog.ModelsID:
3026		if cmd := m.openModelsDialog(); cmd != nil {
3027			cmds = append(cmds, cmd)
3028		}
3029	case dialog.CommandsID:
3030		if cmd := m.openCommandsDialog(); cmd != nil {
3031			cmds = append(cmds, cmd)
3032		}
3033	case dialog.ReasoningID:
3034		if cmd := m.openReasoningDialog(); cmd != nil {
3035			cmds = append(cmds, cmd)
3036		}
3037	case dialog.FilePickerID:
3038		if cmd := m.openFilesDialog(); cmd != nil {
3039			cmds = append(cmds, cmd)
3040		}
3041	case dialog.QuitID:
3042		if cmd := m.openQuitDialog(); cmd != nil {
3043			cmds = append(cmds, cmd)
3044		}
3045	default:
3046		// Unknown dialog
3047		break
3048	}
3049	return tea.Batch(cmds...)
3050}
3051
3052// openQuitDialog opens the quit confirmation dialog.
3053func (m *UI) openQuitDialog() tea.Cmd {
3054	if m.dialog.ContainsDialog(dialog.QuitID) {
3055		// Bring to front
3056		m.dialog.BringToFront(dialog.QuitID)
3057		return nil
3058	}
3059
3060	quitDialog := dialog.NewQuit(m.com)
3061	m.dialog.OpenDialog(quitDialog)
3062	return nil
3063}
3064
3065// openModelsDialog opens the models dialog.
3066func (m *UI) openModelsDialog() tea.Cmd {
3067	if m.dialog.ContainsDialog(dialog.ModelsID) {
3068		// Bring to front
3069		m.dialog.BringToFront(dialog.ModelsID)
3070		return nil
3071	}
3072
3073	isOnboarding := m.state == uiOnboarding
3074	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3075	if err != nil {
3076		return util.ReportError(err)
3077	}
3078
3079	m.dialog.OpenDialog(modelsDialog)
3080
3081	return nil
3082}
3083
3084// openCommandsDialog opens the commands dialog.
3085func (m *UI) openCommandsDialog() tea.Cmd {
3086	if m.dialog.ContainsDialog(dialog.CommandsID) {
3087		// Bring to front
3088		m.dialog.BringToFront(dialog.CommandsID)
3089		return nil
3090	}
3091
3092	var sessionID string
3093	hasSession := m.session != nil
3094	if hasSession {
3095		sessionID = m.session.ID
3096	}
3097	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3098	hasQueue := m.promptQueue > 0
3099
3100	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3101	if err != nil {
3102		return util.ReportError(err)
3103	}
3104
3105	m.dialog.OpenDialog(commands)
3106
3107	return commands.InitialCmd()
3108}
3109
3110// openReasoningDialog opens the reasoning effort dialog.
3111func (m *UI) openReasoningDialog() tea.Cmd {
3112	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3113		m.dialog.BringToFront(dialog.ReasoningID)
3114		return nil
3115	}
3116
3117	reasoningDialog, err := dialog.NewReasoning(m.com)
3118	if err != nil {
3119		return util.ReportError(err)
3120	}
3121
3122	m.dialog.OpenDialog(reasoningDialog)
3123	return nil
3124}
3125
3126// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3127// it brings it to the front. Otherwise, it will list all the sessions and open
3128// the dialog.
3129func (m *UI) openSessionsDialog() tea.Cmd {
3130	if m.dialog.ContainsDialog(dialog.SessionsID) {
3131		// Bring to front
3132		m.dialog.BringToFront(dialog.SessionsID)
3133		return nil
3134	}
3135
3136	selectedSessionID := ""
3137	if m.session != nil {
3138		selectedSessionID = m.session.ID
3139	}
3140
3141	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3142	if err != nil {
3143		return util.ReportError(err)
3144	}
3145
3146	m.dialog.OpenDialog(dialog)
3147	return nil
3148}
3149
3150// openFilesDialog opens the file picker dialog.
3151func (m *UI) openFilesDialog() tea.Cmd {
3152	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3153		// Bring to front
3154		m.dialog.BringToFront(dialog.FilePickerID)
3155		return nil
3156	}
3157
3158	filePicker, cmd := dialog.NewFilePicker(m.com)
3159	filePicker.SetImageCapabilities(&m.caps)
3160	m.dialog.OpenDialog(filePicker)
3161
3162	return cmd
3163}
3164
3165// openPermissionsDialog opens the permissions dialog for a permission request.
3166func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3167	// Close any existing permissions dialog first.
3168	m.dialog.CloseDialog(dialog.PermissionsID)
3169
3170	// Get diff mode from config.
3171	var opts []dialog.PermissionsOption
3172	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3173		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3174	}
3175
3176	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3177	m.dialog.OpenDialog(permDialog)
3178	return nil
3179}
3180
3181// handlePermissionNotification updates tool items when permission state changes.
3182func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3183	toolItem := m.chat.MessageItem(notification.ToolCallID)
3184	if toolItem == nil {
3185		return
3186	}
3187
3188	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3189		if notification.Granted {
3190			permItem.SetStatus(chat.ToolStatusRunning)
3191		} else {
3192			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3193		}
3194	}
3195}
3196
3197// handleAgentNotification translates domain agent events into desktop
3198// notifications using the UI notification backend.
3199func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3200	switch n.Type {
3201	case notify.TypeAgentFinished:
3202		return m.sendNotification(notification.Notification{
3203			Title:   "Crush is waiting...",
3204			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3205		})
3206	default:
3207		return nil
3208	}
3209}
3210
3211// newSession clears the current session state and prepares for a new session.
3212// The actual session creation happens when the user sends their first message.
3213// Returns a command to reload prompt history.
3214func (m *UI) newSession() tea.Cmd {
3215	if !m.hasSession() {
3216		return nil
3217	}
3218
3219	m.session = nil
3220	m.sessionFiles = nil
3221	m.sessionFileReads = nil
3222	m.setState(uiLanding, uiFocusEditor)
3223	m.textarea.Focus()
3224	m.chat.Blur()
3225	m.chat.ClearMessages()
3226	m.pillsExpanded = false
3227	m.promptQueue = 0
3228	m.pillsView = ""
3229	m.historyReset()
3230	agenttools.ResetCache()
3231	return tea.Batch(
3232		func() tea.Msg {
3233			m.com.Workspace.LSPStopAll(context.Background())
3234			return nil
3235		},
3236		m.loadPromptHistory(),
3237	)
3238}
3239
3240// handlePasteMsg handles a paste message.
3241func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3242	if m.dialog.HasDialogs() {
3243		return m.handleDialogMsg(msg)
3244	}
3245
3246	if m.focus != uiFocusEditor {
3247		return nil
3248	}
3249
3250	if hasPasteExceededThreshold(msg) {
3251		return func() tea.Msg {
3252			content := []byte(msg.Content)
3253			if int64(len(content)) > common.MaxAttachmentSize {
3254				return util.ReportWarn("Paste is too big (>5mb)")
3255			}
3256			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3257			mimeBufferSize := min(512, len(content))
3258			mimeType := http.DetectContentType(content[:mimeBufferSize])
3259			return message.Attachment{
3260				FileName: name,
3261				FilePath: name,
3262				MimeType: mimeType,
3263				Content:  content,
3264			}
3265		}
3266	}
3267
3268	// Attempt to parse pasted content as file paths. If possible to parse,
3269	// all files exist and are valid, add as attachments.
3270	// Otherwise, paste as text.
3271	paths := fsext.ParsePastedFiles(msg.Content)
3272	allExistsAndValid := func() bool {
3273		if len(paths) == 0 {
3274			return false
3275		}
3276		for _, path := range paths {
3277			if _, err := os.Stat(path); os.IsNotExist(err) {
3278				return false
3279			}
3280
3281			lowerPath := strings.ToLower(path)
3282			isValid := false
3283			for _, ext := range common.AllowedImageTypes {
3284				if strings.HasSuffix(lowerPath, ext) {
3285					isValid = true
3286					break
3287				}
3288			}
3289			if !isValid {
3290				return false
3291			}
3292		}
3293		return true
3294	}
3295	if !allExistsAndValid() {
3296		prevHeight := m.textarea.Height()
3297		return m.updateTextareaWithPrevHeight(msg, prevHeight)
3298	}
3299
3300	var cmds []tea.Cmd
3301	for _, path := range paths {
3302		cmds = append(cmds, m.handleFilePathPaste(path))
3303	}
3304	return tea.Batch(cmds...)
3305}
3306
3307func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3308	var (
3309		lineCount = 0
3310		colCount  = 0
3311	)
3312	for line := range strings.SplitSeq(msg.Content, "\n") {
3313		lineCount++
3314		colCount = max(colCount, len(line))
3315
3316		if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3317			return true
3318		}
3319	}
3320	return false
3321}
3322
3323// handleFilePathPaste handles a pasted file path.
3324func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3325	return func() tea.Msg {
3326		fileInfo, err := os.Stat(path)
3327		if err != nil {
3328			return util.ReportError(err)
3329		}
3330		if fileInfo.IsDir() {
3331			return util.ReportWarn("Cannot attach a directory")
3332		}
3333		if fileInfo.Size() > common.MaxAttachmentSize {
3334			return util.ReportWarn("File is too big (>5mb)")
3335		}
3336
3337		content, err := os.ReadFile(path)
3338		if err != nil {
3339			return util.ReportError(err)
3340		}
3341
3342		mimeBufferSize := min(512, len(content))
3343		mimeType := http.DetectContentType(content[:mimeBufferSize])
3344		fileName := filepath.Base(path)
3345		return message.Attachment{
3346			FilePath: path,
3347			FileName: fileName,
3348			MimeType: mimeType,
3349			Content:  content,
3350		}
3351	}
3352}
3353
3354// pasteImageFromClipboard reads image data from the system clipboard and
3355// creates an attachment. If no image data is found, it falls back to
3356// interpreting clipboard text as a file path.
3357func (m *UI) pasteImageFromClipboard() tea.Msg {
3358	imageData, err := readClipboard(clipboardFormatImage)
3359	if int64(len(imageData)) > common.MaxAttachmentSize {
3360		return util.InfoMsg{
3361			Type: util.InfoTypeError,
3362			Msg:  "File too large, max 5MB",
3363		}
3364	}
3365	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3366	if err == nil {
3367		return message.Attachment{
3368			FilePath: name,
3369			FileName: name,
3370			MimeType: mimeOf(imageData),
3371			Content:  imageData,
3372		}
3373	}
3374
3375	textData, textErr := readClipboard(clipboardFormatText)
3376	if textErr != nil || len(textData) == 0 {
3377		return nil // Clipboard is empty or does not contain an image
3378	}
3379
3380	path := strings.TrimSpace(string(textData))
3381	path = strings.ReplaceAll(path, "\\ ", " ")
3382	if _, statErr := os.Stat(path); statErr != nil {
3383		return nil // Clipboard does not contain an image or valid file path
3384	}
3385
3386	lowerPath := strings.ToLower(path)
3387	isAllowed := false
3388	for _, ext := range common.AllowedImageTypes {
3389		if strings.HasSuffix(lowerPath, ext) {
3390			isAllowed = true
3391			break
3392		}
3393	}
3394	if !isAllowed {
3395		return util.NewInfoMsg("File type is not a supported image format")
3396	}
3397
3398	fileInfo, statErr := os.Stat(path)
3399	if statErr != nil {
3400		return util.InfoMsg{
3401			Type: util.InfoTypeError,
3402			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3403		}
3404	}
3405	if fileInfo.Size() > common.MaxAttachmentSize {
3406		return util.InfoMsg{
3407			Type: util.InfoTypeError,
3408			Msg:  "File too large, max 5MB",
3409		}
3410	}
3411
3412	content, readErr := os.ReadFile(path)
3413	if readErr != nil {
3414		return util.InfoMsg{
3415			Type: util.InfoTypeError,
3416			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3417		}
3418	}
3419
3420	return message.Attachment{
3421		FilePath: path,
3422		FileName: filepath.Base(path),
3423		MimeType: mimeOf(content),
3424		Content:  content,
3425	}
3426}
3427
3428var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3429
3430func (m *UI) pasteIdx() int {
3431	result := 0
3432	for _, at := range m.attachments.List() {
3433		found := pasteRE.FindStringSubmatch(at.FileName)
3434		if len(found) == 0 {
3435			continue
3436		}
3437		idx, err := strconv.Atoi(found[1])
3438		if err == nil {
3439			result = max(result, idx)
3440		}
3441	}
3442	return result + 1
3443}
3444
3445// drawSessionDetails draws the session details in compact mode.
3446func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3447	if m.session == nil {
3448		return
3449	}
3450
3451	s := m.com.Styles
3452
3453	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3454	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3455
3456	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3457	blocks := []string{
3458		title,
3459		"",
3460		m.modelInfo(width),
3461		"",
3462	}
3463
3464	detailsHeader := lipgloss.JoinVertical(
3465		lipgloss.Left,
3466		blocks...,
3467	)
3468
3469	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3470
3471	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3472
3473	const maxSectionWidth = 50
3474	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3475	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3476
3477	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3478	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3479	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3480	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3481	uv.NewStyledString(
3482		s.CompactDetails.View.
3483			Width(area.Dx()).
3484			Render(
3485				lipgloss.JoinVertical(
3486					lipgloss.Left,
3487					detailsHeader,
3488					sections,
3489					version,
3490				),
3491			),
3492	).Draw(scr, area)
3493}
3494
3495func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3496	load := func() tea.Msg {
3497		prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3498		if err != nil {
3499			// TODO: make this better
3500			return util.ReportError(err)()
3501		}
3502
3503		if prompt == "" {
3504			return nil
3505		}
3506		return sendMessageMsg{
3507			Content: prompt,
3508		}
3509	}
3510
3511	var cmds []tea.Cmd
3512	if cmd := m.dialog.StartLoading(); cmd != nil {
3513		cmds = append(cmds, cmd)
3514	}
3515	cmds = append(cmds, load, func() tea.Msg {
3516		return closeDialogMsg{}
3517	})
3518
3519	return tea.Sequence(cmds...)
3520}
3521
3522func (m *UI) handleStateChanged() tea.Cmd {
3523	return func() tea.Msg {
3524		m.com.Workspace.UpdateAgentModel(context.Background())
3525		return mcpStateChangedMsg{
3526			states: m.com.Workspace.MCPGetStates(),
3527		}
3528	}
3529}
3530
3531func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3532	return func() tea.Msg {
3533		ws.MCPRefreshPrompts(context.Background(), name)
3534		return nil
3535	}
3536}
3537
3538func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3539	return func() tea.Msg {
3540		ws.RefreshMCPTools(context.Background(), name)
3541		return nil
3542	}
3543}
3544
3545func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3546	return func() tea.Msg {
3547		ws.MCPRefreshResources(context.Background(), name)
3548		return nil
3549	}
3550}
3551
3552func (m *UI) copyChatHighlight() tea.Cmd {
3553	text := m.chat.HighlightContent()
3554	return common.CopyToClipboardWithCallback(
3555		text,
3556		"Selected text copied to clipboard",
3557		func() tea.Msg {
3558			m.chat.ClearMouse()
3559			return nil
3560		},
3561	)
3562}
3563
3564func (m *UI) enableDockerMCP() tea.Msg {
3565	ctx := context.Background()
3566	if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3567		return util.ReportError(err)()
3568	}
3569
3570	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3571}
3572
3573func (m *UI) disableDockerMCP() tea.Msg {
3574	if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3575		return util.ReportError(err)()
3576	}
3577
3578	return util.NewInfoMsg("Docker MCP disabled successfully")
3579}
3580
3581// renderLogo renders the Crush logo with the given styles and dimensions.
3582func renderLogo(t *styles.Styles, compact bool, width int) string {
3583	return logo.Render(t, version.Version, compact, logo.Opts{
3584		FieldColor:   t.LogoFieldColor,
3585		TitleColorA:  t.LogoTitleColorA,
3586		TitleColorB:  t.LogoTitleColorB,
3587		CharmColor:   t.LogoCharmColor,
3588		VersionColor: t.LogoVersionColor,
3589		Width:        width,
3590	})
3591}