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