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