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