ui.go

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