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