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