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