ui.go

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