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