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