ui.go

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