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