ui.go

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