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