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