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