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