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