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(false)
 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	)
2113}
2114
2115// Draw implements [uv.Drawable] and draws the UI model.
2116func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
2117	layout := m.generateLayout(area.Dx(), area.Dy())
2118
2119	if m.layout != layout {
2120		m.layout = layout
2121		m.updateSize()
2122	}
2123
2124	// Clear the screen first
2125	screen.Clear(scr)
2126
2127	switch m.state {
2128	case uiOnboarding:
2129		m.drawHeader(scr, layout.header)
2130
2131		// NOTE: Onboarding flow will be rendered as dialogs below, but
2132		// positioned at the bottom left of the screen.
2133
2134	case uiInitialize:
2135		m.drawHeader(scr, layout.header)
2136
2137		main := uv.NewStyledString(m.initializeView())
2138		main.Draw(scr, layout.main)
2139
2140	case uiLanding:
2141		m.drawHeader(scr, layout.header)
2142		main := uv.NewStyledString(m.landingView())
2143		main.Draw(scr, layout.main)
2144
2145		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
2146		editor.Draw(scr, layout.editor)
2147
2148	case uiChat:
2149		if m.isCompact {
2150			m.drawHeader(scr, layout.header)
2151		} else {
2152			m.drawSidebar(scr, layout.sidebar)
2153		}
2154
2155		m.chat.Draw(scr, layout.main)
2156		if layout.pills.Dy() > 0 && m.pillsView != "" {
2157			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2158		}
2159
2160		editorWidth := scr.Bounds().Dx()
2161		if !m.isCompact {
2162			editorWidth -= layout.sidebar.Dx()
2163		}
2164		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2165		editor.Draw(scr, layout.editor)
2166
2167		// Draw details overlay in compact mode when open
2168		if m.isCompact && m.detailsOpen {
2169			m.drawSessionDetails(scr, layout.sessionDetails)
2170		}
2171	}
2172
2173	isOnboarding := m.state == uiOnboarding
2174
2175	// Add status and help layer
2176	m.status.SetHideHelp(isOnboarding)
2177	m.status.Draw(scr, layout.status)
2178
2179	// Draw completions popup if open
2180	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2181		w, h := m.completions.Size()
2182		x := m.completionsPositionStart.X
2183		y := m.completionsPositionStart.Y - h
2184
2185		screenW := area.Dx()
2186		if x+w > screenW {
2187			x = screenW - w
2188		}
2189		x = max(0, x)
2190		y = max(0, y+1) // Offset for attachments row
2191
2192		completionsView := uv.NewStyledString(m.completions.Render())
2193		completionsView.Draw(scr, image.Rectangle{
2194			Min: image.Pt(x, y),
2195			Max: image.Pt(x+w, y+h),
2196		})
2197	}
2198
2199	// Debugging rendering (visually see when the tui rerenders)
2200	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2201		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2202		debug := uv.NewStyledString(debugView.String())
2203		debug.Draw(scr, image.Rectangle{
2204			Min: image.Pt(4, 1),
2205			Max: image.Pt(8, 3),
2206		})
2207	}
2208
2209	// This needs to come last to overlay on top of everything. We always pass
2210	// the full screen bounds because the dialogs will position themselves
2211	// accordingly.
2212	if m.dialog.HasDialogs() {
2213		return m.dialog.Draw(scr, scr.Bounds())
2214	}
2215
2216	switch m.focus {
2217	case uiFocusEditor:
2218		if m.layout.editor.Dy() <= 0 {
2219			// Don't show cursor if editor is not visible
2220			return nil
2221		}
2222		if m.detailsOpen && m.isCompact {
2223			// Don't show cursor if details overlay is open
2224			return nil
2225		}
2226
2227		if m.textarea.Focused() {
2228			cur := m.textarea.Cursor()
2229			cur.X++                            // Adjust for app margins
2230			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2231			return cur
2232		}
2233	}
2234	return nil
2235}
2236
2237// View renders the UI model's view.
2238func (m *UI) View() tea.View {
2239	var v tea.View
2240	v.AltScreen = true
2241	if !m.isTransparent {
2242		v.BackgroundColor = m.com.Styles.Background
2243	}
2244	v.MouseMode = tea.MouseModeCellMotion
2245	v.ReportFocus = m.caps.ReportFocusEvents
2246	v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2247
2248	canvas := uv.NewScreenBuffer(m.width, m.height)
2249	v.Cursor = m.Draw(canvas, canvas.Bounds())
2250
2251	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2252	contentLines := strings.Split(content, "\n")
2253	for i, line := range contentLines {
2254		// Trim trailing spaces for concise rendering
2255		contentLines[i] = strings.TrimRight(line, " ")
2256	}
2257
2258	content = strings.Join(contentLines, "\n")
2259
2260	v.Content = content
2261	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2262		// HACK: use a random percentage to prevent ghostty from hiding it
2263		// after a timeout.
2264		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2265	}
2266
2267	return v
2268}
2269
2270// ShortHelp implements [help.KeyMap].
2271func (m *UI) ShortHelp() []key.Binding {
2272	var binds []key.Binding
2273	k := &m.keyMap
2274	tab := k.Tab
2275	commands := k.Commands
2276	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2277		commands.SetHelp("/ or ctrl+p", "commands")
2278	}
2279
2280	switch m.state {
2281	case uiInitialize:
2282		binds = append(binds, k.Quit)
2283	case uiChat:
2284		// Show cancel binding if agent is busy.
2285		if m.isAgentBusy() {
2286			cancelBinding := k.Chat.Cancel
2287			if m.isCanceling {
2288				cancelBinding.SetHelp("esc", "press again to cancel")
2289			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2290				cancelBinding.SetHelp("esc", "clear queue")
2291			}
2292			binds = append(binds, cancelBinding)
2293		}
2294
2295		if m.focus == uiFocusEditor {
2296			tab.SetHelp("tab", "focus chat")
2297		} else {
2298			tab.SetHelp("tab", "focus editor")
2299		}
2300
2301		binds = append(binds,
2302			tab,
2303			commands,
2304			k.Models,
2305		)
2306
2307		switch m.focus {
2308		case uiFocusEditor:
2309			binds = append(binds,
2310				k.Editor.Newline,
2311			)
2312		case uiFocusMain:
2313			binds = append(binds,
2314				k.Chat.UpDown,
2315				k.Chat.UpDownOneItem,
2316				k.Chat.PageUp,
2317				k.Chat.PageDown,
2318				k.Chat.Copy,
2319			)
2320			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2321				binds = append(binds, k.Chat.PillLeft)
2322			}
2323		}
2324	default:
2325		// TODO: other states
2326		// if m.session == nil {
2327		// no session selected
2328		binds = append(binds,
2329			commands,
2330			k.Models,
2331			k.Editor.Newline,
2332		)
2333	}
2334
2335	binds = append(binds,
2336		k.Quit,
2337		k.Help,
2338	)
2339
2340	return binds
2341}
2342
2343// FullHelp implements [help.KeyMap].
2344func (m *UI) FullHelp() [][]key.Binding {
2345	var binds [][]key.Binding
2346	k := &m.keyMap
2347	help := k.Help
2348	help.SetHelp("ctrl+g", "less")
2349	hasAttachments := len(m.attachments.List()) > 0
2350	hasSession := m.hasSession()
2351	commands := k.Commands
2352	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2353		commands.SetHelp("/ or ctrl+p", "commands")
2354	}
2355
2356	switch m.state {
2357	case uiInitialize:
2358		binds = append(binds,
2359			[]key.Binding{
2360				k.Quit,
2361			})
2362	case uiChat:
2363		// Show cancel binding if agent is busy.
2364		if m.isAgentBusy() {
2365			cancelBinding := k.Chat.Cancel
2366			if m.isCanceling {
2367				cancelBinding.SetHelp("esc", "press again to cancel")
2368			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2369				cancelBinding.SetHelp("esc", "clear queue")
2370			}
2371			binds = append(binds, []key.Binding{cancelBinding})
2372		}
2373
2374		mainBinds := []key.Binding{}
2375		tab := k.Tab
2376		if m.focus == uiFocusEditor {
2377			tab.SetHelp("tab", "focus chat")
2378		} else {
2379			tab.SetHelp("tab", "focus editor")
2380		}
2381
2382		mainBinds = append(mainBinds,
2383			tab,
2384			commands,
2385			k.Models,
2386			k.Sessions,
2387		)
2388		if hasSession {
2389			mainBinds = append(mainBinds, k.Chat.NewSession)
2390		}
2391
2392		binds = append(binds, mainBinds)
2393
2394		switch m.focus {
2395		case uiFocusEditor:
2396			editorBinds := []key.Binding{
2397				k.Editor.Newline,
2398				k.Editor.MentionFile,
2399				k.Editor.OpenEditor,
2400			}
2401			if m.currentModelSupportsImages() {
2402				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2403			}
2404			binds = append(binds, editorBinds)
2405			if hasAttachments {
2406				binds = append(binds,
2407					[]key.Binding{
2408						k.Editor.AttachmentDeleteMode,
2409						k.Editor.DeleteAllAttachments,
2410						k.Editor.Escape,
2411					},
2412				)
2413			}
2414		case uiFocusMain:
2415			binds = append(binds,
2416				[]key.Binding{
2417					k.Chat.UpDown,
2418					k.Chat.UpDownOneItem,
2419					k.Chat.PageUp,
2420					k.Chat.PageDown,
2421				},
2422				[]key.Binding{
2423					k.Chat.HalfPageUp,
2424					k.Chat.HalfPageDown,
2425					k.Chat.Home,
2426					k.Chat.End,
2427				},
2428				[]key.Binding{
2429					k.Chat.Copy,
2430					k.Chat.ClearHighlight,
2431				},
2432			)
2433			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2434				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2435			}
2436		}
2437	default:
2438		if m.session == nil {
2439			// no session selected
2440			binds = append(binds,
2441				[]key.Binding{
2442					commands,
2443					k.Models,
2444					k.Sessions,
2445				},
2446			)
2447			editorBinds := []key.Binding{
2448				k.Editor.Newline,
2449				k.Editor.MentionFile,
2450				k.Editor.OpenEditor,
2451			}
2452			if m.currentModelSupportsImages() {
2453				editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2454			}
2455			binds = append(binds, editorBinds)
2456			if hasAttachments {
2457				binds = append(binds,
2458					[]key.Binding{
2459						k.Editor.AttachmentDeleteMode,
2460						k.Editor.DeleteAllAttachments,
2461						k.Editor.Escape,
2462					},
2463				)
2464			}
2465		}
2466	}
2467
2468	binds = append(binds,
2469		[]key.Binding{
2470			help,
2471			k.Quit,
2472		},
2473	)
2474
2475	return binds
2476}
2477
2478func (m *UI) currentModelSupportsImages() bool {
2479	cfg := m.com.Config()
2480	if cfg == nil {
2481		return false
2482	}
2483	agentCfg, ok := cfg.Agents[config.AgentCoder]
2484	if !ok {
2485		return false
2486	}
2487	model := cfg.GetModelByType(agentCfg.Model)
2488	return model != nil && model.SupportsImages
2489}
2490
2491// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2492func (m *UI) toggleCompactMode() tea.Cmd {
2493	m.forceCompactMode = !m.forceCompactMode
2494
2495	err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2496	if err != nil {
2497		return util.ReportError(err)
2498	}
2499
2500	m.updateLayoutAndSize()
2501
2502	return nil
2503}
2504
2505// updateLayoutAndSize updates the layout and sizes of UI components.
2506func (m *UI) updateLayoutAndSize() {
2507	// Determine if we should be in compact mode
2508	if m.state == uiChat {
2509		if m.forceCompactMode {
2510			m.isCompact = true
2511		} else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2512			m.isCompact = true
2513		} else {
2514			m.isCompact = false
2515		}
2516	}
2517
2518	// First pass sizes components from the current textarea height.
2519	m.layout = m.generateLayout(m.width, m.height)
2520	prevHeight := m.textarea.Height()
2521	m.updateSize()
2522
2523	// SetWidth can change textarea height due to soft-wrap recalculation.
2524	// If that happens, run one reconciliation pass with the new height.
2525	if m.textarea.Height() != prevHeight {
2526		m.layout = m.generateLayout(m.width, m.height)
2527		m.updateSize()
2528	}
2529}
2530
2531// handleTextareaHeightChange checks whether the textarea height changed and,
2532// if so, recalculates the layout. When the chat is in follow mode it keeps
2533// the view scrolled to the bottom. The returned command, if non-nil, must be
2534// batched by the caller.
2535func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2536	if m.textarea.Height() == prevHeight {
2537		return nil
2538	}
2539	m.updateLayoutAndSize()
2540	if m.state == uiChat && m.chat.Follow() {
2541		return m.chat.ScrollToBottomAndAnimate()
2542	}
2543	return nil
2544}
2545
2546// updateTextarea updates the textarea for msg and then reconciles layout if
2547// the textarea height changed as a result.
2548func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2549	return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2550}
2551
2552// updateTextareaWithPrevHeight is for cases when the height of the layout may
2553// have changed.
2554//
2555// Particularly, it's for cases where the textarea changes before
2556// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2557// pass the height from before those changes took place so we can compare
2558// "before" vs "after" sizing and recalculate the layout if the textarea grew
2559// or shrank.
2560func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2561	ta, cmd := m.textarea.Update(msg)
2562	m.textarea = ta
2563	return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2564}
2565
2566// updateSize updates the sizes of UI components based on the current layout.
2567func (m *UI) updateSize() {
2568	// Set status width
2569	m.status.SetWidth(m.layout.status.Dx())
2570
2571	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2572	m.textarea.MaxHeight = TextareaMaxHeight
2573	m.textarea.SetWidth(m.layout.editor.Dx())
2574	m.renderPills()
2575
2576	// Handle different app states
2577	switch m.state {
2578	case uiChat:
2579		if !m.isCompact {
2580			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2581		}
2582	}
2583}
2584
2585// generateLayout calculates the layout rectangles for all UI components based
2586// on the current UI state and terminal dimensions.
2587func (m *UI) generateLayout(w, h int) uiLayout {
2588	// The screen area we're working with
2589	area := image.Rect(0, 0, w, h)
2590
2591	// The help height
2592	helpHeight := 1
2593	// The editor height: textarea height + margin for attachments and bottom spacing.
2594	editorHeight := m.textarea.Height() + editorHeightMargin
2595	// The sidebar width
2596	sidebarWidth := 30
2597	// The header height
2598	const landingHeaderHeight = 4
2599
2600	var helpKeyMap help.KeyMap = m
2601	if m.status != nil && m.status.ShowingAll() {
2602		for _, row := range helpKeyMap.FullHelp() {
2603			helpHeight = max(helpHeight, len(row))
2604		}
2605	}
2606
2607	// Add app margins
2608	var appRect, helpRect image.Rectangle
2609	layout.Vertical(
2610		layout.Len(area.Dy()-helpHeight),
2611		layout.Fill(1),
2612	).Split(area).Assign(&appRect, &helpRect)
2613	appRect.Min.Y += 1
2614	appRect.Max.Y -= 1
2615	helpRect.Min.Y -= 1
2616	appRect.Min.X += 1
2617	appRect.Max.X -= 1
2618
2619	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2620		// extra padding on left and right for these states
2621		appRect.Min.X += 1
2622		appRect.Max.X -= 1
2623	}
2624
2625	uiLayout := uiLayout{
2626		area:   area,
2627		status: helpRect,
2628	}
2629
2630	// Handle different app states
2631	switch m.state {
2632	case uiOnboarding, uiInitialize:
2633		// Layout
2634		//
2635		// header
2636		// ------
2637		// main
2638		// ------
2639		// help
2640
2641		var headerRect, mainRect image.Rectangle
2642		layout.Vertical(
2643			layout.Len(landingHeaderHeight),
2644			layout.Fill(1),
2645		).Split(appRect).Assign(&headerRect, &mainRect)
2646		uiLayout.header = headerRect
2647		uiLayout.main = mainRect
2648
2649	case uiLanding:
2650		// Layout
2651		//
2652		// header
2653		// ------
2654		// main
2655		// ------
2656		// editor
2657		// ------
2658		// help
2659		var headerRect, mainRect image.Rectangle
2660		layout.Vertical(
2661			layout.Len(landingHeaderHeight),
2662			layout.Fill(1),
2663		).Split(appRect).Assign(&headerRect, &mainRect)
2664		var editorRect image.Rectangle
2665		layout.Vertical(
2666			layout.Len(mainRect.Dy()-editorHeight),
2667			layout.Fill(1),
2668		).Split(mainRect).Assign(&mainRect, &editorRect)
2669		// Remove extra padding from editor (but keep it for header and main)
2670		editorRect.Min.X -= 1
2671		editorRect.Max.X += 1
2672		uiLayout.header = headerRect
2673		uiLayout.main = mainRect
2674		uiLayout.editor = editorRect
2675
2676	case uiChat:
2677		if m.isCompact {
2678			// Layout
2679			//
2680			// compact-header
2681			// ------
2682			// main
2683			// ------
2684			// editor
2685			// ------
2686			// help
2687			const compactHeaderHeight = 1
2688			var headerRect, mainRect image.Rectangle
2689			layout.Vertical(
2690				layout.Len(compactHeaderHeight),
2691				layout.Fill(1),
2692			).Split(appRect).Assign(&headerRect, &mainRect)
2693			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2694			var sessionDetailsArea image.Rectangle
2695			layout.Vertical(
2696				layout.Len(detailsHeight),
2697				layout.Fill(1),
2698			).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
2699			uiLayout.sessionDetails = sessionDetailsArea
2700			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2701			// Add one line gap between header and main content
2702			mainRect.Min.Y += 1
2703			var editorRect image.Rectangle
2704			layout.Vertical(
2705				layout.Len(mainRect.Dy()-editorHeight),
2706				layout.Fill(1),
2707			).Split(mainRect).Assign(&mainRect, &editorRect)
2708			mainRect.Max.X -= 1 // Add padding right
2709			uiLayout.header = headerRect
2710			pillsHeight := m.pillsAreaHeight()
2711			if pillsHeight > 0 {
2712				pillsHeight = min(pillsHeight, mainRect.Dy())
2713				var chatRect, pillsRect image.Rectangle
2714				layout.Vertical(
2715					layout.Len(mainRect.Dy()-pillsHeight),
2716					layout.Fill(1),
2717				).Split(mainRect).Assign(&chatRect, &pillsRect)
2718				uiLayout.main = chatRect
2719				uiLayout.pills = pillsRect
2720			} else {
2721				uiLayout.main = mainRect
2722			}
2723			// Add bottom margin to main
2724			uiLayout.main.Max.Y -= 1
2725			uiLayout.editor = editorRect
2726		} else {
2727			// Layout
2728			//
2729			// ------|---
2730			// main  |
2731			// ------| side
2732			// editor|
2733			// ----------
2734			// help
2735
2736			var mainRect, sideRect image.Rectangle
2737			layout.Horizontal(
2738				layout.Len(appRect.Dx()-sidebarWidth),
2739				layout.Fill(1),
2740			).Split(appRect).Assign(&mainRect, &sideRect)
2741			// Add padding left
2742			sideRect.Min.X += 1
2743			var editorRect image.Rectangle
2744			layout.Vertical(
2745				layout.Len(mainRect.Dy()-editorHeight),
2746				layout.Fill(1),
2747			).Split(mainRect).Assign(&mainRect, &editorRect)
2748			mainRect.Max.X -= 1 // Add padding right
2749			uiLayout.sidebar = sideRect
2750			pillsHeight := m.pillsAreaHeight()
2751			if pillsHeight > 0 {
2752				pillsHeight = min(pillsHeight, mainRect.Dy())
2753				var chatRect, pillsRect image.Rectangle
2754				layout.Vertical(
2755					layout.Len(mainRect.Dy()-pillsHeight),
2756					layout.Fill(1),
2757				).Split(mainRect).Assign(&chatRect, &pillsRect)
2758				uiLayout.main = chatRect
2759				uiLayout.pills = pillsRect
2760			} else {
2761				uiLayout.main = mainRect
2762			}
2763			// Add bottom margin to main
2764			uiLayout.main.Max.Y -= 1
2765			uiLayout.editor = editorRect
2766		}
2767	}
2768
2769	return uiLayout
2770}
2771
2772// uiLayout defines the positioning of UI elements.
2773type uiLayout struct {
2774	// area is the overall available area.
2775	area uv.Rectangle
2776
2777	// header is the header shown in special cases
2778	// e.x when the sidebar is collapsed
2779	// or when in the landing page
2780	// or in init/config
2781	header uv.Rectangle
2782
2783	// main is the area for the main pane. (e.x chat, configure, landing)
2784	main uv.Rectangle
2785
2786	// pills is the area for the pills panel.
2787	pills uv.Rectangle
2788
2789	// editor is the area for the editor pane.
2790	editor uv.Rectangle
2791
2792	// sidebar is the area for the sidebar.
2793	sidebar uv.Rectangle
2794
2795	// status is the area for the status view.
2796	status uv.Rectangle
2797
2798	// session details is the area for the session details overlay in compact mode.
2799	sessionDetails uv.Rectangle
2800}
2801
2802func (m *UI) openEditor(value string) tea.Cmd {
2803	tmpfile, err := os.CreateTemp("", "msg_*.md")
2804	if err != nil {
2805		return util.ReportError(err)
2806	}
2807	tmpPath := tmpfile.Name()
2808	defer tmpfile.Close() //nolint:errcheck
2809	if _, err := tmpfile.WriteString(value); err != nil {
2810		return util.ReportError(err)
2811	}
2812	cmd, err := editor.Command(
2813		"crush",
2814		tmpPath,
2815		editor.AtPosition(
2816			m.textarea.Line()+1,
2817			m.textarea.Column()+1,
2818		),
2819	)
2820	if err != nil {
2821		return util.ReportError(err)
2822	}
2823	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2824		defer func() {
2825			_ = os.Remove(tmpPath)
2826		}()
2827
2828		if err != nil {
2829			return util.ReportError(err)
2830		}
2831		content, err := os.ReadFile(tmpPath)
2832		if err != nil {
2833			return util.ReportError(err)
2834		}
2835		if len(content) == 0 {
2836			return util.ReportWarn("Message is empty")
2837		}
2838		return openEditorMsg{
2839			Text: strings.TrimSpace(string(content)),
2840		}
2841	})
2842}
2843
2844// setEditorPrompt configures the textarea prompt function based on whether
2845// yolo mode is enabled.
2846func (m *UI) setEditorPrompt(yolo bool) {
2847	if yolo {
2848		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2849		return
2850	}
2851	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2852}
2853
2854// normalPromptFunc returns the normal editor prompt style ("  > " on first
2855// line, "::: " on subsequent lines).
2856func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2857	t := m.com.Styles
2858	if info.LineNumber == 0 {
2859		if info.Focused {
2860			return "  > "
2861		}
2862		return "::: "
2863	}
2864	if info.Focused {
2865		return t.Editor.PromptNormalFocused.Render()
2866	}
2867	return t.Editor.PromptNormalBlurred.Render()
2868}
2869
2870// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2871// and colored dots.
2872func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2873	t := m.com.Styles
2874	if info.LineNumber == 0 {
2875		if info.Focused {
2876			return t.Editor.PromptYoloIconFocused.Render()
2877		} else {
2878			return t.Editor.PromptYoloIconBlurred.Render()
2879		}
2880	}
2881	if info.Focused {
2882		return t.Editor.PromptYoloDotsFocused.Render()
2883	}
2884	return t.Editor.PromptYoloDotsBlurred.Render()
2885}
2886
2887// closeCompletions closes the completions popup and resets state.
2888func (m *UI) closeCompletions() {
2889	m.completionsOpen = false
2890	m.completionsQuery = ""
2891	m.completionsStartIndex = 0
2892	m.completions.Close()
2893}
2894
2895// insertCompletionText replaces the @query in the textarea with the given text.
2896// Returns false if the replacement cannot be performed.
2897func (m *UI) insertCompletionText(text string) bool {
2898	value := m.textarea.Value()
2899	if m.completionsStartIndex > len(value) {
2900		return false
2901	}
2902
2903	word := m.textareaWord()
2904	endIdx := min(m.completionsStartIndex+len(word), len(value))
2905	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2906	m.textarea.SetValue(newValue)
2907	m.textarea.MoveToEnd()
2908	m.textarea.InsertRune(' ')
2909	return true
2910}
2911
2912// insertFileCompletion inserts the selected file path into the textarea,
2913// replacing the @query, and adds the file as an attachment.
2914func (m *UI) insertFileCompletion(path string) tea.Cmd {
2915	prevHeight := m.textarea.Height()
2916	if !m.insertCompletionText(path) {
2917		return nil
2918	}
2919	heightCmd := m.handleTextareaHeightChange(prevHeight)
2920
2921	fileCmd := func() tea.Msg {
2922		absPath, _ := filepath.Abs(path)
2923
2924		if m.hasSession() {
2925			// Skip attachment if file was already read and hasn't been modified.
2926			lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2927			if !lastRead.IsZero() {
2928				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2929					return nil
2930				}
2931			}
2932		} else if slices.Contains(m.sessionFileReads, absPath) {
2933			return nil
2934		}
2935
2936		m.sessionFileReads = append(m.sessionFileReads, absPath)
2937
2938		// Add file as attachment.
2939		content, err := os.ReadFile(path)
2940		if err != nil {
2941			// If it fails, let the LLM handle it later.
2942			return nil
2943		}
2944
2945		return message.Attachment{
2946			FilePath: path,
2947			FileName: filepath.Base(path),
2948			MimeType: mimeOf(content),
2949			Content:  content,
2950		}
2951	}
2952	return tea.Batch(heightCmd, fileCmd)
2953}
2954
2955// insertMCPResourceCompletion inserts the selected resource into the textarea,
2956// replacing the @query, and adds the resource as an attachment.
2957func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2958	displayText := cmp.Or(item.Title, item.URI)
2959
2960	prevHeight := m.textarea.Height()
2961	if !m.insertCompletionText(displayText) {
2962		return nil
2963	}
2964	heightCmd := m.handleTextareaHeightChange(prevHeight)
2965
2966	resourceCmd := func() tea.Msg {
2967		contents, err := m.com.Workspace.ReadMCPResource(
2968			context.Background(),
2969			item.MCPName,
2970			item.URI,
2971		)
2972		if err != nil {
2973			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2974			return nil
2975		}
2976		if len(contents) == 0 {
2977			return nil
2978		}
2979
2980		content := contents[0]
2981		var data []byte
2982		if content.Text != "" {
2983			data = []byte(content.Text)
2984		} else if len(content.Blob) > 0 {
2985			data = content.Blob
2986		}
2987		if len(data) == 0 {
2988			return nil
2989		}
2990
2991		mimeType := item.MIMEType
2992		if mimeType == "" && content.MIMEType != "" {
2993			mimeType = content.MIMEType
2994		}
2995		if mimeType == "" {
2996			mimeType = "text/plain"
2997		}
2998
2999		return message.Attachment{
3000			FilePath: item.URI,
3001			FileName: displayText,
3002			MimeType: mimeType,
3003			Content:  data,
3004		}
3005	}
3006	return tea.Batch(heightCmd, resourceCmd)
3007}
3008
3009// completionsPosition returns the X and Y position for the completions popup.
3010func (m *UI) completionsPosition() image.Point {
3011	cur := m.textarea.Cursor()
3012	if cur == nil {
3013		return image.Point{
3014			X: m.layout.editor.Min.X,
3015			Y: m.layout.editor.Min.Y,
3016		}
3017	}
3018	return image.Point{
3019		X: cur.X + m.layout.editor.Min.X,
3020		Y: m.layout.editor.Min.Y + cur.Y,
3021	}
3022}
3023
3024// textareaWord returns the current word at the cursor position.
3025func (m *UI) textareaWord() string {
3026	return m.textarea.Word()
3027}
3028
3029// isWhitespace returns true if the byte is a whitespace character.
3030func isWhitespace(b byte) bool {
3031	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
3032}
3033
3034// isAgentBusy returns true if the agent coordinator exists and is currently
3035// busy processing a request.
3036func (m *UI) isAgentBusy() bool {
3037	return m.com.Workspace.AgentIsReady() &&
3038		m.com.Workspace.AgentIsBusy()
3039}
3040
3041// hasSession returns true if there is an active session with a valid ID.
3042func (m *UI) hasSession() bool {
3043	return m.session != nil && m.session.ID != ""
3044}
3045
3046// mimeOf detects the MIME type of the given content.
3047func mimeOf(content []byte) string {
3048	mimeBufferSize := min(512, len(content))
3049	return http.DetectContentType(content[:mimeBufferSize])
3050}
3051
3052var readyPlaceholders = [...]string{
3053	"Ready!",
3054	"Ready...",
3055	"Ready?",
3056	"Ready for instructions",
3057}
3058
3059var workingPlaceholders = [...]string{
3060	"Working!",
3061	"Working...",
3062	"Brrrrr...",
3063	"Prrrrrrrr...",
3064	"Processing...",
3065	"Thinking...",
3066}
3067
3068// randomizePlaceholders selects random placeholder text for the textarea's
3069// ready and working states.
3070func (m *UI) randomizePlaceholders() {
3071	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
3072	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
3073}
3074
3075// renderEditorView renders the editor view with attachments if any.
3076func (m *UI) renderEditorView(width int) string {
3077	var attachmentsView string
3078	if len(m.attachments.List()) > 0 {
3079		attachmentsView = m.attachments.Render(width)
3080	}
3081	return strings.Join([]string{
3082		attachmentsView,
3083		m.textarea.View(),
3084		"", // margin at bottom of editor
3085	}, "\n")
3086}
3087
3088// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
3089func (m *UI) cacheSidebarLogo(width int) {
3090	m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
3091}
3092
3093// applyTheme replaces the active styles with the given theme and
3094// refreshes every component that caches style data.
3095func (m *UI) applyTheme(s styles.Styles) {
3096	*m.com.Styles = s
3097	m.refreshStyles()
3098}
3099
3100// refreshStyles pushes the current *m.com.Styles into every subcomponent
3101// that copies or pre-renders style-dependent values at construction time.
3102func (m *UI) refreshStyles() {
3103	t := m.com.Styles
3104	m.header.refresh()
3105	if m.layout.sidebar.Dx() > 0 {
3106		m.cacheSidebarLogo(m.layout.sidebar.Dx())
3107	}
3108	m.textarea.SetStyles(t.Editor.Textarea)
3109	m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
3110	m.attachments.Renderer().SetStyles(
3111		t.Attachments.Normal,
3112		t.Attachments.Deleting,
3113		t.Attachments.Image,
3114		t.Attachments.Text,
3115	)
3116	m.todoSpinner.Style = t.Pills.TodoSpinner
3117	m.status.help.Styles = t.Help
3118	m.chat.InvalidateRenderCaches()
3119}
3120
3121// sendMessage sends a message with the given content and attachments.
3122func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3123	if !m.com.Workspace.AgentIsReady() {
3124		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
3125	}
3126
3127	var cmds []tea.Cmd
3128	if !m.hasSession() {
3129		newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3130		if err != nil {
3131			return util.ReportError(err)
3132		}
3133		if m.forceCompactMode {
3134			m.isCompact = true
3135		}
3136		if newSession.ID != "" {
3137			m.session = &newSession
3138			cmds = append(cmds, m.loadSession(newSession.ID))
3139		}
3140		m.setState(uiChat, m.focus)
3141	}
3142
3143	ctx := context.Background()
3144	cmds = append(cmds, func() tea.Msg {
3145		for _, path := range m.sessionFileReads {
3146			m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
3147			m.com.Workspace.LSPStart(ctx, path)
3148		}
3149		return nil
3150	})
3151
3152	// Capture session ID to avoid race with main goroutine updating m.session.
3153	sessionID := m.session.ID
3154	cmds = append(cmds, func() tea.Msg {
3155		err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3156		if err != nil {
3157			isCancelErr := errors.Is(err, context.Canceled)
3158			if isCancelErr {
3159				return nil
3160			}
3161			return util.InfoMsg{
3162				Type: util.InfoTypeError,
3163				Msg:  fmt.Sprintf("%v", err),
3164			}
3165		}
3166		return nil
3167	})
3168	return tea.Batch(cmds...)
3169}
3170
3171const cancelTimerDuration = 2 * time.Second
3172
3173// cancelTimerCmd creates a command that expires the cancel timer.
3174func cancelTimerCmd() tea.Cmd {
3175	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
3176		return cancelTimerExpiredMsg{}
3177	})
3178}
3179
3180// cancelAgent handles the cancel key press. The first press sets isCanceling to true
3181// and starts a timer. The second press (before the timer expires) actually
3182// cancels the agent.
3183func (m *UI) cancelAgent() tea.Cmd {
3184	if !m.hasSession() {
3185		return nil
3186	}
3187
3188	if !m.com.Workspace.AgentIsReady() {
3189		return nil
3190	}
3191
3192	if m.isCanceling {
3193		// Second escape press - actually cancel the agent.
3194		m.isCanceling = false
3195		m.com.Workspace.AgentCancel(m.session.ID)
3196		// Stop the spinning todo indicator.
3197		m.todoIsSpinning = false
3198		m.renderPills()
3199		return nil
3200	}
3201
3202	// Check if there are queued prompts - if so, clear the queue.
3203	if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3204		m.com.Workspace.AgentClearQueue(m.session.ID)
3205		return nil
3206	}
3207
3208	// First escape press - set canceling state and start timer.
3209	m.isCanceling = true
3210	return cancelTimerCmd()
3211}
3212
3213// openDialog opens a dialog by its ID.
3214func (m *UI) openDialog(id string) tea.Cmd {
3215	var cmds []tea.Cmd
3216	switch id {
3217	case dialog.SessionsID:
3218		if cmd := m.openSessionsDialog(); cmd != nil {
3219			cmds = append(cmds, cmd)
3220		}
3221	case dialog.ModelsID:
3222		if cmd := m.openModelsDialog(); cmd != nil {
3223			cmds = append(cmds, cmd)
3224		}
3225	case dialog.CommandsID:
3226		if cmd := m.openCommandsDialog(); cmd != nil {
3227			cmds = append(cmds, cmd)
3228		}
3229	case dialog.ReasoningID:
3230		if cmd := m.openReasoningDialog(); cmd != nil {
3231			cmds = append(cmds, cmd)
3232		}
3233	case dialog.FilePickerID:
3234		if cmd := m.openFilesDialog(); cmd != nil {
3235			cmds = append(cmds, cmd)
3236		}
3237	case dialog.QuitID:
3238		if cmd := m.openQuitDialog(); cmd != nil {
3239			cmds = append(cmds, cmd)
3240		}
3241	default:
3242		// Unknown dialog
3243		break
3244	}
3245	return tea.Batch(cmds...)
3246}
3247
3248// openQuitDialog opens the quit confirmation dialog.
3249func (m *UI) openQuitDialog() tea.Cmd {
3250	if m.dialog.ContainsDialog(dialog.QuitID) {
3251		// Bring to front
3252		m.dialog.BringToFront(dialog.QuitID)
3253		return nil
3254	}
3255
3256	quitDialog := dialog.NewQuit(m.com)
3257	m.dialog.OpenDialog(quitDialog)
3258	return nil
3259}
3260
3261// openModelsDialog opens the models dialog.
3262func (m *UI) openModelsDialog() tea.Cmd {
3263	if m.dialog.ContainsDialog(dialog.ModelsID) {
3264		// Bring to front
3265		m.dialog.BringToFront(dialog.ModelsID)
3266		return nil
3267	}
3268
3269	isOnboarding := m.state == uiOnboarding
3270	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3271	if err != nil {
3272		return util.ReportError(err)
3273	}
3274
3275	m.dialog.OpenDialog(modelsDialog)
3276
3277	return nil
3278}
3279
3280// openCommandsDialog opens the commands dialog.
3281func (m *UI) openCommandsDialog() tea.Cmd {
3282	if m.dialog.ContainsDialog(dialog.CommandsID) {
3283		// Bring to front
3284		m.dialog.BringToFront(dialog.CommandsID)
3285		return nil
3286	}
3287
3288	var sessionID string
3289	hasSession := m.session != nil
3290	if hasSession {
3291		sessionID = m.session.ID
3292	}
3293	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3294	hasQueue := m.promptQueue > 0
3295
3296	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3297	if err != nil {
3298		return util.ReportError(err)
3299	}
3300
3301	m.dialog.OpenDialog(commands)
3302
3303	return commands.InitialCmd()
3304}
3305
3306// openReasoningDialog opens the reasoning effort dialog.
3307func (m *UI) openReasoningDialog() tea.Cmd {
3308	if m.dialog.ContainsDialog(dialog.ReasoningID) {
3309		m.dialog.BringToFront(dialog.ReasoningID)
3310		return nil
3311	}
3312
3313	reasoningDialog, err := dialog.NewReasoning(m.com)
3314	if err != nil {
3315		return util.ReportError(err)
3316	}
3317
3318	m.dialog.OpenDialog(reasoningDialog)
3319	return nil
3320}
3321
3322// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3323// it brings it to the front. Otherwise, it will list all the sessions and open
3324// the dialog.
3325func (m *UI) openSessionsDialog() tea.Cmd {
3326	if m.dialog.ContainsDialog(dialog.SessionsID) {
3327		// Bring to front
3328		m.dialog.BringToFront(dialog.SessionsID)
3329		return nil
3330	}
3331
3332	selectedSessionID := ""
3333	if m.session != nil {
3334		selectedSessionID = m.session.ID
3335	}
3336
3337	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3338	if err != nil {
3339		return util.ReportError(err)
3340	}
3341
3342	m.dialog.OpenDialog(dialog)
3343	return nil
3344}
3345
3346// openFilesDialog opens the file picker dialog.
3347func (m *UI) openFilesDialog() tea.Cmd {
3348	if m.dialog.ContainsDialog(dialog.FilePickerID) {
3349		// Bring to front
3350		m.dialog.BringToFront(dialog.FilePickerID)
3351		return nil
3352	}
3353
3354	filePicker, cmd := dialog.NewFilePicker(m.com)
3355	filePicker.SetImageCapabilities(&m.caps)
3356	m.dialog.OpenDialog(filePicker)
3357
3358	return cmd
3359}
3360
3361// openPermissionsDialog opens the permissions dialog for a permission request.
3362func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3363	// Close any existing permissions dialog first.
3364	m.dialog.CloseDialog(dialog.PermissionsID)
3365
3366	// Get diff mode from config.
3367	var opts []dialog.PermissionsOption
3368	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3369		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3370	}
3371
3372	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3373	m.dialog.OpenDialog(permDialog)
3374	return nil
3375}
3376
3377// handlePermissionNotification updates tool items when permission state changes.
3378func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3379	toolItem := m.chat.MessageItem(notification.ToolCallID)
3380	if toolItem == nil {
3381		return
3382	}
3383
3384	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3385		if notification.Granted {
3386			permItem.SetStatus(chat.ToolStatusRunning)
3387		} else {
3388			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3389		}
3390	}
3391}
3392
3393// handleAgentNotification translates domain agent events into desktop
3394// notifications using the UI notification backend.
3395func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3396	switch n.Type {
3397	case notify.TypeAgentFinished:
3398		var cmds []tea.Cmd
3399		cmds = append(cmds, m.sendNotification(notification.Notification{
3400			Title:   "Crush is waiting...",
3401			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3402		}))
3403		if m.com.IsHyper() {
3404			cmds = append(cmds, m.fetchHyperCredits())
3405		}
3406		return tea.Batch(cmds...)
3407	case notify.TypeReAuthenticate:
3408		return m.handleReAuthenticate(n.ProviderID)
3409	default:
3410		return nil
3411	}
3412}
3413
3414func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3415	cfg := m.com.Config()
3416	if cfg == nil {
3417		return nil
3418	}
3419	providerCfg, ok := cfg.Providers.Get(providerID)
3420	if !ok {
3421		return nil
3422	}
3423	agentCfg, ok := cfg.Agents[config.AgentCoder]
3424	if !ok {
3425		return nil
3426	}
3427	return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3428}
3429
3430// newSession clears the current session state and prepares for a new session.
3431// The actual session creation happens when the user sends their first message.
3432// Returns a command to reload prompt history.
3433func (m *UI) newSession() tea.Cmd {
3434	if !m.hasSession() {
3435		return nil
3436	}
3437
3438	m.session = nil
3439	m.sessionFiles = nil
3440	m.sessionFileReads = nil
3441	m.setState(uiLanding, uiFocusEditor)
3442	m.textarea.Focus()
3443	m.chat.Blur()
3444	m.chat.ClearMessages()
3445	m.pillsExpanded = false
3446	m.promptQueue = 0
3447	m.pillsView = ""
3448	m.historyReset()
3449	agenttools.ResetCache()
3450	return tea.Batch(
3451		func() tea.Msg {
3452			m.com.Workspace.LSPStopAll(context.Background())
3453			return nil
3454		},
3455		m.loadPromptHistory(),
3456	)
3457}
3458
3459// handlePasteMsg handles a paste message.
3460func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3461	if m.dialog.HasDialogs() {
3462		return m.handleDialogMsg(msg)
3463	}
3464
3465	if m.focus != uiFocusEditor {
3466		return nil
3467	}
3468
3469	if hasPasteExceededThreshold(msg) {
3470		return func() tea.Msg {
3471			content := []byte(msg.Content)
3472			if int64(len(content)) > common.MaxAttachmentSize {
3473				return util.ReportWarn("Paste is too big (>5mb)")
3474			}
3475			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3476			mimeBufferSize := min(512, len(content))
3477			mimeType := http.DetectContentType(content[:mimeBufferSize])
3478			return message.Attachment{
3479				FileName: name,
3480				FilePath: name,
3481				MimeType: mimeType,
3482				Content:  content,
3483			}
3484		}
3485	}
3486
3487	// Attempt to parse pasted content as file paths. If possible to parse,
3488	// all files exist and are valid, add as attachments.
3489	// Otherwise, paste as text.
3490	paths := fsext.ParsePastedFiles(msg.Content)
3491	allExistsAndValid := func() bool {
3492		if len(paths) == 0 {
3493			return false
3494		}
3495		for _, path := range paths {
3496			if _, err := os.Stat(path); os.IsNotExist(err) {
3497				return false
3498			}
3499
3500			lowerPath := strings.ToLower(path)
3501			isValid := false
3502			for _, ext := range common.AllowedImageTypes {
3503				if strings.HasSuffix(lowerPath, ext) {
3504					isValid = true
3505					break
3506				}
3507			}
3508			if !isValid {
3509				return false
3510			}
3511		}
3512		return true
3513	}
3514	if !allExistsAndValid() {
3515		prevHeight := m.textarea.Height()
3516		return m.updateTextareaWithPrevHeight(msg, prevHeight)
3517	}
3518
3519	var cmds []tea.Cmd
3520	for _, path := range paths {
3521		cmds = append(cmds, m.handleFilePathPaste(path))
3522	}
3523	return tea.Batch(cmds...)
3524}
3525
3526func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3527	var (
3528		lineCount = 0
3529		colCount  = 0
3530	)
3531	for line := range strings.SplitSeq(msg.Content, "\n") {
3532		lineCount++
3533		colCount = max(colCount, len(line))
3534
3535		if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3536			return true
3537		}
3538	}
3539	return false
3540}
3541
3542// handleFilePathPaste handles a pasted file path.
3543func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3544	return func() tea.Msg {
3545		fileInfo, err := os.Stat(path)
3546		if err != nil {
3547			return util.ReportError(err)
3548		}
3549		if fileInfo.IsDir() {
3550			return util.ReportWarn("Cannot attach a directory")
3551		}
3552		if fileInfo.Size() > common.MaxAttachmentSize {
3553			return util.ReportWarn("File is too big (>5mb)")
3554		}
3555
3556		content, err := os.ReadFile(path)
3557		if err != nil {
3558			return util.ReportError(err)
3559		}
3560
3561		mimeBufferSize := min(512, len(content))
3562		mimeType := http.DetectContentType(content[:mimeBufferSize])
3563		fileName := filepath.Base(path)
3564		return message.Attachment{
3565			FilePath: path,
3566			FileName: fileName,
3567			MimeType: mimeType,
3568			Content:  content,
3569		}
3570	}
3571}
3572
3573// pasteImageFromClipboard reads image data from the system clipboard and
3574// creates an attachment. If no image data is found, it falls back to
3575// interpreting clipboard text as a file path.
3576func (m *UI) pasteImageFromClipboard() tea.Msg {
3577	imageData, err := readClipboard(clipboardFormatImage)
3578	if int64(len(imageData)) > common.MaxAttachmentSize {
3579		return util.InfoMsg{
3580			Type: util.InfoTypeError,
3581			Msg:  "File too large, max 5MB",
3582		}
3583	}
3584	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3585	if err == nil {
3586		return message.Attachment{
3587			FilePath: name,
3588			FileName: name,
3589			MimeType: mimeOf(imageData),
3590			Content:  imageData,
3591		}
3592	}
3593
3594	textData, textErr := readClipboard(clipboardFormatText)
3595	if textErr != nil || len(textData) == 0 {
3596		return nil // Clipboard is empty or does not contain an image
3597	}
3598
3599	path := strings.TrimSpace(string(textData))
3600	path = strings.ReplaceAll(path, "\\ ", " ")
3601	if _, statErr := os.Stat(path); statErr != nil {
3602		return nil // Clipboard does not contain an image or valid file path
3603	}
3604
3605	lowerPath := strings.ToLower(path)
3606	isAllowed := false
3607	for _, ext := range common.AllowedImageTypes {
3608		if strings.HasSuffix(lowerPath, ext) {
3609			isAllowed = true
3610			break
3611		}
3612	}
3613	if !isAllowed {
3614		return util.NewInfoMsg("File type is not a supported image format")
3615	}
3616
3617	fileInfo, statErr := os.Stat(path)
3618	if statErr != nil {
3619		return util.InfoMsg{
3620			Type: util.InfoTypeError,
3621			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3622		}
3623	}
3624	if fileInfo.Size() > common.MaxAttachmentSize {
3625		return util.InfoMsg{
3626			Type: util.InfoTypeError,
3627			Msg:  "File too large, max 5MB",
3628		}
3629	}
3630
3631	content, readErr := os.ReadFile(path)
3632	if readErr != nil {
3633		return util.InfoMsg{
3634			Type: util.InfoTypeError,
3635			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3636		}
3637	}
3638
3639	return message.Attachment{
3640		FilePath: path,
3641		FileName: filepath.Base(path),
3642		MimeType: mimeOf(content),
3643		Content:  content,
3644	}
3645}
3646
3647var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3648
3649func (m *UI) pasteIdx() int {
3650	result := 0
3651	for _, at := range m.attachments.List() {
3652		found := pasteRE.FindStringSubmatch(at.FileName)
3653		if len(found) == 0 {
3654			continue
3655		}
3656		idx, err := strconv.Atoi(found[1])
3657		if err == nil {
3658			result = max(result, idx)
3659		}
3660	}
3661	return result + 1
3662}
3663
3664// drawSessionDetails draws the session details in compact mode.
3665func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3666	if m.session == nil {
3667		return
3668	}
3669
3670	s := m.com.Styles
3671
3672	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3673	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3674
3675	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3676	blocks := []string{
3677		title,
3678		"",
3679		m.modelInfo(width),
3680		"",
3681	}
3682
3683	detailsHeader := lipgloss.JoinVertical(
3684		lipgloss.Left,
3685		blocks...,
3686	)
3687
3688	version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3689
3690	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3691
3692	const maxSectionWidth = 50
3693	sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3694	maxItemsPerSection := remainingHeight - 3               // Account for section title and spacing
3695
3696	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3697	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3698	skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3699	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3700	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3701	uv.NewStyledString(
3702		s.CompactDetails.View.
3703			Width(area.Dx()).
3704			Render(
3705				lipgloss.JoinVertical(
3706					lipgloss.Left,
3707					detailsHeader,
3708					sections,
3709					version,
3710				),
3711			),
3712	).Draw(scr, area)
3713}
3714
3715func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3716	load := func() tea.Msg {
3717		prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3718		if err != nil {
3719			// TODO: make this better
3720			return util.ReportError(err)()
3721		}
3722
3723		if prompt == "" {
3724			return nil
3725		}
3726		return sendMessageMsg{
3727			Content: prompt,
3728		}
3729	}
3730
3731	var cmds []tea.Cmd
3732	if cmd := m.dialog.StartLoading(); cmd != nil {
3733		cmds = append(cmds, cmd)
3734	}
3735	cmds = append(cmds, load, func() tea.Msg {
3736		return closeDialogMsg{}
3737	})
3738
3739	return tea.Sequence(cmds...)
3740}
3741
3742func (m *UI) handleStateChanged() tea.Cmd {
3743	return func() tea.Msg {
3744		m.com.Workspace.UpdateAgentModel(context.Background())
3745		return mcpStateChangedMsg{
3746			states: m.com.Workspace.MCPGetStates(),
3747		}
3748	}
3749}
3750
3751func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3752	return func() tea.Msg {
3753		ws.MCPRefreshPrompts(context.Background(), name)
3754		return nil
3755	}
3756}
3757
3758func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3759	return func() tea.Msg {
3760		ws.RefreshMCPTools(context.Background(), name)
3761		return nil
3762	}
3763}
3764
3765func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3766	return func() tea.Msg {
3767		ws.MCPRefreshResources(context.Background(), name)
3768		return nil
3769	}
3770}
3771
3772func (m *UI) copyChatHighlight() tea.Cmd {
3773	text := m.chat.HighlightContent()
3774	return common.CopyToClipboardWithCallback(
3775		text,
3776		"Selected text copied to clipboard",
3777		func() tea.Msg {
3778			m.chat.ClearMouse()
3779			return nil
3780		},
3781	)
3782}
3783
3784func (m *UI) enableDockerMCP() tea.Msg {
3785	ctx := context.Background()
3786	if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3787		return util.ReportError(err)()
3788	}
3789
3790	return util.NewInfoMsg("Docker MCP enabled and started successfully")
3791}
3792
3793func (m *UI) disableDockerMCP() tea.Msg {
3794	if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3795		return util.ReportError(err)()
3796	}
3797
3798	return util.NewInfoMsg("Docker MCP disabled successfully")
3799}
3800
3801// renderLogo renders the Crush logo with the given styles and dimensions.
3802func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
3803	return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
3804		FieldColor:   t.Logo.FieldColor,
3805		TitleColorA:  t.Logo.TitleColorA,
3806		TitleColorB:  t.Logo.TitleColorB,
3807		CharmColor:   t.Logo.CharmColor,
3808		VersionColor: t.Logo.VersionColor,
3809		Width:        width,
3810		Hyper:        hyper,
3811	})
3812}