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