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	var appRect, helpRect image.Rectangle
2485	layout.Vertical(
2486		layout.Len(area.Dy()-helpHeight),
2487		layout.Fill(1),
2488	).Split(area).Assign(&appRect, &helpRect)
2489	appRect.Min.Y += 1
2490	appRect.Max.Y -= 1
2491	helpRect.Min.Y -= 1
2492	appRect.Min.X += 1
2493	appRect.Max.X -= 1
2494
2495	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2496		// extra padding on left and right for these states
2497		appRect.Min.X += 1
2498		appRect.Max.X -= 1
2499	}
2500
2501	uiLayout := uiLayout{
2502		area:   area,
2503		status: helpRect,
2504	}
2505
2506	// Handle different app states
2507	switch m.state {
2508	case uiOnboarding, uiInitialize:
2509		// Layout
2510		//
2511		// header
2512		// ------
2513		// main
2514		// ------
2515		// help
2516
2517		var headerRect, mainRect image.Rectangle
2518		layout.Vertical(
2519			layout.Len(landingHeaderHeight),
2520			layout.Fill(1),
2521		).Split(appRect).Assign(&headerRect, &mainRect)
2522		uiLayout.header = headerRect
2523		uiLayout.main = mainRect
2524
2525	case uiLanding:
2526		// Layout
2527		//
2528		// header
2529		// ------
2530		// main
2531		// ------
2532		// editor
2533		// ------
2534		// help
2535		var headerRect, mainRect image.Rectangle
2536		layout.Vertical(
2537			layout.Len(landingHeaderHeight),
2538			layout.Fill(1),
2539		).Split(appRect).Assign(&headerRect, &mainRect)
2540		var editorRect image.Rectangle
2541		layout.Vertical(
2542			layout.Len(mainRect.Dy()-editorHeight),
2543			layout.Fill(1),
2544		).Split(mainRect).Assign(&mainRect, &editorRect)
2545		// Remove extra padding from editor (but keep it for header and main)
2546		editorRect.Min.X -= 1
2547		editorRect.Max.X += 1
2548		uiLayout.header = headerRect
2549		uiLayout.main = mainRect
2550		uiLayout.editor = editorRect
2551
2552	case uiChat:
2553		if m.isCompact {
2554			// Layout
2555			//
2556			// compact-header
2557			// ------
2558			// main
2559			// ------
2560			// editor
2561			// ------
2562			// help
2563			const compactHeaderHeight = 1
2564			var headerRect, mainRect image.Rectangle
2565			layout.Vertical(
2566				layout.Len(compactHeaderHeight),
2567				layout.Fill(1),
2568			).Split(appRect).Assign(&headerRect, &mainRect)
2569			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2570			var sessionDetailsArea image.Rectangle
2571			layout.Vertical(
2572				layout.Len(detailsHeight),
2573				layout.Fill(1),
2574			).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
2575			uiLayout.sessionDetails = sessionDetailsArea
2576			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2577			// Add one line gap between header and main content
2578			mainRect.Min.Y += 1
2579			var editorRect image.Rectangle
2580			layout.Vertical(
2581				layout.Len(mainRect.Dy()-editorHeight),
2582				layout.Fill(1),
2583			).Split(mainRect).Assign(&mainRect, &editorRect)
2584			mainRect.Max.X -= 1 // Add padding right
2585			uiLayout.header = headerRect
2586			pillsHeight := m.pillsAreaHeight()
2587			if pillsHeight > 0 {
2588				pillsHeight = min(pillsHeight, mainRect.Dy())
2589				var chatRect, pillsRect image.Rectangle
2590				layout.Vertical(
2591					layout.Len(mainRect.Dy()-pillsHeight),
2592					layout.Fill(1),
2593				).Split(mainRect).Assign(&chatRect, &pillsRect)
2594				uiLayout.main = chatRect
2595				uiLayout.pills = pillsRect
2596			} else {
2597				uiLayout.main = mainRect
2598			}
2599			// Add bottom margin to main
2600			uiLayout.main.Max.Y -= 1
2601			uiLayout.editor = editorRect
2602		} else {
2603			// Layout
2604			//
2605			// ------|---
2606			// main  |
2607			// ------| side
2608			// editor|
2609			// ----------
2610			// help
2611
2612			var mainRect, sideRect image.Rectangle
2613			layout.Horizontal(
2614				layout.Len(appRect.Dx()-sidebarWidth),
2615				layout.Fill(1),
2616			).Split(appRect).Assign(&mainRect, &sideRect)
2617			// Add padding left
2618			sideRect.Min.X += 1
2619			var editorRect image.Rectangle
2620			layout.Vertical(
2621				layout.Len(mainRect.Dy()-editorHeight),
2622				layout.Fill(1),
2623			).Split(mainRect).Assign(&mainRect, &editorRect)
2624			mainRect.Max.X -= 1 // Add padding right
2625			uiLayout.sidebar = sideRect
2626			pillsHeight := m.pillsAreaHeight()
2627			if pillsHeight > 0 {
2628				pillsHeight = min(pillsHeight, mainRect.Dy())
2629				var chatRect, pillsRect image.Rectangle
2630				layout.Vertical(
2631					layout.Len(mainRect.Dy()-pillsHeight),
2632					layout.Fill(1),
2633				).Split(mainRect).Assign(&chatRect, &pillsRect)
2634				uiLayout.main = chatRect
2635				uiLayout.pills = pillsRect
2636			} else {
2637				uiLayout.main = mainRect
2638			}
2639			// Add bottom margin to main
2640			uiLayout.main.Max.Y -= 1
2641			uiLayout.editor = editorRect
2642		}
2643	}
2644
2645	return uiLayout
2646}
2647
2648// uiLayout defines the positioning of UI elements.
2649type uiLayout struct {
2650	// area is the overall available area.
2651	area uv.Rectangle
2652
2653	// header is the header shown in special cases
2654	// e.x when the sidebar is collapsed
2655	// or when in the landing page
2656	// or in init/config
2657	header uv.Rectangle
2658
2659	// main is the area for the main pane. (e.x chat, configure, landing)
2660	main uv.Rectangle
2661
2662	// pills is the area for the pills panel.
2663	pills uv.Rectangle
2664
2665	// editor is the area for the editor pane.
2666	editor uv.Rectangle
2667
2668	// sidebar is the area for the sidebar.
2669	sidebar uv.Rectangle
2670
2671	// status is the area for the status view.
2672	status uv.Rectangle
2673
2674	// session details is the area for the session details overlay in compact mode.
2675	sessionDetails uv.Rectangle
2676}
2677
2678func (m *UI) openEditor(value string) tea.Cmd {
2679	tmpfile, err := os.CreateTemp("", "msg_*.md")
2680	if err != nil {
2681		return util.ReportError(err)
2682	}
2683	tmpPath := tmpfile.Name()
2684	defer tmpfile.Close() //nolint:errcheck
2685	if _, err := tmpfile.WriteString(value); err != nil {
2686		return util.ReportError(err)
2687	}
2688	cmd, err := editor.Command(
2689		"crush",
2690		tmpPath,
2691		editor.AtPosition(
2692			m.textarea.Line()+1,
2693			m.textarea.Column()+1,
2694		),
2695	)
2696	if err != nil {
2697		return util.ReportError(err)
2698	}
2699	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2700		defer func() {
2701			_ = os.Remove(tmpPath)
2702		}()
2703
2704		if err != nil {
2705			return util.ReportError(err)
2706		}
2707		content, err := os.ReadFile(tmpPath)
2708		if err != nil {
2709			return util.ReportError(err)
2710		}
2711		if len(content) == 0 {
2712			return util.ReportWarn("Message is empty")
2713		}
2714		return openEditorMsg{
2715			Text: strings.TrimSpace(string(content)),
2716		}
2717	})
2718}
2719
2720// setEditorPrompt configures the textarea prompt function based on whether
2721// yolo mode is enabled.
2722func (m *UI) setEditorPrompt(yolo bool) {
2723	if yolo {
2724		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2725		return
2726	}
2727	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2728}
2729
2730// normalPromptFunc returns the normal editor prompt style ("  > " on first
2731// line, "::: " on subsequent lines).
2732func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2733	t := m.com.Styles
2734	if info.LineNumber == 0 {
2735		if info.Focused {
2736			return "  > "
2737		}
2738		return "::: "
2739	}
2740	if info.Focused {
2741		return t.EditorPromptNormalFocused.Render()
2742	}
2743	return t.EditorPromptNormalBlurred.Render()
2744}
2745
2746// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2747// and colored dots.
2748func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2749	t := m.com.Styles
2750	if info.LineNumber == 0 {
2751		if info.Focused {
2752			return t.EditorPromptYoloIconFocused.Render()
2753		} else {
2754			return t.EditorPromptYoloIconBlurred.Render()
2755		}
2756	}
2757	if info.Focused {
2758		return t.EditorPromptYoloDotsFocused.Render()
2759	}
2760	return t.EditorPromptYoloDotsBlurred.Render()
2761}
2762
2763// closeCompletions closes the completions popup and resets state.
2764func (m *UI) closeCompletions() {
2765	m.completionsOpen = false
2766	m.completionsQuery = ""
2767	m.completionsStartIndex = 0
2768	m.completions.Close()
2769}
2770
2771// insertCompletionText replaces the @query in the textarea with the given text.
2772// Returns false if the replacement cannot be performed.
2773func (m *UI) insertCompletionText(text string) bool {
2774	value := m.textarea.Value()
2775	if m.completionsStartIndex > len(value) {
2776		return false
2777	}
2778
2779	word := m.textareaWord()
2780	endIdx := min(m.completionsStartIndex+len(word), len(value))
2781	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2782	m.textarea.SetValue(newValue)
2783	m.textarea.MoveToEnd()
2784	m.textarea.InsertRune(' ')
2785	return true
2786}
2787
2788// insertFileCompletion inserts the selected file path into the textarea,
2789// replacing the @query, and adds the file as an attachment.
2790func (m *UI) insertFileCompletion(path string) tea.Cmd {
2791	prevHeight := m.textarea.Height()
2792	if !m.insertCompletionText(path) {
2793		return nil
2794	}
2795	heightCmd := m.handleTextareaHeightChange(prevHeight)
2796
2797	fileCmd := func() tea.Msg {
2798		absPath, _ := filepath.Abs(path)
2799
2800		if m.hasSession() {
2801			// Skip attachment if file was already read and hasn't been modified.
2802			lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2803			if !lastRead.IsZero() {
2804				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2805					return nil
2806				}
2807			}
2808		} else if slices.Contains(m.sessionFileReads, absPath) {
2809			return nil
2810		}
2811
2812		m.sessionFileReads = append(m.sessionFileReads, absPath)
2813
2814		// Add file as attachment.
2815		content, err := os.ReadFile(path)
2816		if err != nil {
2817			// If it fails, let the LLM handle it later.
2818			return nil
2819		}
2820
2821		return message.Attachment{
2822			FilePath: path,
2823			FileName: filepath.Base(path),
2824			MimeType: mimeOf(content),
2825			Content:  content,
2826		}
2827	}
2828	return tea.Batch(heightCmd, fileCmd)
2829}
2830
2831// insertMCPResourceCompletion inserts the selected resource into the textarea,
2832// replacing the @query, and adds the resource as an attachment.
2833func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2834	displayText := cmp.Or(item.Title, item.URI)
2835
2836	prevHeight := m.textarea.Height()
2837	if !m.insertCompletionText(displayText) {
2838		return nil
2839	}
2840	heightCmd := m.handleTextareaHeightChange(prevHeight)
2841
2842	resourceCmd := func() tea.Msg {
2843		contents, err := m.com.Workspace.ReadMCPResource(
2844			context.Background(),
2845			item.MCPName,
2846			item.URI,
2847		)
2848		if err != nil {
2849			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2850			return nil
2851		}
2852		if len(contents) == 0 {
2853			return nil
2854		}
2855
2856		content := contents[0]
2857		var data []byte
2858		if content.Text != "" {
2859			data = []byte(content.Text)
2860		} else if len(content.Blob) > 0 {
2861			data = content.Blob
2862		}
2863		if len(data) == 0 {
2864			return nil
2865		}
2866
2867		mimeType := item.MIMEType
2868		if mimeType == "" && content.MIMEType != "" {
2869			mimeType = content.MIMEType
2870		}
2871		if mimeType == "" {
2872			mimeType = "text/plain"
2873		}
2874
2875		return message.Attachment{
2876			FilePath: item.URI,
2877			FileName: displayText,
2878			MimeType: mimeType,
2879			Content:  data,
2880		}
2881	}
2882	return tea.Batch(heightCmd, resourceCmd)
2883}
2884
2885// completionsPosition returns the X and Y position for the completions popup.
2886func (m *UI) completionsPosition() image.Point {
2887	cur := m.textarea.Cursor()
2888	if cur == nil {
2889		return image.Point{
2890			X: m.layout.editor.Min.X,
2891			Y: m.layout.editor.Min.Y,
2892		}
2893	}
2894	return image.Point{
2895		X: cur.X + m.layout.editor.Min.X,
2896		Y: m.layout.editor.Min.Y + cur.Y,
2897	}
2898}
2899
2900// textareaWord returns the current word at the cursor position.
2901func (m *UI) textareaWord() string {
2902	return m.textarea.Word()
2903}
2904
2905// isWhitespace returns true if the byte is a whitespace character.
2906func isWhitespace(b byte) bool {
2907	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2908}
2909
2910// isAgentBusy returns true if the agent coordinator exists and is currently
2911// busy processing a request.
2912func (m *UI) isAgentBusy() bool {
2913	return m.com.Workspace.AgentIsReady() &&
2914		m.com.Workspace.AgentIsBusy()
2915}
2916
2917// hasSession returns true if there is an active session with a valid ID.
2918func (m *UI) hasSession() bool {
2919	return m.session != nil && m.session.ID != ""
2920}
2921
2922// mimeOf detects the MIME type of the given content.
2923func mimeOf(content []byte) string {
2924	mimeBufferSize := min(512, len(content))
2925	return http.DetectContentType(content[:mimeBufferSize])
2926}
2927
2928var readyPlaceholders = [...]string{
2929	"Ready!",
2930	"Ready...",
2931	"Ready?",
2932	"Ready for instructions",
2933}
2934
2935var workingPlaceholders = [...]string{
2936	"Working!",
2937	"Working...",
2938	"Brrrrr...",
2939	"Prrrrrrrr...",
2940	"Processing...",
2941	"Thinking...",
2942}
2943
2944// randomizePlaceholders selects random placeholder text for the textarea's
2945// ready and working states.
2946func (m *UI) randomizePlaceholders() {
2947	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2948	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2949}
2950
2951// renderEditorView renders the editor view with attachments if any.
2952func (m *UI) renderEditorView(width int) string {
2953	var attachmentsView string
2954	if len(m.attachments.List()) > 0 {
2955		attachmentsView = m.attachments.Render(width)
2956	}
2957	return strings.Join([]string{
2958		attachmentsView,
2959		m.textarea.View(),
2960		"", // margin at bottom of editor
2961	}, "\n")
2962}
2963
2964// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2965func (m *UI) cacheSidebarLogo(width int) {
2966	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2967}
2968
2969// sendMessage sends a message with the given content and attachments.
2970func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2971	if !m.com.Workspace.AgentIsReady() {
2972		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2973	}
2974
2975	var cmds []tea.Cmd
2976	if !m.hasSession() {
2977		newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2978		if err != nil {
2979			return util.ReportError(err)
2980		}
2981		if m.forceCompactMode {
2982			m.isCompact = true
2983		}
2984		if newSession.ID != "" {
2985			m.session = &newSession
2986			cmds = append(cmds, m.loadSession(newSession.ID))
2987		}
2988		m.setState(uiChat, m.focus)
2989	}
2990
2991	ctx := context.Background()
2992	cmds = append(cmds, func() tea.Msg {
2993		for _, path := range m.sessionFileReads {
2994			m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2995			m.com.Workspace.LSPStart(ctx, path)
2996		}
2997		return nil
2998	})
2999
3000	// Capture session ID to avoid race with main goroutine updating m.session.
3001	sessionID := m.session.ID
3002	cmds = append(cmds, func() tea.Msg {
3003		err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3004		if err != nil {
3005			isCancelErr := errors.Is(err, context.Canceled)
3006			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
3007			if isCancelErr || isPermissionErr {
3008				return nil
3009			}
3010			return util.InfoMsg{
3011				Type: util.InfoTypeError,
3012				Msg:  fmt.Sprintf("%v", err),
3013			}
3014		}
3015		return nil
3016	})
3017	return tea.Batch(cmds...)
3018}
3019
3020const cancelTimerDuration = 2 * time.Second
3021
3022// cancelTimerCmd creates a command that expires the cancel timer.
3023func cancelTimerCmd() tea.Cmd {
3024	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
3025		return cancelTimerExpiredMsg{}
3026	})
3027}
3028
3029// cancelAgent handles the cancel key press. The first press sets isCanceling to true
3030// and starts a timer. The second press (before the timer expires) actually
3031// cancels the agent.
3032func (m *UI) cancelAgent() tea.Cmd {
3033	if !m.hasSession() {
3034		return nil
3035	}
3036
3037	if !m.com.Workspace.AgentIsReady() {
3038		return nil
3039	}
3040
3041	if m.isCanceling {
3042		// Second escape press - actually cancel the agent.
3043		m.isCanceling = false
3044		m.com.Workspace.AgentCancel(m.session.ID)
3045		// Stop the spinning todo indicator.
3046		m.todoIsSpinning = false
3047		m.renderPills()
3048		return nil
3049	}
3050
3051	// Check if there are queued prompts - if so, clear the queue.
3052	if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3053		m.com.Workspace.AgentClearQueue(m.session.ID)
3054		return nil
3055	}
3056
3057	// First escape press - set canceling state and start timer.
3058	m.isCanceling = true
3059	return cancelTimerCmd()
3060}
3061
3062// openDialog opens a dialog by its ID.
3063func (m *UI) openDialog(id string) tea.Cmd {
3064	var cmds []tea.Cmd
3065	switch id {
3066	case dialog.SessionsID:
3067		if cmd := m.openSessionsDialog(); cmd != nil {
3068			cmds = append(cmds, cmd)
3069		}
3070	case dialog.ModelsID:
3071		if cmd := m.openModelsDialog(); cmd != nil {
3072			cmds = append(cmds, cmd)
3073		}
3074	case dialog.CommandsID:
3075		if cmd := m.openCommandsDialog(); cmd != nil {
3076			cmds = append(cmds, cmd)
3077		}
3078	case dialog.ReasoningID:
3079		if cmd := m.openReasoningDialog(); cmd != nil {
3080			cmds = append(cmds, cmd)
3081		}
3082	case dialog.FilePickerID:
3083		if cmd := m.openFilesDialog(); cmd != nil {
3084			cmds = append(cmds, cmd)
3085		}
3086	case dialog.QuitID:
3087		if cmd := m.openQuitDialog(); cmd != nil {
3088			cmds = append(cmds, cmd)
3089		}
3090	default:
3091		// Unknown dialog
3092		break
3093	}
3094	return tea.Batch(cmds...)
3095}
3096
3097// openQuitDialog opens the quit confirmation dialog.
3098func (m *UI) openQuitDialog() tea.Cmd {
3099	if m.dialog.ContainsDialog(dialog.QuitID) {
3100		// Bring to front
3101		m.dialog.BringToFront(dialog.QuitID)
3102		return nil
3103	}
3104
3105	quitDialog := dialog.NewQuit(m.com)
3106	m.dialog.OpenDialog(quitDialog)
3107	return nil
3108}
3109
3110// openModelsDialog opens the models dialog.
3111func (m *UI) openModelsDialog() tea.Cmd {
3112	if m.dialog.ContainsDialog(dialog.ModelsID) {
3113		// Bring to front
3114		m.dialog.BringToFront(dialog.ModelsID)
3115		return nil
3116	}
3117
3118	isOnboarding := m.state == uiOnboarding
3119	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3120	if err != nil {
3121		return util.ReportError(err)
3122	}
3123
3124	m.dialog.OpenDialog(modelsDialog)
3125
3126	return nil
3127}
3128
3129// openCommandsDialog opens the commands dialog.
3130func (m *UI) openCommandsDialog() tea.Cmd {
3131	if m.dialog.ContainsDialog(dialog.CommandsID) {
3132		// Bring to front
3133		m.dialog.BringToFront(dialog.CommandsID)
3134		return nil
3135	}
3136
3137	var sessionID string
3138	hasSession := m.session != nil
3139	if hasSession {
3140		sessionID = m.session.ID
3141	}
3142	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3143	hasQueue := m.promptQueue > 0
3144
3145	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3146	if err != nil {
3147		return util.ReportError(err)
3148	}
3149
3150	m.dialog.OpenDialog(commands)
3151
3152	return commands.InitialCmd()
3153}
3154
3155// openReasoningDialog opens the reasoning effort dialog.
3156func (m *UI) openReasoningDialog() tea.Cmd {
3157	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3158		m.dialog.BringToFront(dialog.ReasoningID)
3159		return nil
3160	}
3161
3162	reasoningDialog, err := dialog.NewReasoning(m.com)
3163	if err != nil {
3164		return util.ReportError(err)
3165	}
3166
3167	m.dialog.OpenDialog(reasoningDialog)
3168	return nil
3169}
3170
3171// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3172// it brings it to the front. Otherwise, it will list all the sessions and open
3173// the dialog.
3174func (m *UI) openSessionsDialog() tea.Cmd {
3175	if m.dialog.ContainsDialog(dialog.SessionsID) {
3176		// Bring to front
3177		m.dialog.BringToFront(dialog.SessionsID)
3178		return nil
3179	}
3180
3181	selectedSessionID := ""
3182	if m.session != nil {
3183		selectedSessionID = m.session.ID
3184	}
3185
3186	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3187	if err != nil {
3188		return util.ReportError(err)
3189	}
3190
3191	m.dialog.OpenDialog(dialog)
3192	return nil
3193}
3194
3195// openFilesDialog opens the file picker dialog.
3196func (m *UI) openFilesDialog() tea.Cmd {
3197	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3198		// Bring to front
3199		m.dialog.BringToFront(dialog.FilePickerID)
3200		return nil
3201	}
3202
3203	filePicker, cmd := dialog.NewFilePicker(m.com)
3204	filePicker.SetImageCapabilities(&m.caps)
3205	m.dialog.OpenDialog(filePicker)
3206
3207	return cmd
3208}
3209
3210// openPermissionsDialog opens the permissions dialog for a permission request.
3211func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3212	// Close any existing permissions dialog first.
3213	m.dialog.CloseDialog(dialog.PermissionsID)
3214
3215	// Get diff mode from config.
3216	var opts []dialog.PermissionsOption
3217	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3218		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3219	}
3220
3221	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3222	m.dialog.OpenDialog(permDialog)
3223	return nil
3224}
3225
3226// handlePermissionNotification updates tool items when permission state changes.
3227func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3228	toolItem := m.chat.MessageItem(notification.ToolCallID)
3229	if toolItem == nil {
3230		return
3231	}
3232
3233	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3234		if notification.Granted {
3235			permItem.SetStatus(chat.ToolStatusRunning)
3236		} else {
3237			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3238		}
3239	}
3240}
3241
3242// handleAgentNotification translates domain agent events into desktop
3243// notifications using the UI notification backend.
3244func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3245	switch n.Type {
3246	case notify.TypeAgentFinished:
3247		return m.sendNotification(notification.Notification{
3248			Title:   "Crush is waiting...",
3249			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3250		})
3251	case notify.TypeReAuthenticate:
3252		return m.handleReAuthenticate(n.ProviderID)
3253	default:
3254		return nil
3255	}
3256}
3257
3258func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3259	cfg := m.com.Config()
3260	if cfg == nil {
3261		return nil
3262	}
3263	providerCfg, ok := cfg.Providers.Get(providerID)
3264	if !ok {
3265		return nil
3266	}
3267	agentCfg, ok := cfg.Agents[config.AgentCoder]
3268	if !ok {
3269		return nil
3270	}
3271	return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3272}
3273
3274// newSession clears the current session state and prepares for a new session.
3275// The actual session creation happens when the user sends their first message.
3276// Returns a command to reload prompt history.
3277func (m *UI) newSession() tea.Cmd {
3278	if !m.hasSession() {
3279		return nil
3280	}
3281
3282	m.session = nil
3283	m.sessionFiles = nil
3284	m.sessionFileReads = nil
3285	m.setState(uiLanding, uiFocusEditor)
3286	m.textarea.Focus()
3287	m.chat.Blur()
3288	m.chat.ClearMessages()
3289	m.pillsExpanded = false
3290	m.promptQueue = 0
3291	m.pillsView = ""
3292	m.historyReset()
3293	agenttools.ResetCache()
3294	return tea.Batch(
3295		func() tea.Msg {
3296			m.com.Workspace.LSPStopAll(context.Background())
3297			return nil
3298		},
3299		m.loadPromptHistory(),
3300	)
3301}
3302
3303// handlePasteMsg handles a paste message.
3304func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3305	if m.dialog.HasDialogs() {
3306		return m.handleDialogMsg(msg)
3307	}
3308
3309	if m.focus != uiFocusEditor {
3310		return nil
3311	}
3312
3313	if hasPasteExceededThreshold(msg) {
3314		return func() tea.Msg {
3315			content := []byte(msg.Content)
3316			if int64(len(content)) > common.MaxAttachmentSize {
3317				return util.ReportWarn("Paste is too big (>5mb)")
3318			}
3319			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3320			mimeBufferSize := min(512, len(content))
3321			mimeType := http.DetectContentType(content[:mimeBufferSize])
3322			return message.Attachment{
3323				FileName: name,
3324				FilePath: name,
3325				MimeType: mimeType,
3326				Content:  content,
3327			}
3328		}
3329	}
3330
3331	// Attempt to parse pasted content as file paths. If possible to parse,
3332	// all files exist and are valid, add as attachments.
3333	// Otherwise, paste as text.
3334	paths := fsext.ParsePastedFiles(msg.Content)
3335	allExistsAndValid := func() bool {
3336		if len(paths) == 0 {
3337			return false
3338		}
3339		for _, path := range paths {
3340			if _, err := os.Stat(path); os.IsNotExist(err) {
3341				return false
3342			}
3343
3344			lowerPath := strings.ToLower(path)
3345			isValid := false
3346			for _, ext := range common.AllowedImageTypes {
3347				if strings.HasSuffix(lowerPath, ext) {
3348					isValid = true
3349					break
3350				}
3351			}
3352			if !isValid {
3353				return false
3354			}
3355		}
3356		return true
3357	}
3358	if !allExistsAndValid() {
3359		prevHeight := m.textarea.Height()
3360		return m.updateTextareaWithPrevHeight(msg, prevHeight)
3361	}
3362
3363	var cmds []tea.Cmd
3364	for _, path := range paths {
3365		cmds = append(cmds, m.handleFilePathPaste(path))
3366	}
3367	return tea.Batch(cmds...)
3368}
3369
3370func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3371	var (
3372		lineCount = 0
3373		colCount  = 0
3374	)
3375	for line := range strings.SplitSeq(msg.Content, "\n") {
3376		lineCount++
3377		colCount = max(colCount, len(line))
3378
3379		if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3380			return true
3381		}
3382	}
3383	return false
3384}
3385
3386// handleFilePathPaste handles a pasted file path.
3387func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3388	return func() tea.Msg {
3389		fileInfo, err := os.Stat(path)
3390		if err != nil {
3391			return util.ReportError(err)
3392		}
3393		if fileInfo.IsDir() {
3394			return util.ReportWarn("Cannot attach a directory")
3395		}
3396		if fileInfo.Size() > common.MaxAttachmentSize {
3397			return util.ReportWarn("File is too big (>5mb)")
3398		}
3399
3400		content, err := os.ReadFile(path)
3401		if err != nil {
3402			return util.ReportError(err)
3403		}
3404
3405		mimeBufferSize := min(512, len(content))
3406		mimeType := http.DetectContentType(content[:mimeBufferSize])
3407		fileName := filepath.Base(path)
3408		return message.Attachment{
3409			FilePath: path,
3410			FileName: fileName,
3411			MimeType: mimeType,
3412			Content:  content,
3413		}
3414	}
3415}
3416
3417// pasteImageFromClipboard reads image data from the system clipboard and
3418// creates an attachment. If no image data is found, it falls back to
3419// interpreting clipboard text as a file path.
3420func (m *UI) pasteImageFromClipboard() tea.Msg {
3421	imageData, err := readClipboard(clipboardFormatImage)
3422	if int64(len(imageData)) > common.MaxAttachmentSize {
3423		return util.InfoMsg{
3424			Type: util.InfoTypeError,
3425			Msg:  "File too large, max 5MB",
3426		}
3427	}
3428	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3429	if err == nil {
3430		return message.Attachment{
3431			FilePath: name,
3432			FileName: name,
3433			MimeType: mimeOf(imageData),
3434			Content:  imageData,
3435		}
3436	}
3437
3438	textData, textErr := readClipboard(clipboardFormatText)
3439	if textErr != nil || len(textData) == 0 {
3440		return nil // Clipboard is empty or does not contain an image
3441	}
3442
3443	path := strings.TrimSpace(string(textData))
3444	path = strings.ReplaceAll(path, "\\ ", " ")
3445	if _, statErr := os.Stat(path); statErr != nil {
3446		return nil // Clipboard does not contain an image or valid file path
3447	}
3448
3449	lowerPath := strings.ToLower(path)
3450	isAllowed := false
3451	for _, ext := range common.AllowedImageTypes {
3452		if strings.HasSuffix(lowerPath, ext) {
3453			isAllowed = true
3454			break
3455		}
3456	}
3457	if !isAllowed {
3458		return util.NewInfoMsg("File type is not a supported image format")
3459	}
3460
3461	fileInfo, statErr := os.Stat(path)
3462	if statErr != nil {
3463		return util.InfoMsg{
3464			Type: util.InfoTypeError,
3465			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3466		}
3467	}
3468	if fileInfo.Size() > common.MaxAttachmentSize {
3469		return util.InfoMsg{
3470			Type: util.InfoTypeError,
3471			Msg:  "File too large, max 5MB",
3472		}
3473	}
3474
3475	content, readErr := os.ReadFile(path)
3476	if readErr != nil {
3477		return util.InfoMsg{
3478			Type: util.InfoTypeError,
3479			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3480		}
3481	}
3482
3483	return message.Attachment{
3484		FilePath: path,
3485		FileName: filepath.Base(path),
3486		MimeType: mimeOf(content),
3487		Content:  content,
3488	}
3489}
3490
3491var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3492
3493func (m *UI) pasteIdx() int {
3494	result := 0
3495	for _, at := range m.attachments.List() {
3496		found := pasteRE.FindStringSubmatch(at.FileName)
3497		if len(found) == 0 {
3498			continue
3499		}
3500		idx, err := strconv.Atoi(found[1])
3501		if err == nil {
3502			result = max(result, idx)
3503		}
3504	}
3505	return result + 1
3506}
3507
3508// drawSessionDetails draws the session details in compact mode.
3509func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3510	if m.session == nil {
3511		return
3512	}
3513
3514	s := m.com.Styles
3515
3516	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3517	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3518
3519	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3520	blocks := []string{
3521		title,
3522		"",
3523		m.modelInfo(width),
3524		"",
3525	}
3526
3527	detailsHeader := lipgloss.JoinVertical(
3528		lipgloss.Left,
3529		blocks...,
3530	)
3531
3532	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3533
3534	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3535
3536	const maxSectionWidth = 50
3537	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3538	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3539
3540	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3541	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3542	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3543	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3544	uv.NewStyledString(
3545		s.CompactDetails.View.
3546			Width(area.Dx()).
3547			Render(
3548				lipgloss.JoinVertical(
3549					lipgloss.Left,
3550					detailsHeader,
3551					sections,
3552					version,
3553				),
3554			),
3555	).Draw(scr, area)
3556}
3557
3558func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3559	load := func() tea.Msg {
3560		prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3561		if err != nil {
3562			// TODO: make this better
3563			return util.ReportError(err)()
3564		}
3565
3566		if prompt == "" {
3567			return nil
3568		}
3569		return sendMessageMsg{
3570			Content: prompt,
3571		}
3572	}
3573
3574	var cmds []tea.Cmd
3575	if cmd := m.dialog.StartLoading(); cmd != nil {
3576		cmds = append(cmds, cmd)
3577	}
3578	cmds = append(cmds, load, func() tea.Msg {
3579		return closeDialogMsg{}
3580	})
3581
3582	return tea.Sequence(cmds...)
3583}
3584
3585func (m *UI) handleStateChanged() tea.Cmd {
3586	return func() tea.Msg {
3587		m.com.Workspace.UpdateAgentModel(context.Background())
3588		return mcpStateChangedMsg{
3589			states: m.com.Workspace.MCPGetStates(),
3590		}
3591	}
3592}
3593
3594func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3595	return func() tea.Msg {
3596		ws.MCPRefreshPrompts(context.Background(), name)
3597		return nil
3598	}
3599}
3600
3601func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3602	return func() tea.Msg {
3603		ws.RefreshMCPTools(context.Background(), name)
3604		return nil
3605	}
3606}
3607
3608func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3609	return func() tea.Msg {
3610		ws.MCPRefreshResources(context.Background(), name)
3611		return nil
3612	}
3613}
3614
3615func (m *UI) copyChatHighlight() tea.Cmd {
3616	text := m.chat.HighlightContent()
3617	return common.CopyToClipboardWithCallback(
3618		text,
3619		"Selected text copied to clipboard",
3620		func() tea.Msg {
3621			m.chat.ClearMouse()
3622			return nil
3623		},
3624	)
3625}
3626
3627func (m *UI) enableDockerMCP() tea.Msg {
3628	ctx := context.Background()
3629	if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3630		return util.ReportError(err)()
3631	}
3632
3633	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3634}
3635
3636func (m *UI) disableDockerMCP() tea.Msg {
3637	if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3638		return util.ReportError(err)()
3639	}
3640
3641	return util.NewInfoMsg("Docker MCP disabled successfully")
3642}
3643
3644// renderLogo renders the Crush logo with the given styles and dimensions.
3645func renderLogo(t *styles.Styles, compact bool, width int) string {
3646	return logo.Render(t, version.Version, compact, logo.Opts{
3647		FieldColor:   t.LogoFieldColor,
3648		TitleColorA:  t.LogoTitleColorA,
3649		TitleColorB:  t.LogoTitleColorB,
3650		CharmColor:   t.LogoCharmColor,
3651		VersionColor: t.LogoVersionColor,
3652		Width:        width,
3653	})
3654}