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