ui.go

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