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