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