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