ui.go

   1package model
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"image"
   8	"math/rand"
   9	"net/http"
  10	"os"
  11	"path/filepath"
  12	"regexp"
  13	"slices"
  14	"strconv"
  15	"strings"
  16
  17	"charm.land/bubbles/v2/help"
  18	"charm.land/bubbles/v2/key"
  19	"charm.land/bubbles/v2/textarea"
  20	tea "charm.land/bubbletea/v2"
  21	"charm.land/lipgloss/v2"
  22	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  23	"github.com/charmbracelet/crush/internal/app"
  24	"github.com/charmbracelet/crush/internal/config"
  25	"github.com/charmbracelet/crush/internal/filetracker"
  26	"github.com/charmbracelet/crush/internal/history"
  27	"github.com/charmbracelet/crush/internal/message"
  28	"github.com/charmbracelet/crush/internal/permission"
  29	"github.com/charmbracelet/crush/internal/pubsub"
  30	"github.com/charmbracelet/crush/internal/session"
  31	"github.com/charmbracelet/crush/internal/ui/anim"
  32	"github.com/charmbracelet/crush/internal/ui/attachments"
  33	"github.com/charmbracelet/crush/internal/ui/chat"
  34	"github.com/charmbracelet/crush/internal/ui/common"
  35	"github.com/charmbracelet/crush/internal/ui/completions"
  36	"github.com/charmbracelet/crush/internal/ui/dialog"
  37	"github.com/charmbracelet/crush/internal/ui/logo"
  38	"github.com/charmbracelet/crush/internal/ui/styles"
  39	"github.com/charmbracelet/crush/internal/uiutil"
  40	"github.com/charmbracelet/crush/internal/version"
  41	uv "github.com/charmbracelet/ultraviolet"
  42	"github.com/charmbracelet/ultraviolet/screen"
  43	"github.com/charmbracelet/x/editor"
  44)
  45
  46// Max file size set to 5M.
  47const maxAttachmentSize = int64(5 * 1024 * 1024)
  48
  49// Allowed image formats.
  50var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
  51
  52// uiFocusState represents the current focus state of the UI.
  53type uiFocusState uint8
  54
  55// Possible uiFocusState values.
  56const (
  57	uiFocusNone uiFocusState = iota
  58	uiFocusEditor
  59	uiFocusMain
  60)
  61
  62type uiState uint8
  63
  64// Possible uiState values.
  65const (
  66	uiConfigure uiState = iota
  67	uiInitialize
  68	uiLanding
  69	uiChat
  70	uiChatCompact
  71)
  72
  73type openEditorMsg struct {
  74	Text string
  75}
  76
  77// UI represents the main user interface model.
  78type UI struct {
  79	com          *common.Common
  80	session      *session.Session
  81	sessionFiles []SessionFile
  82
  83	// The width and height of the terminal in cells.
  84	width  int
  85	height int
  86	layout layout
  87
  88	focus uiFocusState
  89	state uiState
  90
  91	keyMap KeyMap
  92	keyenh tea.KeyboardEnhancementsMsg
  93
  94	dialog *dialog.Overlay
  95	status *Status
  96
  97	// header is the last cached header logo
  98	header string
  99
 100	// sendProgressBar instructs the TUI to send progress bar updates to the
 101	// terminal.
 102	sendProgressBar bool
 103
 104	// QueryVersion instructs the TUI to query for the terminal version when it
 105	// starts.
 106	QueryVersion bool
 107
 108	// Editor components
 109	textarea textarea.Model
 110
 111	// Attachment list
 112	attachments *attachments.Attachments
 113
 114	readyPlaceholder   string
 115	workingPlaceholder string
 116
 117	// Completions state
 118	completions              *completions.Completions
 119	completionsOpen          bool
 120	completionsStartIndex    int
 121	completionsQuery         string
 122	completionsPositionStart image.Point // x,y where user typed '@'
 123
 124	// Chat components
 125	chat *Chat
 126
 127	// onboarding state
 128	onboarding struct {
 129		yesInitializeSelected bool
 130	}
 131
 132	// lsp
 133	lspStates map[string]app.LSPClientInfo
 134
 135	// mcp
 136	mcpStates map[string]mcp.ClientInfo
 137
 138	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 139	sidebarLogo string
 140}
 141
 142// New creates a new instance of the [UI] model.
 143func New(com *common.Common) *UI {
 144	// Editor components
 145	ta := textarea.New()
 146	ta.SetStyles(com.Styles.TextArea)
 147	ta.ShowLineNumbers = false
 148	ta.CharLimit = -1
 149	ta.SetVirtualCursor(false)
 150	ta.Focus()
 151
 152	ch := NewChat(com)
 153
 154	keyMap := DefaultKeyMap()
 155
 156	// Completions component
 157	comp := completions.New(
 158		com.Styles.Completions.Normal,
 159		com.Styles.Completions.Focused,
 160		com.Styles.Completions.Match,
 161	)
 162
 163	// Attachments component
 164	attachments := attachments.New(
 165		attachments.NewRenderer(
 166			com.Styles.Attachments.Normal,
 167			com.Styles.Attachments.Deleting,
 168			com.Styles.Attachments.Image,
 169			com.Styles.Attachments.Text,
 170		),
 171		attachments.Keymap{
 172			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 173			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 174			Escape:     keyMap.Editor.Escape,
 175		},
 176	)
 177
 178	ui := &UI{
 179		com:         com,
 180		dialog:      dialog.NewOverlay(),
 181		keyMap:      keyMap,
 182		focus:       uiFocusNone,
 183		state:       uiConfigure,
 184		textarea:    ta,
 185		chat:        ch,
 186		completions: comp,
 187		attachments: attachments,
 188	}
 189
 190	status := NewStatus(com, ui)
 191
 192	// set onboarding state defaults
 193	ui.onboarding.yesInitializeSelected = true
 194
 195	// If no provider is configured show the user the provider list
 196	if !com.Config().IsConfigured() {
 197		ui.state = uiConfigure
 198		// if the project needs initialization show the user the question
 199	} else if n, _ := config.ProjectNeedsInitialization(); n {
 200		ui.state = uiInitialize
 201		// otherwise go to the landing UI
 202	} else {
 203		ui.state = uiLanding
 204		ui.focus = uiFocusEditor
 205	}
 206
 207	ui.setEditorPrompt(false)
 208	ui.randomizePlaceholders()
 209	ui.textarea.Placeholder = ui.readyPlaceholder
 210	ui.status = status
 211
 212	return ui
 213}
 214
 215// Init initializes the UI model.
 216func (m *UI) Init() tea.Cmd {
 217	var cmds []tea.Cmd
 218	if m.QueryVersion {
 219		cmds = append(cmds, tea.RequestTerminalVersion)
 220	}
 221	return tea.Batch(cmds...)
 222}
 223
 224// Update handles updates to the UI model.
 225func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 226	var cmds []tea.Cmd
 227	switch msg := msg.(type) {
 228	case tea.EnvMsg:
 229		// Is this Windows Terminal?
 230		if !m.sendProgressBar {
 231			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 232		}
 233	case loadSessionMsg:
 234		m.state = uiChat
 235		m.session = msg.session
 236		m.sessionFiles = msg.files
 237		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 238		if err != nil {
 239			cmds = append(cmds, uiutil.ReportError(err))
 240			break
 241		}
 242		if cmd := m.setSessionMessages(msgs); cmd != nil {
 243			cmds = append(cmds, cmd)
 244		}
 245
 246	case pubsub.Event[message.Message]:
 247		// Check if this is a child session message for an agent tool.
 248		if m.session == nil {
 249			break
 250		}
 251		if msg.Payload.SessionID != m.session.ID {
 252			// This might be a child session message from an agent tool.
 253			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 254				cmds = append(cmds, cmd)
 255			}
 256			break
 257		}
 258		switch msg.Type {
 259		case pubsub.CreatedEvent:
 260			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 261		case pubsub.UpdatedEvent:
 262			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 263		}
 264	case pubsub.Event[history.File]:
 265		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 266	case pubsub.Event[app.LSPEvent]:
 267		m.lspStates = app.GetLSPStates()
 268	case pubsub.Event[mcp.Event]:
 269		m.mcpStates = mcp.GetStates()
 270		if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
 271			dia := m.dialog.Dialog(dialog.CommandsID)
 272			if dia == nil {
 273				break
 274			}
 275
 276			commands, ok := dia.(*dialog.Commands)
 277			if ok {
 278				if cmd := commands.ReloadMCPPrompts(); cmd != nil {
 279					cmds = append(cmds, cmd)
 280				}
 281			}
 282		}
 283	case tea.TerminalVersionMsg:
 284		termVersion := strings.ToLower(msg.Name)
 285		// Only enable progress bar for the following terminals.
 286		if !m.sendProgressBar {
 287			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 288		}
 289		return m, nil
 290	case tea.WindowSizeMsg:
 291		m.width, m.height = msg.Width, msg.Height
 292		m.updateLayoutAndSize()
 293	case tea.KeyboardEnhancementsMsg:
 294		m.keyenh = msg
 295		if msg.SupportsKeyDisambiguation() {
 296			m.keyMap.Models.SetHelp("ctrl+m", "models")
 297			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 298		}
 299	case tea.MouseClickMsg:
 300		switch m.state {
 301		case uiChat:
 302			x, y := msg.X, msg.Y
 303			// Adjust for chat area position
 304			x -= m.layout.main.Min.X
 305			y -= m.layout.main.Min.Y
 306			m.chat.HandleMouseDown(x, y)
 307		}
 308
 309	case tea.MouseMotionMsg:
 310		switch m.state {
 311		case uiChat:
 312			if msg.Y <= 0 {
 313				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 314					cmds = append(cmds, cmd)
 315				}
 316				if !m.chat.SelectedItemInView() {
 317					m.chat.SelectPrev()
 318					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 319						cmds = append(cmds, cmd)
 320					}
 321				}
 322			} else if msg.Y >= m.chat.Height()-1 {
 323				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 324					cmds = append(cmds, cmd)
 325				}
 326				if !m.chat.SelectedItemInView() {
 327					m.chat.SelectNext()
 328					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 329						cmds = append(cmds, cmd)
 330					}
 331				}
 332			}
 333
 334			x, y := msg.X, msg.Y
 335			// Adjust for chat area position
 336			x -= m.layout.main.Min.X
 337			y -= m.layout.main.Min.Y
 338			m.chat.HandleMouseDrag(x, y)
 339		}
 340
 341	case tea.MouseReleaseMsg:
 342		switch m.state {
 343		case uiChat:
 344			x, y := msg.X, msg.Y
 345			// Adjust for chat area position
 346			x -= m.layout.main.Min.X
 347			y -= m.layout.main.Min.Y
 348			m.chat.HandleMouseUp(x, y)
 349		}
 350	case tea.MouseWheelMsg:
 351		switch m.state {
 352		case uiChat:
 353			switch msg.Button {
 354			case tea.MouseWheelUp:
 355				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
 356					cmds = append(cmds, cmd)
 357				}
 358				if !m.chat.SelectedItemInView() {
 359					m.chat.SelectPrev()
 360					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 361						cmds = append(cmds, cmd)
 362					}
 363				}
 364			case tea.MouseWheelDown:
 365				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
 366					cmds = append(cmds, cmd)
 367				}
 368				if !m.chat.SelectedItemInView() {
 369					m.chat.SelectNext()
 370					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 371						cmds = append(cmds, cmd)
 372					}
 373				}
 374			}
 375		}
 376	case anim.StepMsg:
 377		if m.state == uiChat {
 378			if cmd := m.chat.Animate(msg); cmd != nil {
 379				cmds = append(cmds, cmd)
 380			}
 381		}
 382	case tea.KeyPressMsg:
 383		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 384			cmds = append(cmds, cmd)
 385		}
 386	case tea.PasteMsg:
 387		if cmd := m.handlePasteMsg(msg); cmd != nil {
 388			cmds = append(cmds, cmd)
 389		}
 390	case openEditorMsg:
 391		m.textarea.SetValue(msg.Text)
 392		m.textarea.MoveToEnd()
 393	case uiutil.InfoMsg:
 394		m.status.SetInfoMsg(msg)
 395		ttl := msg.TTL
 396		if ttl <= 0 {
 397			ttl = DefaultStatusTTL
 398		}
 399		cmds = append(cmds, clearInfoMsgCmd(ttl))
 400	case uiutil.ClearStatusMsg:
 401		m.status.ClearInfoMsg()
 402	case completions.FilesLoadedMsg:
 403		// Handle async file loading for completions.
 404		if m.completionsOpen {
 405			m.completions.SetFiles(msg.Files)
 406		}
 407	}
 408
 409	// This logic gets triggered on any message type, but should it?
 410	switch m.focus {
 411	case uiFocusMain:
 412	case uiFocusEditor:
 413		// Textarea placeholder logic
 414		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 415			m.textarea.Placeholder = m.workingPlaceholder
 416		} else {
 417			m.textarea.Placeholder = m.readyPlaceholder
 418		}
 419		if m.com.App.Permissions.SkipRequests() {
 420			m.textarea.Placeholder = "Yolo mode!"
 421		}
 422	}
 423
 424	// at this point this can only handle [message.Attachment] message, and we
 425	// should return all cmds anyway.
 426	_ = m.attachments.Update(msg)
 427	return m, tea.Batch(cmds...)
 428}
 429
 430// setSessionMessages sets the messages for the current session in the chat
 431func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 432	var cmds []tea.Cmd
 433	// Build tool result map to link tool calls with their results
 434	msgPtrs := make([]*message.Message, len(msgs))
 435	for i := range msgs {
 436		msgPtrs[i] = &msgs[i]
 437	}
 438	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 439
 440	// Add messages to chat with linked tool results
 441	items := make([]chat.MessageItem, 0, len(msgs)*2)
 442	for _, msg := range msgPtrs {
 443		items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 444	}
 445
 446	// Load nested tool calls for agent/agentic_fetch tools.
 447	m.loadNestedToolCalls(items)
 448
 449	// If the user switches between sessions while the agent is working we want
 450	// to make sure the animations are shown.
 451	for _, item := range items {
 452		if animatable, ok := item.(chat.Animatable); ok {
 453			if cmd := animatable.StartAnimation(); cmd != nil {
 454				cmds = append(cmds, cmd)
 455			}
 456		}
 457	}
 458
 459	m.chat.SetMessages(items...)
 460	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 461		cmds = append(cmds, cmd)
 462	}
 463	m.chat.SelectLast()
 464	return tea.Batch(cmds...)
 465}
 466
 467// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 468func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 469	for _, item := range items {
 470		nestedContainer, ok := item.(chat.NestedToolContainer)
 471		if !ok {
 472			continue
 473		}
 474		toolItem, ok := item.(chat.ToolMessageItem)
 475		if !ok {
 476			continue
 477		}
 478
 479		tc := toolItem.ToolCall()
 480		messageID := toolItem.MessageID()
 481
 482		// Get the agent tool session ID.
 483		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 484
 485		// Fetch nested messages.
 486		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
 487		if err != nil || len(nestedMsgs) == 0 {
 488			continue
 489		}
 490
 491		// Build tool result map for nested messages.
 492		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 493		for i := range nestedMsgs {
 494			nestedMsgPtrs[i] = &nestedMsgs[i]
 495		}
 496		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 497
 498		// Extract nested tool items.
 499		var nestedTools []chat.ToolMessageItem
 500		for _, nestedMsg := range nestedMsgPtrs {
 501			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 502			for _, nestedItem := range nestedItems {
 503				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 504					// Mark nested tools as simple (compact) rendering.
 505					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 506						simplifiable.SetCompact(true)
 507					}
 508					nestedTools = append(nestedTools, nestedToolItem)
 509				}
 510			}
 511		}
 512
 513		// Recursively load nested tool calls for any agent tools within.
 514		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 515		for i, nt := range nestedTools {
 516			nestedMessageItems[i] = nt
 517		}
 518		m.loadNestedToolCalls(nestedMessageItems)
 519
 520		// Set nested tools on the parent.
 521		nestedContainer.SetNestedTools(nestedTools)
 522	}
 523}
 524
 525// appendSessionMessage appends a new message to the current session in the chat
 526// if the message is a tool result it will update the corresponding tool call message
 527func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 528	var cmds []tea.Cmd
 529	switch msg.Role {
 530	case message.User, message.Assistant:
 531		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 532		for _, item := range items {
 533			if animatable, ok := item.(chat.Animatable); ok {
 534				if cmd := animatable.StartAnimation(); cmd != nil {
 535					cmds = append(cmds, cmd)
 536				}
 537			}
 538		}
 539		m.chat.AppendMessages(items...)
 540		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 541			cmds = append(cmds, cmd)
 542		}
 543	case message.Tool:
 544		for _, tr := range msg.ToolResults() {
 545			toolItem := m.chat.MessageItem(tr.ToolCallID)
 546			if toolItem == nil {
 547				// we should have an item!
 548				continue
 549			}
 550			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 551				toolMsgItem.SetResult(&tr)
 552			}
 553		}
 554	}
 555	return tea.Batch(cmds...)
 556}
 557
 558// updateSessionMessage updates an existing message in the current session in the chat
 559// when an assistant message is updated it may include updated tool calls as well
 560// that is why we need to handle creating/updating each tool call message too
 561func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 562	var cmds []tea.Cmd
 563	existingItem := m.chat.MessageItem(msg.ID)
 564
 565	if existingItem != nil {
 566		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
 567			assistantItem.SetMessage(&msg)
 568		}
 569	}
 570
 571	// if the message of the assistant does not have any  response just tool calls we need to remove it
 572	if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
 573		m.chat.RemoveMessage(msg.ID)
 574	}
 575
 576	var items []chat.MessageItem
 577	for _, tc := range msg.ToolCalls() {
 578		existingToolItem := m.chat.MessageItem(tc.ID)
 579		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
 580			existingToolCall := toolItem.ToolCall()
 581			// only update if finished state changed or input changed
 582			// to avoid clearing the cache
 583			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
 584				toolItem.SetToolCall(tc)
 585			}
 586		}
 587		if existingToolItem == nil {
 588			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
 589		}
 590	}
 591
 592	for _, item := range items {
 593		if animatable, ok := item.(chat.Animatable); ok {
 594			if cmd := animatable.StartAnimation(); cmd != nil {
 595				cmds = append(cmds, cmd)
 596			}
 597		}
 598	}
 599	m.chat.AppendMessages(items...)
 600	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 601		cmds = append(cmds, cmd)
 602	}
 603
 604	return tea.Batch(cmds...)
 605}
 606
 607// handleChildSessionMessage handles messages from child sessions (agent tools).
 608func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 609	var cmds []tea.Cmd
 610
 611	// Only process messages with tool calls or results.
 612	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 613		return nil
 614	}
 615
 616	// Check if this is an agent tool session and parse it.
 617	childSessionID := event.Payload.SessionID
 618	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
 619	if !ok {
 620		return nil
 621	}
 622
 623	// Find the parent agent tool item.
 624	var agentItem chat.NestedToolContainer
 625	for i := 0; i < m.chat.Len(); i++ {
 626		item := m.chat.MessageItem(toolCallID)
 627		if item == nil {
 628			continue
 629		}
 630		if agent, ok := item.(chat.NestedToolContainer); ok {
 631			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
 632				if toolMessageItem.ToolCall().ID == toolCallID {
 633					// Verify this agent belongs to the correct parent message.
 634					// We can't directly check parentMessageID on the item, so we trust the session parsing.
 635					agentItem = agent
 636					break
 637				}
 638			}
 639		}
 640	}
 641
 642	if agentItem == nil {
 643		return nil
 644	}
 645
 646	// Get existing nested tools.
 647	nestedTools := agentItem.NestedTools()
 648
 649	// Update or create nested tool calls.
 650	for _, tc := range event.Payload.ToolCalls() {
 651		found := false
 652		for _, existingTool := range nestedTools {
 653			if existingTool.ToolCall().ID == tc.ID {
 654				existingTool.SetToolCall(tc)
 655				found = true
 656				break
 657			}
 658		}
 659		if !found {
 660			// Create a new nested tool item.
 661			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
 662			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
 663				simplifiable.SetCompact(true)
 664			}
 665			if animatable, ok := nestedItem.(chat.Animatable); ok {
 666				if cmd := animatable.StartAnimation(); cmd != nil {
 667					cmds = append(cmds, cmd)
 668				}
 669			}
 670			nestedTools = append(nestedTools, nestedItem)
 671		}
 672	}
 673
 674	// Update nested tool results.
 675	for _, tr := range event.Payload.ToolResults() {
 676		for _, nestedTool := range nestedTools {
 677			if nestedTool.ToolCall().ID == tr.ToolCallID {
 678				nestedTool.SetResult(&tr)
 679				break
 680			}
 681		}
 682	}
 683
 684	// Update the agent item with the new nested tools.
 685	agentItem.SetNestedTools(nestedTools)
 686
 687	// Update the chat so it updates the index map for animations to work as expected
 688	m.chat.UpdateNestedToolIDs(toolCallID)
 689
 690	return tea.Batch(cmds...)
 691}
 692
 693func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 694	var cmds []tea.Cmd
 695
 696	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
 697		switch {
 698		case key.Matches(msg, m.keyMap.Help):
 699			m.status.ToggleHelp()
 700			m.updateLayoutAndSize()
 701			return true
 702		case key.Matches(msg, m.keyMap.Commands):
 703			if cmd := m.openCommandsDialog(); cmd != nil {
 704				cmds = append(cmds, cmd)
 705			}
 706			return true
 707		case key.Matches(msg, m.keyMap.Models):
 708			if cmd := m.openModelsDialog(); cmd != nil {
 709				cmds = append(cmds, cmd)
 710			}
 711			return true
 712		case key.Matches(msg, m.keyMap.Sessions):
 713			if cmd := m.openSessionsDialog(); cmd != nil {
 714				cmds = append(cmds, cmd)
 715			}
 716			return true
 717		}
 718		return false
 719	}
 720
 721	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
 722		// Always handle quit keys first
 723		if cmd := m.openQuitDialog(); cmd != nil {
 724			cmds = append(cmds, cmd)
 725		}
 726
 727		return tea.Batch(cmds...)
 728	}
 729
 730	// Route all messages to dialog if one is open.
 731	if m.dialog.HasDialogs() {
 732		msg := m.dialog.Update(msg)
 733		if msg == nil {
 734			return tea.Batch(cmds...)
 735		}
 736
 737		switch msg := msg.(type) {
 738		// Generic dialog messages
 739		case dialog.CloseMsg:
 740			m.dialog.CloseFrontDialog()
 741			if m.focus == uiFocusEditor {
 742				cmds = append(cmds, m.textarea.Focus())
 743			}
 744
 745		// Session dialog messages
 746		case dialog.SessionSelectedMsg:
 747			m.dialog.CloseDialog(dialog.SessionsID)
 748			cmds = append(cmds, m.loadSession(msg.Session.ID))
 749
 750		// Open dialog message
 751		case dialog.OpenDialogMsg:
 752			switch msg.DialogID {
 753			case dialog.SessionsID:
 754				if cmd := m.openSessionsDialog(); cmd != nil {
 755					cmds = append(cmds, cmd)
 756				}
 757			case dialog.ModelsID:
 758				if cmd := m.openModelsDialog(); cmd != nil {
 759					cmds = append(cmds, cmd)
 760				}
 761			default:
 762				// Unknown dialog
 763				break
 764			}
 765
 766			m.dialog.CloseDialog(dialog.CommandsID)
 767
 768		// Command dialog messages
 769		case dialog.ToggleYoloModeMsg:
 770			yolo := !m.com.App.Permissions.SkipRequests()
 771			m.com.App.Permissions.SetSkipRequests(yolo)
 772			m.setEditorPrompt(yolo)
 773			m.dialog.CloseDialog(dialog.CommandsID)
 774		case dialog.NewSessionsMsg:
 775			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 776				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 777				break
 778			}
 779			m.newSession()
 780			m.dialog.CloseDialog(dialog.CommandsID)
 781		case dialog.CompactMsg:
 782			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 783				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 784				break
 785			}
 786			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 787			if err != nil {
 788				cmds = append(cmds, uiutil.ReportError(err))
 789			}
 790		case dialog.ToggleHelpMsg:
 791			m.status.ToggleHelp()
 792			m.dialog.CloseDialog(dialog.CommandsID)
 793		case dialog.QuitMsg:
 794			cmds = append(cmds, tea.Quit)
 795		case dialog.ModelSelectedMsg:
 796			if m.com.App.AgentCoordinator.IsBusy() {
 797				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
 798				break
 799			}
 800
 801			// TODO: Validate model API and authentication here?
 802
 803			cfg := m.com.Config()
 804			if cfg == nil {
 805				cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
 806				break
 807			}
 808
 809			if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
 810				cmds = append(cmds, uiutil.ReportError(err))
 811			}
 812
 813			// XXX: Should this be in a separate goroutine?
 814			go m.com.App.UpdateAgentModel(context.TODO())
 815
 816			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 817			cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 818			m.dialog.CloseDialog(dialog.ModelsID)
 819		}
 820
 821		return tea.Batch(cmds...)
 822	}
 823
 824	switch m.state {
 825	case uiConfigure:
 826		return tea.Batch(cmds...)
 827	case uiInitialize:
 828		cmds = append(cmds, m.updateInitializeView(msg)...)
 829		return tea.Batch(cmds...)
 830	case uiChat, uiLanding, uiChatCompact:
 831		switch m.focus {
 832		case uiFocusEditor:
 833			// Handle completions if open.
 834			if m.completionsOpen {
 835				if msg, ok := m.completions.Update(msg); ok {
 836					switch msg := msg.(type) {
 837					case completions.SelectionMsg:
 838						// Handle file completion selection.
 839						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
 840							cmds = append(cmds, m.insertFileCompletion(item.Path))
 841						}
 842						if !msg.Insert {
 843							m.closeCompletions()
 844						}
 845					case completions.ClosedMsg:
 846						m.completionsOpen = false
 847					}
 848					return tea.Batch(cmds...)
 849				}
 850			}
 851
 852			if ok := m.attachments.Update(msg); ok {
 853				return tea.Batch(cmds...)
 854			}
 855
 856			switch {
 857			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 858				value := m.textarea.Value()
 859				if before, ok := strings.CutSuffix(value, "\\"); ok {
 860					// If the last character is a backslash, remove it and add a newline.
 861					m.textarea.SetValue(before)
 862					break
 863				}
 864
 865				// Otherwise, send the message
 866				m.textarea.Reset()
 867
 868				value = strings.TrimSpace(value)
 869				if value == "exit" || value == "quit" {
 870					return m.openQuitDialog()
 871				}
 872
 873				attachments := m.attachments.List()
 874				m.attachments.Reset()
 875				if len(value) == 0 {
 876					return nil
 877				}
 878
 879				m.randomizePlaceholders()
 880
 881				return m.sendMessage(value, attachments)
 882			case key.Matches(msg, m.keyMap.Chat.NewSession):
 883				if m.session == nil || m.session.ID == "" {
 884					break
 885				}
 886				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 887					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 888					break
 889				}
 890				m.newSession()
 891			case key.Matches(msg, m.keyMap.Tab):
 892				m.focus = uiFocusMain
 893				m.textarea.Blur()
 894				m.chat.Focus()
 895				m.chat.SetSelected(m.chat.Len() - 1)
 896			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
 897				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
 898					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
 899					break
 900				}
 901				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 902			case key.Matches(msg, m.keyMap.Editor.Newline):
 903				m.textarea.InsertRune('\n')
 904				m.closeCompletions()
 905			default:
 906				if handleGlobalKeys(msg) {
 907					// Handle global keys first before passing to textarea.
 908					break
 909				}
 910
 911				// Check for @ trigger before passing to textarea.
 912				curValue := m.textarea.Value()
 913				curIdx := len(curValue)
 914
 915				// Trigger completions on @.
 916				if msg.String() == "@" && !m.completionsOpen {
 917					// Only show if beginning of prompt or after whitespace.
 918					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
 919						m.completionsOpen = true
 920						m.completionsQuery = ""
 921						m.completionsStartIndex = curIdx
 922						m.completionsPositionStart = m.completionsPosition()
 923						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
 924						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
 925					}
 926				}
 927
 928				ta, cmd := m.textarea.Update(msg)
 929				m.textarea = ta
 930				cmds = append(cmds, cmd)
 931
 932				// After updating textarea, check if we need to filter completions.
 933				// Skip filtering on the initial @ keystroke since items are loading async.
 934				if m.completionsOpen && msg.String() != "@" {
 935					newValue := m.textarea.Value()
 936					newIdx := len(newValue)
 937
 938					// Close completions if cursor moved before start.
 939					if newIdx <= m.completionsStartIndex {
 940						m.closeCompletions()
 941					} else if msg.String() == "space" {
 942						// Close on space.
 943						m.closeCompletions()
 944					} else {
 945						// Extract current word and filter.
 946						word := m.textareaWord()
 947						if strings.HasPrefix(word, "@") {
 948							m.completionsQuery = word[1:]
 949							m.completions.Filter(m.completionsQuery)
 950						} else if m.completionsOpen {
 951							m.closeCompletions()
 952						}
 953					}
 954				}
 955			}
 956		case uiFocusMain:
 957			switch {
 958			case key.Matches(msg, m.keyMap.Tab):
 959				m.focus = uiFocusEditor
 960				cmds = append(cmds, m.textarea.Focus())
 961				m.chat.Blur()
 962			case key.Matches(msg, m.keyMap.Chat.Expand):
 963				m.chat.ToggleExpandedSelectedItem()
 964			case key.Matches(msg, m.keyMap.Chat.Up):
 965				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 966					cmds = append(cmds, cmd)
 967				}
 968				if !m.chat.SelectedItemInView() {
 969					m.chat.SelectPrev()
 970					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 971						cmds = append(cmds, cmd)
 972					}
 973				}
 974			case key.Matches(msg, m.keyMap.Chat.Down):
 975				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 976					cmds = append(cmds, cmd)
 977				}
 978				if !m.chat.SelectedItemInView() {
 979					m.chat.SelectNext()
 980					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 981						cmds = append(cmds, cmd)
 982					}
 983				}
 984			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 985				m.chat.SelectPrev()
 986				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 987					cmds = append(cmds, cmd)
 988				}
 989			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 990				m.chat.SelectNext()
 991				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 992					cmds = append(cmds, cmd)
 993				}
 994			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
 995				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
 996					cmds = append(cmds, cmd)
 997				}
 998				m.chat.SelectFirstInView()
 999			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1000				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1001					cmds = append(cmds, cmd)
