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