ui.go

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