1002				}
1003				m.chat.SelectLastInView()
1004			case key.Matches(msg, m.keyMap.Chat.PageUp):
1005				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1006					cmds = append(cmds, cmd)
1007				}
1008				m.chat.SelectFirstInView()
1009			case key.Matches(msg, m.keyMap.Chat.PageDown):
1010				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1011					cmds = append(cmds, cmd)
1012				}
1013				m.chat.SelectLastInView()
1014			case key.Matches(msg, m.keyMap.Chat.Home):
1015				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1016					cmds = append(cmds, cmd)
1017				}
1018				m.chat.SelectFirst()
1019			case key.Matches(msg, m.keyMap.Chat.End):
1020				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1021					cmds = append(cmds, cmd)
1022				}
1023				m.chat.SelectLast()
1024			default:
1025				handleGlobalKeys(msg)
1026			}
1027		default:
1028			handleGlobalKeys(msg)
1029		}
1030	default:
1031		handleGlobalKeys(msg)
1032	}
1033
1034	return tea.Batch(cmds...)
1035}
1036
1037// Draw implements [uv.Drawable] and draws the UI model.
1038func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1039	layout := m.generateLayout(area.Dx(), area.Dy())
1040
1041	if m.layout != layout {
1042		m.layout = layout
1043		m.updateSize()
1044	}
1045
1046	// Clear the screen first
1047	screen.Clear(scr)
1048
1049	switch m.state {
1050	case uiConfigure:
1051		header := uv.NewStyledString(m.header)
1052		header.Draw(scr, layout.header)
1053
1054		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1055			Height(layout.main.Dy()).
1056			Background(lipgloss.ANSIColor(rand.Intn(256))).
1057			Render(" Configure ")
1058		main := uv.NewStyledString(mainView)
1059		main.Draw(scr, layout.main)
1060
1061	case uiInitialize:
1062		header := uv.NewStyledString(m.header)
1063		header.Draw(scr, layout.header)
1064
1065		main := uv.NewStyledString(m.initializeView())
1066		main.Draw(scr, layout.main)
1067
1068	case uiLanding:
1069		header := uv.NewStyledString(m.header)
1070		header.Draw(scr, layout.header)
1071		main := uv.NewStyledString(m.landingView())
1072		main.Draw(scr, layout.main)
1073
1074		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1075		editor.Draw(scr, layout.editor)
1076
1077	case uiChat:
1078		m.chat.Draw(scr, layout.main)
1079
1080		header := uv.NewStyledString(m.header)
1081		header.Draw(scr, layout.header)
1082		m.drawSidebar(scr, layout.sidebar)
1083
1084		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
1085		editor.Draw(scr, layout.editor)
1086
1087	case uiChatCompact:
1088		header := uv.NewStyledString(m.header)
1089		header.Draw(scr, layout.header)
1090
1091		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1092			Height(layout.main.Dy()).
1093			Background(lipgloss.ANSIColor(rand.Intn(256))).
1094			Render(" Compact Chat Messages ")
1095		main := uv.NewStyledString(mainView)
1096		main.Draw(scr, layout.main)
1097
1098		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1099		editor.Draw(scr, layout.editor)
1100	}
1101
1102	// Add status and help layer
1103	m.status.Draw(scr, layout.status)
1104
1105	// Draw completions popup if open
1106	if m.completionsOpen && m.completions.HasItems() {
1107		w, h := m.completions.Size()
1108		x := m.completionsPositionStart.X
1109		y := m.completionsPositionStart.Y - h
1110
1111		screenW := area.Dx()
1112		if x+w > screenW {
1113			x = screenW - w
1114		}
1115		x = max(0, x)
1116		y = max(0, y)
1117
1118		completionsView := uv.NewStyledString(m.completions.Render())
1119		completionsView.Draw(scr, image.Rectangle{
1120			Min: image.Pt(x, y),
1121			Max: image.Pt(x+w, y+h),
1122		})
1123	}
1124
1125	// Debugging rendering (visually see when the tui rerenders)
1126	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1127		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1128		debug := uv.NewStyledString(debugView.String())
1129		debug.Draw(scr, image.Rectangle{
1130			Min: image.Pt(4, 1),
1131			Max: image.Pt(8, 3),
1132		})
1133	}
1134
1135	// This needs to come last to overlay on top of everything
1136	if m.dialog.HasDialogs() {
1137		m.dialog.Draw(scr, area)
1138	}
1139}
1140
1141// Cursor returns the cursor position and properties for the UI model. It
1142// returns nil if the cursor should not be shown.
1143func (m *UI) Cursor() *tea.Cursor {
1144	if m.layout.editor.Dy() <= 0 {
1145		// Don't show cursor if editor is not visible
1146		return nil
1147	}
1148	if m.dialog.HasDialogs() {
1149		if front := m.dialog.DialogLast(); front != nil {
1150			c, ok := front.(uiutil.Cursor)
1151			if ok {
1152				cur := c.Cursor()
1153				if cur != nil {
1154					pos := m.dialog.CenterPosition(m.layout.area, front.ID())
1155					cur.X += pos.Min.X
1156					cur.Y += pos.Min.Y
1157					return cur
1158				}
1159			}
1160		}
1161		return nil
1162	}
1163	switch m.focus {
1164	case uiFocusEditor:
1165		if m.textarea.Focused() {
1166			cur := m.textarea.Cursor()
1167			cur.X++ // Adjust for app margins
1168			cur.Y += m.layout.editor.Min.Y
1169			// Offset for attachment row if present.
1170			if len(m.attachments.List()) > 0 {
1171				cur.Y++
1172			}
1173			return cur
1174		}
1175	}
1176	return nil
1177}
1178
1179// View renders the UI model's view.
1180func (m *UI) View() tea.View {
1181	var v tea.View
1182	v.AltScreen = true
1183	v.BackgroundColor = m.com.Styles.Background
1184	v.Cursor = m.Cursor()
1185	v.MouseMode = tea.MouseModeCellMotion
1186
1187	canvas := uv.NewScreenBuffer(m.width, m.height)
1188	m.Draw(canvas, canvas.Bounds())
1189
1190	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1191	contentLines := strings.Split(content, "\n")
1192	for i, line := range contentLines {
1193		// Trim trailing spaces for concise rendering
1194		contentLines[i] = strings.TrimRight(line, " ")
1195	}
1196
1197	content = strings.Join(contentLines, "\n")
1198
1199	v.Content = content
1200	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1201		// HACK: use a random percentage to prevent ghostty from hiding it
1202		// after a timeout.
1203		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1204	}
1205
1206	return v
1207}
1208
1209// ShortHelp implements [help.KeyMap].
1210func (m *UI) ShortHelp() []key.Binding {
1211	var binds []key.Binding
1212	k := &m.keyMap
1213	tab := k.Tab
1214	commands := k.Commands
1215	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1216		commands.SetHelp("/ or ctrl+p", "commands")
1217	}
1218
1219	switch m.state {
1220	case uiInitialize:
1221		binds = append(binds, k.Quit)
1222	case uiChat:
1223		if m.focus == uiFocusEditor {
1224			tab.SetHelp("tab", "focus chat")
1225		} else {
1226			tab.SetHelp("tab", "focus editor")
1227		}
1228
1229		binds = append(binds,
1230			tab,
1231			commands,
1232			k.Models,
1233		)
1234
1235		switch m.focus {
1236		case uiFocusEditor:
1237			binds = append(binds,
1238				k.Editor.Newline,
1239			)
1240		case uiFocusMain:
1241			binds = append(binds,
1242				k.Chat.UpDown,
1243				k.Chat.UpDownOneItem,
1244				k.Chat.PageUp,
1245				k.Chat.PageDown,
1246				k.Chat.Copy,
1247			)
1248		}
1249	default:
1250		// TODO: other states
1251		// if m.session == nil {
1252		// no session selected
1253		binds = append(binds,
1254			commands,
1255			k.Models,
1256			k.Editor.Newline,
1257		)
1258	}
1259
1260	binds = append(binds,
1261		k.Quit,
1262		k.Help,
1263	)
1264
1265	return binds
1266}
1267
1268// FullHelp implements [help.KeyMap].
1269func (m *UI) FullHelp() [][]key.Binding {
1270	var binds [][]key.Binding
1271	k := &m.keyMap
1272	help := k.Help
1273	help.SetHelp("ctrl+g", "less")
1274	hasAttachments := len(m.attachments.List()) > 0
1275	hasSession := m.session != nil && m.session.ID != ""
1276	commands := k.Commands
1277	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1278		commands.SetHelp("/ or ctrl+p", "commands")
1279	}
1280
1281	switch m.state {
1282	case uiInitialize:
1283		binds = append(binds,
1284			[]key.Binding{
1285				k.Quit,
1286			})
1287	case uiChat:
1288		mainBinds := []key.Binding{}
1289		tab := k.Tab
1290		if m.focus == uiFocusEditor {
1291			tab.SetHelp("tab", "focus chat")
1292		} else {
1293			tab.SetHelp("tab", "focus editor")
1294		}
1295
1296		mainBinds = append(mainBinds,
1297			tab,
1298			commands,
1299			k.Models,
1300			k.Sessions,
1301		)
1302		if hasSession {
1303			mainBinds = append(mainBinds, k.Chat.NewSession)
1304		}
1305
1306		binds = append(binds, mainBinds)
1307
1308		switch m.focus {
1309		case uiFocusEditor:
1310			binds = append(binds,
1311				[]key.Binding{
1312					k.Editor.Newline,
1313					k.Editor.AddImage,
1314					k.Editor.MentionFile,
1315					k.Editor.OpenEditor,
1316				},
1317			)
1318			if hasAttachments {
1319				binds = append(binds,
1320					[]key.Binding{
1321						k.Editor.AttachmentDeleteMode,
1322						k.Editor.DeleteAllAttachments,
1323						k.Editor.Escape,
1324					},
1325				)
1326			}
1327		case uiFocusMain:
1328			binds = append(binds,
1329				[]key.Binding{
1330					k.Chat.UpDown,
1331					k.Chat.UpDownOneItem,
1332					k.Chat.PageUp,
1333					k.Chat.PageDown,
1334				},
1335				[]key.Binding{
1336					k.Chat.HalfPageUp,
1337					k.Chat.HalfPageDown,
1338					k.Chat.Home,
1339					k.Chat.End,
1340				},
1341				[]key.Binding{
1342					k.Chat.Copy,
1343					k.Chat.ClearHighlight,
1344				},
1345			)
1346		}
1347	default:
1348		if m.session == nil {
1349			// no session selected
1350			binds = append(binds,
1351				[]key.Binding{
1352					commands,
1353					k.Models,
1354					k.Sessions,
1355				},
1356				[]key.Binding{
1357					k.Editor.Newline,
1358					k.Editor.AddImage,
1359					k.Editor.MentionFile,
1360					k.Editor.OpenEditor,
1361				},
1362			)
1363			if hasAttachments {
1364				binds = append(binds,
1365					[]key.Binding{
1366						k.Editor.AttachmentDeleteMode,
1367						k.Editor.DeleteAllAttachments,
1368						k.Editor.Escape,
1369					},
1370				)
1371			}
1372			binds = append(binds,
1373				[]key.Binding{
1374					help,
1375				},
1376			)
1377		}
1378	}
1379
1380	binds = append(binds,
1381		[]key.Binding{
1382			help,
1383			k.Quit,
1384		},
1385	)
1386
1387	return binds
1388}
1389
1390// updateLayoutAndSize updates the layout and sizes of UI components.
1391func (m *UI) updateLayoutAndSize() {
1392	m.layout = m.generateLayout(m.width, m.height)
1393	m.updateSize()
1394}
1395
1396// updateSize updates the sizes of UI components based on the current layout.
1397func (m *UI) updateSize() {
1398	// Set status width
1399	m.status.SetWidth(m.layout.status.Dx())
1400
1401	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1402	m.textarea.SetWidth(m.layout.editor.Dx())
1403	m.textarea.SetHeight(m.layout.editor.Dy())
1404
1405	// Handle different app states
1406	switch m.state {
1407	case uiConfigure, uiInitialize, uiLanding:
1408		m.renderHeader(false, m.layout.header.Dx())
1409
1410	case uiChat:
1411		m.renderSidebarLogo(m.layout.sidebar.Dx())
1412
1413	case uiChatCompact:
1414		// TODO: set the width and heigh of the chat component
1415		m.renderHeader(true, m.layout.header.Dx())
1416	}
1417}
1418
1419// generateLayout calculates the layout rectangles for all UI components based
1420// on the current UI state and terminal dimensions.
1421func (m *UI) generateLayout(w, h int) layout {
1422	// The screen area we're working with
1423	area := image.Rect(0, 0, w, h)
1424
1425	// The help height
1426	helpHeight := 1
1427	// The editor height
1428	editorHeight := 5
1429	// The sidebar width
1430	sidebarWidth := 30
1431	// The header height
1432	// TODO: handle compact
1433	headerHeight := 4
1434
1435	var helpKeyMap help.KeyMap = m
1436	if m.status.ShowingAll() {
1437		for _, row := range helpKeyMap.FullHelp() {
1438			helpHeight = max(helpHeight, len(row))
1439		}
1440	}
1441
1442	// Add app margins
1443	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1444	appRect.Min.Y += 1
1445	appRect.Max.Y -= 1
1446	helpRect.Min.Y -= 1
1447	appRect.Min.X += 1
1448	appRect.Max.X -= 1
1449
1450	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1451		// extra padding on left and right for these states
1452		appRect.Min.X += 1
1453		appRect.Max.X -= 1
1454	}
1455
1456	layout := layout{
1457		area:   area,
1458		status: helpRect,
1459	}
1460
1461	// Handle different app states
1462	switch m.state {
1463	case uiConfigure, uiInitialize:
1464		// Layout
1465		//
1466		// header
1467		// ------
1468		// main
1469		// ------
1470		// help
1471
1472		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1473		layout.header = headerRect
1474		layout.main = mainRect
1475
1476	case uiLanding:
1477		// Layout
1478		//
1479		// header
1480		// ------
1481		// main
1482		// ------
1483		// editor
1484		// ------
1485		// help
1486		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1487		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1488		// Remove extra padding from editor (but keep it for header and main)
1489		editorRect.Min.X -= 1
1490		editorRect.Max.X += 1
1491		layout.header = headerRect
1492		layout.main = mainRect
1493		layout.editor = editorRect
1494
1495	case uiChat:
1496		// Layout
1497		//
1498		// ------|---
1499		// main  |
1500		// ------| side
1501		// editor|
1502		// ----------
1503		// help
1504
1505		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1506		// Add padding left
1507		sideRect.Min.X += 1
1508		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1509		mainRect.Max.X -= 1 // Add padding right
1510		// Add bottom margin to main
1511		mainRect.Max.Y -= 1
1512		layout.sidebar = sideRect
1513		layout.main = mainRect
1514		layout.editor = editorRect
1515
1516	case uiChatCompact:
1517		// Layout
1518		//
1519		// compact-header
1520		// ------
1521		// main
1522		// ------
1523		// editor
1524		// ------
1525		// help
1526		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1527		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1528		layout.header = headerRect
1529		layout.main = mainRect
1530		layout.editor = editorRect
1531	}
1532
1533	if !layout.editor.Empty() {
1534		// Add editor margins 1 top and bottom
1535		layout.editor.Min.Y += 1
1536		layout.editor.Max.Y -= 1
1537	}
1538
1539	return layout
1540}
1541
1542// layout defines the positioning of UI elements.
1543type layout struct {
1544	// area is the overall available area.
1545	area uv.Rectangle
1546
1547	// header is the header shown in special cases
1548	// e.x when the sidebar is collapsed
1549	// or when in the landing page
1550	// or in init/config
1551	header uv.Rectangle
1552
1553	// main is the area for the main pane. (e.x chat, configure, landing)
1554	main uv.Rectangle
1555
1556	// editor is the area for the editor pane.
1557	editor uv.Rectangle
1558
1559	// sidebar is the area for the sidebar.
1560	sidebar uv.Rectangle
1561
1562	// status is the area for the status view.
1563	status uv.Rectangle
1564}
1565
1566func (m *UI) openEditor(value string) tea.Cmd {
1567	tmpfile, err := os.CreateTemp("", "msg_*.md")
1568	if err != nil {
1569		return uiutil.ReportError(err)
1570	}
1571	defer tmpfile.Close() //nolint:errcheck
1572	if _, err := tmpfile.WriteString(value); err != nil {
1573		return uiutil.ReportError(err)
1574	}
1575	cmd, err := editor.Command(
1576		"crush",
1577		tmpfile.Name(),
1578		editor.AtPosition(
1579			m.textarea.Line()+1,
1580			m.textarea.Column()+1,
1581		),
1582	)
1583	if err != nil {
1584		return uiutil.ReportError(err)
1585	}
1586	return tea.ExecProcess(cmd, func(err error) tea.Msg {
1587		if err != nil {
1588			return uiutil.ReportError(err)
1589		}
1590		content, err := os.ReadFile(tmpfile.Name())
1591		if err != nil {
1592			return uiutil.ReportError(err)
1593		}
1594		if len(content) == 0 {
1595			return uiutil.ReportWarn("Message is empty")
1596		}
1597		os.Remove(tmpfile.Name())
1598		return openEditorMsg{
1599			Text: strings.TrimSpace(string(content)),
1600		}
1601	})
1602}
1603
1604// setEditorPrompt configures the textarea prompt function based on whether
1605// yolo mode is enabled.
1606func (m *UI) setEditorPrompt(yolo bool) {
1607	if yolo {
1608		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1609		return
1610	}
1611	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1612}
1613
1614// normalPromptFunc returns the normal editor prompt style ("  > " on first
1615// line, "::: " on subsequent lines).
1616func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1617	t := m.com.Styles
1618	if info.LineNumber == 0 {
1619		if info.Focused {
1620			return "  > "
1621		}
1622		return "::: "
1623	}
1624	if info.Focused {
1625		return t.EditorPromptNormalFocused.Render()
1626	}
1627	return t.EditorPromptNormalBlurred.Render()
1628}
1629
1630// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1631// and colored dots.
1632func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1633	t := m.com.Styles
1634	if info.LineNumber == 0 {
1635		if info.Focused {
1636			return t.EditorPromptYoloIconFocused.Render()
1637		} else {
1638			return t.EditorPromptYoloIconBlurred.Render()
1639		}
1640	}
1641	if info.Focused {
1642		return t.EditorPromptYoloDotsFocused.Render()
1643	}
1644	return t.EditorPromptYoloDotsBlurred.Render()
1645}
1646
1647// closeCompletions closes the completions popup and resets state.
1648func (m *UI) closeCompletions() {
1649	m.completionsOpen = false
1650	m.completionsQuery = ""
1651	m.completionsStartIndex = 0
1652	m.completions.Close()
1653}
1654
1655// insertFileCompletion inserts the selected file path into the textarea,
1656// replacing the @query, and adds the file as an attachment.
1657func (m *UI) insertFileCompletion(path string) tea.Cmd {
1658	value := m.textarea.Value()
1659	word := m.textareaWord()
1660
1661	// Find the @ and query to replace.
1662	if m.completionsStartIndex > len(value) {
1663		return nil
1664	}
1665
1666	// Build the new value: everything before @, the path, everything after query.
1667	endIdx := min(m.completionsStartIndex+len(word), len(value))
1668
1669	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1670	m.textarea.SetValue(newValue)
1671	m.textarea.MoveToEnd()
1672	m.textarea.InsertRune(' ')
1673
1674	return func() tea.Msg {
1675		absPath, _ := filepath.Abs(path)
1676		// Skip attachment if file was already read and hasn't been modified.
1677		lastRead := filetracker.LastReadTime(absPath)
1678		if !lastRead.IsZero() {
1679			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
1680				return nil
1681			}
1682		}
1683
1684		// Add file as attachment.
1685		content, err := os.ReadFile(path)
1686		if err != nil {
1687			// If it fails, let the LLM handle it later.
1688			return nil
1689		}
1690		filetracker.RecordRead(absPath)
1691
1692		return message.Attachment{
1693			FilePath: path,
1694			FileName: filepath.Base(path),
1695			MimeType: mimeOf(content),
1696			Content:  content,
1697		}
1698	}
1699}
1700
1701// completionsPosition returns the X and Y position for the completions popup.
1702func (m *UI) completionsPosition() image.Point {
1703	cur := m.textarea.Cursor()
1704	if cur == nil {
1705		return image.Point{
1706			X: m.layout.editor.Min.X,
1707			Y: m.layout.editor.Min.Y,
1708		}
1709	}
1710	return image.Point{
1711		X: cur.X + m.layout.editor.Min.X,
1712		Y: m.layout.editor.Min.Y + cur.Y,
1713	}
1714}
1715
1716// textareaWord returns the current word at the cursor position.
1717func (m *UI) textareaWord() string {
1718	return m.textarea.Word()
1719}
1720
1721// isWhitespace returns true if the byte is a whitespace character.
1722func isWhitespace(b byte) bool {
1723	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
1724}
1725
1726// mimeOf detects the MIME type of the given content.
1727func mimeOf(content []byte) string {
1728	mimeBufferSize := min(512, len(content))
1729	return http.DetectContentType(content[:mimeBufferSize])
1730}
1731
1732var readyPlaceholders = [...]string{
1733	"Ready!",
1734	"Ready...",
1735	"Ready?",
1736	"Ready for instructions",
1737}
1738
1739var workingPlaceholders = [...]string{
1740	"Working!",
1741	"Working...",
1742	"Brrrrr...",
1743	"Prrrrrrrr...",
1744	"Processing...",
1745	"Thinking...",
1746}
1747
1748// randomizePlaceholders selects random placeholder text for the textarea's
1749// ready and working states.
1750func (m *UI) randomizePlaceholders() {
1751	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1752	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1753}
1754
1755// renderEditorView renders the editor view with attachments if any.
1756func (m *UI) renderEditorView(width int) string {
1757	if len(m.attachments.List()) == 0 {
1758		return m.textarea.View()
1759	}
1760	return lipgloss.JoinVertical(
1761		lipgloss.Top,
1762		m.attachments.Render(width),
1763		m.textarea.View(),
1764	)
1765}
1766
1767// renderHeader renders and caches the header logo at the specified width.
1768func (m *UI) renderHeader(compact bool, width int) {
1769	// TODO: handle the compact case differently
1770	m.header = renderLogo(m.com.Styles, compact, width)
1771}
1772
1773// renderSidebarLogo renders and caches the sidebar logo at the specified
1774// width.
1775func (m *UI) renderSidebarLogo(width int) {
1776	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1777}
1778
1779// sendMessage sends a message with the given content and attachments.
1780func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1781	if m.com.App.AgentCoordinator == nil {
1782		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1783	}
1784
1785	var cmds []tea.Cmd
1786	if m.session == nil || m.session.ID == "" {
1787		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1788		if err != nil {
1789			return uiutil.ReportError(err)
1790		}
1791		m.state = uiChat
1792		m.session = &newSession
1793		cmds = append(cmds, m.loadSession(newSession.ID))
1794	}
1795
1796	// Capture session ID to avoid race with main goroutine updating m.session.
1797	sessionID := m.session.ID
1798	cmds = append(cmds, func() tea.Msg {
1799		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1800		if err != nil {
1801			isCancelErr := errors.Is(err, context.Canceled)
1802			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1803			if isCancelErr || isPermissionErr {
1804				return nil
1805			}
1806			return uiutil.InfoMsg{
1807				Type: uiutil.InfoTypeError,
1808				Msg:  err.Error(),
1809			}
1810		}
1811		return nil
1812	})
1813	return tea.Batch(cmds...)
1814}
1815
1816// openQuitDialog opens the quit confirmation dialog.
1817func (m *UI) openQuitDialog() tea.Cmd {
1818	if m.dialog.ContainsDialog(dialog.QuitID) {
1819		// Bring to front
1820		m.dialog.BringToFront(dialog.QuitID)
1821		return nil
1822	}
1823
1824	quitDialog := dialog.NewQuit(m.com)
1825	m.dialog.OpenDialog(quitDialog)
1826	return nil
1827}
1828
1829// openModelsDialog opens the models dialog.
1830func (m *UI) openModelsDialog() tea.Cmd {
1831	if m.dialog.ContainsDialog(dialog.ModelsID) {
1832		// Bring to front
1833		m.dialog.BringToFront(dialog.ModelsID)
1834		return nil
1835	}
1836
1837	modelsDialog, err := dialog.NewModels(m.com)
1838	if err != nil {
1839		return uiutil.ReportError(err)
1840	}
1841
1842	modelsDialog.SetSize(min(60, m.width-8), 30)
1843	m.dialog.OpenDialog(modelsDialog)
1844
1845	return nil
1846}
1847
1848// openCommandsDialog opens the commands dialog.
1849func (m *UI) openCommandsDialog() tea.Cmd {
1850	if m.dialog.ContainsDialog(dialog.CommandsID) {
1851		// Bring to front
1852		m.dialog.BringToFront(dialog.CommandsID)
1853		return nil
1854	}
1855
1856	sessionID := ""
1857	if m.session != nil {
1858		sessionID = m.session.ID
1859	}
1860
1861	commands, err := dialog.NewCommands(m.com, sessionID)
1862	if err != nil {
1863		return uiutil.ReportError(err)
1864	}
1865
1866	// TODO: Get. Rid. Of. Magic numbers!
1867	commands.SetSize(min(120, m.width-8), 30)
1868	m.dialog.OpenDialog(commands)
1869
1870	return nil
1871}
1872
1873// openSessionsDialog opens the sessions dialog. If the dialog is already open,
1874// it brings it to the front. Otherwise, it will list all the sessions and open
1875// the dialog.
1876func (m *UI) openSessionsDialog() tea.Cmd {
1877	if m.dialog.ContainsDialog(dialog.SessionsID) {
1878		// Bring to front
1879		m.dialog.BringToFront(dialog.SessionsID)
1880		return nil
1881	}
1882
1883	selectedSessionID := ""
1884	if m.session != nil {
1885		selectedSessionID = m.session.ID
1886	}
1887
1888	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
1889	if err != nil {
1890		return uiutil.ReportError(err)
1891	}
1892
1893	// TODO: Get. Rid. Of. Magic numbers!
1894	dialog.SetSize(min(120, m.width-8), 30)
1895	m.dialog.OpenDialog(dialog)
1896
1897	return nil
1898}
1899
1900// newSession clears the current session state and prepares for a new session.
1901// The actual session creation happens when the user sends their first message.
1902func (m *UI) newSession() {
1903	if m.session == nil || m.session.ID == "" {
1904		return
1905	}
1906
1907	m.session = nil
1908	m.sessionFiles = nil
1909	m.state = uiLanding
1910	m.focus = uiFocusEditor
1911	m.textarea.Focus()
1912	m.chat.Blur()
1913	m.chat.ClearMessages()
1914}
1915
1916// handlePasteMsg handles a paste message.
1917func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1918	if m.focus != uiFocusEditor {
1919		return nil
1920	}
1921
1922	// If pasted text has more than 2 newlines, treat it as a file attachment.
1923	if strings.Count(msg.Content, "\n") > 2 {
1924		return func() tea.Msg {
1925			content := []byte(msg.Content)
1926			if int64(len(content)) > maxAttachmentSize {
1927				return uiutil.ReportWarn("Paste is too big (>5mb)")
1928			}
1929			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
1930			mimeBufferSize := min(512, len(content))
1931			mimeType := http.DetectContentType(content[:mimeBufferSize])
1932			return message.Attachment{
1933				FileName: name,
1934				FilePath: name,
1935				MimeType: mimeType,
1936				Content:  content,
1937			}
1938		}
1939	}
1940
1941	var cmd tea.Cmd
1942	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1943	// Try to get an image.
1944	path, err := filepath.Abs(strings.TrimSpace(path))
1945	if err != nil {
1946		m.textarea, cmd = m.textarea.Update(msg)
1947		return cmd
1948	}
1949
1950	// Check if file has an allowed image extension.
1951	isAllowedType := false
1952	lowerPath := strings.ToLower(path)
1953	for _, ext := range allowedImageTypes {
1954		if strings.HasSuffix(lowerPath, ext) {
1955			isAllowedType = true
1956			break
1957		}
1958	}
1959	if !isAllowedType {
1960		m.textarea, cmd = m.textarea.Update(msg)
1961		return cmd
1962	}
1963
1964	return func() tea.Msg {
1965		fileInfo, err := os.Stat(path)
1966		if err != nil {
1967			return uiutil.ReportError(err)
1968		}
1969		if fileInfo.Size() > maxAttachmentSize {
1970			return uiutil.ReportWarn("File is too big (>5mb)")
1971		}
1972
1973		content, err := os.ReadFile(path)
1974		if err != nil {
1975			return uiutil.ReportError(err)
1976		}
1977
1978		mimeBufferSize := min(512, len(content))
1979		mimeType := http.DetectContentType(content[:mimeBufferSize])
1980		fileName := filepath.Base(path)
1981		return message.Attachment{
1982			FilePath: path,
1983			FileName: fileName,
1984			MimeType: mimeType,
1985			Content:  content,
1986		}
1987	}
1988}
1989
1990var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
1991
1992func (m *UI) pasteIdx() int {
1993	result := 0
1994	for _, at := range m.attachments.List() {
1995		found := pasteRE.FindStringSubmatch(at.FileName)
1996		if len(found) == 0 {
1997			continue
1998		}
1999		idx, err := strconv.Atoi(found[1])
2000		if err == nil {
2001			result = max(result, idx)
2002		}
2003	}
2004	return result + 1
2005}
2006
2007// renderLogo renders the Crush logo with the given styles and dimensions.
2008func renderLogo(t *styles.Styles, compact bool, width int) string {
2009	return logo.Render(version.Version, compact, logo.Opts{
2010		FieldColor:   t.LogoFieldColor,
2011		TitleColorA:  t.LogoTitleColorA,
2012		TitleColorB:  t.LogoTitleColorB,
2013		CharmColor:   t.LogoCharmColor,
2014		VersionColor: t.LogoVersionColor,
2015		Width:        width,
2016	})
2017}