ui.go

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