ui.go

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