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