ui.go

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