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