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