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