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