ui.go

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