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