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