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
1110	case dialog.ActionSelectModel:
1111		if m.isAgentBusy() {
1112			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1113			break
1114		}
1115
1116		cfg := m.com.Config()
1117		if cfg == nil {
1118			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1119			break
1120		}
1121
1122		var (
1123			providerID   = msg.Model.Provider
1124			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1125			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1126		)
1127
1128		// Attempt to import GitHub Copilot tokens from VSCode if available.
1129		if isCopilot && !isConfigured() {
1130			config.Get().ImportCopilot()
1131		}
1132
1133		if !isConfigured() {
1134			m.dialog.CloseDialog(dialog.ModelsID)
1135			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1136				cmds = append(cmds, cmd)
1137			}
1138			break
1139		}
1140
1141		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1142			cmds = append(cmds, uiutil.ReportError(err))
1143		}
1144
1145		cmds = append(cmds, func() tea.Msg {
1146			m.com.App.UpdateAgentModel(context.TODO())
1147
1148			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1149
1150			return uiutil.NewInfoMsg(modelMsg)
1151		})
1152
1153		m.dialog.CloseDialog(dialog.APIKeyInputID)
1154		m.dialog.CloseDialog(dialog.OAuthID)
1155		m.dialog.CloseDialog(dialog.ModelsID)
1156	case dialog.ActionSelectReasoningEffort:
1157		if m.isAgentBusy() {
1158			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1159			break
1160		}
1161
1162		cfg := m.com.Config()
1163		if cfg == nil {
1164			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1165			break
1166		}
1167
1168		agentCfg, ok := cfg.Agents[config.AgentCoder]
1169		if !ok {
1170			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
1171			break
1172		}
1173
1174		currentModel := cfg.Models[agentCfg.Model]
1175		currentModel.ReasoningEffort = msg.Effort
1176		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1177			cmds = append(cmds, uiutil.ReportError(err))
1178			break
1179		}
1180
1181		cmds = append(cmds, func() tea.Msg {
1182			m.com.App.UpdateAgentModel(context.TODO())
1183			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1184		})
1185		m.dialog.CloseDialog(dialog.ReasoningID)
1186	case dialog.ActionPermissionResponse:
1187		m.dialog.CloseDialog(dialog.PermissionsID)
1188		switch msg.Action {
1189		case dialog.PermissionAllow:
1190			m.com.App.Permissions.Grant(msg.Permission)
1191		case dialog.PermissionAllowForSession:
1192			m.com.App.Permissions.GrantPersistent(msg.Permission)
1193		case dialog.PermissionDeny:
1194			m.com.App.Permissions.Deny(msg.Permission)
1195		}
1196
1197	case dialog.ActionFilePickerSelected:
1198		cmds = append(cmds, tea.Sequence(
1199			msg.Cmd(),
1200			func() tea.Msg {
1201				m.dialog.CloseDialog(dialog.FilePickerID)
1202				return nil
1203			},
1204		))
1205
1206	case dialog.ActionRunCustomCommand:
1207		if len(msg.Arguments) > 0 && msg.Args == nil {
1208			m.dialog.CloseFrontDialog()
1209			argsDialog := dialog.NewArguments(
1210				m.com,
1211				"Custom Command Arguments",
1212				"",
1213				msg.Arguments,
1214				msg, // Pass the action as the result
1215			)
1216			m.dialog.OpenDialog(argsDialog)
1217			break
1218		}
1219		content := msg.Content
1220		if msg.Args != nil {
1221			content = substituteArgs(content, msg.Args)
1222		}
1223		cmds = append(cmds, m.sendMessage(content))
1224		m.dialog.CloseFrontDialog()
1225	case dialog.ActionRunMCPPrompt:
1226		if len(msg.Arguments) > 0 && msg.Args == nil {
1227			m.dialog.CloseFrontDialog()
1228			title := msg.Title
1229			if title == "" {
1230				title = "MCP Prompt Arguments"
1231			}
1232			argsDialog := dialog.NewArguments(
1233				m.com,
1234				title,
1235				msg.Description,
1236				msg.Arguments,
1237				msg, // Pass the action as the result
1238			)
1239			m.dialog.OpenDialog(argsDialog)
1240			break
1241		}
1242		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1243	default:
1244		cmds = append(cmds, uiutil.CmdHandler(msg))
1245	}
1246
1247	return tea.Batch(cmds...)
1248}
1249
1250// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1251func substituteArgs(content string, args map[string]string) string {
1252	for name, value := range args {
1253		placeholder := "$" + name
1254		content = strings.ReplaceAll(content, placeholder, value)
1255	}
1256	return content
1257}
1258
1259func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1260	var (
1261		dlg dialog.Dialog
1262		cmd tea.Cmd
1263	)
1264
1265	switch provider.ID {
1266	case "hyper":
1267		dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
1268	case catwalk.InferenceProviderCopilot:
1269		dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1270	default:
1271		dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1272	}
1273
1274	if m.dialog.ContainsDialog(dlg.ID()) {
1275		m.dialog.BringToFront(dlg.ID())
1276		return nil
1277	}
1278
1279	m.dialog.OpenDialog(dlg)
1280	return cmd
1281}
1282
1283func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1284	var cmds []tea.Cmd
1285
1286	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1287		switch {
1288		case key.Matches(msg, m.keyMap.Help):
1289			m.status.ToggleHelp()
1290			m.updateLayoutAndSize()
1291			return true
1292		case key.Matches(msg, m.keyMap.Commands):
1293			if cmd := m.openCommandsDialog(); cmd != nil {
1294				cmds = append(cmds, cmd)
1295			}
1296			return true
1297		case key.Matches(msg, m.keyMap.Models):
1298			if cmd := m.openModelsDialog(); cmd != nil {
1299				cmds = append(cmds, cmd)
1300			}
1301			return true
1302		case key.Matches(msg, m.keyMap.Sessions):
1303			if cmd := m.openSessionsDialog(); cmd != nil {
1304				cmds = append(cmds, cmd)
1305			}
1306			return true
1307		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1308			m.detailsOpen = !m.detailsOpen
1309			m.updateLayoutAndSize()
1310			return true
1311		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1312			if m.state == uiChat && m.hasSession() {
1313				if cmd := m.togglePillsExpanded(); cmd != nil {
1314					cmds = append(cmds, cmd)
1315				}
1316				return true
1317			}
1318		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1319			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1320				if cmd := m.switchPillSection(-1); cmd != nil {
1321					cmds = append(cmds, cmd)
1322				}
1323				return true
1324			}
1325		case key.Matches(msg, m.keyMap.Chat.PillRight):
1326			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1327				if cmd := m.switchPillSection(1); cmd != nil {
1328					cmds = append(cmds, cmd)
1329				}
1330				return true
1331			}
1332		}
1333		return false
1334	}
1335
1336	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1337		// Always handle quit keys first
1338		if cmd := m.openQuitDialog(); cmd != nil {
1339			cmds = append(cmds, cmd)
1340		}
1341
1342		return tea.Batch(cmds...)
1343	}
1344
1345	// Route all messages to dialog if one is open.
1346	if m.dialog.HasDialogs() {
1347		return m.handleDialogMsg(msg)
1348	}
1349
1350	// Handle cancel key when agent is busy.
1351	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1352		if m.isAgentBusy() {
1353			if cmd := m.cancelAgent(); cmd != nil {
1354				cmds = append(cmds, cmd)
1355			}
1356			return tea.Batch(cmds...)
1357		}
1358	}
1359
1360	switch m.state {
1361	case uiConfigure:
1362		return tea.Batch(cmds...)
1363	case uiInitialize:
1364		cmds = append(cmds, m.updateInitializeView(msg)...)
1365		return tea.Batch(cmds...)
1366	case uiChat, uiLanding:
1367		switch m.focus {
1368		case uiFocusEditor:
1369			// Handle completions if open.
1370			if m.completionsOpen {
1371				if msg, ok := m.completions.Update(msg); ok {
1372					switch msg := msg.(type) {
1373					case completions.SelectionMsg:
1374						// Handle file completion selection.
1375						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1376							cmds = append(cmds, m.insertFileCompletion(item.Path))
1377						}
1378						if !msg.Insert {
1379							m.closeCompletions()
1380						}
1381					case completions.ClosedMsg:
1382						m.completionsOpen = false
1383					}
1384					return tea.Batch(cmds...)
1385				}
1386			}
1387
1388			if ok := m.attachments.Update(msg); ok {
1389				return tea.Batch(cmds...)
1390			}
1391
1392			switch {
1393			case key.Matches(msg, m.keyMap.Editor.AddImage):
1394				if cmd := m.openFilesDialog(); cmd != nil {
1395					cmds = append(cmds, cmd)
1396				}
1397
1398			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1399				value := m.textarea.Value()
1400				if before, ok := strings.CutSuffix(value, "\\"); ok {
1401					// If the last character is a backslash, remove it and add a newline.
1402					m.textarea.SetValue(before)
1403					break
1404				}
1405
1406				// Otherwise, send the message
1407				m.textarea.Reset()
1408
1409				value = strings.TrimSpace(value)
1410				if value == "exit" || value == "quit" {
1411					return m.openQuitDialog()
1412				}
1413
1414				attachments := m.attachments.List()
1415				m.attachments.Reset()
1416				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1417					return nil
1418				}
1419
1420				m.randomizePlaceholders()
1421
1422				return m.sendMessage(value, attachments...)
1423			case key.Matches(msg, m.keyMap.Chat.NewSession):
1424				if !m.hasSession() {
1425					break
1426				}
1427				if m.isAgentBusy() {
1428					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1429					break
1430				}
1431				m.newSession()
1432			case key.Matches(msg, m.keyMap.Tab):
1433				m.focus = uiFocusMain
1434				m.textarea.Blur()
1435				m.chat.Focus()
1436				m.chat.SetSelected(m.chat.Len() - 1)
1437			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1438				if m.isAgentBusy() {
1439					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1440					break
1441				}
1442				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1443			case key.Matches(msg, m.keyMap.Editor.Newline):
1444				m.textarea.InsertRune('\n')
1445				m.closeCompletions()
1446			default:
1447				if handleGlobalKeys(msg) {
1448					// Handle global keys first before passing to textarea.
1449					break
1450				}
1451
1452				// Check for @ trigger before passing to textarea.
1453				curValue := m.textarea.Value()
1454				curIdx := len(curValue)
1455
1456				// Trigger completions on @.
1457				if msg.String() == "@" && !m.completionsOpen {
1458					// Only show if beginning of prompt or after whitespace.
1459					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1460						m.completionsOpen = true
1461						m.completionsQuery = ""
1462						m.completionsStartIndex = curIdx
1463						m.completionsPositionStart = m.completionsPosition()
1464						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1465						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1466					}
1467				}
1468
1469				// remove the details if they are open when user starts typing
1470				if m.detailsOpen {
1471					m.detailsOpen = false
1472					m.updateLayoutAndSize()
1473				}
1474
1475				ta, cmd := m.textarea.Update(msg)
1476				m.textarea = ta
1477				cmds = append(cmds, cmd)
1478
1479				// After updating textarea, check if we need to filter completions.
1480				// Skip filtering on the initial @ keystroke since items are loading async.
1481				if m.completionsOpen && msg.String() != "@" {
1482					newValue := m.textarea.Value()
1483					newIdx := len(newValue)
1484
1485					// Close completions if cursor moved before start.
1486					if newIdx <= m.completionsStartIndex {
1487						m.closeCompletions()
1488					} else if msg.String() == "space" {
1489						// Close on space.
1490						m.closeCompletions()
1491					} else {
1492						// Extract current word and filter.
1493						word := m.textareaWord()
1494						if strings.HasPrefix(word, "@") {
1495							m.completionsQuery = word[1:]
1496							m.completions.Filter(m.completionsQuery)
1497						} else if m.completionsOpen {
1498							m.closeCompletions()
1499						}
1500					}
1501				}
1502			}
1503		case uiFocusMain:
1504			switch {
1505			case key.Matches(msg, m.keyMap.Tab):
1506				m.focus = uiFocusEditor
1507				cmds = append(cmds, m.textarea.Focus())
1508				m.chat.Blur()
1509			case key.Matches(msg, m.keyMap.Chat.Expand):
1510				m.chat.ToggleExpandedSelectedItem()
1511			case key.Matches(msg, m.keyMap.Chat.Up):
1512				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1513					cmds = append(cmds, cmd)
1514				}
1515				if !m.chat.SelectedItemInView() {
1516					m.chat.SelectPrev()
1517					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1518						cmds = append(cmds, cmd)
1519					}
1520				}
1521			case key.Matches(msg, m.keyMap.Chat.Down):
1522				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1523					cmds = append(cmds, cmd)
1524				}
1525				if !m.chat.SelectedItemInView() {
1526					m.chat.SelectNext()
1527					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1528						cmds = append(cmds, cmd)
1529					}
1530				}
1531			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1532				m.chat.SelectPrev()
1533				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1534					cmds = append(cmds, cmd)
1535				}
1536			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1537				m.chat.SelectNext()
1538				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1539					cmds = append(cmds, cmd)
1540				}
1541			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1542				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1543					cmds = append(cmds, cmd)
1544				}
1545				m.chat.SelectFirstInView()
1546			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1547				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1548					cmds = append(cmds, cmd)
1549				}
1550				m.chat.SelectLastInView()
1551			case key.Matches(msg, m.keyMap.Chat.PageUp):
1552				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1553					cmds = append(cmds, cmd)
1554				}
1555				m.chat.SelectFirstInView()
1556			case key.Matches(msg, m.keyMap.Chat.PageDown):
1557				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1558					cmds = append(cmds, cmd)
1559				}
1560				m.chat.SelectLastInView()
1561			case key.Matches(msg, m.keyMap.Chat.Home):
1562				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1563					cmds = append(cmds, cmd)
1564				}
1565				m.chat.SelectFirst()
1566			case key.Matches(msg, m.keyMap.Chat.End):
1567				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1568					cmds = append(cmds, cmd)
1569				}
1570				m.chat.SelectLast()
1571			default:
1572				handleGlobalKeys(msg)
1573			}
1574		default:
1575			handleGlobalKeys(msg)
1576		}
1577	default:
1578		handleGlobalKeys(msg)
1579	}
1580
1581	return tea.Batch(cmds...)
1582}
1583
1584// Draw implements [uv.Drawable] and draws the UI model.
1585func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1586	layout := m.generateLayout(area.Dx(), area.Dy())
1587
1588	if m.layout != layout {
1589		m.layout = layout
1590		m.updateSize()
1591	}
1592
1593	// Clear the screen first
1594	screen.Clear(scr)
1595
1596	switch m.state {
1597	case uiConfigure:
1598		header := uv.NewStyledString(m.header)
1599		header.Draw(scr, layout.header)
1600
1601		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1602			Height(layout.main.Dy()).
1603			Background(lipgloss.ANSIColor(rand.Intn(256))).
1604			Render(" Configure ")
1605		main := uv.NewStyledString(mainView)
1606		main.Draw(scr, layout.main)
1607
1608	case uiInitialize:
1609		header := uv.NewStyledString(m.header)
1610		header.Draw(scr, layout.header)
1611
1612		main := uv.NewStyledString(m.initializeView())
1613		main.Draw(scr, layout.main)
1614
1615	case uiLanding:
1616		header := uv.NewStyledString(m.header)
1617		header.Draw(scr, layout.header)
1618		main := uv.NewStyledString(m.landingView())
1619		main.Draw(scr, layout.main)
1620
1621		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1622		editor.Draw(scr, layout.editor)
1623
1624	case uiChat:
1625		if m.isCompact {
1626			header := uv.NewStyledString(m.header)
1627			header.Draw(scr, layout.header)
1628		} else {
1629			m.drawSidebar(scr, layout.sidebar)
1630		}
1631
1632		m.chat.Draw(scr, layout.main)
1633		if layout.pills.Dy() > 0 && m.pillsView != "" {
1634			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1635		}
1636
1637		editorWidth := scr.Bounds().Dx()
1638		if !m.isCompact {
1639			editorWidth -= layout.sidebar.Dx()
1640		}
1641		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1642		editor.Draw(scr, layout.editor)
1643
1644		// Draw details overlay in compact mode when open
1645		if m.isCompact && m.detailsOpen {
1646			m.drawSessionDetails(scr, layout.sessionDetails)
1647		}
1648	}
1649
1650	// Add status and help layer
1651	m.status.Draw(scr, layout.status)
1652
1653	// Draw completions popup if open
1654	if m.completionsOpen && m.completions.HasItems() {
1655		w, h := m.completions.Size()
1656		x := m.completionsPositionStart.X
1657		y := m.completionsPositionStart.Y - h
1658
1659		screenW := area.Dx()
1660		if x+w > screenW {
1661			x = screenW - w
1662		}
1663		x = max(0, x)
1664		y = max(0, y)
1665
1666		completionsView := uv.NewStyledString(m.completions.Render())
1667		completionsView.Draw(scr, image.Rectangle{
1668			Min: image.Pt(x, y),
1669			Max: image.Pt(x+w, y+h),
1670		})
1671	}
1672
1673	// Debugging rendering (visually see when the tui rerenders)
1674	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1675		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1676		debug := uv.NewStyledString(debugView.String())
1677		debug.Draw(scr, image.Rectangle{
1678			Min: image.Pt(4, 1),
1679			Max: image.Pt(8, 3),
1680		})
1681	}
1682
1683	// This needs to come last to overlay on top of everything. We always pass
1684	// the full screen bounds because the dialogs will position themselves
1685	// accordingly.
1686	if m.dialog.HasDialogs() {
1687		return m.dialog.Draw(scr, scr.Bounds())
1688	}
1689
1690	switch m.focus {
1691	case uiFocusEditor:
1692		if m.layout.editor.Dy() <= 0 {
1693			// Don't show cursor if editor is not visible
1694			return nil
1695		}
1696		if m.detailsOpen && m.isCompact {
1697			// Don't show cursor if details overlay is open
1698			return nil
1699		}
1700
1701		if m.textarea.Focused() {
1702			cur := m.textarea.Cursor()
1703			cur.X++ // Adjust for app margins
1704			cur.Y += m.layout.editor.Min.Y
1705			// Offset for attachment row if present.
1706			if len(m.attachments.List()) > 0 {
1707				cur.Y++
1708			}
1709			return cur
1710		}
1711	}
1712	return nil
1713}
1714
1715// View renders the UI model's view.
1716func (m *UI) View() tea.View {
1717	var v tea.View
1718	v.AltScreen = true
1719	v.BackgroundColor = m.com.Styles.Background
1720	v.MouseMode = tea.MouseModeCellMotion
1721	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1722
1723	canvas := uv.NewScreenBuffer(m.width, m.height)
1724	v.Cursor = m.Draw(canvas, canvas.Bounds())
1725
1726	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1727	contentLines := strings.Split(content, "\n")
1728	for i, line := range contentLines {
1729		// Trim trailing spaces for concise rendering
1730		contentLines[i] = strings.TrimRight(line, " ")
1731	}
1732
1733	content = strings.Join(contentLines, "\n")
1734
1735	v.Content = content
1736	if m.sendProgressBar && m.isAgentBusy() {
1737		// HACK: use a random percentage to prevent ghostty from hiding it
1738		// after a timeout.
1739		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1740	}
1741
1742	return v
1743}
1744
1745// ShortHelp implements [help.KeyMap].
1746func (m *UI) ShortHelp() []key.Binding {
1747	var binds []key.Binding
1748	k := &m.keyMap
1749	tab := k.Tab
1750	commands := k.Commands
1751	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1752		commands.SetHelp("/ or ctrl+p", "commands")
1753	}
1754
1755	switch m.state {
1756	case uiInitialize:
1757		binds = append(binds, k.Quit)
1758	case uiChat:
1759		// Show cancel binding if agent is busy.
1760		if m.isAgentBusy() {
1761			cancelBinding := k.Chat.Cancel
1762			if m.isCanceling {
1763				cancelBinding.SetHelp("esc", "press again to cancel")
1764			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1765				cancelBinding.SetHelp("esc", "clear queue")
1766			}
1767			binds = append(binds, cancelBinding)
1768		}
1769
1770		if m.focus == uiFocusEditor {
1771			tab.SetHelp("tab", "focus chat")
1772		} else {
1773			tab.SetHelp("tab", "focus editor")
1774		}
1775
1776		binds = append(binds,
1777			tab,
1778			commands,
1779			k.Models,
1780		)
1781
1782		switch m.focus {
1783		case uiFocusEditor:
1784			binds = append(binds,
1785				k.Editor.Newline,
1786			)
1787		case uiFocusMain:
1788			binds = append(binds,
1789				k.Chat.UpDown,
1790				k.Chat.UpDownOneItem,
1791				k.Chat.PageUp,
1792				k.Chat.PageDown,
1793				k.Chat.Copy,
1794			)
1795			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1796				binds = append(binds, k.Chat.PillLeft)
1797			}
1798		}
1799	default:
1800		// TODO: other states
1801		// if m.session == nil {
1802		// no session selected
1803		binds = append(binds,
1804			commands,
1805			k.Models,
1806			k.Editor.Newline,
1807		)
1808	}
1809
1810	binds = append(binds,
1811		k.Quit,
1812		k.Help,
1813	)
1814
1815	return binds
1816}
1817
1818// FullHelp implements [help.KeyMap].
1819func (m *UI) FullHelp() [][]key.Binding {
1820	var binds [][]key.Binding
1821	k := &m.keyMap
1822	help := k.Help
1823	help.SetHelp("ctrl+g", "less")
1824	hasAttachments := len(m.attachments.List()) > 0
1825	hasSession := m.hasSession()
1826	commands := k.Commands
1827	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1828		commands.SetHelp("/ or ctrl+p", "commands")
1829	}
1830
1831	switch m.state {
1832	case uiInitialize:
1833		binds = append(binds,
1834			[]key.Binding{
1835				k.Quit,
1836			})
1837	case uiChat:
1838		// Show cancel binding if agent is busy.
1839		if m.isAgentBusy() {
1840			cancelBinding := k.Chat.Cancel
1841			if m.isCanceling {
1842				cancelBinding.SetHelp("esc", "press again to cancel")
1843			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1844				cancelBinding.SetHelp("esc", "clear queue")
1845			}
1846			binds = append(binds, []key.Binding{cancelBinding})
1847		}
1848
1849		mainBinds := []key.Binding{}
1850		tab := k.Tab
1851		if m.focus == uiFocusEditor {
1852			tab.SetHelp("tab", "focus chat")
1853		} else {
1854			tab.SetHelp("tab", "focus editor")
1855		}
1856
1857		mainBinds = append(mainBinds,
1858			tab,
1859			commands,
1860			k.Models,
1861			k.Sessions,
1862		)
1863		if hasSession {
1864			mainBinds = append(mainBinds, k.Chat.NewSession)
1865		}
1866
1867		binds = append(binds, mainBinds)
1868
1869		switch m.focus {
1870		case uiFocusEditor:
1871			binds = append(binds,
1872				[]key.Binding{
1873					k.Editor.Newline,
1874					k.Editor.AddImage,
1875					k.Editor.MentionFile,
1876					k.Editor.OpenEditor,
1877				},
1878			)
1879			if hasAttachments {
1880				binds = append(binds,
1881					[]key.Binding{
1882						k.Editor.AttachmentDeleteMode,
1883						k.Editor.DeleteAllAttachments,
1884						k.Editor.Escape,
1885					},
1886				)
1887			}
1888		case uiFocusMain:
1889			binds = append(binds,
1890				[]key.Binding{
1891					k.Chat.UpDown,
1892					k.Chat.UpDownOneItem,
1893					k.Chat.PageUp,
1894					k.Chat.PageDown,
1895				},
1896				[]key.Binding{
1897					k.Chat.HalfPageUp,
1898					k.Chat.HalfPageDown,
1899					k.Chat.Home,
1900					k.Chat.End,
1901				},
1902				[]key.Binding{
1903					k.Chat.Copy,
1904					k.Chat.ClearHighlight,
1905				},
1906			)
1907			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1908				binds = append(binds, []key.Binding{k.Chat.PillLeft})
1909			}
1910		}
1911	default:
1912		if m.session == nil {
1913			// no session selected
1914			binds = append(binds,
1915				[]key.Binding{
1916					commands,
1917					k.Models,
1918					k.Sessions,
1919				},
1920				[]key.Binding{
1921					k.Editor.Newline,
1922					k.Editor.AddImage,
1923					k.Editor.MentionFile,
1924					k.Editor.OpenEditor,
1925				},
1926			)
1927			if hasAttachments {
1928				binds = append(binds,
1929					[]key.Binding{
1930						k.Editor.AttachmentDeleteMode,
1931						k.Editor.DeleteAllAttachments,
1932						k.Editor.Escape,
1933					},
1934				)
1935			}
1936			binds = append(binds,
1937				[]key.Binding{
1938					help,
1939				},
1940			)
1941		}
1942	}
1943
1944	binds = append(binds,
1945		[]key.Binding{
1946			help,
1947			k.Quit,
1948		},
1949	)
1950
1951	return binds
1952}
1953
1954// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1955func (m *UI) toggleCompactMode() tea.Cmd {
1956	m.forceCompactMode = !m.forceCompactMode
1957
1958	err := m.com.Config().SetCompactMode(m.forceCompactMode)
1959	if err != nil {
1960		return uiutil.ReportError(err)
1961	}
1962
1963	m.handleCompactMode(m.width, m.height)
1964	m.updateLayoutAndSize()
1965
1966	return nil
1967}
1968
1969// handleCompactMode updates the UI state based on window size and compact mode setting.
1970func (m *UI) handleCompactMode(newWidth, newHeight int) {
1971	if m.state == uiChat {
1972		if m.forceCompactMode {
1973			m.isCompact = true
1974			return
1975		}
1976		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1977			m.isCompact = true
1978		} else {
1979			m.isCompact = false
1980		}
1981	}
1982}
1983
1984// updateLayoutAndSize updates the layout and sizes of UI components.
1985func (m *UI) updateLayoutAndSize() {
1986	m.layout = m.generateLayout(m.width, m.height)
1987	m.updateSize()
1988}
1989
1990// updateSize updates the sizes of UI components based on the current layout.
1991func (m *UI) updateSize() {
1992	// Set status width
1993	m.status.SetWidth(m.layout.status.Dx())
1994
1995	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1996	m.textarea.SetWidth(m.layout.editor.Dx())
1997	m.textarea.SetHeight(m.layout.editor.Dy())
1998	m.renderPills()
1999
2000	// Handle different app states
2001	switch m.state {
2002	case uiConfigure, uiInitialize, uiLanding:
2003		m.renderHeader(false, m.layout.header.Dx())
2004
2005	case uiChat:
2006		if m.isCompact {
2007			m.renderHeader(true, m.layout.header.Dx())
2008		} else {
2009			m.renderSidebarLogo(m.layout.sidebar.Dx())
2010		}
2011	}
2012}
2013
2014// generateLayout calculates the layout rectangles for all UI components based
2015// on the current UI state and terminal dimensions.
2016func (m *UI) generateLayout(w, h int) layout {
2017	// The screen area we're working with
2018	area := image.Rect(0, 0, w, h)
2019
2020	// The help height
2021	helpHeight := 1
2022	// The editor height
2023	editorHeight := 5
2024	// The sidebar width
2025	sidebarWidth := 30
2026	// The header height
2027	const landingHeaderHeight = 4
2028
2029	var helpKeyMap help.KeyMap = m
2030	if m.status.ShowingAll() {
2031		for _, row := range helpKeyMap.FullHelp() {
2032			helpHeight = max(helpHeight, len(row))
2033		}
2034	}
2035
2036	// Add app margins
2037	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2038	appRect.Min.Y += 1
2039	appRect.Max.Y -= 1
2040	helpRect.Min.Y -= 1
2041	appRect.Min.X += 1
2042	appRect.Max.X -= 1
2043
2044	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
2045		// extra padding on left and right for these states
2046		appRect.Min.X += 1
2047		appRect.Max.X -= 1
2048	}
2049
2050	layout := layout{
2051		area:   area,
2052		status: helpRect,
2053	}
2054
2055	// Handle different app states
2056	switch m.state {
2057	case uiConfigure, uiInitialize:
2058		// Layout
2059		//
2060		// header
2061		// ------
2062		// main
2063		// ------
2064		// help
2065
2066		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2067		layout.header = headerRect
2068		layout.main = mainRect
2069
2070	case uiLanding:
2071		// Layout
2072		//
2073		// header
2074		// ------
2075		// main
2076		// ------
2077		// editor
2078		// ------
2079		// help
2080		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2081		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2082		// Remove extra padding from editor (but keep it for header and main)
2083		editorRect.Min.X -= 1
2084		editorRect.Max.X += 1
2085		layout.header = headerRect
2086		layout.main = mainRect
2087		layout.editor = editorRect
2088
2089	case uiChat:
2090		if m.isCompact {
2091			// Layout
2092			//
2093			// compact-header
2094			// ------
2095			// main
2096			// ------
2097			// editor
2098			// ------
2099			// help
2100			const compactHeaderHeight = 1
2101			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2102			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2103			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2104			layout.sessionDetails = sessionDetailsArea
2105			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2106			// Add one line gap between header and main content
2107			mainRect.Min.Y += 1
2108			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2109			mainRect.Max.X -= 1 // Add padding right
2110			layout.header = headerRect
2111			pillsHeight := m.pillsAreaHeight()
2112			if pillsHeight > 0 {
2113				pillsHeight = min(pillsHeight, mainRect.Dy())
2114				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2115				layout.main = chatRect
2116				layout.pills = pillsRect
2117			} else {
2118				layout.main = mainRect
2119			}
2120			// Add bottom margin to main
2121			layout.main.Max.Y -= 1
2122			layout.editor = editorRect
2123		} else {
2124			// Layout
2125			//
2126			// ------|---
2127			// main  |
2128			// ------| side
2129			// editor|
2130			// ----------
2131			// help
2132
2133			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2134			// Add padding left
2135			sideRect.Min.X += 1
2136			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2137			mainRect.Max.X -= 1 // Add padding right
2138			layout.sidebar = sideRect
2139			pillsHeight := m.pillsAreaHeight()
2140			if pillsHeight > 0 {
2141				pillsHeight = min(pillsHeight, mainRect.Dy())
2142				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2143				layout.main = chatRect
2144				layout.pills = pillsRect
2145			} else {
2146				layout.main = mainRect
2147			}
2148			// Add bottom margin to main
2149			layout.main.Max.Y -= 1
2150			layout.editor = editorRect
2151		}
2152	}
2153
2154	if !layout.editor.Empty() {
2155		// Add editor margins 1 top and bottom
2156		layout.editor.Min.Y += 1
2157		layout.editor.Max.Y -= 1
2158	}
2159
2160	return layout
2161}
2162
2163// layout defines the positioning of UI elements.
2164type layout struct {
2165	// area is the overall available area.
2166	area uv.Rectangle
2167
2168	// header is the header shown in special cases
2169	// e.x when the sidebar is collapsed
2170	// or when in the landing page
2171	// or in init/config
2172	header uv.Rectangle
2173
2174	// main is the area for the main pane. (e.x chat, configure, landing)
2175	main uv.Rectangle
2176
2177	// pills is the area for the pills panel.
2178	pills uv.Rectangle
2179
2180	// editor is the area for the editor pane.
2181	editor uv.Rectangle
2182
2183	// sidebar is the area for the sidebar.
2184	sidebar uv.Rectangle
2185
2186	// status is the area for the status view.
2187	status uv.Rectangle
2188
2189	// session details is the area for the session details overlay in compact mode.
2190	sessionDetails uv.Rectangle
2191}
2192
2193func (m *UI) openEditor(value string) tea.Cmd {
2194	tmpfile, err := os.CreateTemp("", "msg_*.md")
2195	if err != nil {
2196		return uiutil.ReportError(err)
2197	}
2198	defer tmpfile.Close() //nolint:errcheck
2199	if _, err := tmpfile.WriteString(value); err != nil {
2200		return uiutil.ReportError(err)
2201	}
2202	cmd, err := editor.Command(
2203		"crush",
2204		tmpfile.Name(),
2205		editor.AtPosition(
2206			m.textarea.Line()+1,
2207			m.textarea.Column()+1,
2208		),
2209	)
2210	if err != nil {
2211		return uiutil.ReportError(err)
2212	}
2213	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2214		if err != nil {
2215			return uiutil.ReportError(err)
2216		}
2217		content, err := os.ReadFile(tmpfile.Name())
2218		if err != nil {
2219			return uiutil.ReportError(err)
2220		}
2221		if len(content) == 0 {
2222			return uiutil.ReportWarn("Message is empty")
2223		}
2224		os.Remove(tmpfile.Name())
2225		return openEditorMsg{
2226			Text: strings.TrimSpace(string(content)),
2227		}
2228	})
2229}
2230
2231// setEditorPrompt configures the textarea prompt function based on whether
2232// yolo mode is enabled.
2233func (m *UI) setEditorPrompt(yolo bool) {
2234	if yolo {
2235		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2236		return
2237	}
2238	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2239}
2240
2241// normalPromptFunc returns the normal editor prompt style ("  > " on first
2242// line, "::: " on subsequent lines).
2243func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2244	t := m.com.Styles
2245	if info.LineNumber == 0 {
2246		if info.Focused {
2247			return "  > "
2248		}
2249		return "::: "
2250	}
2251	if info.Focused {
2252		return t.EditorPromptNormalFocused.Render()
2253	}
2254	return t.EditorPromptNormalBlurred.Render()
2255}
2256
2257// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2258// and colored dots.
2259func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2260	t := m.com.Styles
2261	if info.LineNumber == 0 {
2262		if info.Focused {
2263			return t.EditorPromptYoloIconFocused.Render()
2264		} else {
2265			return t.EditorPromptYoloIconBlurred.Render()
2266		}
2267	}
2268	if info.Focused {
2269		return t.EditorPromptYoloDotsFocused.Render()
2270	}
2271	return t.EditorPromptYoloDotsBlurred.Render()
2272}
2273
2274// closeCompletions closes the completions popup and resets state.
2275func (m *UI) closeCompletions() {
2276	m.completionsOpen = false
2277	m.completionsQuery = ""
2278	m.completionsStartIndex = 0
2279	m.completions.Close()
2280}
2281
2282// insertFileCompletion inserts the selected file path into the textarea,
2283// replacing the @query, and adds the file as an attachment.
2284func (m *UI) insertFileCompletion(path string) tea.Cmd {
2285	value := m.textarea.Value()
2286	word := m.textareaWord()
2287
2288	// Find the @ and query to replace.
2289	if m.completionsStartIndex > len(value) {
2290		return nil
2291	}
2292
2293	// Build the new value: everything before @, the path, everything after query.
2294	endIdx := min(m.completionsStartIndex+len(word), len(value))
2295
2296	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2297	m.textarea.SetValue(newValue)
2298	m.textarea.MoveToEnd()
2299	m.textarea.InsertRune(' ')
2300
2301	return func() tea.Msg {
2302		absPath, _ := filepath.Abs(path)
2303		// Skip attachment if file was already read and hasn't been modified.
2304		lastRead := filetracker.LastReadTime(absPath)
2305		if !lastRead.IsZero() {
2306			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2307				return nil
2308			}
2309		}
2310
2311		// Add file as attachment.
2312		content, err := os.ReadFile(path)
2313		if err != nil {
2314			// If it fails, let the LLM handle it later.
2315			return nil
2316		}
2317		filetracker.RecordRead(absPath)
2318
2319		return message.Attachment{
2320			FilePath: path,
2321			FileName: filepath.Base(path),
2322			MimeType: mimeOf(content),
2323			Content:  content,
2324		}
2325	}
2326}
2327
2328// completionsPosition returns the X and Y position for the completions popup.
2329func (m *UI) completionsPosition() image.Point {
2330	cur := m.textarea.Cursor()
2331	if cur == nil {
2332		return image.Point{
2333			X: m.layout.editor.Min.X,
2334			Y: m.layout.editor.Min.Y,
2335		}
2336	}
2337	return image.Point{
2338		X: cur.X + m.layout.editor.Min.X,
2339		Y: m.layout.editor.Min.Y + cur.Y,
2340	}
2341}
2342
2343// textareaWord returns the current word at the cursor position.
2344func (m *UI) textareaWord() string {
2345	return m.textarea.Word()
2346}
2347
2348// isWhitespace returns true if the byte is a whitespace character.
2349func isWhitespace(b byte) bool {
2350	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2351}
2352
2353// isAgentBusy returns true if the agent coordinator exists and is currently
2354// busy processing a request.
2355func (m *UI) isAgentBusy() bool {
2356	return m.com.App != nil &&
2357		m.com.App.AgentCoordinator != nil &&
2358		m.com.App.AgentCoordinator.IsBusy()
2359}
2360
2361// hasSession returns true if there is an active session with a valid ID.
2362func (m *UI) hasSession() bool {
2363	return m.session != nil && m.session.ID != ""
2364}
2365
2366// mimeOf detects the MIME type of the given content.
2367func mimeOf(content []byte) string {
2368	mimeBufferSize := min(512, len(content))
2369	return http.DetectContentType(content[:mimeBufferSize])
2370}
2371
2372var readyPlaceholders = [...]string{
2373	"Ready!",
2374	"Ready...",
2375	"Ready?",
2376	"Ready for instructions",
2377}
2378
2379var workingPlaceholders = [...]string{
2380	"Working!",
2381	"Working...",
2382	"Brrrrr...",
2383	"Prrrrrrrr...",
2384	"Processing...",
2385	"Thinking...",
2386}
2387
2388// randomizePlaceholders selects random placeholder text for the textarea's
2389// ready and working states.
2390func (m *UI) randomizePlaceholders() {
2391	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2392	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2393}
2394
2395// renderEditorView renders the editor view with attachments if any.
2396func (m *UI) renderEditorView(width int) string {
2397	if len(m.attachments.List()) == 0 {
2398		return m.textarea.View()
2399	}
2400	return lipgloss.JoinVertical(
2401		lipgloss.Top,
2402		m.attachments.Render(width),
2403		m.textarea.View(),
2404	)
2405}
2406
2407// renderHeader renders and caches the header logo at the specified width.
2408func (m *UI) renderHeader(compact bool, width int) {
2409	if compact && m.session != nil && m.com.App != nil {
2410		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2411	} else {
2412		m.header = renderLogo(m.com.Styles, compact, width)
2413	}
2414}
2415
2416// renderSidebarLogo renders and caches the sidebar logo at the specified
2417// width.
2418func (m *UI) renderSidebarLogo(width int) {
2419	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2420}
2421
2422// sendMessage sends a message with the given content and attachments.
2423func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2424	if m.com.App.AgentCoordinator == nil {
2425		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2426	}
2427
2428	var cmds []tea.Cmd
2429	if !m.hasSession() {
2430		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2431		if err != nil {
2432			return uiutil.ReportError(err)
2433		}
2434		m.state = uiChat
2435		if m.forceCompactMode {
2436			m.isCompact = true
2437		}
2438		if newSession.ID != "" {
2439			m.session = &newSession
2440			cmds = append(cmds, m.loadSession(newSession.ID))
2441		}
2442	}
2443
2444	// Capture session ID to avoid race with main goroutine updating m.session.
2445	sessionID := m.session.ID
2446	cmds = append(cmds, func() tea.Msg {
2447		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2448		if err != nil {
2449			isCancelErr := errors.Is(err, context.Canceled)
2450			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2451			if isCancelErr || isPermissionErr {
2452				return nil
2453			}
2454			return uiutil.InfoMsg{
2455				Type: uiutil.InfoTypeError,
2456				Msg:  err.Error(),
2457			}
2458		}
2459		return nil
2460	})
2461	return tea.Batch(cmds...)
2462}
2463
2464const cancelTimerDuration = 2 * time.Second
2465
2466// cancelTimerCmd creates a command that expires the cancel timer.
2467func cancelTimerCmd() tea.Cmd {
2468	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2469		return cancelTimerExpiredMsg{}
2470	})
2471}
2472
2473// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2474// and starts a timer. The second press (before the timer expires) actually
2475// cancels the agent.
2476func (m *UI) cancelAgent() tea.Cmd {
2477	if !m.hasSession() {
2478		return nil
2479	}
2480
2481	coordinator := m.com.App.AgentCoordinator
2482	if coordinator == nil {
2483		return nil
2484	}
2485
2486	if m.isCanceling {
2487		// Second escape press - actually cancel the agent.
2488		m.isCanceling = false
2489		coordinator.Cancel(m.session.ID)
2490		// Stop the spinning todo indicator.
2491		m.todoIsSpinning = false
2492		m.renderPills()
2493		return nil
2494	}
2495
2496	// Check if there are queued prompts - if so, clear the queue.
2497	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2498		coordinator.ClearQueue(m.session.ID)
2499		return nil
2500	}
2501
2502	// First escape press - set canceling state and start timer.
2503	m.isCanceling = true
2504	return cancelTimerCmd()
2505}
2506
2507// openDialog opens a dialog by its ID.
2508func (m *UI) openDialog(id string) tea.Cmd {
2509	var cmds []tea.Cmd
2510	switch id {
2511	case dialog.SessionsID:
2512		if cmd := m.openSessionsDialog(); cmd != nil {
2513			cmds = append(cmds, cmd)
2514		}
2515	case dialog.ModelsID:
2516		if cmd := m.openModelsDialog(); cmd != nil {
2517			cmds = append(cmds, cmd)
2518		}
2519	case dialog.CommandsID:
2520		if cmd := m.openCommandsDialog(); cmd != nil {
2521			cmds = append(cmds, cmd)
2522		}
2523	case dialog.ReasoningID:
2524		if cmd := m.openReasoningDialog(); cmd != nil {
2525			cmds = append(cmds, cmd)
2526		}
2527	case dialog.QuitID:
2528		if cmd := m.openQuitDialog(); cmd != nil {
2529			cmds = append(cmds, cmd)
2530		}
2531	default:
2532		// Unknown dialog
2533		break
2534	}
2535	return tea.Batch(cmds...)
2536}
2537
2538// openQuitDialog opens the quit confirmation dialog.
2539func (m *UI) openQuitDialog() tea.Cmd {
2540	if m.dialog.ContainsDialog(dialog.QuitID) {
2541		// Bring to front
2542		m.dialog.BringToFront(dialog.QuitID)
2543		return nil
2544	}
2545
2546	quitDialog := dialog.NewQuit(m.com)
2547	m.dialog.OpenDialog(quitDialog)
2548	return nil
2549}
2550
2551// openModelsDialog opens the models dialog.
2552func (m *UI) openModelsDialog() tea.Cmd {
2553	if m.dialog.ContainsDialog(dialog.ModelsID) {
2554		// Bring to front
2555		m.dialog.BringToFront(dialog.ModelsID)
2556		return nil
2557	}
2558
2559	modelsDialog, err := dialog.NewModels(m.com)
2560	if err != nil {
2561		return uiutil.ReportError(err)
2562	}
2563
2564	m.dialog.OpenDialog(modelsDialog)
2565
2566	return nil
2567}
2568
2569// openCommandsDialog opens the commands dialog.
2570func (m *UI) openCommandsDialog() tea.Cmd {
2571	if m.dialog.ContainsDialog(dialog.CommandsID) {
2572		// Bring to front
2573		m.dialog.BringToFront(dialog.CommandsID)
2574		return nil
2575	}
2576
2577	sessionID := ""
2578	if m.session != nil {
2579		sessionID = m.session.ID
2580	}
2581
2582	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2583	if err != nil {
2584		return uiutil.ReportError(err)
2585	}
2586
2587	m.dialog.OpenDialog(commands)
2588
2589	return nil
2590}
2591
2592// openReasoningDialog opens the reasoning effort dialog.
2593func (m *UI) openReasoningDialog() tea.Cmd {
2594	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2595		m.dialog.BringToFront(dialog.ReasoningID)
2596		return nil
2597	}
2598
2599	reasoningDialog, err := dialog.NewReasoning(m.com)
2600	if err != nil {
2601		return uiutil.ReportError(err)
2602	}
2603
2604	m.dialog.OpenDialog(reasoningDialog)
2605	return nil
2606}
2607
2608// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2609// it brings it to the front. Otherwise, it will list all the sessions and open
2610// the dialog.
2611func (m *UI) openSessionsDialog() tea.Cmd {
2612	if m.dialog.ContainsDialog(dialog.SessionsID) {
2613		// Bring to front
2614		m.dialog.BringToFront(dialog.SessionsID)
2615		return nil
2616	}
2617
2618	selectedSessionID := ""
2619	if m.session != nil {
2620		selectedSessionID = m.session.ID
2621	}
2622
2623	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2624	if err != nil {
2625		return uiutil.ReportError(err)
2626	}
2627
2628	m.dialog.OpenDialog(dialog)
2629	return nil
2630}
2631
2632// openFilesDialog opens the file picker dialog.
2633func (m *UI) openFilesDialog() tea.Cmd {
2634	if m.dialog.ContainsDialog(dialog.FilePickerID) {
2635		// Bring to front
2636		m.dialog.BringToFront(dialog.FilePickerID)
2637		return nil
2638	}
2639
2640	filePicker, cmd := dialog.NewFilePicker(m.com)
2641	filePicker.SetImageCapabilities(&m.imgCaps)
2642	m.dialog.OpenDialog(filePicker)
2643
2644	return cmd
2645}
2646
2647// openPermissionsDialog opens the permissions dialog for a permission request.
2648func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2649	// Close any existing permissions dialog first.
2650	m.dialog.CloseDialog(dialog.PermissionsID)
2651
2652	// Get diff mode from config.
2653	var opts []dialog.PermissionsOption
2654	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2655		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2656	}
2657
2658	permDialog := dialog.NewPermissions(m.com, perm, opts...)
2659	m.dialog.OpenDialog(permDialog)
2660	return nil
2661}
2662
2663// handlePermissionNotification updates tool items when permission state changes.
2664func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2665	toolItem := m.chat.MessageItem(notification.ToolCallID)
2666	if toolItem == nil {
2667		return
2668	}
2669
2670	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2671		if notification.Granted {
2672			permItem.SetStatus(chat.ToolStatusRunning)
2673		} else {
2674			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2675		}
2676	}
2677}
2678
2679// newSession clears the current session state and prepares for a new session.
2680// The actual session creation happens when the user sends their first message.
2681func (m *UI) newSession() {
2682	if !m.hasSession() {
2683		return
2684	}
2685
2686	m.session = nil
2687	m.sessionFiles = nil
2688	m.state = uiLanding
2689	m.focus = uiFocusEditor
2690	m.textarea.Focus()
2691	m.chat.Blur()
2692	m.chat.ClearMessages()
2693	m.pillsExpanded = false
2694	m.promptQueue = 0
2695	m.pillsView = ""
2696}
2697
2698// handlePasteMsg handles a paste message.
2699func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2700	if m.dialog.HasDialogs() {
2701		return m.handleDialogMsg(msg)
2702	}
2703
2704	if m.focus != uiFocusEditor {
2705		return nil
2706	}
2707
2708	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2709		return func() tea.Msg {
2710			content := []byte(msg.Content)
2711			if int64(len(content)) > common.MaxAttachmentSize {
2712				return uiutil.ReportWarn("Paste is too big (>5mb)")
2713			}
2714			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2715			mimeBufferSize := min(512, len(content))
2716			mimeType := http.DetectContentType(content[:mimeBufferSize])
2717			return message.Attachment{
2718				FileName: name,
2719				FilePath: name,
2720				MimeType: mimeType,
2721				Content:  content,
2722			}
2723		}
2724	}
2725
2726	var cmd tea.Cmd
2727	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2728	// Try to get an image.
2729	path, err := filepath.Abs(strings.TrimSpace(path))
2730	if err != nil {
2731		m.textarea, cmd = m.textarea.Update(msg)
2732		return cmd
2733	}
2734
2735	// Check if file has an allowed image extension.
2736	isAllowedType := false
2737	lowerPath := strings.ToLower(path)
2738	for _, ext := range common.AllowedImageTypes {
2739		if strings.HasSuffix(lowerPath, ext) {
2740			isAllowedType = true
2741			break
2742		}
2743	}
2744	if !isAllowedType {
2745		m.textarea, cmd = m.textarea.Update(msg)
2746		return cmd
2747	}
2748
2749	return func() tea.Msg {
2750		fileInfo, err := os.Stat(path)
2751		if err != nil {
2752			return uiutil.ReportError(err)
2753		}
2754		if fileInfo.Size() > common.MaxAttachmentSize {
2755			return uiutil.ReportWarn("File is too big (>5mb)")
2756		}
2757
2758		content, err := os.ReadFile(path)
2759		if err != nil {
2760			return uiutil.ReportError(err)
2761		}
2762
2763		mimeBufferSize := min(512, len(content))
2764		mimeType := http.DetectContentType(content[:mimeBufferSize])
2765		fileName := filepath.Base(path)
2766		return message.Attachment{
2767			FilePath: path,
2768			FileName: fileName,
2769			MimeType: mimeType,
2770			Content:  content,
2771		}
2772	}
2773}
2774
2775var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2776
2777func (m *UI) pasteIdx() int {
2778	result := 0
2779	for _, at := range m.attachments.List() {
2780		found := pasteRE.FindStringSubmatch(at.FileName)
2781		if len(found) == 0 {
2782			continue
2783		}
2784		idx, err := strconv.Atoi(found[1])
2785		if err == nil {
2786			result = max(result, idx)
2787		}
2788	}
2789	return result + 1
2790}
2791
2792// drawSessionDetails draws the session details in compact mode.
2793func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2794	if m.session == nil {
2795		return
2796	}
2797
2798	s := m.com.Styles
2799
2800	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2801	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2802
2803	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2804	blocks := []string{
2805		title,
2806		"",
2807		m.modelInfo(width),
2808		"",
2809	}
2810
2811	detailsHeader := lipgloss.JoinVertical(
2812		lipgloss.Left,
2813		blocks...,
2814	)
2815
2816	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2817
2818	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2819
2820	const maxSectionWidth = 50
2821	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2822	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
2823
2824	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2825	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2826	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2827	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2828	uv.NewStyledString(
2829		s.CompactDetails.View.
2830			Width(area.Dx()).
2831			Render(
2832				lipgloss.JoinVertical(
2833					lipgloss.Left,
2834					detailsHeader,
2835					sections,
2836					version,
2837				),
2838			),
2839	).Draw(scr, area)
2840}
2841
2842func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2843	load := func() tea.Msg {
2844		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2845		if err != nil {
2846			// TODO: make this better
2847			return uiutil.ReportError(err)()
2848		}
2849
2850		if prompt == "" {
2851			return nil
2852		}
2853		return sendMessageMsg{
2854			Content: prompt,
2855		}
2856	}
2857
2858	var cmds []tea.Cmd
2859	if cmd := m.dialog.StartLoading(); cmd != nil {
2860		cmds = append(cmds, cmd)
2861	}
2862	cmds = append(cmds, load, func() tea.Msg {
2863		return closeDialogMsg{}
2864	})
2865
2866	return tea.Sequence(cmds...)
2867}
2868
2869func (m *UI) copyChatHighlight() tea.Cmd {
2870	text := m.chat.HighlighContent()
2871	return tea.Sequence(
2872		tea.SetClipboard(text),
2873		func() tea.Msg {
2874			_ = clipboard.WriteAll(text)
2875			return nil
2876		},
2877		func() tea.Msg {
2878			m.chat.ClearMouse()
2879			return nil
2880		},
2881		uiutil.ReportInfo("Selected text copied to clipboard"),
2882	)
2883}
2884
2885// renderLogo renders the Crush logo with the given styles and dimensions.
2886func renderLogo(t *styles.Styles, compact bool, width int) string {
2887	return logo.Render(version.Version, compact, logo.Opts{
2888		FieldColor:   t.LogoFieldColor,
2889		TitleColorA:  t.LogoTitleColorA,
2890		TitleColorB:  t.LogoTitleColorB,
2891		CharmColor:   t.LogoCharmColor,
2892		VersionColor: t.LogoVersionColor,
2893		Width:        width,
2894	})
2895}