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