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
 387// appendSessionMessage appends a new message to the current session in the chat
 388func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 389	items := chat.GetMessageItems(m.com.Styles, &msg, nil)
 390	var cmds []tea.Cmd
 391	for _, item := range items {
 392		if animatable, ok := item.(chat.Animatable); ok {
 393			if cmd := animatable.StartAnimation(); cmd != nil {
 394				cmds = append(cmds, cmd)
 395			}
 396		}
 397	}
 398	m.chat.AppendMessages(items...)
 399	m.chat.ScrollToBottom()
 400	return tea.Batch(cmds...)
 401}
 402
 403// updateSessionMessage updates an existing message in the current session in the chat
 404// INFO: currently only updates the assistant when I add tools this will get a bit more complex
 405func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 406	existingItem := m.chat.GetMessageItem(msg.ID)
 407	switch msg.Role {
 408	case message.Assistant:
 409		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
 410			assistantItem.SetMessage(&msg)
 411		}
 412	}
 413
 414	return nil
 415}
 416
 417func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 418	var cmds []tea.Cmd
 419
 420	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
 421		switch {
 422		case key.Matches(msg, m.keyMap.Help):
 423			m.help.ShowAll = !m.help.ShowAll
 424			m.updateLayoutAndSize()
 425			return true
 426		case key.Matches(msg, m.keyMap.Commands):
 427			if cmd := m.openCommandsDialog(); cmd != nil {
 428				cmds = append(cmds, cmd)
 429			}
 430			return true
 431		case key.Matches(msg, m.keyMap.Models):
 432			// TODO: Implement me
 433			return true
 434		case key.Matches(msg, m.keyMap.Sessions):
 435			if m.dialog.ContainsDialog(dialog.SessionsID) {
 436				// Bring to front
 437				m.dialog.BringToFront(dialog.SessionsID)
 438			} else {
 439				cmds = append(cmds, m.listSessions)
 440			}
 441			return true
 442		}
 443		return false
 444	}
 445
 446	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
 447		// Always handle quit keys first
 448		if cmd := m.openQuitDialog(); cmd != nil {
 449			cmds = append(cmds, cmd)
 450		}
 451
 452		return tea.Batch(cmds...)
 453	}
 454
 455	// Route all messages to dialog if one is open.
 456	if m.dialog.HasDialogs() {
 457		msg := m.dialog.Update(msg)
 458		if msg == nil {
 459			return tea.Batch(cmds...)
 460		}
 461
 462		switch msg := msg.(type) {
 463		// Generic dialog messages
 464		case dialog.CloseMsg:
 465			m.dialog.CloseFrontDialog()
 466
 467		// Session dialog messages
 468		case dialog.SessionSelectedMsg:
 469			m.dialog.CloseDialog(dialog.SessionsID)
 470			cmds = append(cmds, m.loadSession(msg.Session.ID))
 471
 472		// Command dialog messages
 473		case dialog.ToggleYoloModeMsg:
 474			yolo := !m.com.App.Permissions.SkipRequests()
 475			m.com.App.Permissions.SetSkipRequests(yolo)
 476			m.setEditorPrompt(yolo)
 477			m.dialog.CloseDialog(dialog.CommandsID)
 478		case dialog.SwitchSessionsMsg:
 479			cmds = append(cmds, m.listSessions)
 480			m.dialog.CloseDialog(dialog.CommandsID)
 481		case dialog.CompactMsg:
 482			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 483			if err != nil {
 484				cmds = append(cmds, uiutil.ReportError(err))
 485			}
 486		case dialog.ToggleHelpMsg:
 487			m.help.ShowAll = !m.help.ShowAll
 488			m.dialog.CloseDialog(dialog.CommandsID)
 489		case dialog.QuitMsg:
 490			cmds = append(cmds, tea.Quit)
 491		}
 492
 493		return tea.Batch(cmds...)
 494	}
 495
 496	switch m.state {
 497	case uiConfigure:
 498		return tea.Batch(cmds...)
 499	case uiInitialize:
 500		cmds = append(cmds, m.updateInitializeView(msg)...)
 501		return tea.Batch(cmds...)
 502	case uiChat, uiLanding, uiChatCompact:
 503		switch m.focus {
 504		case uiFocusEditor:
 505			switch {
 506			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 507				value := m.textarea.Value()
 508				if strings.HasSuffix(value, "\\") {
 509					// If the last character is a backslash, remove it and add a newline.
 510					m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
 511					break
 512				}
 513
 514				// Otherwise, send the message
 515				m.textarea.Reset()
 516
 517				value = strings.TrimSpace(value)
 518				if value == "exit" || value == "quit" {
 519					return m.openQuitDialog()
 520				}
 521
 522				attachments := m.attachments
 523				m.attachments = nil
 524				if len(value) == 0 {
 525					return nil
 526				}
 527
 528				m.randomizePlaceholders()
 529
 530				return m.sendMessage(value, attachments)
 531			case key.Matches(msg, m.keyMap.Tab):
 532				m.focus = uiFocusMain
 533				m.textarea.Blur()
 534				m.chat.Focus()
 535				m.chat.SetSelected(m.chat.Len() - 1)
 536			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
 537				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
 538					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
 539					break
 540				}
 541				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 542			case key.Matches(msg, m.keyMap.Editor.Newline):
 543				m.textarea.InsertRune('\n')
 544			default:
 545				if handleGlobalKeys(msg) {
 546					// Handle global keys first before passing to textarea.
 547					break
 548				}
 549
 550				ta, cmd := m.textarea.Update(msg)
 551				m.textarea = ta
 552				cmds = append(cmds, cmd)
 553			}
 554		case uiFocusMain:
 555			switch {
 556			case key.Matches(msg, m.keyMap.Tab):
 557				m.focus = uiFocusEditor
 558				cmds = append(cmds, m.textarea.Focus())
 559				m.chat.Blur()
 560			case key.Matches(msg, m.keyMap.Chat.Up):
 561				m.chat.ScrollBy(-1)
 562				if !m.chat.SelectedItemInView() {
 563					m.chat.SelectPrev()
 564					m.chat.ScrollToSelected()
 565				}
 566			case key.Matches(msg, m.keyMap.Chat.Down):
 567				m.chat.ScrollBy(1)
 568				if !m.chat.SelectedItemInView() {
 569					m.chat.SelectNext()
 570					m.chat.ScrollToSelected()
 571				}
 572			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 573				m.chat.SelectPrev()
 574				m.chat.ScrollToSelected()
 575			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 576				m.chat.SelectNext()
 577				m.chat.ScrollToSelected()
 578			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
 579				m.chat.ScrollBy(-m.chat.Height() / 2)
 580				m.chat.SelectFirstInView()
 581			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
 582				m.chat.ScrollBy(m.chat.Height() / 2)
 583				m.chat.SelectLastInView()
 584			case key.Matches(msg, m.keyMap.Chat.PageUp):
 585				m.chat.ScrollBy(-m.chat.Height())
 586				m.chat.SelectFirstInView()
 587			case key.Matches(msg, m.keyMap.Chat.PageDown):
 588				m.chat.ScrollBy(m.chat.Height())
 589				m.chat.SelectLastInView()
 590			case key.Matches(msg, m.keyMap.Chat.Home):
 591				m.chat.ScrollToTop()
 592				m.chat.SelectFirst()
 593			case key.Matches(msg, m.keyMap.Chat.End):
 594				m.chat.ScrollToBottom()
 595				m.chat.SelectLast()
 596			default:
 597				handleGlobalKeys(msg)
 598			}
 599		default:
 600			handleGlobalKeys(msg)
 601		}
 602	default:
 603		handleGlobalKeys(msg)
 604	}
 605
 606	return tea.Batch(cmds...)
 607}
 608
 609// Draw implements [tea.Layer] and draws the UI model.
 610func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 611	layout := m.generateLayout(area.Dx(), area.Dy())
 612
 613	if m.layout != layout {
 614		m.layout = layout
 615		m.updateSize()
 616	}
 617
 618	// Clear the screen first
 619	screen.Clear(scr)
 620
 621	switch m.state {
 622	case uiConfigure:
 623		header := uv.NewStyledString(m.header)
 624		header.Draw(scr, layout.header)
 625
 626		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 627			Height(layout.main.Dy()).
 628			Background(lipgloss.ANSIColor(rand.Intn(256))).
 629			Render(" Configure ")
 630		main := uv.NewStyledString(mainView)
 631		main.Draw(scr, layout.main)
 632
 633	case uiInitialize:
 634		header := uv.NewStyledString(m.header)
 635		header.Draw(scr, layout.header)
 636
 637		main := uv.NewStyledString(m.initializeView())
 638		main.Draw(scr, layout.main)
 639
 640	case uiLanding:
 641		header := uv.NewStyledString(m.header)
 642		header.Draw(scr, layout.header)
 643		main := uv.NewStyledString(m.landingView())
 644		main.Draw(scr, layout.main)
 645
 646		editor := uv.NewStyledString(m.textarea.View())
 647		editor.Draw(scr, layout.editor)
 648
 649	case uiChat:
 650		m.chat.Draw(scr, layout.main)
 651
 652		header := uv.NewStyledString(m.header)
 653		header.Draw(scr, layout.header)
 654		m.drawSidebar(scr, layout.sidebar)
 655
 656		editor := uv.NewStyledString(m.textarea.View())
 657		editor.Draw(scr, layout.editor)
 658
 659	case uiChatCompact:
 660		header := uv.NewStyledString(m.header)
 661		header.Draw(scr, layout.header)
 662
 663		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 664			Height(layout.main.Dy()).
 665			Background(lipgloss.ANSIColor(rand.Intn(256))).
 666			Render(" Compact Chat Messages ")
 667		main := uv.NewStyledString(mainView)
 668		main.Draw(scr, layout.main)
 669
 670		editor := uv.NewStyledString(m.textarea.View())
 671		editor.Draw(scr, layout.editor)
 672	}
 673
 674	// Add help layer
 675	help := uv.NewStyledString(m.help.View(m))
 676	help.Draw(scr, layout.help)
 677
 678	// Debugging rendering (visually see when the tui rerenders)
 679	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
 680		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
 681		debug := uv.NewStyledString(debugView.String())
 682		debug.Draw(scr, image.Rectangle{
 683			Min: image.Pt(4, 1),
 684			Max: image.Pt(8, 3),
 685		})
 686	}
 687
 688	// This needs to come last to overlay on top of everything
 689	if m.dialog.HasDialogs() {
 690		m.dialog.Draw(scr, area)
 691	}
 692}
 693
 694// Cursor returns the cursor position and properties for the UI model. It
 695// returns nil if the cursor should not be shown.
 696func (m *UI) Cursor() *tea.Cursor {
 697	if m.layout.editor.Dy() <= 0 {
 698		// Don't show cursor if editor is not visible
 699		return nil
 700	}
 701	if m.dialog.HasDialogs() {
 702		if front := m.dialog.DialogLast(); front != nil {
 703			c, ok := front.(uiutil.Cursor)
 704			if ok {
 705				cur := c.Cursor()
 706				if cur != nil {
 707					pos := m.dialog.CenterPosition(m.layout.area, front.ID())
 708					cur.X += pos.Min.X
 709					cur.Y += pos.Min.Y
 710					return cur
 711				}
 712			}
 713		}
 714		return nil
 715	}
 716	switch m.focus {
 717	case uiFocusEditor:
 718		if m.textarea.Focused() {
 719			cur := m.textarea.Cursor()
 720			cur.X++ // Adjust for app margins
 721			cur.Y += m.layout.editor.Min.Y
 722			return cur
 723		}
 724	}
 725	return nil
 726}
 727
 728// View renders the UI model's view.
 729func (m *UI) View() tea.View {
 730	var v tea.View
 731	v.AltScreen = true
 732	v.BackgroundColor = m.com.Styles.Background
 733	v.Cursor = m.Cursor()
 734	v.MouseMode = tea.MouseModeCellMotion
 735
 736	canvas := uv.NewScreenBuffer(m.width, m.height)
 737	m.Draw(canvas, canvas.Bounds())
 738
 739	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
 740	contentLines := strings.Split(content, "\n")
 741	for i, line := range contentLines {
 742		// Trim trailing spaces for concise rendering
 743		contentLines[i] = strings.TrimRight(line, " ")
 744	}
 745
 746	content = strings.Join(contentLines, "\n")
 747
 748	v.Content = content
 749	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 750		// HACK: use a random percentage to prevent ghostty from hiding it
 751		// after a timeout.
 752		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
 753	}
 754
 755	return v
 756}
 757
 758// ShortHelp implements [help.KeyMap].
 759func (m *UI) ShortHelp() []key.Binding {
 760	var binds []key.Binding
 761	k := &m.keyMap
 762	tab := k.Tab
 763	commands := k.Commands
 764	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 765		commands.SetHelp("/ or ctrl+p", "commands")
 766	}
 767
 768	switch m.state {
 769	case uiInitialize:
 770		binds = append(binds, k.Quit)
 771	case uiChat:
 772		if m.focus == uiFocusEditor {
 773			tab.SetHelp("tab", "focus chat")
 774		} else {
 775			tab.SetHelp("tab", "focus editor")
 776		}
 777
 778		binds = append(binds,
 779			tab,
 780			commands,
 781			k.Models,
 782		)
 783
 784		switch m.focus {
 785		case uiFocusEditor:
 786			binds = append(binds,
 787				k.Editor.Newline,
 788			)
 789		case uiFocusMain:
 790			binds = append(binds,
 791				k.Chat.UpDown,
 792				k.Chat.UpDownOneItem,
 793				k.Chat.PageUp,
 794				k.Chat.PageDown,
 795				k.Chat.Copy,
 796			)
 797		}
 798	default:
 799		// TODO: other states
 800		// if m.session == nil {
 801		// no session selected
 802		binds = append(binds,
 803			commands,
 804			k.Models,
 805			k.Editor.Newline,
 806		)
 807	}
 808
 809	binds = append(binds,
 810		k.Quit,
 811		k.Help,
 812	)
 813
 814	return binds
 815}
 816
 817// FullHelp implements [help.KeyMap].
 818func (m *UI) FullHelp() [][]key.Binding {
 819	var binds [][]key.Binding
 820	k := &m.keyMap
 821	help := k.Help
 822	help.SetHelp("ctrl+g", "less")
 823	hasAttachments := false // TODO: implement attachments
 824	hasSession := m.session != nil && m.session.ID != ""
 825	commands := k.Commands
 826	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 827		commands.SetHelp("/ or ctrl+p", "commands")
 828	}
 829
 830	switch m.state {
 831	case uiInitialize:
 832		binds = append(binds,
 833			[]key.Binding{
 834				k.Quit,
 835			})
 836	case uiChat:
 837		mainBinds := []key.Binding{}
 838		tab := k.Tab
 839		if m.focus == uiFocusEditor {
 840			tab.SetHelp("tab", "focus chat")
 841		} else {
 842			tab.SetHelp("tab", "focus editor")
 843		}
 844
 845		mainBinds = append(mainBinds,
 846			tab,
 847			commands,
 848			k.Models,
 849			k.Sessions,
 850		)
 851		if hasSession {
 852			mainBinds = append(mainBinds, k.Chat.NewSession)
 853		}
 854
 855		binds = append(binds, mainBinds)
 856
 857		switch m.focus {
 858		case uiFocusEditor:
 859			binds = append(binds,
 860				[]key.Binding{
 861					k.Editor.Newline,
 862					k.Editor.AddImage,
 863					k.Editor.MentionFile,
 864					k.Editor.OpenEditor,
 865				},
 866			)
 867			if hasAttachments {
 868				binds = append(binds,
 869					[]key.Binding{
 870						k.Editor.AttachmentDeleteMode,
 871						k.Editor.DeleteAllAttachments,
 872						k.Editor.Escape,
 873					},
 874				)
 875			}
 876		case uiFocusMain:
 877			binds = append(binds,
 878				[]key.Binding{
 879					k.Chat.UpDown,
 880					k.Chat.UpDownOneItem,
 881					k.Chat.PageUp,
 882					k.Chat.PageDown,
 883				},
 884				[]key.Binding{
 885					k.Chat.HalfPageUp,
 886					k.Chat.HalfPageDown,
 887					k.Chat.Home,
 888					k.Chat.End,
 889				},
 890				[]key.Binding{
 891					k.Chat.Copy,
 892					k.Chat.ClearHighlight,
 893				},
 894			)
 895		}
 896	default:
 897		if m.session == nil {
 898			// no session selected
 899			binds = append(binds,
 900				[]key.Binding{
 901					commands,
 902					k.Models,
 903					k.Sessions,
 904				},
 905				[]key.Binding{
 906					k.Editor.Newline,
 907					k.Editor.AddImage,
 908					k.Editor.MentionFile,
 909					k.Editor.OpenEditor,
 910				},
 911				[]key.Binding{
 912					help,
 913				},
 914			)
 915		}
 916	}
 917
 918	binds = append(binds,
 919		[]key.Binding{
 920			help,
 921			k.Quit,
 922		},
 923	)
 924
 925	return binds
 926}
 927
 928// updateLayoutAndSize updates the layout and sizes of UI components.
 929func (m *UI) updateLayoutAndSize() {
 930	m.layout = m.generateLayout(m.width, m.height)
 931	m.updateSize()
 932}
 933
 934// updateSize updates the sizes of UI components based on the current layout.
 935func (m *UI) updateSize() {
 936	// Set help width
 937	m.help.SetWidth(m.layout.help.Dx())
 938
 939	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 940	m.textarea.SetWidth(m.layout.editor.Dx())
 941	m.textarea.SetHeight(m.layout.editor.Dy())
 942
 943	// Handle different app states
 944	switch m.state {
 945	case uiConfigure, uiInitialize, uiLanding:
 946		m.renderHeader(false, m.layout.header.Dx())
 947
 948	case uiChat:
 949		m.renderSidebarLogo(m.layout.sidebar.Dx())
 950
 951	case uiChatCompact:
 952		// TODO: set the width and heigh of the chat component
 953		m.renderHeader(true, m.layout.header.Dx())
 954	}
 955}
 956
 957// generateLayout calculates the layout rectangles for all UI components based
 958// on the current UI state and terminal dimensions.
 959func (m *UI) generateLayout(w, h int) layout {
 960	// The screen area we're working with
 961	area := image.Rect(0, 0, w, h)
 962
 963	// The help height
 964	helpHeight := 1
 965	// The editor height
 966	editorHeight := 5
 967	// The sidebar width
 968	sidebarWidth := 30
 969	// The header height
 970	// TODO: handle compact
 971	headerHeight := 4
 972
 973	var helpKeyMap help.KeyMap = m
 974	if m.help.ShowAll {
 975		for _, row := range helpKeyMap.FullHelp() {
 976			helpHeight = max(helpHeight, len(row))
 977		}
 978	}
 979
 980	// Add app margins
 981	appRect := area
 982	appRect.Min.X += 1
 983	appRect.Min.Y += 1
 984	appRect.Max.X -= 1
 985	appRect.Max.Y -= 1
 986
 987	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
 988		// extra padding on left and right for these states
 989		appRect.Min.X += 1
 990		appRect.Max.X -= 1
 991	}
 992
 993	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
 994
 995	layout := layout{
 996		area: area,
 997		help: helpRect,
 998	}
 999
