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