ui.go

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