ui.go

   1package model
   2
   3import (
   4	"bytes"
   5	"context"
   6	"errors"
   7	"fmt"
   8	"image"
   9	"log/slog"
  10	"math/rand"
  11	"net/http"
  12	"os"
  13	"path/filepath"
  14	"regexp"
  15	"slices"
  16	"strconv"
  17	"strings"
  18	"time"
  19
  20	"charm.land/bubbles/v2/help"
  21	"charm.land/bubbles/v2/key"
  22	"charm.land/bubbles/v2/spinner"
  23	"charm.land/bubbles/v2/textarea"
  24	tea "charm.land/bubbletea/v2"
  25	"charm.land/lipgloss/v2"
  26	"github.com/charmbracelet/catwalk/pkg/catwalk"
  27	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  28	"github.com/charmbracelet/crush/internal/app"
  29	"github.com/charmbracelet/crush/internal/commands"
  30	"github.com/charmbracelet/crush/internal/config"
  31	"github.com/charmbracelet/crush/internal/fsext"
  32	"github.com/charmbracelet/crush/internal/history"
  33	"github.com/charmbracelet/crush/internal/home"
  34	"github.com/charmbracelet/crush/internal/message"
  35	"github.com/charmbracelet/crush/internal/permission"
  36	"github.com/charmbracelet/crush/internal/pubsub"
  37	"github.com/charmbracelet/crush/internal/session"
  38	"github.com/charmbracelet/crush/internal/ui/anim"
  39	"github.com/charmbracelet/crush/internal/ui/attachments"
  40	"github.com/charmbracelet/crush/internal/ui/chat"
  41	"github.com/charmbracelet/crush/internal/ui/common"
  42	"github.com/charmbracelet/crush/internal/ui/completions"
  43	"github.com/charmbracelet/crush/internal/ui/dialog"
  44	"github.com/charmbracelet/crush/internal/ui/logo"
  45	"github.com/charmbracelet/crush/internal/ui/styles"
  46	"github.com/charmbracelet/crush/internal/uiutil"
  47	"github.com/charmbracelet/crush/internal/version"
  48	uv "github.com/charmbracelet/ultraviolet"
  49	"github.com/charmbracelet/ultraviolet/screen"
  50	"github.com/charmbracelet/x/editor"
  51)
  52
  53// Compact mode breakpoints.
  54const (
  55	compactModeWidthBreakpoint  = 120
  56	compactModeHeightBreakpoint = 30
  57)
  58
  59// If pasted text has more than 2 newlines, treat it as a file attachment.
  60const pasteLinesThreshold = 10
  61
  62// Session details panel max height.
  63const sessionDetailsMaxHeight = 20
  64
  65// uiFocusState represents the current focus state of the UI.
  66type uiFocusState uint8
  67
  68// Possible uiFocusState values.
  69const (
  70	uiFocusNone uiFocusState = iota
  71	uiFocusEditor
  72	uiFocusMain
  73)
  74
  75type uiState uint8
  76
  77// Possible uiState values.
  78const (
  79	uiOnboarding uiState = iota
  80	uiInitialize
  81	uiLanding
  82	uiChat
  83)
  84
  85type openEditorMsg struct {
  86	Text string
  87}
  88
  89type (
  90	// cancelTimerExpiredMsg is sent when the cancel timer expires.
  91	cancelTimerExpiredMsg struct{}
  92	// userCommandsLoadedMsg is sent when user commands are loaded.
  93	userCommandsLoadedMsg struct {
  94		Commands []commands.CustomCommand
  95	}
  96	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
  97	mcpPromptsLoadedMsg struct {
  98		Prompts []commands.MCPPrompt
  99	}
 100	// sendMessageMsg is sent to send a message.
 101	// currently only used for mcp prompts.
 102	sendMessageMsg struct {
 103		Content     string
 104		Attachments []message.Attachment
 105	}
 106
 107	// closeDialogMsg is sent to close the current dialog.
 108	closeDialogMsg struct{}
 109
 110	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
 111	copyChatHighlightMsg struct{}
 112)
 113
 114// UI represents the main user interface model.
 115type UI struct {
 116	com          *common.Common
 117	session      *session.Session
 118	sessionFiles []SessionFile
 119
 120	// keeps track of read files while we don't have a session id
 121	sessionFileReads []string
 122
 123	lastUserMessageTime int64
 124
 125	// The width and height of the terminal in cells.
 126	width  int
 127	height int
 128	layout layout
 129
 130	focus uiFocusState
 131	state uiState
 132
 133	keyMap KeyMap
 134	keyenh tea.KeyboardEnhancementsMsg
 135
 136	dialog *dialog.Overlay
 137	status *Status
 138
 139	// isCanceling tracks whether the user has pressed escape once to cancel.
 140	isCanceling bool
 141
 142	// header is the last cached header logo
 143	header string
 144
 145	// sendProgressBar instructs the TUI to send progress bar updates to the
 146	// terminal.
 147	sendProgressBar bool
 148
 149	// caps hold different terminal capabilities that we query for.
 150	caps common.Capabilities
 151
 152	// Editor components
 153	textarea textarea.Model
 154
 155	// Attachment list
 156	attachments *attachments.Attachments
 157
 158	readyPlaceholder   string
 159	workingPlaceholder string
 160
 161	// Completions state
 162	completions              *completions.Completions
 163	completionsOpen          bool
 164	completionsStartIndex    int
 165	completionsQuery         string
 166	completionsPositionStart image.Point // x,y where user typed '@'
 167
 168	// Chat components
 169	chat *Chat
 170
 171	// onboarding state
 172	onboarding struct {
 173		yesInitializeSelected bool
 174	}
 175
 176	// lsp
 177	lspStates map[string]app.LSPClientInfo
 178
 179	// mcp
 180	mcpStates map[string]mcp.ClientInfo
 181
 182	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 183	sidebarLogo string
 184
 185	// custom commands & mcp commands
 186	customCommands []commands.CustomCommand
 187	mcpPrompts     []commands.MCPPrompt
 188
 189	// forceCompactMode tracks whether compact mode is forced by user toggle
 190	forceCompactMode bool
 191
 192	// isCompact tracks whether we're currently in compact layout mode (either
 193	// by user toggle or auto-switch based on window size)
 194	isCompact bool
 195
 196	// detailsOpen tracks whether the details panel is open (in compact mode)
 197	detailsOpen bool
 198
 199	// pills state
 200	pillsExpanded      bool
 201	focusedPillSection pillSection
 202	promptQueue        int
 203	pillsView          string
 204
 205	// Todo spinner
 206	todoSpinner    spinner.Model
 207	todoIsSpinning bool
 208
 209	// mouse highlighting related state
 210	lastClickTime time.Time
 211
 212	// Prompt history for up/down navigation through previous messages.
 213	promptHistory struct {
 214		messages []string
 215		index    int
 216		draft    string
 217	}
 218}
 219
 220// New creates a new instance of the [UI] model.
 221func New(com *common.Common) *UI {
 222	// Editor components
 223	ta := textarea.New()
 224	ta.SetStyles(com.Styles.TextArea)
 225	ta.ShowLineNumbers = false
 226	ta.CharLimit = -1
 227	ta.SetVirtualCursor(false)
 228	ta.Focus()
 229
 230	ch := NewChat(com)
 231
 232	keyMap := DefaultKeyMap()
 233
 234	// Completions component
 235	comp := completions.New(
 236		com.Styles.Completions.Normal,
 237		com.Styles.Completions.Focused,
 238		com.Styles.Completions.Match,
 239	)
 240
 241	todoSpinner := spinner.New(
 242		spinner.WithSpinner(spinner.MiniDot),
 243		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
 244	)
 245
 246	// Attachments component
 247	attachments := attachments.New(
 248		attachments.NewRenderer(
 249			com.Styles.Attachments.Normal,
 250			com.Styles.Attachments.Deleting,
 251			com.Styles.Attachments.Image,
 252			com.Styles.Attachments.Text,
 253		),
 254		attachments.Keymap{
 255			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 256			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 257			Escape:     keyMap.Editor.Escape,
 258		},
 259	)
 260
 261	ui := &UI{
 262		com:         com,
 263		dialog:      dialog.NewOverlay(),
 264		keyMap:      keyMap,
 265		textarea:    ta,
 266		chat:        ch,
 267		completions: comp,
 268		attachments: attachments,
 269		todoSpinner: todoSpinner,
 270		lspStates:   make(map[string]app.LSPClientInfo),
 271		mcpStates:   make(map[string]mcp.ClientInfo),
 272	}
 273
 274	status := NewStatus(com, ui)
 275
 276	ui.setEditorPrompt(false)
 277	ui.randomizePlaceholders()
 278	ui.textarea.Placeholder = ui.readyPlaceholder
 279	ui.status = status
 280
 281	// Initialize compact mode from config
 282	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
 283
 284	// set onboarding state defaults
 285	ui.onboarding.yesInitializeSelected = true
 286
 287	desiredState := uiLanding
 288	desiredFocus := uiFocusEditor
 289	if !com.Config().IsConfigured() {
 290		desiredState = uiOnboarding
 291	} else if n, _ := config.ProjectNeedsInitialization(); n {
 292		desiredState = uiInitialize
 293	}
 294
 295	// set initial state
 296	ui.setState(desiredState, desiredFocus)
 297
 298	return ui
 299}
 300
 301// Init initializes the UI model.
 302func (m *UI) Init() tea.Cmd {
 303	var cmds []tea.Cmd
 304	if m.state == uiOnboarding {
 305		if cmd := m.openModelsDialog(); cmd != nil {
 306			cmds = append(cmds, cmd)
 307		}
 308	}
 309	// load the user commands async
 310	cmds = append(cmds, m.loadCustomCommands())
 311	// load prompt history async
 312	cmds = append(cmds, m.loadPromptHistory())
 313	return tea.Batch(cmds...)
 314}
 315
 316// setState changes the UI state and focus.
 317func (m *UI) setState(state uiState, focus uiFocusState) {
 318	m.state = state
 319	m.focus = focus
 320	// Changing the state may change layout, so update it.
 321	m.updateLayoutAndSize()
 322}
 323
 324// loadCustomCommands loads the custom commands asynchronously.
 325func (m *UI) loadCustomCommands() tea.Cmd {
 326	return func() tea.Msg {
 327		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 328		if err != nil {
 329			slog.Error("failed to load custom commands", "error", err)
 330		}
 331		return userCommandsLoadedMsg{Commands: customCommands}
 332	}
 333}
 334
 335// loadMCPrompts loads the MCP prompts asynchronously.
 336func (m *UI) loadMCPrompts() tea.Cmd {
 337	return func() tea.Msg {
 338		prompts, err := commands.LoadMCPPrompts()
 339		if err != nil {
 340			slog.Error("failed to load mcp prompts", "error", err)
 341		}
 342		if prompts == nil {
 343			// flag them as loaded even if there is none or an error
 344			prompts = []commands.MCPPrompt{}
 345		}
 346		return mcpPromptsLoadedMsg{Prompts: prompts}
 347	}
 348}
 349
 350// Update handles updates to the UI model.
 351func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 352	var cmds []tea.Cmd
 353	if m.hasSession() && m.isAgentBusy() {
 354		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
 355		if queueSize != m.promptQueue {
 356			m.promptQueue = queueSize
 357			m.updateLayoutAndSize()
 358		}
 359	}
 360	// Update terminal capabilities
 361	m.caps.Update(msg)
 362	switch msg := msg.(type) {
 363	case tea.EnvMsg:
 364		// Is this Windows Terminal?
 365		if !m.sendProgressBar {
 366			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 367		}
 368		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 369	case loadSessionMsg:
 370		if m.forceCompactMode {
 371			m.isCompact = true
 372		}
 373		m.setState(uiChat, m.focus)
 374		m.session = msg.session
 375		m.sessionFiles = msg.files
 376		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 377		if err != nil {
 378			cmds = append(cmds, uiutil.ReportError(err))
 379			break
 380		}
 381		if cmd := m.setSessionMessages(msgs); cmd != nil {
 382			cmds = append(cmds, cmd)
 383		}
 384		if hasInProgressTodo(m.session.Todos) {
 385			// only start spinner if there is an in-progress todo
 386			if m.isAgentBusy() {
 387				m.todoIsSpinning = true
 388				cmds = append(cmds, m.todoSpinner.Tick)
 389			}
 390			m.updateLayoutAndSize()
 391		}
 392		// Reload prompt history for the new session.
 393		m.historyReset()
 394		cmds = append(cmds, m.loadPromptHistory())
 395
 396	case sendMessageMsg:
 397		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 398
 399	case userCommandsLoadedMsg:
 400		m.customCommands = msg.Commands
 401		dia := m.dialog.Dialog(dialog.CommandsID)
 402		if dia == nil {
 403			break
 404		}
 405
 406		commands, ok := dia.(*dialog.Commands)
 407		if ok {
 408			commands.SetCustomCommands(m.customCommands)
 409		}
 410	case mcpPromptsLoadedMsg:
 411		m.mcpPrompts = msg.Prompts
 412		dia := m.dialog.Dialog(dialog.CommandsID)
 413		if dia == nil {
 414			break
 415		}
 416
 417		commands, ok := dia.(*dialog.Commands)
 418		if ok {
 419			commands.SetMCPPrompts(m.mcpPrompts)
 420		}
 421
 422	case promptHistoryLoadedMsg:
 423		m.promptHistory.messages = msg.messages
 424		m.promptHistory.index = -1
 425		m.promptHistory.draft = ""
 426
 427	case closeDialogMsg:
 428		m.dialog.CloseFrontDialog()
 429
 430	case pubsub.Event[session.Session]:
 431		if msg.Type == pubsub.DeletedEvent {
 432			if m.session != nil && m.session.ID == msg.Payload.ID {
 433				if cmd := m.newSession(); cmd != nil {
 434					cmds = append(cmds, cmd)
 435				}
 436			}
 437			break
 438		}
 439		if m.session != nil && msg.Payload.ID == m.session.ID {
 440			prevHasInProgress := hasInProgressTodo(m.session.Todos)
 441			m.session = &msg.Payload
 442			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
 443				m.todoIsSpinning = true
 444				cmds = append(cmds, m.todoSpinner.Tick)
 445				m.updateLayoutAndSize()
 446			}
 447		}
 448	case pubsub.Event[message.Message]:
 449		// Check if this is a child session message for an agent tool.
 450		if m.session == nil {
 451			break
 452		}
 453		if msg.Payload.SessionID != m.session.ID {
 454			// This might be a child session message from an agent tool.
 455			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 456				cmds = append(cmds, cmd)
 457			}
 458			break
 459		}
 460		switch msg.Type {
 461		case pubsub.CreatedEvent:
 462			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 463		case pubsub.UpdatedEvent:
 464			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 465		case pubsub.DeletedEvent:
 466			m.chat.RemoveMessage(msg.Payload.ID)
 467		}
 468		// start the spinner if there is a new message
 469		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
 470			m.todoIsSpinning = true
 471			cmds = append(cmds, m.todoSpinner.Tick)
 472		}
 473		// stop the spinner if the agent is not busy anymore
 474		if m.todoIsSpinning && !m.isAgentBusy() {
 475			m.todoIsSpinning = false
 476		}
 477		// there is a number of things that could change the pills here so we want to re-render
 478		m.renderPills()
 479	case pubsub.Event[history.File]:
 480		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 481	case pubsub.Event[app.LSPEvent]:
 482		m.lspStates = app.GetLSPStates()
 483	case pubsub.Event[mcp.Event]:
 484		m.mcpStates = mcp.GetStates()
 485		// check if all mcps are initialized
 486		initialized := true
 487		for _, state := range m.mcpStates {
 488			if state.State == mcp.StateStarting {
 489				initialized = false
 490				break
 491			}
 492		}
 493		if initialized && m.mcpPrompts == nil {
 494			cmds = append(cmds, m.loadMCPrompts())
 495		}
 496	case pubsub.Event[permission.PermissionRequest]:
 497		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
 498			cmds = append(cmds, cmd)
 499		}
 500	case pubsub.Event[permission.PermissionNotification]:
 501		m.handlePermissionNotification(msg.Payload)
 502	case cancelTimerExpiredMsg:
 503		m.isCanceling = false
 504	case tea.TerminalVersionMsg:
 505		termVersion := strings.ToLower(msg.Name)
 506		// Only enable progress bar for the following terminals.
 507		if !m.sendProgressBar {
 508			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 509		}
 510		return m, nil
 511	case tea.WindowSizeMsg:
 512		m.width, m.height = msg.Width, msg.Height
 513		m.updateLayoutAndSize()
 514	case tea.KeyboardEnhancementsMsg:
 515		m.keyenh = msg
 516		if msg.SupportsKeyDisambiguation() {
 517			m.keyMap.Models.SetHelp("ctrl+m", "models")
 518			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 519		}
 520	case copyChatHighlightMsg:
 521		cmds = append(cmds, m.copyChatHighlight())
 522	case tea.MouseClickMsg:
 523		// Pass mouse events to dialogs first if any are open.
 524		if m.dialog.HasDialogs() {
 525			m.dialog.Update(msg)
 526			return m, tea.Batch(cmds...)
 527		}
 528		switch m.state {
 529		case uiChat:
 530			x, y := msg.X, msg.Y
 531			// Adjust for chat area position
 532			x -= m.layout.main.Min.X
 533			y -= m.layout.main.Min.Y
 534			if m.chat.HandleMouseDown(x, y) {
 535				m.lastClickTime = time.Now()
 536			}
 537		}
 538
 539	case tea.MouseMotionMsg:
 540		// Pass mouse events to dialogs first if any are open.
 541		if m.dialog.HasDialogs() {
 542			m.dialog.Update(msg)
 543			return m, tea.Batch(cmds...)
 544		}
 545
 546		switch m.state {
 547		case uiChat:
 548			if msg.Y <= 0 {
 549				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 550					cmds = append(cmds, cmd)
 551				}
 552				if !m.chat.SelectedItemInView() {
 553					m.chat.SelectPrev()
 554					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 555						cmds = append(cmds, cmd)
 556					}
 557				}
 558			} else if msg.Y >= m.chat.Height()-1 {
 559				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 560					cmds = append(cmds, cmd)
 561				}
 562				if !m.chat.SelectedItemInView() {
 563					m.chat.SelectNext()
 564					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 565						cmds = append(cmds, cmd)
 566					}
 567				}
 568			}
 569
 570			x, y := msg.X, msg.Y
 571			// Adjust for chat area position
 572			x -= m.layout.main.Min.X
 573			y -= m.layout.main.Min.Y
 574			m.chat.HandleMouseDrag(x, y)
 575		}
 576
 577	case tea.MouseReleaseMsg:
 578		// Pass mouse events to dialogs first if any are open.
 579		if m.dialog.HasDialogs() {
 580			m.dialog.Update(msg)
 581			return m, tea.Batch(cmds...)
 582		}
 583		const doubleClickThreshold = 500 * time.Millisecond
 584
 585		switch m.state {
 586		case uiChat:
 587			x, y := msg.X, msg.Y
 588			// Adjust for chat area position
 589			x -= m.layout.main.Min.X
 590			y -= m.layout.main.Min.Y
 591			if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
 592				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
 593					if time.Since(m.lastClickTime) >= doubleClickThreshold {
 594						return copyChatHighlightMsg{}
 595					}
 596					return nil
 597				}))
 598			}
 599		}
 600	case tea.MouseWheelMsg:
 601		// Pass mouse events to dialogs first if any are open.
 602		if m.dialog.HasDialogs() {
 603			m.dialog.Update(msg)
 604			return m, tea.Batch(cmds...)
 605		}
 606
 607		// Otherwise handle mouse wheel for chat.
 608		switch m.state {
 609		case uiChat:
 610			switch msg.Button {
 611			case tea.MouseWheelUp:
 612				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
 613					cmds = append(cmds, cmd)
 614				}
 615				if !m.chat.SelectedItemInView() {
 616					m.chat.SelectPrev()
 617					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 618						cmds = append(cmds, cmd)
 619					}
 620				}
 621			case tea.MouseWheelDown:
 622				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
 623					cmds = append(cmds, cmd)
 624				}
 625				if !m.chat.SelectedItemInView() {
 626					m.chat.SelectNext()
 627					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 628						cmds = append(cmds, cmd)
 629					}
 630				}
 631			}
 632		}
 633	case anim.StepMsg:
 634		if m.state == uiChat {
 635			if cmd := m.chat.Animate(msg); cmd != nil {
 636				cmds = append(cmds, cmd)
 637			}
 638		}
 639	case spinner.TickMsg:
 640		if m.dialog.HasDialogs() {
 641			// route to dialog
 642			if cmd := m.handleDialogMsg(msg); cmd != nil {
 643				cmds = append(cmds, cmd)
 644			}
 645		}
 646		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
 647			var cmd tea.Cmd
 648			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
 649			if cmd != nil {
 650				m.renderPills()
 651				cmds = append(cmds, cmd)
 652			}
 653		}
 654
 655	case tea.KeyPressMsg:
 656		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 657			cmds = append(cmds, cmd)
 658		}
 659	case tea.PasteMsg:
 660		if cmd := m.handlePasteMsg(msg); cmd != nil {
 661			cmds = append(cmds, cmd)
 662		}
 663	case openEditorMsg:
 664		m.textarea.SetValue(msg.Text)
 665		m.textarea.MoveToEnd()
 666	case uiutil.InfoMsg:
 667		m.status.SetInfoMsg(msg)
 668		ttl := msg.TTL
 669		if ttl <= 0 {
 670			ttl = DefaultStatusTTL
 671		}
 672		cmds = append(cmds, clearInfoMsgCmd(ttl))
 673	case uiutil.ClearStatusMsg:
 674		m.status.ClearInfoMsg()
 675	case completions.FilesLoadedMsg:
 676		// Handle async file loading for completions.
 677		if m.completionsOpen {
 678			m.completions.SetFiles(msg.Files)
 679		}
 680	case uv.KittyGraphicsEvent:
 681		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 682			slog.Warn("unexpected Kitty graphics response",
 683				"response", string(msg.Payload),
 684				"options", msg.Options)
 685		}
 686	default:
 687		if m.dialog.HasDialogs() {
 688			if cmd := m.handleDialogMsg(msg); cmd != nil {
 689				cmds = append(cmds, cmd)
 690			}
 691		}
 692	}
 693
 694	// This logic gets triggered on any message type, but should it?
 695	switch m.focus {
 696	case uiFocusMain:
 697	case uiFocusEditor:
 698		// Textarea placeholder logic
 699		if m.isAgentBusy() {
 700			m.textarea.Placeholder = m.workingPlaceholder
 701		} else {
 702			m.textarea.Placeholder = m.readyPlaceholder
 703		}
 704		if m.com.App.Permissions.SkipRequests() {
 705			m.textarea.Placeholder = "Yolo mode!"
 706		}
 707	}
 708
 709	// at this point this can only handle [message.Attachment] message, and we
 710	// should return all cmds anyway.
 711	_ = m.attachments.Update(msg)
 712	return m, tea.Batch(cmds...)
 713}
 714
 715// setSessionMessages sets the messages for the current session in the chat
 716func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 717	var cmds []tea.Cmd
 718	// Build tool result map to link tool calls with their results
 719	msgPtrs := make([]*message.Message, len(msgs))
 720	for i := range msgs {
 721		msgPtrs[i] = &msgs[i]
 722	}
 723	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 724	if len(msgPtrs) > 0 {
 725		m.lastUserMessageTime = msgPtrs[0].CreatedAt
 726	}
 727
 728	// Add messages to chat with linked tool results
 729	items := make([]chat.MessageItem, 0, len(msgs)*2)
 730	for _, msg := range msgPtrs {
 731		switch msg.Role {
 732		case message.User:
 733			m.lastUserMessageTime = msg.CreatedAt
 734			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 735		case message.Assistant:
 736			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 737			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 738				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
 739				items = append(items, infoItem)
 740			}
 741		default:
 742			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 743		}
 744	}
 745
 746	// Load nested tool calls for agent/agentic_fetch tools.
 747	m.loadNestedToolCalls(items)
 748
 749	// If the user switches between sessions while the agent is working we want
 750	// to make sure the animations are shown.
 751	for _, item := range items {
 752		if animatable, ok := item.(chat.Animatable); ok {
 753			if cmd := animatable.StartAnimation(); cmd != nil {
 754				cmds = append(cmds, cmd)
 755			}
 756		}
 757	}
 758
 759	m.chat.SetMessages(items...)
 760	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 761		cmds = append(cmds, cmd)
 762	}
 763	m.chat.SelectLast()
 764	return tea.Batch(cmds...)
 765}
 766
 767// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 768func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 769	for _, item := range items {
 770		nestedContainer, ok := item.(chat.NestedToolContainer)
 771		if !ok {
 772			continue
 773		}
 774		toolItem, ok := item.(chat.ToolMessageItem)
 775		if !ok {
 776			continue
 777		}
 778
 779		tc := toolItem.ToolCall()
 780		messageID := toolItem.MessageID()
 781
 782		// Get the agent tool session ID.
 783		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 784
 785		// Fetch nested messages.
 786		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
 787		if err != nil || len(nestedMsgs) == 0 {
 788			continue
 789		}
 790
 791		// Build tool result map for nested messages.
 792		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 793		for i := range nestedMsgs {
 794			nestedMsgPtrs[i] = &nestedMsgs[i]
 795		}
 796		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 797
 798		// Extract nested tool items.
 799		var nestedTools []chat.ToolMessageItem
 800		for _, nestedMsg := range nestedMsgPtrs {
 801			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 802			for _, nestedItem := range nestedItems {
 803				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 804					// Mark nested tools as simple (compact) rendering.
 805					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 806						simplifiable.SetCompact(true)
 807					}
 808					nestedTools = append(nestedTools, nestedToolItem)
 809				}
 810			}
 811		}
 812
 813		// Recursively load nested tool calls for any agent tools within.
 814		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 815		for i, nt := range nestedTools {
 816			nestedMessageItems[i] = nt
 817		}
 818		m.loadNestedToolCalls(nestedMessageItems)
 819
 820		// Set nested tools on the parent.
 821		nestedContainer.SetNestedTools(nestedTools)
 822	}
 823}
 824
 825// appendSessionMessage appends a new message to the current session in the chat
 826// if the message is a tool result it will update the corresponding tool call message
 827func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 828	var cmds []tea.Cmd
 829	existing := m.chat.MessageItem(msg.ID)
 830	if existing != nil {
 831		// message already exists, skip
 832		return nil
 833	}
 834	switch msg.Role {
 835	case message.User:
 836		m.lastUserMessageTime = msg.CreatedAt
 837		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 838		for _, item := range items {
 839			if animatable, ok := item.(chat.Animatable); ok {
 840				if cmd := animatable.StartAnimation(); cmd != nil {
 841					cmds = append(cmds, cmd)
 842				}
 843			}
 844		}
 845		m.chat.AppendMessages(items...)
 846		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 847			cmds = append(cmds, cmd)
 848		}
 849	case message.Assistant:
 850		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 851		for _, item := range items {
 852			if animatable, ok := item.(chat.Animatable); ok {
 853				if cmd := animatable.StartAnimation(); cmd != nil {
 854					cmds = append(cmds, cmd)
 855				}
 856			}
 857		}
 858		m.chat.AppendMessages(items...)
 859		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 860			cmds = append(cmds, cmd)
 861		}
 862		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 863			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
 864			m.chat.AppendMessages(infoItem)
 865			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 866				cmds = append(cmds, cmd)
 867			}
 868		}
 869	case message.Tool:
 870		for _, tr := range msg.ToolResults() {
 871			toolItem := m.chat.MessageItem(tr.ToolCallID)
 872			if toolItem == nil {
 873				// we should have an item!
 874				continue
 875			}
 876			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 877				toolMsgItem.SetResult(&tr)
 878			}
 879		}
 880	}
 881	return tea.Batch(cmds...)
 882}
 883
 884// updateSessionMessage updates an existing message in the current session in the chat
 885// when an assistant message is updated it may include updated tool calls as well
 886// that is why we need to handle creating/updating each tool call message too
 887func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 888	var cmds []tea.Cmd
 889	existingItem := m.chat.MessageItem(msg.ID)
 890	atBottom := m.chat.list.AtBottom()
 891
 892	if existingItem != nil {
 893		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
 894			assistantItem.SetMessage(&msg)
 895		}
 896	}
 897
 898	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
 899	// if the message of the assistant does not have any  response just tool calls we need to remove it
 900	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
 901		m.chat.RemoveMessage(msg.ID)
 902		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
 903			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
 904		}
 905	}
 906
 907	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 908		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
 909			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
 910			m.chat.AppendMessages(newInfoItem)
 911		}
 912	}
 913
 914	var items []chat.MessageItem
 915	for _, tc := range msg.ToolCalls() {
 916		existingToolItem := m.chat.MessageItem(tc.ID)
 917		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
 918			existingToolCall := toolItem.ToolCall()
 919			// only update if finished state changed or input changed
 920			// to avoid clearing the cache
 921			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
 922				toolItem.SetToolCall(tc)
 923			}
 924		}
 925		if existingToolItem == nil {
 926			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
 927		}
 928	}
 929
 930	for _, item := range items {
 931		if animatable, ok := item.(chat.Animatable); ok {
 932			if cmd := animatable.StartAnimation(); cmd != nil {
 933				cmds = append(cmds, cmd)
 934			}
 935		}
 936	}
 937
 938	m.chat.AppendMessages(items...)
 939	if atBottom {
 940		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 941			cmds = append(cmds, cmd)
 942		}
 943	}
 944
 945	return tea.Batch(cmds...)
 946}
 947
 948// handleChildSessionMessage handles messages from child sessions (agent tools).
 949func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 950	var cmds []tea.Cmd
 951
 952	atBottom := m.chat.list.AtBottom()
 953	// Only process messages with tool calls or results.
 954	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 955		return nil
 956	}
 957
 958	// Check if this is an agent tool session and parse it.
 959	childSessionID := event.Payload.SessionID
 960	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
 961	if !ok {
 962		return nil
 963	}
 964
 965	// Find the parent agent tool item.
 966	var agentItem chat.NestedToolContainer
 967	for i := 0; i < m.chat.Len(); i++ {
 968		item := m.chat.MessageItem(toolCallID)
 969		if item == nil {
 970			continue
 971		}
 972		if agent, ok := item.(chat.NestedToolContainer); ok {
 973			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
 974				if toolMessageItem.ToolCall().ID == toolCallID {
 975					// Verify this agent belongs to the correct parent message.
 976					// We can't directly check parentMessageID on the item, so we trust the session parsing.
 977					agentItem = agent
 978					break
 979				}
 980			}
 981		}
 982	}
 983
 984	if agentItem == nil {
 985		return nil
 986	}
 987
 988	// Get existing nested tools.
 989	nestedTools := agentItem.NestedTools()
 990
 991	// Update or create nested tool calls.
 992	for _, tc := range event.Payload.ToolCalls() {
 993		found := false
 994		for _, existingTool := range nestedTools {
 995			if existingTool.ToolCall().ID == tc.ID {
 996				existingTool.SetToolCall(tc)
 997				found = true
 998				break
 999			}
1000		}
1001		if !found {
1002			// Create a new nested tool item.
1003			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1004			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1005				simplifiable.SetCompact(true)
1006			}
1007			if animatable, ok := nestedItem.(chat.Animatable); ok {
1008				if cmd := animatable.StartAnimation(); cmd != nil {
1009					cmds = append(cmds, cmd)
1010				}
1011			}
1012			nestedTools = append(nestedTools, nestedItem)
1013		}
1014	}
1015
1016	// Update nested tool results.
1017	for _, tr := range event.Payload.ToolResults() {
1018		for _, nestedTool := range nestedTools {
1019			if nestedTool.ToolCall().ID == tr.ToolCallID {
1020				nestedTool.SetResult(&tr)
1021				break
1022			}
1023		}
1024	}
1025
1026	// Update the agent item with the new nested tools.
1027	agentItem.SetNestedTools(nestedTools)
1028
1029	// Update the chat so it updates the index map for animations to work as expected
1030	m.chat.UpdateNestedToolIDs(toolCallID)
1031
1032	if atBottom {
1033		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1034			cmds = append(cmds, cmd)
1035		}
1036	}
1037
1038	return tea.Batch(cmds...)
1039}
1040
1041func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1042	var cmds []tea.Cmd
1043	action := m.dialog.Update(msg)
1044	if action == nil {
1045		return tea.Batch(cmds...)
1046	}
1047
1048	isOnboarding := m.state == uiOnboarding
1049
1050	switch msg := action.(type) {
1051	// Generic dialog messages
1052	case dialog.ActionClose:
1053		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1054			break
1055		}
1056
1057		m.dialog.CloseFrontDialog()
1058
1059		if isOnboarding {
1060			if cmd := m.openModelsDialog(); cmd != nil {
1061				cmds = append(cmds, cmd)
1062			}
1063		}
1064
1065		if m.focus == uiFocusEditor {
1066			cmds = append(cmds, m.textarea.Focus())
1067		}
1068	case dialog.ActionCmd:
1069		if msg.Cmd != nil {
1070			cmds = append(cmds, msg.Cmd)
1071		}
1072
1073	// Session dialog messages
1074	case dialog.ActionSelectSession:
1075		m.dialog.CloseDialog(dialog.SessionsID)
1076		cmds = append(cmds, m.loadSession(msg.Session.ID))
1077
1078	// Open dialog message
1079	case dialog.ActionOpenDialog:
1080		m.dialog.CloseDialog(dialog.CommandsID)
1081		if cmd := m.openDialog(msg.DialogID); cmd != nil {
1082			cmds = append(cmds, cmd)
1083		}
1084
1085	// Command dialog messages
1086	case dialog.ActionToggleYoloMode:
1087		yolo := !m.com.App.Permissions.SkipRequests()
1088		m.com.App.Permissions.SetSkipRequests(yolo)
1089		m.setEditorPrompt(yolo)
1090		m.dialog.CloseDialog(dialog.CommandsID)
1091	case dialog.ActionNewSession:
1092		if m.isAgentBusy() {
1093			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1094			break
1095		}
1096		if cmd := m.newSession(); cmd != nil {
1097			cmds = append(cmds, cmd)
1098		}
1099		m.dialog.CloseDialog(dialog.CommandsID)
1100	case dialog.ActionSummarize:
1101		if m.isAgentBusy() {
1102			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1103			break
1104		}
1105		cmds = append(cmds, func() tea.Msg {
1106			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1107			if err != nil {
1108				return uiutil.ReportError(err)()
1109			}
1110			return nil
1111		})
1112		m.dialog.CloseDialog(dialog.CommandsID)
1113	case dialog.ActionToggleHelp:
1114		m.status.ToggleHelp()
1115		m.dialog.CloseDialog(dialog.CommandsID)
1116	case dialog.ActionExternalEditor:
1117		if m.isAgentBusy() {
1118			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1119			break
1120		}
1121		cmds = append(cmds, m.openEditor(m.textarea.Value()))
1122		m.dialog.CloseDialog(dialog.CommandsID)
1123	case dialog.ActionToggleCompactMode:
1124		cmds = append(cmds, m.toggleCompactMode())
1125		m.dialog.CloseDialog(dialog.CommandsID)
1126	case dialog.ActionToggleThinking:
1127		cmds = append(cmds, func() tea.Msg {
1128			cfg := m.com.Config()
1129			if cfg == nil {
1130				return uiutil.ReportError(errors.New("configuration not found"))()
1131			}
1132
1133			agentCfg, ok := cfg.Agents[config.AgentCoder]
1134			if !ok {
1135				return uiutil.ReportError(errors.New("agent configuration not found"))()
1136			}
1137
1138			currentModel := cfg.Models[agentCfg.Model]
1139			currentModel.Think = !currentModel.Think
1140			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1141				return uiutil.ReportError(err)()
1142			}
1143			m.com.App.UpdateAgentModel(context.TODO())
1144			status := "disabled"
1145			if currentModel.Think {
1146				status = "enabled"
1147			}
1148			return uiutil.NewInfoMsg("Thinking mode " + status)
1149		})
1150		m.dialog.CloseDialog(dialog.CommandsID)
1151	case dialog.ActionQuit:
1152		cmds = append(cmds, tea.Quit)
1153	case dialog.ActionInitializeProject:
1154		if m.isAgentBusy() {
1155			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1156			break
1157		}
1158		cmds = append(cmds, m.initializeProject())
1159		m.dialog.CloseDialog(dialog.CommandsID)
1160
1161	case dialog.ActionSelectModel:
1162		if m.isAgentBusy() {
1163			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1164			break
1165		}
1166
1167		cfg := m.com.Config()
1168		if cfg == nil {
1169			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1170			break
1171		}
1172
1173		var (
1174			providerID   = msg.Model.Provider
1175			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1176			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1177		)
1178
1179		// Attempt to import GitHub Copilot tokens from VSCode if available.
1180		if isCopilot && !isConfigured() {
1181			config.Get().ImportCopilot()
1182		}
1183
1184		if !isConfigured() {
1185			m.dialog.CloseDialog(dialog.ModelsID)
1186			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1187				cmds = append(cmds, cmd)
1188			}
1189			break
1190		}
1191
1192		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1193			cmds = append(cmds, uiutil.ReportError(err))
1194		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1195			// Ensure small model is set is unset.
1196			smallModel := m.com.App.GetDefaultSmallModel(providerID)
1197			if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
1198				cmds = append(cmds, uiutil.ReportError(err))
1199			}
1200		}
1201
1202		cmds = append(cmds, func() tea.Msg {
1203			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1204				return uiutil.ReportError(err)
1205			}
1206
1207			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1208
1209			return uiutil.NewInfoMsg(modelMsg)
1210		})
1211
1212		m.dialog.CloseDialog(dialog.APIKeyInputID)
1213		m.dialog.CloseDialog(dialog.OAuthID)
1214		m.dialog.CloseDialog(dialog.ModelsID)
1215
1216		if isOnboarding {
1217			m.setState(uiLanding, uiFocusEditor)
1218			m.com.Config().SetupAgents()
1219			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1220				cmds = append(cmds, uiutil.ReportError(err))
1221			}
1222		}
1223	case dialog.ActionSelectReasoningEffort:
1224		if m.isAgentBusy() {
1225			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1226			break
1227		}
1228
1229		cfg := m.com.Config()
1230		if cfg == nil {
1231			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1232			break
1233		}
1234
1235		agentCfg, ok := cfg.Agents[config.AgentCoder]
1236		if !ok {
1237			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
1238			break
1239		}
1240
1241		currentModel := cfg.Models[agentCfg.Model]
1242		currentModel.ReasoningEffort = msg.Effort
1243		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1244			cmds = append(cmds, uiutil.ReportError(err))
1245			break
1246		}
1247
1248		cmds = append(cmds, func() tea.Msg {
1249			m.com.App.UpdateAgentModel(context.TODO())
1250			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1251		})
1252		m.dialog.CloseDialog(dialog.ReasoningID)
1253	case dialog.ActionPermissionResponse:
1254		m.dialog.CloseDialog(dialog.PermissionsID)
1255		switch msg.Action {
1256		case dialog.PermissionAllow:
1257			m.com.App.Permissions.Grant(msg.Permission)
1258		case dialog.PermissionAllowForSession:
1259			m.com.App.Permissions.GrantPersistent(msg.Permission)
1260		case dialog.PermissionDeny:
1261			m.com.App.Permissions.Deny(msg.Permission)
1262		}
1263
1264	case dialog.ActionFilePickerSelected:
1265		cmds = append(cmds, tea.Sequence(
1266			msg.Cmd(),
1267			func() tea.Msg {
1268				m.dialog.CloseDialog(dialog.FilePickerID)
1269				return nil
1270			},
1271		))
1272
1273	case dialog.ActionRunCustomCommand:
1274		if len(msg.Arguments) > 0 && msg.Args == nil {
1275			m.dialog.CloseFrontDialog()
1276			argsDialog := dialog.NewArguments(
1277				m.com,
1278				"Custom Command Arguments",
1279				"",
1280				msg.Arguments,
1281				msg, // Pass the action as the result
1282			)
1283			m.dialog.OpenDialog(argsDialog)
1284			break
1285		}
1286		content := msg.Content
1287		if msg.Args != nil {
1288			content = substituteArgs(content, msg.Args)
1289		}
1290		cmds = append(cmds, m.sendMessage(content))
1291		m.dialog.CloseFrontDialog()
1292	case dialog.ActionRunMCPPrompt:
1293		if len(msg.Arguments) > 0 && msg.Args == nil {
1294			m.dialog.CloseFrontDialog()
1295			title := msg.Title
1296			if title == "" {
1297				title = "MCP Prompt Arguments"
1298			}
1299			argsDialog := dialog.NewArguments(
1300				m.com,
1301				title,
1302				msg.Description,
1303				msg.Arguments,
1304				msg, // Pass the action as the result
1305			)
1306			m.dialog.OpenDialog(argsDialog)
1307			break
1308		}
1309		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1310	default:
1311		cmds = append(cmds, uiutil.CmdHandler(msg))
1312	}
1313
1314	return tea.Batch(cmds...)
1315}
1316
1317// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1318func substituteArgs(content string, args map[string]string) string {
1319	for name, value := range args {
1320		placeholder := "$" + name
1321		content = strings.ReplaceAll(content, placeholder, value)
1322	}
1323	return content
1324}
1325
1326func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1327	var (
1328		dlg dialog.Dialog
1329		cmd tea.Cmd
1330
1331		isOnboarding = m.state == uiOnboarding
1332	)
1333
1334	switch provider.ID {
1335	case "hyper":
1336		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1337	case catwalk.InferenceProviderCopilot:
1338		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1339	default:
1340		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1341	}
1342
1343	if m.dialog.ContainsDialog(dlg.ID()) {
1344		m.dialog.BringToFront(dlg.ID())
1345		return nil
1346	}
1347
1348	m.dialog.OpenDialog(dlg)
1349	return cmd
1350}
1351
1352func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1353	var cmds []tea.Cmd
1354
1355	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1356		switch {
1357		case key.Matches(msg, m.keyMap.Help):
1358			m.status.ToggleHelp()
1359			m.updateLayoutAndSize()
1360			return true
1361		case key.Matches(msg, m.keyMap.Commands):
1362			if cmd := m.openCommandsDialog(); cmd != nil {
1363				cmds = append(cmds, cmd)
1364			}
1365			return true
1366		case key.Matches(msg, m.keyMap.Models):
1367			if cmd := m.openModelsDialog(); cmd != nil {
1368				cmds = append(cmds, cmd)
1369			}
1370			return true
1371		case key.Matches(msg, m.keyMap.Sessions):
1372			if cmd := m.openSessionsDialog(); cmd != nil {
1373				cmds = append(cmds, cmd)
1374			}
1375			return true
1376		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1377			m.detailsOpen = !m.detailsOpen
1378			m.updateLayoutAndSize()
1379			return true
1380		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1381			if m.state == uiChat && m.hasSession() {
1382				if cmd := m.togglePillsExpanded(); cmd != nil {
1383					cmds = append(cmds, cmd)
1384				}
1385				return true
1386			}
1387		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1388			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1389				if cmd := m.switchPillSection(-1); cmd != nil {
1390					cmds = append(cmds, cmd)
1391				}
1392				return true
1393			}
1394		case key.Matches(msg, m.keyMap.Chat.PillRight):
1395			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1396				if cmd := m.switchPillSection(1); cmd != nil {
1397					cmds = append(cmds, cmd)
1398				}
1399				return true
1400			}
1401		case key.Matches(msg, m.keyMap.Suspend):
1402			if m.isAgentBusy() {
1403				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1404				return true
1405			}
1406			cmds = append(cmds, tea.Suspend)
1407			return true
1408		}
1409		return false
1410	}
1411
1412	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1413		// Always handle quit keys first
1414		if cmd := m.openQuitDialog(); cmd != nil {
1415			cmds = append(cmds, cmd)
1416		}
1417
1418		return tea.Batch(cmds...)
1419	}
1420
1421	// Route all messages to dialog if one is open.
1422	if m.dialog.HasDialogs() {
1423		return m.handleDialogMsg(msg)
1424	}
1425
1426	// Handle cancel key when agent is busy.
1427	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1428		if m.isAgentBusy() {
1429			if cmd := m.cancelAgent(); cmd != nil {
1430				cmds = append(cmds, cmd)
1431			}
1432			return tea.Batch(cmds...)
1433		}
1434	}
1435
1436	switch m.state {
1437	case uiOnboarding:
1438		return tea.Batch(cmds...)
1439	case uiInitialize:
1440		cmds = append(cmds, m.updateInitializeView(msg)...)
1441		return tea.Batch(cmds...)
1442	case uiChat, uiLanding:
1443		switch m.focus {
1444		case uiFocusEditor:
1445			// Handle completions if open.
1446			if m.completionsOpen {
1447				if msg, ok := m.completions.Update(msg); ok {
1448					switch msg := msg.(type) {
1449					case completions.SelectionMsg:
1450						// Handle file completion selection.
1451						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1452							cmds = append(cmds, m.insertFileCompletion(item.Path))
1453						}
1454						if !msg.Insert {
1455							m.closeCompletions()
1456						}
1457					case completions.ClosedMsg:
1458						m.completionsOpen = false
1459					}
1460					return tea.Batch(cmds...)
1461				}
1462			}
1463
1464			if ok := m.attachments.Update(msg); ok {
1465				return tea.Batch(cmds...)
1466			}
1467
1468			switch {
1469			case key.Matches(msg, m.keyMap.Editor.AddImage):
1470				if cmd := m.openFilesDialog(); cmd != nil {
1471					cmds = append(cmds, cmd)
1472				}
1473
1474			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1475				value := m.textarea.Value()
1476				if before, ok := strings.CutSuffix(value, "\\"); ok {
1477					// If the last character is a backslash, remove it and add a newline.
1478					m.textarea.SetValue(before)
1479					break
1480				}
1481
1482				// Otherwise, send the message
1483				m.textarea.Reset()
1484
1485				value = strings.TrimSpace(value)
1486				if value == "exit" || value == "quit" {
1487					return m.openQuitDialog()
1488				}
1489
1490				attachments := m.attachments.List()
1491				m.attachments.Reset()
1492				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1493					return nil
1494				}
1495
1496				m.randomizePlaceholders()
1497				m.historyReset()
1498
1499				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1500			case key.Matches(msg, m.keyMap.Chat.NewSession):
1501				if !m.hasSession() {
1502					break
1503				}
1504				if m.isAgentBusy() {
1505					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1506					break
1507				}
1508				if cmd := m.newSession(); cmd != nil {
1509					cmds = append(cmds, cmd)
1510				}
1511			case key.Matches(msg, m.keyMap.Tab):
1512				if m.state != uiLanding {
1513					m.setState(m.state, uiFocusMain)
1514					m.textarea.Blur()
1515					m.chat.Focus()
1516					m.chat.SetSelected(m.chat.Len() - 1)
1517				}
1518			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1519				if m.isAgentBusy() {
1520					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1521					break
1522				}
1523				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1524			case key.Matches(msg, m.keyMap.Editor.Newline):
1525				m.textarea.InsertRune('\n')
1526				m.closeCompletions()
1527				ta, cmd := m.textarea.Update(msg)
1528				m.textarea = ta
1529				cmds = append(cmds, cmd)
1530			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1531				cmd := m.handleHistoryUp(msg)
1532				if cmd != nil {
1533					cmds = append(cmds, cmd)
1534				}
1535			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1536				cmd := m.handleHistoryDown(msg)
1537				if cmd != nil {
1538					cmds = append(cmds, cmd)
1539				}
1540			case key.Matches(msg, m.keyMap.Editor.Escape):
1541				cmd := m.handleHistoryEscape(msg)
1542				if cmd != nil {
1543					cmds = append(cmds, cmd)
1544				}
1545			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1546				if cmd := m.openCommandsDialog(); cmd != nil {
1547					cmds = append(cmds, cmd)
1548				}
1549			default:
1550				if handleGlobalKeys(msg) {
1551					// Handle global keys first before passing to textarea.
1552					break
1553				}
1554
1555				// Check for @ trigger before passing to textarea.
1556				curValue := m.textarea.Value()
1557				curIdx := len(curValue)
1558
1559				// Trigger completions on @.
1560				if msg.String() == "@" && !m.completionsOpen {
1561					// Only show if beginning of prompt or after whitespace.
1562					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1563						m.completionsOpen = true
1564						m.completionsQuery = ""
1565						m.completionsStartIndex = curIdx
1566						m.completionsPositionStart = m.completionsPosition()
1567						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1568						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1569					}
1570				}
1571
1572				// remove the details if they are open when user starts typing
1573				if m.detailsOpen {
1574					m.detailsOpen = false
1575					m.updateLayoutAndSize()
1576				}
1577
1578				ta, cmd := m.textarea.Update(msg)
1579				m.textarea = ta
1580				cmds = append(cmds, cmd)
1581
1582				// Any text modification becomes the current draft.
1583				m.updateHistoryDraft(curValue)
1584
1585				// After updating textarea, check if we need to filter completions.
1586				// Skip filtering on the initial @ keystroke since items are loading async.
1587				if m.completionsOpen && msg.String() != "@" {
1588					newValue := m.textarea.Value()
1589					newIdx := len(newValue)
1590
1591					// Close completions if cursor moved before start.
1592					if newIdx <= m.completionsStartIndex {
1593						m.closeCompletions()
1594					} else if msg.String() == "space" {
1595						// Close on space.
1596						m.closeCompletions()
1597					} else {
1598						// Extract current word and filter.
1599						word := m.textareaWord()
1600						if strings.HasPrefix(word, "@") {
1601							m.completionsQuery = word[1:]
1602							m.completions.Filter(m.completionsQuery)
1603						} else if m.completionsOpen {
1604							m.closeCompletions()
1605						}
1606					}
1607				}
1608			}
1609		case uiFocusMain:
1610			switch {
1611			case key.Matches(msg, m.keyMap.Tab):
1612				m.focus = uiFocusEditor
1613				cmds = append(cmds, m.textarea.Focus())
1614				m.chat.Blur()
1615			case key.Matches(msg, m.keyMap.Chat.NewSession):
1616				if !m.hasSession() {
1617					break
1618				}
1619				if m.isAgentBusy() {
1620					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1621					break
1622				}
1623				m.focus = uiFocusEditor
1624				if cmd := m.newSession(); cmd != nil {
1625					cmds = append(cmds, cmd)
1626				}
1627			case key.Matches(msg, m.keyMap.Chat.Expand):
1628				m.chat.ToggleExpandedSelectedItem()
1629			case key.Matches(msg, m.keyMap.Chat.Up):
1630				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1631					cmds = append(cmds, cmd)
1632				}
1633				if !m.chat.SelectedItemInView() {
1634					m.chat.SelectPrev()
1635					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1636						cmds = append(cmds, cmd)
1637					}
1638				}
1639			case key.Matches(msg, m.keyMap.Chat.Down):
1640				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1641					cmds = append(cmds, cmd)
1642				}
1643				if !m.chat.SelectedItemInView() {
1644					m.chat.SelectNext()
1645					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1646						cmds = append(cmds, cmd)
1647					}
1648				}
1649			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1650				m.chat.SelectPrev()
1651				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1652					cmds = append(cmds, cmd)
1653				}
1654			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1655				m.chat.SelectNext()
1656				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1657					cmds = append(cmds, cmd)
1658				}
1659			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1660				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1661					cmds = append(cmds, cmd)
1662				}
1663				m.chat.SelectFirstInView()
1664			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1665				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1666					cmds = append(cmds, cmd)
1667				}
1668				m.chat.SelectLastInView()
1669			case key.Matches(msg, m.keyMap.Chat.PageUp):
1670				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1671					cmds = append(cmds, cmd)
1672				}
1673				m.chat.SelectFirstInView()
1674			case key.Matches(msg, m.keyMap.Chat.PageDown):
1675				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1676					cmds = append(cmds, cmd)
1677				}
1678				m.chat.SelectLastInView()
1679			case key.Matches(msg, m.keyMap.Chat.Home):
1680				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1681					cmds = append(cmds, cmd)
1682				}
1683				m.chat.SelectFirst()
1684			case key.Matches(msg, m.keyMap.Chat.End):
1685				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1686					cmds = append(cmds, cmd)
1687				}
1688				m.chat.SelectLast()
1689			default:
1690				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1691					cmds = append(cmds, cmd)
1692				} else {
1693					handleGlobalKeys(msg)
1694				}
1695			}
1696		default:
1697			handleGlobalKeys(msg)
1698		}
1699	default:
1700		handleGlobalKeys(msg)
1701	}
1702
1703	return tea.Batch(cmds...)
1704}
1705
1706// Draw implements [uv.Drawable] and draws the UI model.
1707func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1708	layout := m.generateLayout(area.Dx(), area.Dy())
1709
1710	if m.layout != layout {
1711		m.layout = layout
1712		m.updateSize()
1713	}
1714
1715	// Clear the screen first
1716	screen.Clear(scr)
1717
1718	switch m.state {
1719	case uiOnboarding:
1720		header := uv.NewStyledString(m.header)
1721		header.Draw(scr, layout.header)
1722
1723		// NOTE: Onboarding flow will be rendered as dialogs below, but
1724		// positioned at the bottom left of the screen.
1725
1726	case uiInitialize:
1727		header := uv.NewStyledString(m.header)
1728		header.Draw(scr, layout.header)
1729
1730		main := uv.NewStyledString(m.initializeView())
1731		main.Draw(scr, layout.main)
1732
1733	case uiLanding:
1734		header := uv.NewStyledString(m.header)
1735		header.Draw(scr, layout.header)
1736		main := uv.NewStyledString(m.landingView())
1737		main.Draw(scr, layout.main)
1738
1739		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1740		editor.Draw(scr, layout.editor)
1741
1742	case uiChat:
1743		if m.isCompact {
1744			header := uv.NewStyledString(m.header)
1745			header.Draw(scr, layout.header)
1746		} else {
1747			m.drawSidebar(scr, layout.sidebar)
1748		}
1749
1750		m.chat.Draw(scr, layout.main)
1751		if layout.pills.Dy() > 0 && m.pillsView != "" {
1752			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1753		}
1754
1755		editorWidth := scr.Bounds().Dx()
1756		if !m.isCompact {
1757			editorWidth -= layout.sidebar.Dx()
1758		}
1759		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1760		editor.Draw(scr, layout.editor)
1761
1762		// Draw details overlay in compact mode when open
1763		if m.isCompact && m.detailsOpen {
1764			m.drawSessionDetails(scr, layout.sessionDetails)
1765		}
1766	}
1767
1768	isOnboarding := m.state == uiOnboarding
1769
1770	// Add status and help layer
1771	m.status.SetHideHelp(isOnboarding)
1772	m.status.Draw(scr, layout.status)
1773
1774	// Draw completions popup if open
1775	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1776		w, h := m.completions.Size()
1777		x := m.completionsPositionStart.X
1778		y := m.completionsPositionStart.Y - h
1779
1780		screenW := area.Dx()
1781		if x+w > screenW {
1782			x = screenW - w
1783		}
1784		x = max(0, x)
1785		y = max(0, y)
1786
1787		completionsView := uv.NewStyledString(m.completions.Render())
1788		completionsView.Draw(scr, image.Rectangle{
1789			Min: image.Pt(x, y),
1790			Max: image.Pt(x+w, y+h),
1791		})
1792	}
1793
1794	// Debugging rendering (visually see when the tui rerenders)
1795	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1796		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1797		debug := uv.NewStyledString(debugView.String())
1798		debug.Draw(scr, image.Rectangle{
1799			Min: image.Pt(4, 1),
1800			Max: image.Pt(8, 3),
1801		})
1802	}
1803
1804	// This needs to come last to overlay on top of everything. We always pass
1805	// the full screen bounds because the dialogs will position themselves
1806	// accordingly.
1807	if m.dialog.HasDialogs() {
1808		return m.dialog.Draw(scr, scr.Bounds())
1809	}
1810
1811	switch m.focus {
1812	case uiFocusEditor:
1813		if m.layout.editor.Dy() <= 0 {
1814			// Don't show cursor if editor is not visible
1815			return nil
1816		}
1817		if m.detailsOpen && m.isCompact {
1818			// Don't show cursor if details overlay is open
1819			return nil
1820		}
1821
1822		if m.textarea.Focused() {
1823			cur := m.textarea.Cursor()
1824			cur.X++ // Adjust for app margins
1825			cur.Y += m.layout.editor.Min.Y
1826			// Offset for attachment row if present.
1827			if len(m.attachments.List()) > 0 {
1828				cur.Y++
1829			}
1830			return cur
1831		}
1832	}
1833	return nil
1834}
1835
1836// View renders the UI model's view.
1837func (m *UI) View() tea.View {
1838	var v tea.View
1839	v.AltScreen = true
1840	v.BackgroundColor = m.com.Styles.Background
1841	v.MouseMode = tea.MouseModeCellMotion
1842	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1843
1844	canvas := uv.NewScreenBuffer(m.width, m.height)
1845	v.Cursor = m.Draw(canvas, canvas.Bounds())
1846
1847	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1848	contentLines := strings.Split(content, "\n")
1849	for i, line := range contentLines {
1850		// Trim trailing spaces for concise rendering
1851		contentLines[i] = strings.TrimRight(line, " ")
1852	}
1853
1854	content = strings.Join(contentLines, "\n")
1855
1856	v.Content = content
1857	if m.sendProgressBar && m.isAgentBusy() {
1858		// HACK: use a random percentage to prevent ghostty from hiding it
1859		// after a timeout.
1860		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1861	}
1862
1863	return v
1864}
1865
1866// ShortHelp implements [help.KeyMap].
1867func (m *UI) ShortHelp() []key.Binding {
1868	var binds []key.Binding
1869	k := &m.keyMap
1870	tab := k.Tab
1871	commands := k.Commands
1872	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
1873		commands.SetHelp("/ or ctrl+p", "commands")
1874	}
1875
1876	switch m.state {
1877	case uiInitialize:
1878		binds = append(binds, k.Quit)
1879	case uiChat:
1880		// Show cancel binding if agent is busy.
1881		if m.isAgentBusy() {
1882			cancelBinding := k.Chat.Cancel
1883			if m.isCanceling {
1884				cancelBinding.SetHelp("esc", "press again to cancel")
1885			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1886				cancelBinding.SetHelp("esc", "clear queue")
1887			}
1888			binds = append(binds, cancelBinding)
1889		}
1890
1891		if m.focus == uiFocusEditor {
1892			tab.SetHelp("tab", "focus chat")
1893		} else {
1894			tab.SetHelp("tab", "focus editor")
1895		}
1896
1897		binds = append(binds,
1898			tab,
1899			commands,
1900			k.Models,
1901		)
1902
1903		switch m.focus {
1904		case uiFocusEditor:
1905			binds = append(binds,
1906				k.Editor.Newline,
1907			)
1908		case uiFocusMain:
1909			binds = append(binds,
1910				k.Chat.UpDown,
1911				k.Chat.UpDownOneItem,
1912				k.Chat.PageUp,
1913				k.Chat.PageDown,
1914				k.Chat.Copy,
1915			)
1916			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1917				binds = append(binds, k.Chat.PillLeft)
1918			}
1919		}
1920	default:
1921		// TODO: other states
1922		// if m.session == nil {
1923		// no session selected
1924		binds = append(binds,
1925			commands,
1926			k.Models,
1927			k.Editor.Newline,
1928		)
1929	}
1930
1931	binds = append(binds,
1932		k.Quit,
1933		k.Help,
1934	)
1935
1936	return binds
1937}
1938
1939// FullHelp implements [help.KeyMap].
1940func (m *UI) FullHelp() [][]key.Binding {
1941	var binds [][]key.Binding
1942	k := &m.keyMap
1943	help := k.Help
1944	help.SetHelp("ctrl+g", "less")
1945	hasAttachments := len(m.attachments.List()) > 0
1946	hasSession := m.hasSession()
1947	commands := k.Commands
1948	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
1949		commands.SetHelp("/ or ctrl+p", "commands")
1950	}
1951
1952	switch m.state {
1953	case uiInitialize:
1954		binds = append(binds,
1955			[]key.Binding{
1956				k.Quit,
1957			})
1958	case uiChat:
1959		// Show cancel binding if agent is busy.
1960		if m.isAgentBusy() {
1961			cancelBinding := k.Chat.Cancel
1962			if m.isCanceling {
1963				cancelBinding.SetHelp("esc", "press again to cancel")
1964			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1965				cancelBinding.SetHelp("esc", "clear queue")
1966			}
1967			binds = append(binds, []key.Binding{cancelBinding})
1968		}
1969
1970		mainBinds := []key.Binding{}
1971		tab := k.Tab
1972		if m.focus == uiFocusEditor {
1973			tab.SetHelp("tab", "focus chat")
1974		} else {
1975			tab.SetHelp("tab", "focus editor")
1976		}
1977
1978		mainBinds = append(mainBinds,
1979			tab,
1980			commands,
1981			k.Models,
1982			k.Sessions,
1983		)
1984		if hasSession {
1985			mainBinds = append(mainBinds, k.Chat.NewSession)
1986		}
1987
1988		binds = append(binds, mainBinds)
1989
1990		switch m.focus {
1991		case uiFocusEditor:
1992			binds = append(binds,
1993				[]key.Binding{
1994					k.Editor.Newline,
1995					k.Editor.AddImage,
1996					k.Editor.MentionFile,
1997					k.Editor.OpenEditor,
1998				},
1999			)
2000			if hasAttachments {
2001				binds = append(binds,
2002					[]key.Binding{
2003						k.Editor.AttachmentDeleteMode,
2004						k.Editor.DeleteAllAttachments,
2005						k.Editor.Escape,
2006					},
2007				)
2008			}
2009		case uiFocusMain:
2010			binds = append(binds,
2011				[]key.Binding{
2012					k.Chat.UpDown,
2013					k.Chat.UpDownOneItem,
2014					k.Chat.PageUp,
2015					k.Chat.PageDown,
2016				},
2017				[]key.Binding{
2018					k.Chat.HalfPageUp,
2019					k.Chat.HalfPageDown,
2020					k.Chat.Home,
2021					k.Chat.End,
2022				},
2023				[]key.Binding{
2024					k.Chat.Copy,
2025					k.Chat.ClearHighlight,
2026				},
2027			)
2028			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2029				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2030			}
2031		}
2032	default:
2033		if m.session == nil {
2034			// no session selected
2035			binds = append(binds,
2036				[]key.Binding{
2037					commands,
2038					k.Models,
2039					k.Sessions,
2040				},
2041				[]key.Binding{
2042					k.Editor.Newline,
2043					k.Editor.AddImage,
2044					k.Editor.MentionFile,
2045					k.Editor.OpenEditor,
2046				},
2047			)
2048			if hasAttachments {
2049				binds = append(binds,
2050					[]key.Binding{
2051						k.Editor.AttachmentDeleteMode,
2052						k.Editor.DeleteAllAttachments,
2053						k.Editor.Escape,
2054					},
2055				)
2056			}
2057			binds = append(binds,
2058				[]key.Binding{
2059					help,
2060				},
2061			)
2062		}
2063	}
2064
2065	binds = append(binds,
2066		[]key.Binding{
2067			help,
2068			k.Quit,
2069		},
2070	)
2071
2072	return binds
2073}
2074
2075// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2076func (m *UI) toggleCompactMode() tea.Cmd {
2077	m.forceCompactMode = !m.forceCompactMode
2078
2079	err := m.com.Config().SetCompactMode(m.forceCompactMode)
2080	if err != nil {
2081		return uiutil.ReportError(err)
2082	}
2083
2084	m.updateLayoutAndSize()
2085
2086	return nil
2087}
2088
2089// updateLayoutAndSize updates the layout and sizes of UI components.
2090func (m *UI) updateLayoutAndSize() {
2091	// Determine if we should be in compact mode
2092	if m.state == uiChat {
2093		if m.forceCompactMode {
2094			m.isCompact = true
2095			return
2096		}
2097		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2098			m.isCompact = true
2099		} else {
2100			m.isCompact = false
2101		}
2102	}
2103
2104	m.layout = m.generateLayout(m.width, m.height)
2105	m.updateSize()
2106}
2107
2108// updateSize updates the sizes of UI components based on the current layout.
2109func (m *UI) updateSize() {
2110	// Set status width
2111	m.status.SetWidth(m.layout.status.Dx())
2112
2113	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2114	m.textarea.SetWidth(m.layout.editor.Dx())
2115	m.textarea.SetHeight(m.layout.editor.Dy())
2116	m.renderPills()
2117
2118	// Handle different app states
2119	switch m.state {
2120	case uiOnboarding, uiInitialize, uiLanding:
2121		m.renderHeader(false, m.layout.header.Dx())
2122
2123	case uiChat:
2124		if m.isCompact {
2125			m.renderHeader(true, m.layout.header.Dx())
2126		} else {
2127			m.renderSidebarLogo(m.layout.sidebar.Dx())
2128		}
2129	}
2130}
2131
2132// generateLayout calculates the layout rectangles for all UI components based
2133// on the current UI state and terminal dimensions.
2134func (m *UI) generateLayout(w, h int) layout {
2135	// The screen area we're working with
2136	area := image.Rect(0, 0, w, h)
2137
2138	// The help height
2139	helpHeight := 1
2140	// The editor height
2141	editorHeight := 5
2142	// The sidebar width
2143	sidebarWidth := 30
2144	// The header height
2145	const landingHeaderHeight = 4
2146
2147	var helpKeyMap help.KeyMap = m
2148	if m.status != nil && m.status.ShowingAll() {
2149		for _, row := range helpKeyMap.FullHelp() {
2150			helpHeight = max(helpHeight, len(row))
2151		}
2152	}
2153
2154	// Add app margins
2155	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2156	appRect.Min.Y += 1
2157	appRect.Max.Y -= 1
2158	helpRect.Min.Y -= 1
2159	appRect.Min.X += 1
2160	appRect.Max.X -= 1
2161
2162	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2163		// extra padding on left and right for these states
2164		appRect.Min.X += 1
2165		appRect.Max.X -= 1
2166	}
2167
2168	layout := layout{
2169		area:   area,
2170		status: helpRect,
2171	}
2172
2173	// Handle different app states
2174	switch m.state {
2175	case uiOnboarding, uiInitialize:
2176		// Layout
2177		//
2178		// header
2179		// ------
2180		// main
2181		// ------
2182		// help
2183
2184		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2185		layout.header = headerRect
2186		layout.main = mainRect
2187
2188	case uiLanding:
2189		// Layout
2190		//
2191		// header
2192		// ------
2193		// main
2194		// ------
2195		// editor
2196		// ------
2197		// help
2198		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2199		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2200		// Remove extra padding from editor (but keep it for header and main)
2201		editorRect.Min.X -= 1
2202		editorRect.Max.X += 1
2203		layout.header = headerRect
2204		layout.main = mainRect
2205		layout.editor = editorRect
2206
2207	case uiChat:
2208		if m.isCompact {
2209			// Layout
2210			//
2211			// compact-header
2212			// ------
2213			// main
2214			// ------
2215			// editor
2216			// ------
2217			// help
2218			const compactHeaderHeight = 1
2219			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2220			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2221			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2222			layout.sessionDetails = sessionDetailsArea
2223			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2224			// Add one line gap between header and main content
2225			mainRect.Min.Y += 1
2226			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2227			mainRect.Max.X -= 1 // Add padding right
2228			layout.header = headerRect
2229			pillsHeight := m.pillsAreaHeight()
2230			if pillsHeight > 0 {
2231				pillsHeight = min(pillsHeight, mainRect.Dy())
2232				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2233				layout.main = chatRect
2234				layout.pills = pillsRect
2235			} else {
2236				layout.main = mainRect
2237			}
2238			// Add bottom margin to main
2239			layout.main.Max.Y -= 1
2240			layout.editor = editorRect
2241		} else {
2242			// Layout
2243			//
2244			// ------|---
2245			// main  |
2246			// ------| side
2247			// editor|
2248			// ----------
2249			// help
2250
2251			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2252			// Add padding left
2253			sideRect.Min.X += 1
2254			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2255			mainRect.Max.X -= 1 // Add padding right
2256			layout.sidebar = sideRect
2257			pillsHeight := m.pillsAreaHeight()
2258			if pillsHeight > 0 {
2259				pillsHeight = min(pillsHeight, mainRect.Dy())
2260				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2261				layout.main = chatRect
2262				layout.pills = pillsRect
2263			} else {
2264				layout.main = mainRect
2265			}
2266			// Add bottom margin to main
2267			layout.main.Max.Y -= 1
2268			layout.editor = editorRect
2269		}
2270	}
2271
2272	if !layout.editor.Empty() {
2273		// Add editor margins 1 top and bottom
2274		if len(m.attachments.List()) == 0 {
2275			layout.editor.Min.Y += 1
2276		}
2277		layout.editor.Max.Y -= 1
2278	}
2279
2280	return layout
2281}
2282
2283// layout defines the positioning of UI elements.
2284type layout struct {
2285	// area is the overall available area.
2286	area uv.Rectangle
2287
2288	// header is the header shown in special cases
2289	// e.x when the sidebar is collapsed
2290	// or when in the landing page
2291	// or in init/config
2292	header uv.Rectangle
2293
2294	// main is the area for the main pane. (e.x chat, configure, landing)
2295	main uv.Rectangle
2296
2297	// pills is the area for the pills panel.
2298	pills uv.Rectangle
2299
2300	// editor is the area for the editor pane.
2301	editor uv.Rectangle
2302
2303	// sidebar is the area for the sidebar.
2304	sidebar uv.Rectangle
2305
2306	// status is the area for the status view.
2307	status uv.Rectangle
2308
2309	// session details is the area for the session details overlay in compact mode.
2310	sessionDetails uv.Rectangle
2311}
2312
2313func (m *UI) openEditor(value string) tea.Cmd {
2314	tmpfile, err := os.CreateTemp("", "msg_*.md")
2315	if err != nil {
2316		return uiutil.ReportError(err)
2317	}
2318	defer tmpfile.Close() //nolint:errcheck
2319	if _, err := tmpfile.WriteString(value); err != nil {
2320		return uiutil.ReportError(err)
2321	}
2322	cmd, err := editor.Command(
2323		"crush",
2324		tmpfile.Name(),
2325		editor.AtPosition(
2326			m.textarea.Line()+1,
2327			m.textarea.Column()+1,
2328		),
2329	)
2330	if err != nil {
2331		return uiutil.ReportError(err)
2332	}
2333	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2334		if err != nil {
2335			return uiutil.ReportError(err)
2336		}
2337		content, err := os.ReadFile(tmpfile.Name())
2338		if err != nil {
2339			return uiutil.ReportError(err)
2340		}
2341		if len(content) == 0 {
2342			return uiutil.ReportWarn("Message is empty")
2343		}
2344		os.Remove(tmpfile.Name())
2345		return openEditorMsg{
2346			Text: strings.TrimSpace(string(content)),
2347		}
2348	})
2349}
2350
2351// setEditorPrompt configures the textarea prompt function based on whether
2352// yolo mode is enabled.
2353func (m *UI) setEditorPrompt(yolo bool) {
2354	if yolo {
2355		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2356		return
2357	}
2358	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2359}
2360
2361// normalPromptFunc returns the normal editor prompt style ("  > " on first
2362// line, "::: " on subsequent lines).
2363func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2364	t := m.com.Styles
2365	if info.LineNumber == 0 {
2366		if info.Focused {
2367			return "  > "
2368		}
2369		return "::: "
2370	}
2371	if info.Focused {
2372		return t.EditorPromptNormalFocused.Render()
2373	}
2374	return t.EditorPromptNormalBlurred.Render()
2375}
2376
2377// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2378// and colored dots.
2379func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2380	t := m.com.Styles
2381	if info.LineNumber == 0 {
2382		if info.Focused {
2383			return t.EditorPromptYoloIconFocused.Render()
2384		} else {
2385			return t.EditorPromptYoloIconBlurred.Render()
2386		}
2387	}
2388	if info.Focused {
2389		return t.EditorPromptYoloDotsFocused.Render()
2390	}
2391	return t.EditorPromptYoloDotsBlurred.Render()
2392}
2393
2394// closeCompletions closes the completions popup and resets state.
2395func (m *UI) closeCompletions() {
2396	m.completionsOpen = false
2397	m.completionsQuery = ""
2398	m.completionsStartIndex = 0
2399	m.completions.Close()
2400}
2401
2402// insertFileCompletion inserts the selected file path into the textarea,
2403// replacing the @query, and adds the file as an attachment.
2404func (m *UI) insertFileCompletion(path string) tea.Cmd {
2405	value := m.textarea.Value()
2406	word := m.textareaWord()
2407
2408	// Find the @ and query to replace.
2409	if m.completionsStartIndex > len(value) {
2410		return nil
2411	}
2412
2413	// Build the new value: everything before @, the path, everything after query.
2414	endIdx := min(m.completionsStartIndex+len(word), len(value))
2415
2416	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2417	m.textarea.SetValue(newValue)
2418	m.textarea.MoveToEnd()
2419	m.textarea.InsertRune(' ')
2420
2421	return func() tea.Msg {
2422		absPath, _ := filepath.Abs(path)
2423
2424		if m.hasSession() {
2425			// Skip attachment if file was already read and hasn't been modified.
2426			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2427			if !lastRead.IsZero() {
2428				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2429					return nil
2430				}
2431			}
2432		} else if slices.Contains(m.sessionFileReads, absPath) {
2433			return nil
2434		}
2435
2436		m.sessionFileReads = append(m.sessionFileReads, absPath)
2437
2438		// Add file as attachment.
2439		content, err := os.ReadFile(path)
2440		if err != nil {
2441			// If it fails, let the LLM handle it later.
2442			return nil
2443		}
2444
2445		return message.Attachment{
2446			FilePath: path,
2447			FileName: filepath.Base(path),
2448			MimeType: mimeOf(content),
2449			Content:  content,
2450		}
2451	}
2452}
2453
2454// completionsPosition returns the X and Y position for the completions popup.
2455func (m *UI) completionsPosition() image.Point {
2456	cur := m.textarea.Cursor()
2457	if cur == nil {
2458		return image.Point{
2459			X: m.layout.editor.Min.X,
2460			Y: m.layout.editor.Min.Y,
2461		}
2462	}
2463	return image.Point{
2464		X: cur.X + m.layout.editor.Min.X,
2465		Y: m.layout.editor.Min.Y + cur.Y,
2466	}
2467}
2468
2469// textareaWord returns the current word at the cursor position.
2470func (m *UI) textareaWord() string {
2471	return m.textarea.Word()
2472}
2473
2474// isWhitespace returns true if the byte is a whitespace character.
2475func isWhitespace(b byte) bool {
2476	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2477}
2478
2479// isAgentBusy returns true if the agent coordinator exists and is currently
2480// busy processing a request.
2481func (m *UI) isAgentBusy() bool {
2482	return m.com.App != nil &&
2483		m.com.App.AgentCoordinator != nil &&
2484		m.com.App.AgentCoordinator.IsBusy()
2485}
2486
2487// hasSession returns true if there is an active session with a valid ID.
2488func (m *UI) hasSession() bool {
2489	return m.session != nil && m.session.ID != ""
2490}
2491
2492// mimeOf detects the MIME type of the given content.
2493func mimeOf(content []byte) string {
2494	mimeBufferSize := min(512, len(content))
2495	return http.DetectContentType(content[:mimeBufferSize])
2496}
2497
2498var readyPlaceholders = [...]string{
2499	"Ready!",
2500	"Ready...",
2501	"Ready?",
2502	"Ready for instructions",
2503}
2504
2505var workingPlaceholders = [...]string{
2506	"Working!",
2507	"Working...",
2508	"Brrrrr...",
2509	"Prrrrrrrr...",
2510	"Processing...",
2511	"Thinking...",
2512}
2513
2514// randomizePlaceholders selects random placeholder text for the textarea's
2515// ready and working states.
2516func (m *UI) randomizePlaceholders() {
2517	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2518	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2519}
2520
2521// renderEditorView renders the editor view with attachments if any.
2522func (m *UI) renderEditorView(width int) string {
2523	if len(m.attachments.List()) == 0 {
2524		return m.textarea.View()
2525	}
2526	return lipgloss.JoinVertical(
2527		lipgloss.Top,
2528		m.attachments.Render(width),
2529		m.textarea.View(),
2530	)
2531}
2532
2533// renderHeader renders and caches the header logo at the specified width.
2534func (m *UI) renderHeader(compact bool, width int) {
2535	if compact && m.session != nil && m.com.App != nil {
2536		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2537	} else {
2538		m.header = renderLogo(m.com.Styles, compact, width)
2539	}
2540}
2541
2542// renderSidebarLogo renders and caches the sidebar logo at the specified
2543// width.
2544func (m *UI) renderSidebarLogo(width int) {
2545	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2546}
2547
2548// sendMessage sends a message with the given content and attachments.
2549func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2550	if m.com.App.AgentCoordinator == nil {
2551		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2552	}
2553
2554	var cmds []tea.Cmd
2555	if !m.hasSession() {
2556		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2557		if err != nil {
2558			return uiutil.ReportError(err)
2559		}
2560		if m.forceCompactMode {
2561			m.isCompact = true
2562		}
2563		if newSession.ID != "" {
2564			m.session = &newSession
2565			cmds = append(cmds, m.loadSession(newSession.ID))
2566		}
2567		m.setState(uiChat, m.focus)
2568	}
2569
2570	for _, path := range m.sessionFileReads {
2571		m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
2572	}
2573
2574	// Capture session ID to avoid race with main goroutine updating m.session.
2575	sessionID := m.session.ID
2576	cmds = append(cmds, func() tea.Msg {
2577		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2578		if err != nil {
2579			isCancelErr := errors.Is(err, context.Canceled)
2580			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2581			if isCancelErr || isPermissionErr {
2582				return nil
2583			}
2584			return uiutil.InfoMsg{
2585				Type: uiutil.InfoTypeError,
2586				Msg:  err.Error(),
2587			}
2588		}
2589		return nil
2590	})
2591	return tea.Batch(cmds...)
2592}
2593
2594const cancelTimerDuration = 2 * time.Second
2595
2596// cancelTimerCmd creates a command that expires the cancel timer.
2597func cancelTimerCmd() tea.Cmd {
2598	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2599		return cancelTimerExpiredMsg{}
2600	})
2601}
2602
2603// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2604// and starts a timer. The second press (before the timer expires) actually
2605// cancels the agent.
2606func (m *UI) cancelAgent() tea.Cmd {
2607	if !m.hasSession() {
2608		return nil
2609	}
2610
2611	coordinator := m.com.App.AgentCoordinator
2612	if coordinator == nil {
2613		return nil
2614	}
2615
2616	if m.isCanceling {
2617		// Second escape press - actually cancel the agent.
2618		m.isCanceling = false
2619		coordinator.Cancel(m.session.ID)
2620		// Stop the spinning todo indicator.
2621		m.todoIsSpinning = false
2622		m.renderPills()
2623		return nil
2624	}
2625
2626	// Check if there are queued prompts - if so, clear the queue.
2627	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2628		coordinator.ClearQueue(m.session.ID)
2629		return nil
2630	}
2631
2632	// First escape press - set canceling state and start timer.
2633	m.isCanceling = true
2634	return cancelTimerCmd()
2635}
2636
2637// openDialog opens a dialog by its ID.
2638func (m *UI) openDialog(id string) tea.Cmd {
2639	var cmds []tea.Cmd
2640	switch id {
2641	case dialog.SessionsID:
2642		if cmd := m.openSessionsDialog(); cmd != nil {
2643			cmds = append(cmds, cmd)
2644		}
2645	case dialog.ModelsID:
2646		if cmd := m.openModelsDialog(); cmd != nil {
2647			cmds = append(cmds, cmd)
2648		}
2649	case dialog.CommandsID:
2650		if cmd := m.openCommandsDialog(); cmd != nil {
2651			cmds = append(cmds, cmd)
2652		}
2653	case dialog.ReasoningID:
2654		if cmd := m.openReasoningDialog(); cmd != nil {
2655			cmds = append(cmds, cmd)
2656		}
2657	case dialog.QuitID:
2658		if cmd := m.openQuitDialog(); cmd != nil {
2659			cmds = append(cmds, cmd)
2660		}
2661	default:
2662		// Unknown dialog
2663		break
2664	}
2665	return tea.Batch(cmds...)
2666}
2667
2668// openQuitDialog opens the quit confirmation dialog.
2669func (m *UI) openQuitDialog() tea.Cmd {
2670	if m.dialog.ContainsDialog(dialog.QuitID) {
2671		// Bring to front
2672		m.dialog.BringToFront(dialog.QuitID)
2673		return nil
2674	}
2675
2676	quitDialog := dialog.NewQuit(m.com)
2677	m.dialog.OpenDialog(quitDialog)
2678	return nil
2679}
2680
2681// openModelsDialog opens the models dialog.
2682func (m *UI) openModelsDialog() tea.Cmd {
2683	if m.dialog.ContainsDialog(dialog.ModelsID) {
2684		// Bring to front
2685		m.dialog.BringToFront(dialog.ModelsID)
2686		return nil
2687	}
2688
2689	isOnboarding := m.state == uiOnboarding
2690	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2691	if err != nil {
2692		return uiutil.ReportError(err)
2693	}
2694
2695	m.dialog.OpenDialog(modelsDialog)
2696
2697	return nil
2698}
2699
2700// openCommandsDialog opens the commands dialog.
2701func (m *UI) openCommandsDialog() tea.Cmd {
2702	if m.dialog.ContainsDialog(dialog.CommandsID) {
2703		// Bring to front
2704		m.dialog.BringToFront(dialog.CommandsID)
2705		return nil
2706	}
2707
2708	sessionID := ""
2709	if m.session != nil {
2710		sessionID = m.session.ID
2711	}
2712
2713	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2714	if err != nil {
2715		return uiutil.ReportError(err)
2716	}
2717
2718	m.dialog.OpenDialog(commands)
2719
2720	return nil
2721}
2722
2723// openReasoningDialog opens the reasoning effort dialog.
2724func (m *UI) openReasoningDialog() tea.Cmd {
2725	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2726		m.dialog.BringToFront(dialog.ReasoningID)
2727		return nil
2728	}
2729
2730	reasoningDialog, err := dialog.NewReasoning(m.com)
2731	if err != nil {
2732		return uiutil.ReportError(err)
2733	}
2734
2735	m.dialog.OpenDialog(reasoningDialog)
2736	return nil
2737}
2738
2739// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2740// it brings it to the front. Otherwise, it will list all the sessions and open
2741// the dialog.
2742func (m *UI) openSessionsDialog() tea.Cmd {
2743	if m.dialog.ContainsDialog(dialog.SessionsID) {
2744		// Bring to front
2745		m.dialog.BringToFront(dialog.SessionsID)
2746		return nil
2747	}
2748
2749	selectedSessionID := ""
2750	if m.session != nil {
2751		selectedSessionID = m.session.ID
2752	}
2753
2754	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2755	if err != nil {
2756		return uiutil.ReportError(err)
2757	}
2758
2759	m.dialog.OpenDialog(dialog)
2760	return nil
2761}
2762
2763// openFilesDialog opens the file picker dialog.
2764func (m *UI) openFilesDialog() tea.Cmd {
2765	if m.dialog.ContainsDialog(dialog.FilePickerID) {
2766		// Bring to front
2767		m.dialog.BringToFront(dialog.FilePickerID)
2768		return nil
2769	}
2770
2771	filePicker, cmd := dialog.NewFilePicker(m.com)
2772	filePicker.SetImageCapabilities(&m.caps)
2773	m.dialog.OpenDialog(filePicker)
2774
2775	return cmd
2776}
2777
2778// openPermissionsDialog opens the permissions dialog for a permission request.
2779func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2780	// Close any existing permissions dialog first.
2781	m.dialog.CloseDialog(dialog.PermissionsID)
2782
2783	// Get diff mode from config.
2784	var opts []dialog.PermissionsOption
2785	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2786		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2787	}
2788
2789	permDialog := dialog.NewPermissions(m.com, perm, opts...)
2790	m.dialog.OpenDialog(permDialog)
2791	return nil
2792}
2793
2794// handlePermissionNotification updates tool items when permission state changes.
2795func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2796	toolItem := m.chat.MessageItem(notification.ToolCallID)
2797	if toolItem == nil {
2798		return
2799	}
2800
2801	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2802		if notification.Granted {
2803			permItem.SetStatus(chat.ToolStatusRunning)
2804		} else {
2805			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2806		}
2807	}
2808}
2809
2810// newSession clears the current session state and prepares for a new session.
2811// The actual session creation happens when the user sends their first message.
2812// Returns a command to reload prompt history.
2813func (m *UI) newSession() tea.Cmd {
2814	if !m.hasSession() {
2815		return nil
2816	}
2817
2818	m.session = nil
2819	m.sessionFiles = nil
2820	m.sessionFileReads = nil
2821	m.setState(uiLanding, uiFocusEditor)
2822	m.textarea.Focus()
2823	m.chat.Blur()
2824	m.chat.ClearMessages()
2825	m.pillsExpanded = false
2826	m.promptQueue = 0
2827	m.pillsView = ""
2828	m.historyReset()
2829	return m.loadPromptHistory()
2830}
2831
2832// handlePasteMsg handles a paste message.
2833func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2834	if m.dialog.HasDialogs() {
2835		return m.handleDialogMsg(msg)
2836	}
2837
2838	if m.focus != uiFocusEditor {
2839		return nil
2840	}
2841
2842	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2843		return func() tea.Msg {
2844			content := []byte(msg.Content)
2845			if int64(len(content)) > common.MaxAttachmentSize {
2846				return uiutil.ReportWarn("Paste is too big (>5mb)")
2847			}
2848			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2849			mimeBufferSize := min(512, len(content))
2850			mimeType := http.DetectContentType(content[:mimeBufferSize])
2851			return message.Attachment{
2852				FileName: name,
2853				FilePath: name,
2854				MimeType: mimeType,
2855				Content:  content,
2856			}
2857		}
2858	}
2859
2860	// Attempt to parse pasted content as file paths. If possible to parse,
2861	// all files exist and are valid, add as attachments.
2862	// Otherwise, paste as text.
2863	paths := fsext.PasteStringToPaths(msg.Content)
2864	allExistsAndValid := func() bool {
2865		for _, path := range paths {
2866			if _, err := os.Stat(path); os.IsNotExist(err) {
2867				return false
2868			}
2869
2870			lowerPath := strings.ToLower(path)
2871			isValid := false
2872			for _, ext := range common.AllowedImageTypes {
2873				if strings.HasSuffix(lowerPath, ext) {
2874					isValid = true
2875					break
2876				}
2877			}
2878			if !isValid {
2879				return false
2880			}
2881		}
2882		return true
2883	}
2884	if !allExistsAndValid() {
2885		var cmd tea.Cmd
2886		m.textarea, cmd = m.textarea.Update(msg)
2887		return cmd
2888	}
2889
2890	var cmds []tea.Cmd
2891	for _, path := range paths {
2892		cmds = append(cmds, m.handleFilePathPaste(path))
2893	}
2894	return tea.Batch(cmds...)
2895}
2896
2897// handleFilePathPaste handles a pasted file path.
2898func (m *UI) handleFilePathPaste(path string) tea.Cmd {
2899	return func() tea.Msg {
2900		fileInfo, err := os.Stat(path)
2901		if err != nil {
2902			return uiutil.ReportError(err)
2903		}
2904		if fileInfo.Size() > common.MaxAttachmentSize {
2905			return uiutil.ReportWarn("File is too big (>5mb)")
2906		}
2907
2908		content, err := os.ReadFile(path)
2909		if err != nil {
2910			return uiutil.ReportError(err)
2911		}
2912
2913		mimeBufferSize := min(512, len(content))
2914		mimeType := http.DetectContentType(content[:mimeBufferSize])
2915		fileName := filepath.Base(path)
2916		return message.Attachment{
2917			FilePath: path,
2918			FileName: fileName,
2919			MimeType: mimeType,
2920			Content:  content,
2921		}
2922	}
2923}
2924
2925var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2926
2927func (m *UI) pasteIdx() int {
2928	result := 0
2929	for _, at := range m.attachments.List() {
2930		found := pasteRE.FindStringSubmatch(at.FileName)
2931		if len(found) == 0 {
2932			continue
2933		}
2934		idx, err := strconv.Atoi(found[1])
2935		if err == nil {
2936			result = max(result, idx)
2937		}
2938	}
2939	return result + 1
2940}
2941
2942// drawSessionDetails draws the session details in compact mode.
2943func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2944	if m.session == nil {
2945		return
2946	}
2947
2948	s := m.com.Styles
2949
2950	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2951	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2952
2953	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2954	blocks := []string{
2955		title,
2956		"",
2957		m.modelInfo(width),
2958		"",
2959	}
2960
2961	detailsHeader := lipgloss.JoinVertical(
2962		lipgloss.Left,
2963		blocks...,
2964	)
2965
2966	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2967
2968	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2969
2970	const maxSectionWidth = 50
2971	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2972	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
2973
2974	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2975	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2976	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2977	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2978	uv.NewStyledString(
2979		s.CompactDetails.View.
2980			Width(area.Dx()).
2981			Render(
2982				lipgloss.JoinVertical(
2983					lipgloss.Left,
2984					detailsHeader,
2985					sections,
2986					version,
2987				),
2988			),
2989	).Draw(scr, area)
2990}
2991
2992func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2993	load := func() tea.Msg {
2994		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2995		if err != nil {
2996			// TODO: make this better
2997			return uiutil.ReportError(err)()
2998		}
2999
3000		if prompt == "" {
3001			return nil
3002		}
3003		return sendMessageMsg{
3004			Content: prompt,
3005		}
3006	}
3007
3008	var cmds []tea.Cmd
3009	if cmd := m.dialog.StartLoading(); cmd != nil {
3010		cmds = append(cmds, cmd)
3011	}
3012	cmds = append(cmds, load, func() tea.Msg {
3013		return closeDialogMsg{}
3014	})
3015
3016	return tea.Sequence(cmds...)
3017}
3018
3019func (m *UI) copyChatHighlight() tea.Cmd {
3020	text := m.chat.HighlightContent()
3021	return common.CopyToClipboardWithCallback(
3022		text,
3023		"Selected text copied to clipboard",
3024		func() tea.Msg {
3025			m.chat.ClearMouse()
3026			return nil
3027		},
3028	)
3029}
3030
3031// renderLogo renders the Crush logo with the given styles and dimensions.
3032func renderLogo(t *styles.Styles, compact bool, width int) string {
3033	return logo.Render(version.Version, compact, logo.Opts{
3034		FieldColor:   t.LogoFieldColor,
3035		TitleColorA:  t.LogoTitleColorA,
3036		TitleColorB:  t.LogoTitleColorB,
3037		CharmColor:   t.LogoCharmColor,
3038		VersionColor: t.LogoVersionColor,
3039		Width:        width,
3040	})
3041}