ui.go

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