ui.go

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