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