ui.go

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