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