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.ActionEnableDockerMCP:
1387		m.dialog.CloseDialog(dialog.CommandsID)
1388		cmds = append(cmds, m.enableDockerMCP)
1389	case dialog.ActionDisableDockerMCP:
1390		m.dialog.CloseDialog(dialog.CommandsID)
1391		cmds = append(cmds, m.disableDockerMCP)
1392	case dialog.ActionInitializeProject:
1393		if m.isAgentBusy() {
1394			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1395			break
1396		}
1397		cmds = append(cmds, m.initializeProject())
1398		m.dialog.CloseDialog(dialog.CommandsID)
1399
1400	case dialog.ActionSelectModel:
1401		if m.isAgentBusy() {
1402			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1403			break
1404		}
1405
1406		cfg := m.com.Config()
1407		if cfg == nil {
1408			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1409			break
1410		}
1411
1412		var (
1413			providerID   = msg.Model.Provider
1414			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1415			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1416		)
1417
1418		// Attempt to import GitHub Copilot tokens from VSCode if available.
1419		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1420			m.com.Store().ImportCopilot()
1421		}
1422
1423		if !isConfigured() || msg.ReAuthenticate {
1424			m.dialog.CloseDialog(dialog.ModelsID)
1425			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1426				cmds = append(cmds, cmd)
1427			}
1428			break
1429		}
1430
1431		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1432			cmds = append(cmds, util.ReportError(err))
1433		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1434			// Ensure small model is set is unset.
1435			smallModel := m.com.App.GetDefaultSmallModel(providerID)
1436			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1437				cmds = append(cmds, util.ReportError(err))
1438			}
1439		}
1440
1441		cmds = append(cmds, func() tea.Msg {
1442			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1443				return util.ReportError(err)
1444			}
1445
1446			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1447
1448			return util.NewInfoMsg(modelMsg)
1449		})
1450
1451		m.dialog.CloseDialog(dialog.APIKeyInputID)
1452		m.dialog.CloseDialog(dialog.OAuthID)
1453		m.dialog.CloseDialog(dialog.ModelsID)
1454
1455		if isOnboarding {
1456			m.setState(uiLanding, uiFocusEditor)
1457			m.com.Config().SetupAgents()
1458			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1459				cmds = append(cmds, util.ReportError(err))
1460			}
1461		}
1462	case dialog.ActionSelectReasoningEffort:
1463		if m.isAgentBusy() {
1464			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1465			break
1466		}
1467
1468		cfg := m.com.Config()
1469		if cfg == nil {
1470			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1471			break
1472		}
1473
1474		agentCfg, ok := cfg.Agents[config.AgentCoder]
1475		if !ok {
1476			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1477			break
1478		}
1479
1480		currentModel := cfg.Models[agentCfg.Model]
1481		currentModel.ReasoningEffort = msg.Effort
1482		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1483			cmds = append(cmds, util.ReportError(err))
1484			break
1485		}
1486
1487		cmds = append(cmds, func() tea.Msg {
1488			m.com.App.UpdateAgentModel(context.TODO())
1489			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1490		})
1491		m.dialog.CloseDialog(dialog.ReasoningID)
1492	case dialog.ActionPermissionResponse:
1493		m.dialog.CloseDialog(dialog.PermissionsID)
1494		switch msg.Action {
1495		case dialog.PermissionAllow:
1496			m.com.App.Permissions.Grant(msg.Permission)
1497		case dialog.PermissionAllowForSession:
1498			m.com.App.Permissions.GrantPersistent(msg.Permission)
1499		case dialog.PermissionDeny:
1500			m.com.App.Permissions.Deny(msg.Permission)
1501		}
1502
1503	case dialog.ActionFilePickerSelected:
1504		cmds = append(cmds, tea.Sequence(
1505			msg.Cmd(),
1506			func() tea.Msg {
1507				m.dialog.CloseDialog(dialog.FilePickerID)
1508				return nil
1509			},
1510			func() tea.Msg {
1511				fimage.ResetCache()
1512				return nil
1513			},
1514		))
1515
1516	case dialog.ActionRunCustomCommand:
1517		if len(msg.Arguments) > 0 && msg.Args == nil {
1518			m.dialog.CloseFrontDialog()
1519			argsDialog := dialog.NewArguments(
1520				m.com,
1521				"Custom Command Arguments",
1522				"",
1523				msg.Arguments,
1524				msg, // Pass the action as the result
1525			)
1526			m.dialog.OpenDialog(argsDialog)
1527			break
1528		}
1529		content := msg.Content
1530		if msg.Args != nil {
1531			content = substituteArgs(content, msg.Args)
1532		}
1533		cmds = append(cmds, m.sendMessage(content))
1534		m.dialog.CloseFrontDialog()
1535	case dialog.ActionRunMCPPrompt:
1536		if len(msg.Arguments) > 0 && msg.Args == nil {
1537			m.dialog.CloseFrontDialog()
1538			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1539			argsDialog := dialog.NewArguments(
1540				m.com,
1541				title,
1542				msg.Description,
1543				msg.Arguments,
1544				msg, // Pass the action as the result
1545			)
1546			m.dialog.OpenDialog(argsDialog)
1547			break
1548		}
1549		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1550	default:
1551		cmds = append(cmds, util.CmdHandler(msg))
1552	}
1553
1554	return tea.Batch(cmds...)
1555}
1556
1557// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1558func substituteArgs(content string, args map[string]string) string {
1559	for name, value := range args {
1560		placeholder := "$" + name
1561		content = strings.ReplaceAll(content, placeholder, value)
1562	}
1563	return content
1564}
1565
1566func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1567	var (
1568		dlg dialog.Dialog
1569		cmd tea.Cmd
1570
1571		isOnboarding = m.state == uiOnboarding
1572	)
1573
1574	switch provider.ID {
1575	case "hyper":
1576		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1577	case catwalk.InferenceProviderCopilot:
1578		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1579	default:
1580		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1581	}
1582
1583	if m.dialog.ContainsDialog(dlg.ID()) {
1584		m.dialog.BringToFront(dlg.ID())
1585		return nil
1586	}
1587
1588	m.dialog.OpenDialog(dlg)
1589	return cmd
1590}
1591
1592func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1593	var cmds []tea.Cmd
1594
1595	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1596		switch {
1597		case key.Matches(msg, m.keyMap.Help):
1598			m.status.ToggleHelp()
1599			m.updateLayoutAndSize()
1600			return true
1601		case key.Matches(msg, m.keyMap.Commands):
1602			if cmd := m.openCommandsDialog(); cmd != nil {
1603				cmds = append(cmds, cmd)
1604			}
1605			return true
1606		case key.Matches(msg, m.keyMap.Models):
1607			if cmd := m.openModelsDialog(); cmd != nil {
1608				cmds = append(cmds, cmd)
1609			}
1610			return true
1611		case key.Matches(msg, m.keyMap.Sessions):
1612			if cmd := m.openSessionsDialog(); cmd != nil {
1613				cmds = append(cmds, cmd)
1614			}
1615			return true
1616		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1617			m.detailsOpen = !m.detailsOpen
1618			m.updateLayoutAndSize()
1619			return true
1620		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1621			if m.state == uiChat && m.hasSession() {
1622				if cmd := m.togglePillsExpanded(); cmd != nil {
1623					cmds = append(cmds, cmd)
1624				}
1625				return true
1626			}
1627		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1628			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1629				if cmd := m.switchPillSection(-1); cmd != nil {
1630					cmds = append(cmds, cmd)
1631				}
1632				return true
1633			}
1634		case key.Matches(msg, m.keyMap.Chat.PillRight):
1635			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1636				if cmd := m.switchPillSection(1); cmd != nil {
1637					cmds = append(cmds, cmd)
1638				}
1639				return true
1640			}
1641		case key.Matches(msg, m.keyMap.Suspend):
1642			if m.isAgentBusy() {
1643				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1644				return true
1645			}
1646			cmds = append(cmds, tea.Suspend)
1647			return true
1648		}
1649		return false
1650	}
1651
1652	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1653		// Always handle quit keys first
1654		if cmd := m.openQuitDialog(); cmd != nil {
1655			cmds = append(cmds, cmd)
1656		}
1657
1658		return tea.Batch(cmds...)
1659	}
1660
1661	// Route all messages to dialog if one is open.
1662	if m.dialog.HasDialogs() {
1663		return m.handleDialogMsg(msg)
1664	}
1665
1666	// Handle cancel key when agent is busy.
1667	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1668		if m.isAgentBusy() {
1669			if cmd := m.cancelAgent(); cmd != nil {
1670				cmds = append(cmds, cmd)
1671			}
1672			return tea.Batch(cmds...)
1673		}
1674	}
1675
1676	switch m.state {
1677	case uiOnboarding:
1678		return tea.Batch(cmds...)
1679	case uiInitialize:
1680		cmds = append(cmds, m.updateInitializeView(msg)...)
1681		return tea.Batch(cmds...)
1682	case uiChat, uiLanding:
1683		switch m.focus {
1684		case uiFocusEditor:
1685			// Handle completions if open.
1686			if m.completionsOpen {
1687				if msg, ok := m.completions.Update(msg); ok {
1688					switch msg := msg.(type) {
1689					case completions.SelectionMsg[completions.FileCompletionValue]:
1690						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1691						if !msg.KeepOpen {
1692							m.closeCompletions()
1693						}
1694					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1695						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1696						if !msg.KeepOpen {
1697							m.closeCompletions()
1698						}
1699					case completions.ClosedMsg:
1700						m.completionsOpen = false
1701					}
1702					return tea.Batch(cmds...)
1703				}
1704			}
1705
1706			if ok := m.attachments.Update(msg); ok {
1707				return tea.Batch(cmds...)
1708			}
1709
1710			switch {
1711			case key.Matches(msg, m.keyMap.Editor.AddImage):
1712				if cmd := m.openFilesDialog(); cmd != nil {
1713					cmds = append(cmds, cmd)
1714				}
1715
1716			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1717				cmds = append(cmds, m.pasteImageFromClipboard)
1718
1719			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1720				value := m.textarea.Value()
1721				if before, ok := strings.CutSuffix(value, "\\"); ok {
1722					// If the last character is a backslash, remove it and add a newline.
1723					m.textarea.SetValue(before)
1724					break
1725				}
1726
1727				// Otherwise, send the message
1728				m.textarea.Reset()
1729
1730				value = strings.TrimSpace(value)
1731				if value == "exit" || value == "quit" {
1732					return m.openQuitDialog()
1733				}
1734
1735				attachments := m.attachments.List()
1736				m.attachments.Reset()
1737				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1738					return nil
1739				}
1740
1741				m.randomizePlaceholders()
1742				m.historyReset()
1743
1744				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1745			case key.Matches(msg, m.keyMap.Chat.NewSession):
1746				if !m.hasSession() {
1747					break
1748				}
1749				if m.isAgentBusy() {
1750					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1751					break
1752				}
1753				if cmd := m.newSession(); cmd != nil {
1754					cmds = append(cmds, cmd)
1755				}
1756			case key.Matches(msg, m.keyMap.Tab):
1757				if m.state != uiLanding {
1758					m.setState(m.state, uiFocusMain)
1759					m.textarea.Blur()
1760					m.chat.Focus()
1761					m.chat.SetSelected(m.chat.Len() - 1)
1762				}
1763			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1764				if m.isAgentBusy() {
1765					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1766					break
1767				}
1768				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1769			case key.Matches(msg, m.keyMap.Editor.Newline):
1770				m.textarea.InsertRune('\n')
1771				m.closeCompletions()
1772				ta, cmd := m.textarea.Update(msg)
1773				m.textarea = ta
1774				cmds = append(cmds, cmd)
1775			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1776				cmd := m.handleHistoryUp(msg)
1777				if cmd != nil {
1778					cmds = append(cmds, cmd)
1779				}
1780			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1781				cmd := m.handleHistoryDown(msg)
1782				if cmd != nil {
1783					cmds = append(cmds, cmd)
1784				}
1785			case key.Matches(msg, m.keyMap.Editor.Escape):
1786				cmd := m.handleHistoryEscape(msg)
1787				if cmd != nil {
1788					cmds = append(cmds, cmd)
1789				}
1790			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1791				if cmd := m.openCommandsDialog(); cmd != nil {
1792					cmds = append(cmds, cmd)
1793				}
1794			default:
1795				if handleGlobalKeys(msg) {
1796					// Handle global keys first before passing to textarea.
1797					break
1798				}
1799
1800				// Check for @ trigger before passing to textarea.
1801				curValue := m.textarea.Value()
1802				curIdx := len(curValue)
1803
1804				// Trigger completions on @.
1805				if msg.String() == "@" && !m.completionsOpen {
1806					// Only show if beginning of prompt or after whitespace.
1807					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1808						m.completionsOpen = true
1809						m.completionsQuery = ""
1810						m.completionsStartIndex = curIdx
1811						m.completionsPositionStart = m.completionsPosition()
1812						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1813						cmds = append(cmds, m.completions.Open(depth, limit))
1814					}
1815				}
1816
1817				// remove the details if they are open when user starts typing
1818				if m.detailsOpen {
1819					m.detailsOpen = false
1820					m.updateLayoutAndSize()
1821				}
1822
1823				ta, cmd := m.textarea.Update(msg)
1824				m.textarea = ta
1825				cmds = append(cmds, cmd)
1826
1827				// Any text modification becomes the current draft.
1828				m.updateHistoryDraft(curValue)
1829
1830				// After updating textarea, check if we need to filter completions.
1831				// Skip filtering on the initial @ keystroke since items are loading async.
1832				if m.completionsOpen && msg.String() != "@" {
1833					newValue := m.textarea.Value()
1834					newIdx := len(newValue)
1835
1836					// Close completions if cursor moved before start.
1837					if newIdx <= m.completionsStartIndex {
1838						m.closeCompletions()
1839					} else if msg.String() == "space" {
1840						// Close on space.
1841						m.closeCompletions()
1842					} else {
1843						// Extract current word and filter.
1844						word := m.textareaWord()
1845						if strings.HasPrefix(word, "@") {
1846							m.completionsQuery = word[1:]
1847							m.completions.Filter(m.completionsQuery)
1848						} else if m.completionsOpen {
1849							m.closeCompletions()
1850						}
1851					}
1852				}
1853			}
1854		case uiFocusMain:
1855			switch {
1856			case key.Matches(msg, m.keyMap.Tab):
1857				m.focus = uiFocusEditor
1858				cmds = append(cmds, m.textarea.Focus())
1859				m.chat.Blur()
1860			case key.Matches(msg, m.keyMap.Chat.NewSession):
1861				if !m.hasSession() {
1862					break
1863				}
1864				if m.isAgentBusy() {
1865					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1866					break
1867				}
1868				m.focus = uiFocusEditor
1869				if cmd := m.newSession(); cmd != nil {
1870					cmds = append(cmds, cmd)
1871				}
1872			case key.Matches(msg, m.keyMap.Chat.Expand):
1873				m.chat.ToggleExpandedSelectedItem()
1874			case key.Matches(msg, m.keyMap.Chat.Up):
1875				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1876					cmds = append(cmds, cmd)
1877				}
1878				if !m.chat.SelectedItemInView() {
1879					m.chat.SelectPrev()
1880					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1881						cmds = append(cmds, cmd)
1882					}
1883				}
1884			case key.Matches(msg, m.keyMap.Chat.Down):
1885				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1886					cmds = append(cmds, cmd)
1887				}
1888				if !m.chat.SelectedItemInView() {
1889					m.chat.SelectNext()
1890					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1891						cmds = append(cmds, cmd)
1892					}
1893				}
1894			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1895				m.chat.SelectPrev()
1896				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1897					cmds = append(cmds, cmd)
1898				}
1899			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1900				m.chat.SelectNext()
1901				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1902					cmds = append(cmds, cmd)
1903				}
1904			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1905				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1906					cmds = append(cmds, cmd)
1907				}
1908				m.chat.SelectFirstInView()
1909			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1910				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1911					cmds = append(cmds, cmd)
1912				}
1913				m.chat.SelectLastInView()
1914			case key.Matches(msg, m.keyMap.Chat.PageUp):
1915				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1916					cmds = append(cmds, cmd)
1917				}
1918				m.chat.SelectFirstInView()
1919			case key.Matches(msg, m.keyMap.Chat.PageDown):
1920				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1921					cmds = append(cmds, cmd)
1922				}
1923				m.chat.SelectLastInView()
1924			case key.Matches(msg, m.keyMap.Chat.Home):
1925				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1926					cmds = append(cmds, cmd)
1927				}
1928				m.chat.SelectFirst()
1929			case key.Matches(msg, m.keyMap.Chat.End):
1930				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1931					cmds = append(cmds, cmd)
1932				}
1933				m.chat.SelectLast()
1934			default:
1935				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1936					cmds = append(cmds, cmd)
1937				} else {
1938					handleGlobalKeys(msg)
1939				}
1940			}
1941		default:
1942			handleGlobalKeys(msg)
1943		}
1944	default:
1945		handleGlobalKeys(msg)
1946	}
1947
1948	return tea.Sequence(cmds...)
1949}
1950
1951// drawHeader draws the header section of the UI.
1952func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1953	m.header.drawHeader(
1954		scr,
1955		area,
1956		m.session,
1957		m.isCompact,
1958		m.detailsOpen,
1959		area.Dx(),
1960	)
1961}
1962
1963// Draw implements [uv.Drawable] and draws the UI model.
1964func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1965	layout := m.generateLayout(area.Dx(), area.Dy())
1966
1967	if m.layout != layout {
1968		m.layout = layout
1969		m.updateSize()
1970	}
1971
1972	// Clear the screen first
1973	screen.Clear(scr)
1974
1975	switch m.state {
1976	case uiOnboarding:
1977		m.drawHeader(scr, layout.header)
1978
1979		// NOTE: Onboarding flow will be rendered as dialogs below, but
1980		// positioned at the bottom left of the screen.
1981
1982	case uiInitialize:
1983		m.drawHeader(scr, layout.header)
1984
1985		main := uv.NewStyledString(m.initializeView())
1986		main.Draw(scr, layout.main)
1987
1988	case uiLanding:
1989		m.drawHeader(scr, layout.header)
1990		main := uv.NewStyledString(m.landingView())
1991		main.Draw(scr, layout.main)
1992
1993		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1994		editor.Draw(scr, layout.editor)
1995
1996	case uiChat:
1997		if m.isCompact {
1998			m.drawHeader(scr, layout.header)
1999		} else {
2000			m.drawSidebar(scr, layout.sidebar)
2001		}
2002
2003		m.chat.Draw(scr, layout.main)
2004		if layout.pills.Dy() > 0 && m.pillsView != "" {
2005			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2006		}
2007
2008		editorWidth := scr.Bounds().Dx()
2009		if !m.isCompact {
2010			editorWidth -= layout.sidebar.Dx()
2011		}
2012		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2013		editor.Draw(scr, layout.editor)
2014
2015		// Draw details overlay in compact mode when open
2016		if m.isCompact && m.detailsOpen {
2017			m.drawSessionDetails(scr, layout.sessionDetails)
2018		}
2019	}
2020
2021	isOnboarding := m.state == uiOnboarding
2022
2023	// Add status and help layer
2024	m.status.SetHideHelp(isOnboarding)
2025	m.status.Draw(scr, layout.status)
2026
2027	// Draw completions popup if open
2028	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2029		w, h := m.completions.Size()
2030		x := m.completionsPositionStart.X
2031		y := m.completionsPositionStart.Y - h
2032
2033		screenW := area.Dx()
2034		if x+w > screenW {
2035			x = screenW - w
2036		}
2037		x = max(0, x)
2038		y = max(0, y+1) // Offset for attachments row
2039
2040		completionsView := uv.NewStyledString(m.completions.Render())
2041		completionsView.Draw(scr, image.Rectangle{
2042			Min: image.Pt(x, y),
2043			Max: image.Pt(x+w, y+h),
2044		})
2045	}
2046
2047	// Debugging rendering (visually see when the tui rerenders)
2048	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2049		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2050		debug := uv.NewStyledString(debugView.String())
2051		debug.Draw(scr, image.Rectangle{
2052			Min: image.Pt(4, 1),
2053			Max: image.Pt(8, 3),
2054		})
2055	}
2056
2057	// This needs to come last to overlay on top of everything. We always pass
2058	// the full screen bounds because the dialogs will position themselves
2059	// accordingly.
2060	if m.dialog.HasDialogs() {
2061		return m.dialog.Draw(scr, scr.Bounds())
2062	}
2063
2064	switch m.focus {
2065	case uiFocusEditor:
2066		if m.layout.editor.Dy() <= 0 {
2067			// Don't show cursor if editor is not visible
2068			return nil
2069		}
2070		if m.detailsOpen && m.isCompact {
2071			// Don't show cursor if details overlay is open
2072			return nil
2073		}
2074
2075		if m.textarea.Focused() {
2076			cur := m.textarea.Cursor()
2077			cur.X++                            // Adjust for app margins
2078			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2079			return cur
2080		}
2081	}
2082	return nil
2083}
2084
2085// View renders the UI model's view.
2086func (m *UI) View() tea.View {
2087	var v tea.View
2088	v.AltScreen = true
2089	if !m.isTransparent {
2090		v.BackgroundColor = m.com.Styles.Background
2091	}
2092	v.MouseMode = tea.MouseModeCellMotion
2093	v.ReportFocus = m.caps.ReportFocusEvents
2094	v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2095
2096	canvas := uv.NewScreenBuffer(m.width, m.height)
2097	v.Cursor = m.Draw(canvas, canvas.Bounds())
2098
2099	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2100	contentLines := strings.Split(content, "\n")
2101	for i, line := range contentLines {
2102		// Trim trailing spaces for concise rendering
2103		contentLines[i] = strings.TrimRight(line, " ")
2104	}
2105
2106	content = strings.Join(contentLines, "\n")
2107
2108	v.Content = content
2109	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2110		// HACK: use a random percentage to prevent ghostty from hiding it
2111		// after a timeout.
2112		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2113	}
2114
2115	return v
2116}
2117
2118// ShortHelp implements [help.KeyMap].
2119func (m *UI) ShortHelp() []key.Binding {
2120	var binds []key.Binding
2121	k := &m.keyMap
2122	tab := k.Tab
2123	commands := k.Commands
2124	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2125		commands.SetHelp("/ or ctrl+p", "commands")
2126	}
2127
2128	switch m.state {
2129	case uiInitialize:
2130		binds = append(binds, k.Quit)
2131	case uiChat:
2132		// Show cancel binding if agent is busy.
2133		if m.isAgentBusy() {
2134			cancelBinding := k.Chat.Cancel
2135			if m.isCanceling {
2136				cancelBinding.SetHelp("esc", "press again to cancel")
2137			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2138				cancelBinding.SetHelp("esc", "clear queue")
2139			}
2140			binds = append(binds, cancelBinding)
2141		}
2142
2143		if m.focus == uiFocusEditor {
2144			tab.SetHelp("tab", "focus chat")
2145		} else {
2146			tab.SetHelp("tab", "focus editor")
2147		}
2148
2149		binds = append(binds,
2150			tab,
2151			commands,
2152			k.Models,
2153		)
2154
2155		switch m.focus {
2156		case uiFocusEditor:
2157			binds = append(binds,
2158				k.Editor.Newline,
2159			)
2160		case uiFocusMain:
2161			binds = append(binds,
2162				k.Chat.UpDown,
2163				k.Chat.UpDownOneItem,
2164				k.Chat.PageUp,
2165				k.Chat.PageDown,
2166				k.Chat.Copy,
2167			)
2168			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2169				binds = append(binds, k.Chat.PillLeft)
2170			}
2171		}
2172	default:
2173		// TODO: other states
2174		// if m.session == nil {
2175		// no session selected
2176		binds = append(binds,
2177			commands,
2178			k.Models,
2179			k.Editor.Newline,
2180		)
2181	}
2182
2183	binds = append(binds,
2184		k.Quit,
2185		k.Help,
2186	)
2187
2188	return binds
2189}
2190
2191// FullHelp implements [help.KeyMap].
2192func (m *UI) FullHelp() [][]key.Binding {
2193	var binds [][]key.Binding
2194	k := &m.keyMap
2195	help := k.Help
2196	help.SetHelp("ctrl+g", "less")
2197	hasAttachments := len(m.attachments.List()) > 0
2198	hasSession := m.hasSession()
2199	commands := k.Commands
2200	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2201		commands.SetHelp("/ or ctrl+p", "commands")
2202	}
2203
2204	switch m.state {
2205	case uiInitialize:
2206		binds = append(binds,
2207			[]key.Binding{
2208				k.Quit,
2209			})
2210	case uiChat:
2211		// Show cancel binding if agent is busy.
2212		if m.isAgentBusy() {
2213			cancelBinding := k.Chat.Cancel
2214			if m.isCanceling {
2215				cancelBinding.SetHelp("esc", "press again to cancel")
2216			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2217				cancelBinding.SetHelp("esc", "clear queue")
2218			}
2219			binds = append(binds, []key.Binding{cancelBinding})
2220		}
2221
2222		mainBinds := []key.Binding{}
2223		tab := k.Tab
2224		if m.focus == uiFocusEditor {
2225			tab.SetHelp("tab", "focus chat")
2226		} else {
2227			tab.SetHelp("tab", "focus editor")
2228		}
2229
2230		mainBinds = append(mainBinds,
2231			tab,
2232			commands,
2233			k.Models,
2234			k.Sessions,
2235		)
2236		if hasSession {
2237			mainBinds = append(mainBinds, k.Chat.NewSession)
2238		}
2239
2240		binds = append(binds, mainBinds)
2241
2242		switch m.focus {
2243		case uiFocusEditor:
2244			binds = append(binds,
2245				[]key.Binding{
2246					k.Editor.Newline,
2247					k.Editor.AddImage,
2248					k.Editor.PasteImage,
2249					k.Editor.MentionFile,
2250					k.Editor.OpenEditor,
2251				},
2252			)
2253			if hasAttachments {
2254				binds = append(binds,
2255					[]key.Binding{
2256						k.Editor.AttachmentDeleteMode,
2257						k.Editor.DeleteAllAttachments,
2258						k.Editor.Escape,
2259					},
2260				)
2261			}
2262		case uiFocusMain:
2263			binds = append(binds,
2264				[]key.Binding{
2265					k.Chat.UpDown,
2266					k.Chat.UpDownOneItem,
2267					k.Chat.PageUp,
2268					k.Chat.PageDown,
2269				},
2270				[]key.Binding{
2271					k.Chat.HalfPageUp,
2272					k.Chat.HalfPageDown,
2273					k.Chat.Home,
2274					k.Chat.End,
2275				},
2276				[]key.Binding{
2277					k.Chat.Copy,
2278					k.Chat.ClearHighlight,
2279				},
2280			)
2281			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2282				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2283			}
2284		}
2285	default:
2286		if m.session == nil {
2287			// no session selected
2288			binds = append(binds,
2289				[]key.Binding{
2290					commands,
2291					k.Models,
2292					k.Sessions,
2293				},
2294				[]key.Binding{
2295					k.Editor.Newline,
2296					k.Editor.AddImage,
2297					k.Editor.PasteImage,
2298					k.Editor.MentionFile,
2299					k.Editor.OpenEditor,
2300				},
2301			)
2302			if hasAttachments {
2303				binds = append(binds,
2304					[]key.Binding{
2305						k.Editor.AttachmentDeleteMode,
2306						k.Editor.DeleteAllAttachments,
2307						k.Editor.Escape,
2308					},
2309				)
2310			}
2311			binds = append(binds,
2312				[]key.Binding{
2313					help,
2314				},
2315			)
2316		}
2317	}
2318
2319	binds = append(binds,
2320		[]key.Binding{
2321			help,
2322			k.Quit,
2323		},
2324	)
2325
2326	return binds
2327}
2328
2329// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2330func (m *UI) toggleCompactMode() tea.Cmd {
2331	m.forceCompactMode = !m.forceCompactMode
2332
2333	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2334	if err != nil {
2335		return util.ReportError(err)
2336	}
2337
2338	m.updateLayoutAndSize()
2339
2340	return nil
2341}
2342
2343// updateLayoutAndSize updates the layout and sizes of UI components.
2344func (m *UI) updateLayoutAndSize() {
2345	// Determine if we should be in compact mode
2346	if m.state == uiChat {
2347		if m.forceCompactMode {
2348			m.isCompact = true
2349			return
2350		}
2351		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2352			m.isCompact = true
2353		} else {
2354			m.isCompact = false
2355		}
2356	}
2357
2358	m.layout = m.generateLayout(m.width, m.height)
2359	m.updateSize()
2360}
2361
2362// updateSize updates the sizes of UI components based on the current layout.
2363func (m *UI) updateSize() {
2364	// Set status width
2365	m.status.SetWidth(m.layout.status.Dx())
2366
2367	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2368	m.textarea.SetWidth(m.layout.editor.Dx())
2369	// TODO: Abstract the textarea and attachments into a single editor
2370	// component so we don't have to manually account for the attachments
2371	// height here.
2372	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2373	m.renderPills()
2374
2375	// Handle different app states
2376	switch m.state {
2377	case uiChat:
2378		if !m.isCompact {
2379			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2380		}
2381	}
2382}
2383
2384// generateLayout calculates the layout rectangles for all UI components based
2385// on the current UI state and terminal dimensions.
2386func (m *UI) generateLayout(w, h int) uiLayout {
2387	// The screen area we're working with
2388	area := image.Rect(0, 0, w, h)
2389
2390	// The help height
2391	helpHeight := 1
2392	// The editor height
2393	editorHeight := 5
2394	// The sidebar width
2395	sidebarWidth := 30
2396	// The header height
2397	const landingHeaderHeight = 4
2398
2399	var helpKeyMap help.KeyMap = m
2400	if m.status != nil && m.status.ShowingAll() {
2401		for _, row := range helpKeyMap.FullHelp() {
2402			helpHeight = max(helpHeight, len(row))
2403		}
2404	}
2405
2406	// Add app margins
2407	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2408	appRect.Min.Y += 1
2409	appRect.Max.Y -= 1
2410	helpRect.Min.Y -= 1
2411	appRect.Min.X += 1
2412	appRect.Max.X -= 1
2413
2414	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2415		// extra padding on left and right for these states
2416		appRect.Min.X += 1
2417		appRect.Max.X -= 1
2418	}
2419
2420	uiLayout := uiLayout{
2421		area:   area,
2422		status: helpRect,
2423	}
2424
2425	// Handle different app states
2426	switch m.state {
2427	case uiOnboarding, uiInitialize:
2428		// Layout
2429		//
2430		// header
2431		// ------
2432		// main
2433		// ------
2434		// help
2435
2436		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2437		uiLayout.header = headerRect
2438		uiLayout.main = mainRect
2439
2440	case uiLanding:
2441		// Layout
2442		//
2443		// header
2444		// ------
2445		// main
2446		// ------
2447		// editor
2448		// ------
2449		// help
2450		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2451		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2452		// Remove extra padding from editor (but keep it for header and main)
2453		editorRect.Min.X -= 1
2454		editorRect.Max.X += 1
2455		uiLayout.header = headerRect
2456		uiLayout.main = mainRect
2457		uiLayout.editor = editorRect
2458
2459	case uiChat:
2460		if m.isCompact {
2461			// Layout
2462			//
2463			// compact-header
2464			// ------
2465			// main
2466			// ------
2467			// editor
2468			// ------
2469			// help
2470			const compactHeaderHeight = 1
2471			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2472			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2473			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2474			uiLayout.sessionDetails = sessionDetailsArea
2475			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2476			// Add one line gap between header and main content
2477			mainRect.Min.Y += 1
2478			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2479			mainRect.Max.X -= 1 // Add padding right
2480			uiLayout.header = headerRect
2481			pillsHeight := m.pillsAreaHeight()
2482			if pillsHeight > 0 {
2483				pillsHeight = min(pillsHeight, mainRect.Dy())
2484				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2485				uiLayout.main = chatRect
2486				uiLayout.pills = pillsRect
2487			} else {
2488				uiLayout.main = mainRect
2489			}
2490			// Add bottom margin to main
2491			uiLayout.main.Max.Y -= 1
2492			uiLayout.editor = editorRect
2493		} else {
2494			// Layout
2495			//
2496			// ------|---
2497			// main  |
2498			// ------| side
2499			// editor|
2500			// ----------
2501			// help
2502
2503			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2504			// Add padding left
2505			sideRect.Min.X += 1
2506			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2507			mainRect.Max.X -= 1 // Add padding right
2508			uiLayout.sidebar = sideRect
2509			pillsHeight := m.pillsAreaHeight()
2510			if pillsHeight > 0 {
2511				pillsHeight = min(pillsHeight, mainRect.Dy())
2512				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2513				uiLayout.main = chatRect
2514				uiLayout.pills = pillsRect
2515			} else {
2516				uiLayout.main = mainRect
2517			}
2518			// Add bottom margin to main
2519			uiLayout.main.Max.Y -= 1
2520			uiLayout.editor = editorRect
2521		}
2522	}
2523
2524	return uiLayout
2525}
2526
2527// uiLayout defines the positioning of UI elements.
2528type uiLayout struct {
2529	// area is the overall available area.
2530	area uv.Rectangle
2531
2532	// header is the header shown in special cases
2533	// e.x when the sidebar is collapsed
2534	// or when in the landing page
2535	// or in init/config
2536	header uv.Rectangle
2537
2538	// main is the area for the main pane. (e.x chat, configure, landing)
2539	main uv.Rectangle
2540
2541	// pills is the area for the pills panel.
2542	pills uv.Rectangle
2543
2544	// editor is the area for the editor pane.
2545	editor uv.Rectangle
2546
2547	// sidebar is the area for the sidebar.
2548	sidebar uv.Rectangle
2549
2550	// status is the area for the status view.
2551	status uv.Rectangle
2552
2553	// session details is the area for the session details overlay in compact mode.
2554	sessionDetails uv.Rectangle
2555}
2556
2557func (m *UI) openEditor(value string) tea.Cmd {
2558	tmpfile, err := os.CreateTemp("", "msg_*.md")
2559	if err != nil {
2560		return util.ReportError(err)
2561	}
2562	defer tmpfile.Close() //nolint:errcheck
2563	if _, err := tmpfile.WriteString(value); err != nil {
2564		return util.ReportError(err)
2565	}
2566	cmd, err := editor.Command(
2567		"crush",
2568		tmpfile.Name(),
2569		editor.AtPosition(
2570			m.textarea.Line()+1,
2571			m.textarea.Column()+1,
2572		),
2573	)
2574	if err != nil {
2575		return util.ReportError(err)
2576	}
2577	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2578		if err != nil {
2579			return util.ReportError(err)
2580		}
2581		content, err := os.ReadFile(tmpfile.Name())
2582		if err != nil {
2583			return util.ReportError(err)
2584		}
2585		if len(content) == 0 {
2586			return util.ReportWarn("Message is empty")
2587		}
2588		os.Remove(tmpfile.Name())
2589		return openEditorMsg{
2590			Text: strings.TrimSpace(string(content)),
2591		}
2592	})
2593}
2594
2595// setEditorPrompt configures the textarea prompt function based on whether
2596// yolo mode is enabled.
2597func (m *UI) setEditorPrompt(yolo bool) {
2598	if yolo {
2599		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2600		return
2601	}
2602	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2603}
2604
2605// normalPromptFunc returns the normal editor prompt style ("  > " on first
2606// line, "::: " on subsequent lines).
2607func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2608	t := m.com.Styles
2609	if info.LineNumber == 0 {
2610		if info.Focused {
2611			return "  > "
2612		}
2613		return "::: "
2614	}
2615	if info.Focused {
2616		return t.EditorPromptNormalFocused.Render()
2617	}
2618	return t.EditorPromptNormalBlurred.Render()
2619}
2620
2621// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2622// and colored dots.
2623func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2624	t := m.com.Styles
2625	if info.LineNumber == 0 {
2626		if info.Focused {
2627			return t.EditorPromptYoloIconFocused.Render()
2628		} else {
2629			return t.EditorPromptYoloIconBlurred.Render()
2630		}
2631	}
2632	if info.Focused {
2633		return t.EditorPromptYoloDotsFocused.Render()
2634	}
2635	return t.EditorPromptYoloDotsBlurred.Render()
2636}
2637
2638// closeCompletions closes the completions popup and resets state.
2639func (m *UI) closeCompletions() {
2640	m.completionsOpen = false
2641	m.completionsQuery = ""
2642	m.completionsStartIndex = 0
2643	m.completions.Close()
2644}
2645
2646// insertCompletionText replaces the @query in the textarea with the given text.
2647// Returns false if the replacement cannot be performed.
2648func (m *UI) insertCompletionText(text string) bool {
2649	value := m.textarea.Value()
2650	if m.completionsStartIndex > len(value) {
2651		return false
2652	}
2653
2654	word := m.textareaWord()
2655	endIdx := min(m.completionsStartIndex+len(word), len(value))
2656	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2657	m.textarea.SetValue(newValue)
2658	m.textarea.MoveToEnd()
2659	m.textarea.InsertRune(' ')
2660	return true
2661}
2662
2663// insertFileCompletion inserts the selected file path into the textarea,
2664// replacing the @query, and adds the file as an attachment.
2665func (m *UI) insertFileCompletion(path string) tea.Cmd {
2666	if !m.insertCompletionText(path) {
2667		return nil
2668	}
2669
2670	return func() tea.Msg {
2671		absPath, _ := filepath.Abs(path)
2672
2673		if m.hasSession() {
2674			// Skip attachment if file was already read and hasn't been modified.
2675			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2676			if !lastRead.IsZero() {
2677				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2678					return nil
2679				}
2680			}
2681		} else if slices.Contains(m.sessionFileReads, absPath) {
2682			return nil
2683		}
2684
2685		m.sessionFileReads = append(m.sessionFileReads, absPath)
2686
2687		// Add file as attachment.
2688		content, err := os.ReadFile(path)
2689		if err != nil {
2690			// If it fails, let the LLM handle it later.
2691			return nil
2692		}
2693
2694		return message.Attachment{
2695			FilePath: path,
2696			FileName: filepath.Base(path),
2697			MimeType: mimeOf(content),
2698			Content:  content,
2699		}
2700	}
2701}
2702
2703// insertMCPResourceCompletion inserts the selected resource into the textarea,
2704// replacing the @query, and adds the resource as an attachment.
2705func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2706	displayText := cmp.Or(item.Title, item.URI)
2707
2708	if !m.insertCompletionText(displayText) {
2709		return nil
2710	}
2711
2712	return func() tea.Msg {
2713		contents, err := mcp.ReadResource(
2714			context.Background(),
2715			m.com.Store(),
2716			item.MCPName,
2717			item.URI,
2718		)
2719		if err != nil {
2720			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2721			return nil
2722		}
2723		if len(contents) == 0 {
2724			return nil
2725		}
2726
2727		content := contents[0]
2728		var data []byte
2729		if content.Text != "" {
2730			data = []byte(content.Text)
2731		} else if len(content.Blob) > 0 {
2732			data = content.Blob
2733		}
2734		if len(data) == 0 {
2735			return nil
2736		}
2737
2738		mimeType := item.MIMEType
2739		if mimeType == "" && content.MIMEType != "" {
2740			mimeType = content.MIMEType
2741		}
2742		if mimeType == "" {
2743			mimeType = "text/plain"
2744		}
2745
2746		return message.Attachment{
2747			FilePath: item.URI,
2748			FileName: displayText,
2749			MimeType: mimeType,
2750			Content:  data,
2751		}
2752	}
2753}
2754
2755// completionsPosition returns the X and Y position for the completions popup.
2756func (m *UI) completionsPosition() image.Point {
2757	cur := m.textarea.Cursor()
2758	if cur == nil {
2759		return image.Point{
2760			X: m.layout.editor.Min.X,
2761			Y: m.layout.editor.Min.Y,
2762		}
2763	}
2764	return image.Point{
2765		X: cur.X + m.layout.editor.Min.X,
2766		Y: m.layout.editor.Min.Y + cur.Y,
2767	}
2768}
2769
2770// textareaWord returns the current word at the cursor position.
2771func (m *UI) textareaWord() string {
2772	return m.textarea.Word()
2773}
2774
2775// isWhitespace returns true if the byte is a whitespace character.
2776func isWhitespace(b byte) bool {
2777	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2778}
2779
2780// isAgentBusy returns true if the agent coordinator exists and is currently
2781// busy processing a request.
2782func (m *UI) isAgentBusy() bool {
2783	return m.com.App != nil &&
2784		m.com.App.AgentCoordinator != nil &&
2785		m.com.App.AgentCoordinator.IsBusy()
2786}
2787
2788// hasSession returns true if there is an active session with a valid ID.
2789func (m *UI) hasSession() bool {
2790	return m.session != nil && m.session.ID != ""
2791}
2792
2793// mimeOf detects the MIME type of the given content.
2794func mimeOf(content []byte) string {
2795	mimeBufferSize := min(512, len(content))
2796	return http.DetectContentType(content[:mimeBufferSize])
2797}
2798
2799var readyPlaceholders = [...]string{
2800	"Ready!",
2801	"Ready...",
2802	"Ready?",
2803	"Ready for instructions",
2804}
2805
2806var workingPlaceholders = [...]string{
2807	"Working!",
2808	"Working...",
2809	"Brrrrr...",
2810	"Prrrrrrrr...",
2811	"Processing...",
2812	"Thinking...",
2813}
2814
2815// randomizePlaceholders selects random placeholder text for the textarea's
2816// ready and working states.
2817func (m *UI) randomizePlaceholders() {
2818	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2819	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2820}
2821
2822// renderEditorView renders the editor view with attachments if any.
2823func (m *UI) renderEditorView(width int) string {
2824	var attachmentsView string
2825	if len(m.attachments.List()) > 0 {
2826		attachmentsView = m.attachments.Render(width)
2827	}
2828	return strings.Join([]string{
2829		attachmentsView,
2830		m.textarea.View(),
2831		"", // margin at bottom of editor
2832	}, "\n")
2833}
2834
2835// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2836func (m *UI) cacheSidebarLogo(width int) {
2837	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2838}
2839
2840// sendMessage sends a message with the given content and attachments.
2841func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2842	if m.com.App.AgentCoordinator == nil {
2843		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2844	}
2845
2846	var cmds []tea.Cmd
2847	if !m.hasSession() {
2848		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2849		if err != nil {
2850			return util.ReportError(err)
2851		}
2852		if m.forceCompactMode {
2853			m.isCompact = true
2854		}
2855		if newSession.ID != "" {
2856			m.session = &newSession
2857			cmds = append(cmds, m.loadSession(newSession.ID))
2858		}
2859		m.setState(uiChat, m.focus)
2860	}
2861
2862	ctx := context.Background()
2863	cmds = append(cmds, func() tea.Msg {
2864		for _, path := range m.sessionFileReads {
2865			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2866			m.com.App.LSPManager.Start(ctx, path)
2867		}
2868		return nil
2869	})
2870
2871	// Capture session ID to avoid race with main goroutine updating m.session.
2872	sessionID := m.session.ID
2873	cmds = append(cmds, func() tea.Msg {
2874		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2875		if err != nil {
2876			isCancelErr := errors.Is(err, context.Canceled)
2877			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2878			if isCancelErr || isPermissionErr {
2879				return nil
2880			}
2881			return util.InfoMsg{
2882				Type: util.InfoTypeError,
2883				Msg:  err.Error(),
2884			}
2885		}
2886		return nil
2887	})
2888	return tea.Batch(cmds...)
2889}
2890
2891const cancelTimerDuration = 2 * time.Second
2892
2893// cancelTimerCmd creates a command that expires the cancel timer.
2894func cancelTimerCmd() tea.Cmd {
2895	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2896		return cancelTimerExpiredMsg{}
2897	})
2898}
2899
2900// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2901// and starts a timer. The second press (before the timer expires) actually
2902// cancels the agent.
2903func (m *UI) cancelAgent() tea.Cmd {
2904	if !m.hasSession() {
2905		return nil
2906	}
2907
2908	coordinator := m.com.App.AgentCoordinator
2909	if coordinator == nil {
2910		return nil
2911	}
2912
2913	if m.isCanceling {
2914		// Second escape press - actually cancel the agent.
2915		m.isCanceling = false
2916		coordinator.Cancel(m.session.ID)
2917		// Stop the spinning todo indicator.
2918		m.todoIsSpinning = false
2919		m.renderPills()
2920		return nil
2921	}
2922
2923	// Check if there are queued prompts - if so, clear the queue.
2924	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2925		coordinator.ClearQueue(m.session.ID)
2926		return nil
2927	}
2928
2929	// First escape press - set canceling state and start timer.
2930	m.isCanceling = true
2931	return cancelTimerCmd()
2932}
2933
2934// openDialog opens a dialog by its ID.
2935func (m *UI) openDialog(id string) tea.Cmd {
2936	var cmds []tea.Cmd
2937	switch id {
2938	case dialog.SessionsID:
2939		if cmd := m.openSessionsDialog(); cmd != nil {
2940			cmds = append(cmds, cmd)
2941		}
2942	case dialog.ModelsID:
2943		if cmd := m.openModelsDialog(); cmd != nil {
2944			cmds = append(cmds, cmd)
2945		}
2946	case dialog.CommandsID:
2947		if cmd := m.openCommandsDialog(); cmd != nil {
2948			cmds = append(cmds, cmd)
2949		}
2950	case dialog.ReasoningID:
2951		if cmd := m.openReasoningDialog(); cmd != nil {
2952			cmds = append(cmds, cmd)
2953		}
2954	case dialog.QuitID:
2955		if cmd := m.openQuitDialog(); cmd != nil {
2956			cmds = append(cmds, cmd)
2957		}
2958	default:
2959		// Unknown dialog
2960		break
2961	}
2962	return tea.Batch(cmds...)
2963}
2964
2965// openQuitDialog opens the quit confirmation dialog.
2966func (m *UI) openQuitDialog() tea.Cmd {
2967	if m.dialog.ContainsDialog(dialog.QuitID) {
2968		// Bring to front
2969		m.dialog.BringToFront(dialog.QuitID)
2970		return nil
2971	}
2972
2973	quitDialog := dialog.NewQuit(m.com)
2974	m.dialog.OpenDialog(quitDialog)
2975	return nil
2976}
2977
2978// openModelsDialog opens the models dialog.
2979func (m *UI) openModelsDialog() tea.Cmd {
2980	if m.dialog.ContainsDialog(dialog.ModelsID) {
2981		// Bring to front
2982		m.dialog.BringToFront(dialog.ModelsID)
2983		return nil
2984	}
2985
2986	isOnboarding := m.state == uiOnboarding
2987	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2988	if err != nil {
2989		return util.ReportError(err)
2990	}
2991
2992	m.dialog.OpenDialog(modelsDialog)
2993
2994	return nil
2995}
2996
2997// openCommandsDialog opens the commands dialog.
2998func (m *UI) openCommandsDialog() tea.Cmd {
2999	if m.dialog.ContainsDialog(dialog.CommandsID) {
3000		// Bring to front
3001		m.dialog.BringToFront(dialog.CommandsID)
3002		return nil
3003	}
3004
3005	var sessionID string
3006	hasSession := m.session != nil
3007	if hasSession {
3008		sessionID = m.session.ID
3009	}
3010	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3011	hasQueue := m.promptQueue > 0
3012
3013	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3014	if err != nil {
3015		return util.ReportError(err)
3016	}
3017
3018	m.dialog.OpenDialog(commands)
3019
3020	return nil
3021}
3022
3023// openReasoningDialog opens the reasoning effort dialog.
3024func (m *UI) openReasoningDialog() tea.Cmd {
3025	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3026		m.dialog.BringToFront(dialog.ReasoningID)
3027		return nil
3028	}
3029
3030	reasoningDialog, err := dialog.NewReasoning(m.com)
3031	if err != nil {
3032		return util.ReportError(err)
3033	}
3034
3035	m.dialog.OpenDialog(reasoningDialog)
3036	return nil
3037}
3038
3039// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3040// it brings it to the front. Otherwise, it will list all the sessions and open
3041// the dialog.
3042func (m *UI) openSessionsDialog() tea.Cmd {
3043	if m.dialog.ContainsDialog(dialog.SessionsID) {
3044		// Bring to front
3045		m.dialog.BringToFront(dialog.SessionsID)
3046		return nil
3047	}
3048
3049	selectedSessionID := ""
3050	if m.session != nil {
3051		selectedSessionID = m.session.ID
3052	}
3053
3054	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3055	if err != nil {
3056		return util.ReportError(err)
3057	}
3058
3059	m.dialog.OpenDialog(dialog)
3060	return nil
3061}
3062
3063// openFilesDialog opens the file picker dialog.
3064func (m *UI) openFilesDialog() tea.Cmd {
3065	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3066		// Bring to front
3067		m.dialog.BringToFront(dialog.FilePickerID)
3068		return nil
3069	}
3070
3071	filePicker, cmd := dialog.NewFilePicker(m.com)
3072	filePicker.SetImageCapabilities(&m.caps)
3073	m.dialog.OpenDialog(filePicker)
3074
3075	return cmd
3076}
3077
3078// openPermissionsDialog opens the permissions dialog for a permission request.
3079func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3080	// Close any existing permissions dialog first.
3081	m.dialog.CloseDialog(dialog.PermissionsID)
3082
3083	// Get diff mode from config.
3084	var opts []dialog.PermissionsOption
3085	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3086		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3087	}
3088
3089	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3090	m.dialog.OpenDialog(permDialog)
3091	return nil
3092}
3093
3094// handlePermissionNotification updates tool items when permission state changes.
3095func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3096	toolItem := m.chat.MessageItem(notification.ToolCallID)
3097	if toolItem == nil {
3098		return
3099	}
3100
3101	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3102		if notification.Granted {
3103			permItem.SetStatus(chat.ToolStatusRunning)
3104		} else {
3105			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3106		}
3107	}
3108}
3109
3110// handleAgentNotification translates domain agent events into desktop
3111// notifications using the UI notification backend.
3112func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3113	switch n.Type {
3114	case notify.TypeAgentFinished:
3115		return m.sendNotification(notification.Notification{
3116			Title:   "Crush is waiting...",
3117			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3118		})
3119	default:
3120		return nil
3121	}
3122}
3123
3124// newSession clears the current session state and prepares for a new session.
3125// The actual session creation happens when the user sends their first message.
3126// Returns a command to reload prompt history.
3127func (m *UI) newSession() tea.Cmd {
3128	if !m.hasSession() {
3129		return nil
3130	}
3131
3132	m.session = nil
3133	m.sessionFiles = nil
3134	m.sessionFileReads = nil
3135	m.setState(uiLanding, uiFocusEditor)
3136	m.textarea.Focus()
3137	m.chat.Blur()
3138	m.chat.ClearMessages()
3139	m.pillsExpanded = false
3140	m.promptQueue = 0
3141	m.pillsView = ""
3142	m.historyReset()
3143	agenttools.ResetCache()
3144	return tea.Batch(
3145		func() tea.Msg {
3146			m.com.App.LSPManager.StopAll(context.Background())
3147			return nil
3148		},
3149		m.loadPromptHistory(),
3150	)
3151}
3152
3153// handlePasteMsg handles a paste message.
3154func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3155	if m.dialog.HasDialogs() {
3156		return m.handleDialogMsg(msg)
3157	}
3158
3159	if m.focus != uiFocusEditor {
3160		return nil
3161	}
3162
3163	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3164		return func() tea.Msg {
3165			content := []byte(msg.Content)
3166			if int64(len(content)) > common.MaxAttachmentSize {
3167				return util.ReportWarn("Paste is too big (>5mb)")
3168			}
3169			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3170			mimeBufferSize := min(512, len(content))
3171			mimeType := http.DetectContentType(content[:mimeBufferSize])
3172			return message.Attachment{
3173				FileName: name,
3174				FilePath: name,
3175				MimeType: mimeType,
3176				Content:  content,
3177			}
3178		}
3179	}
3180
3181	// Attempt to parse pasted content as file paths. If possible to parse,
3182	// all files exist and are valid, add as attachments.
3183	// Otherwise, paste as text.
3184	paths := fsext.ParsePastedFiles(msg.Content)
3185	allExistsAndValid := func() bool {
3186		if len(paths) == 0 {
3187			return false
3188		}
3189		for _, path := range paths {
3190			if _, err := os.Stat(path); os.IsNotExist(err) {
3191				return false
3192			}
3193
3194			lowerPath := strings.ToLower(path)
3195			isValid := false
3196			for _, ext := range common.AllowedImageTypes {
3197				if strings.HasSuffix(lowerPath, ext) {
3198					isValid = true
3199					break
3200				}
3201			}
3202			if !isValid {
3203				return false
3204			}
3205		}
3206		return true
3207	}
3208	if !allExistsAndValid() {
3209		var cmd tea.Cmd
3210		m.textarea, cmd = m.textarea.Update(msg)
3211		return cmd
3212	}
3213
3214	var cmds []tea.Cmd
3215	for _, path := range paths {
3216		cmds = append(cmds, m.handleFilePathPaste(path))
3217	}
3218	return tea.Batch(cmds...)
3219}
3220
3221// handleFilePathPaste handles a pasted file path.
3222func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3223	return func() tea.Msg {
3224		fileInfo, err := os.Stat(path)
3225		if err != nil {
3226			return util.ReportError(err)
3227		}
3228		if fileInfo.IsDir() {
3229			return util.ReportWarn("Cannot attach a directory")
3230		}
3231		if fileInfo.Size() > common.MaxAttachmentSize {
3232			return util.ReportWarn("File is too big (>5mb)")
3233		}
3234
3235		content, err := os.ReadFile(path)
3236		if err != nil {
3237			return util.ReportError(err)
3238		}
3239
3240		mimeBufferSize := min(512, len(content))
3241		mimeType := http.DetectContentType(content[:mimeBufferSize])
3242		fileName := filepath.Base(path)
3243		return message.Attachment{
3244			FilePath: path,
3245			FileName: fileName,
3246			MimeType: mimeType,
3247			Content:  content,
3248		}
3249	}
3250}
3251
3252// pasteImageFromClipboard reads image data from the system clipboard and
3253// creates an attachment. If no image data is found, it falls back to
3254// interpreting clipboard text as a file path.
3255func (m *UI) pasteImageFromClipboard() tea.Msg {
3256	imageData, err := readClipboard(clipboardFormatImage)
3257	if int64(len(imageData)) > common.MaxAttachmentSize {
3258		return util.InfoMsg{
3259			Type: util.InfoTypeError,
3260			Msg:  "File too large, max 5MB",
3261		}
3262	}
3263	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3264	if err == nil {
3265		return message.Attachment{
3266			FilePath: name,
3267			FileName: name,
3268			MimeType: mimeOf(imageData),
3269			Content:  imageData,
3270		}
3271	}
3272
3273	textData, textErr := readClipboard(clipboardFormatText)
3274	if textErr != nil || len(textData) == 0 {
3275		return nil // Clipboard is empty or does not contain an image
3276	}
3277
3278	path := strings.TrimSpace(string(textData))
3279	path = strings.ReplaceAll(path, "\\ ", " ")
3280	if _, statErr := os.Stat(path); statErr != nil {
3281		return nil // Clipboard does not contain an image or valid file path
3282	}
3283
3284	lowerPath := strings.ToLower(path)
3285	isAllowed := false
3286	for _, ext := range common.AllowedImageTypes {
3287		if strings.HasSuffix(lowerPath, ext) {
3288			isAllowed = true
3289			break
3290		}
3291	}
3292	if !isAllowed {
3293		return util.NewInfoMsg("File type is not a supported image format")
3294	}
3295
3296	fileInfo, statErr := os.Stat(path)
3297	if statErr != nil {
3298		return util.InfoMsg{
3299			Type: util.InfoTypeError,
3300			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3301		}
3302	}
3303	if fileInfo.Size() > common.MaxAttachmentSize {
3304		return util.InfoMsg{
3305			Type: util.InfoTypeError,
3306			Msg:  "File too large, max 5MB",
3307		}
3308	}
3309
3310	content, readErr := os.ReadFile(path)
3311	if readErr != nil {
3312		return util.InfoMsg{
3313			Type: util.InfoTypeError,
3314			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3315		}
3316	}
3317
3318	return message.Attachment{
3319		FilePath: path,
3320		FileName: filepath.Base(path),
3321		MimeType: mimeOf(content),
3322		Content:  content,
3323	}
3324}
3325
3326var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3327
3328func (m *UI) pasteIdx() int {
3329	result := 0
3330	for _, at := range m.attachments.List() {
3331		found := pasteRE.FindStringSubmatch(at.FileName)
3332		if len(found) == 0 {
3333			continue
3334		}
3335		idx, err := strconv.Atoi(found[1])
3336		if err == nil {
3337			result = max(result, idx)
3338		}
3339	}
3340	return result + 1
3341}
3342
3343// drawSessionDetails draws the session details in compact mode.
3344func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3345	if m.session == nil {
3346		return
3347	}
3348
3349	s := m.com.Styles
3350
3351	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3352	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3353
3354	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3355	blocks := []string{
3356		title,
3357		"",
3358		m.modelInfo(width),
3359		"",
3360	}
3361
3362	detailsHeader := lipgloss.JoinVertical(
3363		lipgloss.Left,
3364		blocks...,
3365	)
3366
3367	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3368
3369	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3370
3371	const maxSectionWidth = 50
3372	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3373	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3374
3375	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3376	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3377	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3378	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3379	uv.NewStyledString(
3380		s.CompactDetails.View.
3381			Width(area.Dx()).
3382			Render(
3383				lipgloss.JoinVertical(
3384					lipgloss.Left,
3385					detailsHeader,
3386					sections,
3387					version,
3388				),
3389			),
3390	).Draw(scr, area)
3391}
3392
3393func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3394	load := func() tea.Msg {
3395		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3396		if err != nil {
3397			// TODO: make this better
3398			return util.ReportError(err)()
3399		}
3400
3401		if prompt == "" {
3402			return nil
3403		}
3404		return sendMessageMsg{
3405			Content: prompt,
3406		}
3407	}
3408
3409	var cmds []tea.Cmd
3410	if cmd := m.dialog.StartLoading(); cmd != nil {
3411		cmds = append(cmds, cmd)
3412	}
3413	cmds = append(cmds, load, func() tea.Msg {
3414		return closeDialogMsg{}
3415	})
3416
3417	return tea.Sequence(cmds...)
3418}
3419
3420func (m *UI) handleStateChanged() tea.Cmd {
3421	return func() tea.Msg {
3422		m.com.App.UpdateAgentModel(context.Background())
3423		return mcpStateChangedMsg{
3424			states: mcp.GetStates(),
3425		}
3426	}
3427}
3428
3429func handleMCPPromptsEvent(name string) tea.Cmd {
3430	return func() tea.Msg {
3431		mcp.RefreshPrompts(context.Background(), name)
3432		return nil
3433	}
3434}
3435
3436func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3437	return func() tea.Msg {
3438		mcp.RefreshTools(
3439			context.Background(),
3440			cfg,
3441			name,
3442		)
3443		return nil
3444	}
3445}
3446
3447func handleMCPResourcesEvent(name string) tea.Cmd {
3448	return func() tea.Msg {
3449		mcp.RefreshResources(context.Background(), name)
3450		return nil
3451	}
3452}
3453
3454func (m *UI) copyChatHighlight() tea.Cmd {
3455	text := m.chat.HighlightContent()
3456	return common.CopyToClipboardWithCallback(
3457		text,
3458		"Selected text copied to clipboard",
3459		func() tea.Msg {
3460			m.chat.ClearMouse()
3461			return nil
3462		},
3463	)
3464}
3465
3466func (m *UI) enableDockerMCP() tea.Msg {
3467	store := m.com.Store()
3468	if err := store.EnableDockerMCP(); err != nil {
3469		return util.ReportError(err)()
3470	}
3471
3472	// Initialize the Docker MCP client immediately.
3473	ctx := context.Background()
3474	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3475		return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
3476	}
3477
3478	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3479}
3480
3481func (m *UI) disableDockerMCP() tea.Msg {
3482	store := m.com.Store()
3483	// Close the Docker MCP client.
3484	if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3485		return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3486	}
3487
3488	// Remove from config and persist.
3489	if err := store.DisableDockerMCP(); err != nil {
3490		return util.ReportError(err)()
3491	}
3492
3493	return util.NewInfoMsg("Docker MCP disabled successfully")
3494}
3495
3496// renderLogo renders the Crush logo with the given styles and dimensions.
3497func renderLogo(t *styles.Styles, compact bool, width int) string {
3498	return logo.Render(t, version.Version, compact, logo.Opts{
3499		FieldColor:   t.LogoFieldColor,
3500		TitleColorA:  t.LogoTitleColorA,
3501		TitleColorB:  t.LogoTitleColorB,
3502		CharmColor:   t.LogoCharmColor,
3503		VersionColor: t.LogoVersionColor,
3504		Width:        width,
3505	})
3506}