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