1000	// Handle different app states
1001	switch m.state {
1002	case uiConfigure, uiInitialize:
1003		// Layout
1004		//
1005		// header
1006		// ------
1007		// main
1008		// ------
1009		// help
1010
1011		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1012		layout.header = headerRect
1013		layout.main = mainRect
1014
1015	case uiLanding:
1016		// Layout
1017		//
1018		// header
1019		// ------
1020		// main
1021		// ------
1022		// editor
1023		// ------
1024		// help
1025		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1026		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1027		// Remove extra padding from editor (but keep it for header and main)
1028		editorRect.Min.X -= 1
1029		editorRect.Max.X += 1
1030		layout.header = headerRect
1031		layout.main = mainRect
1032		layout.editor = editorRect
1033
1034	case uiChat:
1035		// Layout
1036		//
1037		// ------|---
1038		// main  |
1039		// ------| side
1040		// editor|
1041		// ----------
1042		// help
1043
1044		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1045		// Add padding left
1046		sideRect.Min.X += 1
1047		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1048		mainRect.Max.X -= 1 // Add padding right
1049		// Add bottom margin to main
1050		mainRect.Max.Y -= 1
1051		layout.sidebar = sideRect
1052		layout.main = mainRect
1053		layout.editor = editorRect
1054
1055	case uiChatCompact:
1056		// Layout
1057		//
1058		// compact-header
1059		// ------
1060		// main
1061		// ------
1062		// editor
1063		// ------
1064		// help
1065		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1066		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1067		layout.header = headerRect
1068		layout.main = mainRect
1069		layout.editor = editorRect
1070	}
1071
1072	if !layout.editor.Empty() {
1073		// Add editor margins 1 top and bottom
1074		layout.editor.Min.Y += 1
1075		layout.editor.Max.Y -= 1
1076	}
1077
1078	return layout
1079}
1080
1081// layout defines the positioning of UI elements.
1082type layout struct {
1083	// area is the overall available area.
1084	area uv.Rectangle
1085
1086	// header is the header shown in special cases
1087	// e.x when the sidebar is collapsed
1088	// or when in the landing page
1089	// or in init/config
1090	header uv.Rectangle
1091
1092	// main is the area for the main pane. (e.x chat, configure, landing)
1093	main uv.Rectangle
1094
1095	// editor is the area for the editor pane.
1096	editor uv.Rectangle
1097
1098	// sidebar is the area for the sidebar.
1099	sidebar uv.Rectangle
1100
1101	// help is the area for the help view.
1102	help uv.Rectangle
1103}
1104
1105func (m *UI) openEditor(value string) tea.Cmd {
1106	editor := os.Getenv("EDITOR")
1107	if editor == "" {
1108		// Use platform-appropriate default editor
1109		if runtime.GOOS == "windows" {
1110			editor = "notepad"
1111		} else {
1112			editor = "nvim"
1113		}
1114	}
1115
1116	tmpfile, err := os.CreateTemp("", "msg_*.md")
1117	if err != nil {
1118		return uiutil.ReportError(err)
1119	}
1120	defer tmpfile.Close() //nolint:errcheck
1121	if _, err := tmpfile.WriteString(value); err != nil {
1122		return uiutil.ReportError(err)
1123	}
1124	cmdStr := editor + " " + tmpfile.Name()
1125	return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1126		if err != nil {
1127			return uiutil.ReportError(err)
1128		}
1129		content, err := os.ReadFile(tmpfile.Name())
1130		if err != nil {
1131			return uiutil.ReportError(err)
1132		}
1133		if len(content) == 0 {
1134			return uiutil.ReportWarn("Message is empty")
1135		}
1136		os.Remove(tmpfile.Name())
1137		return openEditorMsg{
1138			Text: strings.TrimSpace(string(content)),
1139		}
1140	})
1141}
1142
1143// setEditorPrompt configures the textarea prompt function based on whether
1144// yolo mode is enabled.
1145func (m *UI) setEditorPrompt(yolo bool) {
1146	if yolo {
1147		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1148		return
1149	}
1150	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1151}
1152
1153// normalPromptFunc returns the normal editor prompt style ("  > " on first
1154// line, "::: " on subsequent lines).
1155func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1156	t := m.com.Styles
1157	if info.LineNumber == 0 {
1158		if info.Focused {
1159			return "  > "
1160		}
1161		return "::: "
1162	}
1163	if info.Focused {
1164		return t.EditorPromptNormalFocused.Render()
1165	}
1166	return t.EditorPromptNormalBlurred.Render()
1167}
1168
1169// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1170// and colored dots.
1171func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1172	t := m.com.Styles
1173	if info.LineNumber == 0 {
1174		if info.Focused {
1175			return t.EditorPromptYoloIconFocused.Render()
1176		} else {
1177			return t.EditorPromptYoloIconBlurred.Render()
1178		}
1179	}
1180	if info.Focused {
1181		return t.EditorPromptYoloDotsFocused.Render()
1182	}
1183	return t.EditorPromptYoloDotsBlurred.Render()
1184}
1185
1186var readyPlaceholders = [...]string{
1187	"Ready!",
1188	"Ready...",
1189	"Ready?",
1190	"Ready for instructions",
1191}
1192
1193var workingPlaceholders = [...]string{
1194	"Working!",
1195	"Working...",
1196	"Brrrrr...",
1197	"Prrrrrrrr...",
1198	"Processing...",
1199	"Thinking...",
1200}
1201
1202// randomizePlaceholders selects random placeholder text for the textarea's
1203// ready and working states.
1204func (m *UI) randomizePlaceholders() {
1205	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1206	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1207}
1208
1209// renderHeader renders and caches the header logo at the specified width.
1210func (m *UI) renderHeader(compact bool, width int) {
1211	// TODO: handle the compact case differently
1212	m.header = renderLogo(m.com.Styles, compact, width)
1213}
1214
1215// renderSidebarLogo renders and caches the sidebar logo at the specified
1216// width.
1217func (m *UI) renderSidebarLogo(width int) {
1218	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1219}
1220
1221// sendMessage sends a message with the given content and attachments.
1222func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1223	if m.com.App.AgentCoordinator == nil {
1224		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1225	}
1226
1227	var cmds []tea.Cmd
1228	if m.session == nil || m.session.ID == "" {
1229		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1230		if err != nil {
1231			return uiutil.ReportError(err)
1232		}
1233		m.state = uiChat
1234		m.session = &newSession
1235		cmds = append(cmds, m.loadSession(newSession.ID))
1236	}
1237
1238	cmds = append(cmds, func() tea.Msg {
1239		_, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, attachments...)
1240		if err != nil {
1241			isCancelErr := errors.Is(err, context.Canceled)
1242			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1243			if isCancelErr || isPermissionErr {
1244				return nil
1245			}
1246			return uiutil.InfoMsg{
1247				Type: uiutil.InfoTypeError,
1248				Msg:  err.Error(),
1249			}
1250		}
1251		return nil
1252	})
1253	return tea.Batch(cmds...)
1254}
1255
1256// openQuitDialog opens the quit confirmation dialog.
1257func (m *UI) openQuitDialog() tea.Cmd {
1258	if m.dialog.ContainsDialog(dialog.QuitID) {
1259		// Bring to front
1260		m.dialog.BringToFront(dialog.QuitID)
1261		return nil
1262	}
1263
1264	quitDialog := dialog.NewQuit(m.com)
1265	m.dialog.OpenDialog(quitDialog)
1266	return nil
1267}
1268
1269// openCommandsDialog opens the commands dialog.
1270func (m *UI) openCommandsDialog() tea.Cmd {
1271	if m.dialog.ContainsDialog(dialog.CommandsID) {
1272		// Bring to front
1273		m.dialog.BringToFront(dialog.CommandsID)
1274		return nil
1275	}
1276
1277	sessionID := ""
1278	if m.session != nil {
1279		sessionID = m.session.ID
1280	}
1281
1282	commands, err := dialog.NewCommands(m.com, sessionID)
1283	if err != nil {
1284		return uiutil.ReportError(err)
1285	}
1286
1287	// TODO: Get. Rid. Of. Magic numbers!
1288	commands.SetSize(min(120, m.width-8), 30)
1289	m.dialog.OpenDialog(commands)
1290
1291	return nil
1292}
1293
1294// openSessionsDialog opens the sessions dialog with the given sessions.
1295func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1296	if m.dialog.ContainsDialog(dialog.SessionsID) {
1297		// Bring to front
1298		m.dialog.BringToFront(dialog.SessionsID)
1299		return nil
1300	}
1301
1302	dialog := dialog.NewSessions(m.com, sessions...)
1303	// TODO: Get. Rid. Of. Magic numbers!
1304	dialog.SetSize(min(120, m.width-8), 30)
1305	m.dialog.OpenDialog(dialog)
1306
1307	return nil
1308}
1309
1310// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1311// [listSessionsMsg].
1312func (m *UI) listSessions() tea.Msg {
1313	allSessions, _ := m.com.App.Sessions.List(context.TODO())
1314	return listSessionsMsg{sessions: allSessions}
1315}
1316
1317// handlePasteMsg handles a paste message.
1318func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1319	if m.focus != uiFocusEditor {
1320		return nil
1321	}
1322
1323	var cmd tea.Cmd
1324	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1325	// try to get an image
1326	path, err := filepath.Abs(strings.TrimSpace(path))
1327	if err != nil {
1328		m.textarea, cmd = m.textarea.Update(msg)
1329		return cmd
1330	}
1331	isAllowedType := false
1332	for _, ext := range filepicker.AllowedTypes {
1333		if strings.HasSuffix(path, ext) {
1334			isAllowedType = true
1335			break
1336		}
1337	}
1338	if !isAllowedType {
1339		m.textarea, cmd = m.textarea.Update(msg)
1340		return cmd
1341	}
1342	tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1343	if tooBig {
1344		m.textarea, cmd = m.textarea.Update(msg)
1345		return cmd
1346	}
1347
1348	content, err := os.ReadFile(path)
1349	if err != nil {
1350		m.textarea, cmd = m.textarea.Update(msg)
1351		return cmd
1352	}
1353	mimeBufferSize := min(512, len(content))
1354	mimeType := http.DetectContentType(content[:mimeBufferSize])
1355	fileName := filepath.Base(path)
1356	attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1357	return uiutil.CmdHandler(filepicker.FilePickedMsg{
1358		Attachment: attachment,
1359	})
1360}
1361
1362// renderLogo renders the Crush logo with the given styles and dimensions.
1363func renderLogo(t *styles.Styles, compact bool, width int) string {
1364	return logo.Render(version.Version, compact, logo.Opts{
1365		FieldColor:   t.LogoFieldColor,
1366		TitleColorA:  t.LogoTitleColorA,
1367		TitleColorB:  t.LogoTitleColorB,
1368		CharmColor:   t.LogoCharmColor,
1369		VersionColor: t.LogoVersionColor,
1370		Width:        width,
1371	})
1372}