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