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