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