ui.go

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