ui.go

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