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