ui.go

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