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