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