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/tools/mcp"
 18	"github.com/charmbracelet/crush/internal/app"
 19	"github.com/charmbracelet/crush/internal/config"
 20	"github.com/charmbracelet/crush/internal/history"
 21	"github.com/charmbracelet/crush/internal/message"
 22	"github.com/charmbracelet/crush/internal/pubsub"
 23	"github.com/charmbracelet/crush/internal/session"
 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
 54type sessionLoadedMsg struct {
 55	sess session.Session
 56}
 57
 58type sessionFilesLoadedMsg struct {
 59	files []SessionFile
 60}
 61
 62// UI represents the main user interface model.
 63type UI struct {
 64	com          *common.Common
 65	session      *session.Session
 66	sessionFiles []SessionFile
 67
 68	// The width and height of the terminal in cells.
 69	width  int
 70	height int
 71	layout layout
 72
 73	focus uiFocusState
 74	state uiState
 75
 76	keyMap KeyMap
 77	keyenh tea.KeyboardEnhancementsMsg
 78
 79	dialog *dialog.Overlay
 80	help   help.Model
 81
 82	// header is the last cached header logo
 83	header string
 84
 85	// sendProgressBar instructs the TUI to send progress bar updates to the
 86	// terminal.
 87	sendProgressBar bool
 88
 89	// QueryVersion instructs the TUI to query for the terminal version when it
 90	// starts.
 91	QueryVersion bool
 92
 93	// Editor components
 94	textarea textarea.Model
 95
 96	attachments []any // TODO: Implement attachments
 97
 98	readyPlaceholder   string
 99	workingPlaceholder string
100
101	// Chat components
102	chat *Chat
103
104	// onboarding state
105	onboarding struct {
106		yesInitializeSelected bool
107	}
108
109	// lsp
110	lspStates map[string]app.LSPClientInfo
111
112	// mcp
113	mcpStates map[string]mcp.ClientInfo
114
115	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
116	sidebarLogo string
117}
118
119// New creates a new instance of the [UI] model.
120func New(com *common.Common) *UI {
121	// Editor components
122	ta := textarea.New()
123	ta.SetStyles(com.Styles.TextArea)
124	ta.ShowLineNumbers = false
125	ta.CharLimit = -1
126	ta.SetVirtualCursor(false)
127	ta.Focus()
128
129	ch := NewChat(com)
130
131	ui := &UI{
132		com:      com,
133		dialog:   dialog.NewOverlay(),
134		keyMap:   DefaultKeyMap(),
135		help:     help.New(),
136		focus:    uiFocusNone,
137		state:    uiConfigure,
138		textarea: ta,
139		chat:     ch,
140	}
141
142	// set onboarding state defaults
143	ui.onboarding.yesInitializeSelected = true
144
145	// If no provider is configured show the user the provider list
146	if !com.Config().IsConfigured() {
147		ui.state = uiConfigure
148		// if the project needs initialization show the user the question
149	} else if n, _ := config.ProjectNeedsInitialization(); n {
150		ui.state = uiInitialize
151		// otherwise go to the landing UI
152	} else {
153		ui.state = uiLanding
154		ui.focus = uiFocusEditor
155	}
156
157	ui.setEditorPrompt()
158	ui.randomizePlaceholders()
159	ui.textarea.Placeholder = ui.readyPlaceholder
160	ui.help.Styles = com.Styles.Help
161
162	return ui
163}
164
165// Init initializes the UI model.
166func (m *UI) Init() tea.Cmd {
167	var cmds []tea.Cmd
168	if m.QueryVersion {
169		cmds = append(cmds, tea.RequestTerminalVersion)
170	}
171	allSessions, _ := m.com.App.Sessions.List(context.Background())
172	if len(allSessions) > 0 {
173		cmds = append(cmds, func() tea.Msg {
174			time.Sleep(2 * time.Second)
175			return m.loadSession(allSessions[1].ID)()
176		})
177	}
178	return tea.Batch(cmds...)
179}
180
181// sessionLoadedDoneMsg indicates that session loading and message appending is
182// done.
183type sessionLoadedDoneMsg struct{}
184
185// Update handles updates to the UI model.
186func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187	var cmds []tea.Cmd
188	switch msg := msg.(type) {
189	case tea.EnvMsg:
190		// Is this Windows Terminal?
191		if !m.sendProgressBar {
192			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
193		}
194	case sessionLoadedMsg:
195		m.state = uiChat
196		m.session = &msg.sess
197		// Load the last 20 messages from this session.
198		msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
199
200		// Build tool result map to link tool calls with their results
201		msgPtrs := make([]*message.Message, len(msgs))
202		for i := range msgs {
203			msgPtrs[i] = &msgs[i]
204		}
205		toolResultMap := BuildToolResultMap(msgPtrs)
206
207		// Add messages to chat with linked tool results
208		items := make([]MessageItem, 0, len(msgs)*2)
209		for _, msg := range msgPtrs {
210			items = append(items, GetMessageItems(msg, toolResultMap)...)
211		}
212		m.chat.AppendMessages(items...)
213
214		// Notify that session loading is done to scroll to bottom. This is
215		// needed because we need to draw the chat list first before we can
216		// scroll to bottom.
217		cmds = append(cmds, func() tea.Msg {
218			return sessionLoadedDoneMsg{}
219		})
220	case sessionLoadedDoneMsg:
221		m.chat.ScrollToBottom()
222		m.chat.SelectLast()
223	case sessionFilesLoadedMsg:
224		m.sessionFiles = msg.files
225	case pubsub.Event[history.File]:
226		cmds = append(cmds, m.handleFileEvent(msg.Payload))
227	case pubsub.Event[app.LSPEvent]:
228		m.lspStates = app.GetLSPStates()
229	case pubsub.Event[mcp.Event]:
230		m.mcpStates = mcp.GetStates()
231	case tea.TerminalVersionMsg:
232		termVersion := strings.ToLower(msg.Name)
233		// Only enable progress bar for the following terminals.
234		if !m.sendProgressBar {
235			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
236		}
237		return m, nil
238	case tea.WindowSizeMsg:
239		m.width, m.height = msg.Width, msg.Height
240		m.updateLayoutAndSize()
241	case tea.KeyboardEnhancementsMsg:
242		m.keyenh = msg
243		if msg.SupportsKeyDisambiguation() {
244			m.keyMap.Models.SetHelp("ctrl+m", "models")
245			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
246		}
247	case tea.MouseClickMsg:
248		switch m.state {
249		case uiChat:
250			m.chat.HandleMouseDown(msg.X, msg.Y)
251		}
252
253	case tea.MouseMotionMsg:
254		switch m.state {
255		case uiChat:
256			if msg.Y <= 0 {
257				m.chat.ScrollBy(-1)
258			} else if msg.Y >= m.chat.Height()-1 {
259				m.chat.ScrollBy(1)
260			}
261			m.chat.HandleMouseDrag(msg.X, msg.Y)
262		}
263
264	case tea.MouseReleaseMsg:
265		switch m.state {
266		case uiChat:
267			m.chat.HandleMouseUp(msg.X, msg.Y)
268		}
269	case tea.MouseWheelMsg:
270		switch m.state {
271		case uiChat:
272			switch msg.Button {
273			case tea.MouseWheelUp:
274				m.chat.ScrollBy(-5)
275				if !m.chat.SelectedItemInView() {
276					m.chat.SelectPrev()
277					m.chat.ScrollToSelected()
278				}
279			case tea.MouseWheelDown:
280				m.chat.ScrollBy(5)
281				if !m.chat.SelectedItemInView() {
282					m.chat.SelectNext()
283					m.chat.ScrollToSelected()
284				}
285			}
286		}
287	case tea.KeyPressMsg:
288		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
289	}
290
291	// This logic gets triggered on any message type, but should it?
292	switch m.focus {
293	case uiFocusMain:
294	case uiFocusEditor:
295		// Textarea placeholder logic
296		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
297			m.textarea.Placeholder = m.workingPlaceholder
298		} else {
299			m.textarea.Placeholder = m.readyPlaceholder
300		}
301		if m.com.App.Permissions.SkipRequests() {
302			m.textarea.Placeholder = "Yolo mode!"
303		}
304	}
305
306	return m, tea.Batch(cmds...)
307}
308
309func (m *UI) loadSession(sessionID string) tea.Cmd {
310	return func() tea.Msg {
311		// TODO: handle error
312		session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
313		return sessionLoadedMsg{session}
314	}
315}
316
317func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
318	if m.dialog.HasDialogs() {
319		return m.updateDialogs(msg)
320	}
321
322	handleGlobalKeys := func(msg tea.KeyPressMsg) {
323		switch {
324		case key.Matches(msg, m.keyMap.Tab):
325		case key.Matches(msg, m.keyMap.Help):
326			m.help.ShowAll = !m.help.ShowAll
327			m.updateLayoutAndSize()
328		case key.Matches(msg, m.keyMap.Quit):
329			if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
330				m.dialog.AddDialog(dialog.NewQuit(m.com))
331				return
332			}
333		case key.Matches(msg, m.keyMap.Commands):
334			// TODO: Implement me
335		case key.Matches(msg, m.keyMap.Models):
336			// TODO: Implement me
337		case key.Matches(msg, m.keyMap.Sessions):
338			// TODO: Implement me
339		}
340	}
341
342	switch m.state {
343	case uiChat:
344		switch {
345		case key.Matches(msg, m.keyMap.Tab):
346			if m.focus == uiFocusMain {
347				m.focus = uiFocusEditor
348				cmds = append(cmds, m.textarea.Focus())
349				m.chat.Blur()
350			} else {
351				m.focus = uiFocusMain
352				m.textarea.Blur()
353				m.chat.Focus()
354				m.chat.SetSelected(m.chat.Len() - 1)
355			}
356		case key.Matches(msg, m.keyMap.Chat.Up):
357			m.chat.ScrollBy(-1)
358			if !m.chat.SelectedItemInView() {
359				m.chat.SelectPrev()
360				m.chat.ScrollToSelected()
361			}
362		case key.Matches(msg, m.keyMap.Chat.Down):
363			m.chat.ScrollBy(1)
364			if !m.chat.SelectedItemInView() {
365				m.chat.SelectNext()
366				m.chat.ScrollToSelected()
367			}
368		case key.Matches(msg, m.keyMap.Chat.UpOneItem):
369			m.chat.SelectPrev()
370			m.chat.ScrollToSelected()
371		case key.Matches(msg, m.keyMap.Chat.DownOneItem):
372			m.chat.SelectNext()
373			m.chat.ScrollToSelected()
374		case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
375			m.chat.ScrollBy(-m.chat.Height() / 2)
376			m.chat.SelectFirstInView()
377		case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
378			m.chat.ScrollBy(m.chat.Height() / 2)
379			m.chat.SelectLastInView()
380		case key.Matches(msg, m.keyMap.Chat.PageUp):
381			m.chat.ScrollBy(-m.chat.Height())
382			m.chat.SelectFirstInView()
383		case key.Matches(msg, m.keyMap.Chat.PageDown):
384			m.chat.ScrollBy(m.chat.Height())
385			m.chat.SelectLastInView()
386		case key.Matches(msg, m.keyMap.Chat.Home):
387			m.chat.ScrollToTop()
388			m.chat.SelectFirst()
389		case key.Matches(msg, m.keyMap.Chat.End):
390			m.chat.ScrollToBottom()
391			m.chat.SelectLast()
392		default:
393			handleGlobalKeys(msg)
394		}
395	default:
396		handleGlobalKeys(msg)
397	}
398
399	cmds = append(cmds, m.updateFocused(msg)...)
400	return cmds
401}
402
403// Draw implements [tea.Layer] and draws the UI model.
404func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
405	layout := generateLayout(m, area.Dx(), area.Dy())
406
407	if m.layout != layout {
408		m.layout = layout
409		m.updateSize()
410	}
411
412	// Clear the screen first
413	screen.Clear(scr)
414
415	switch m.state {
416	case uiConfigure:
417		header := uv.NewStyledString(m.header)
418		header.Draw(scr, layout.header)
419
420		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
421			Height(layout.main.Dy()).
422			Background(lipgloss.ANSIColor(rand.Intn(256))).
423			Render(" Configure ")
424		main := uv.NewStyledString(mainView)
425		main.Draw(scr, layout.main)
426
427	case uiInitialize:
428		header := uv.NewStyledString(m.header)
429		header.Draw(scr, layout.header)
430
431		main := uv.NewStyledString(m.initializeView())
432		main.Draw(scr, layout.main)
433
434	case uiLanding:
435		header := uv.NewStyledString(m.header)
436		header.Draw(scr, layout.header)
437		main := uv.NewStyledString(m.landingView())
438		main.Draw(scr, layout.main)
439
440		editor := uv.NewStyledString(m.textarea.View())
441		editor.Draw(scr, layout.editor)
442
443	case uiChat:
444		m.chat.Draw(scr, layout.main)
445
446		header := uv.NewStyledString(m.header)
447		header.Draw(scr, layout.header)
448		m.drawSidebar(scr, layout.sidebar)
449
450		editor := uv.NewStyledString(m.textarea.View())
451		editor.Draw(scr, layout.editor)
452
453	case uiChatCompact:
454		header := uv.NewStyledString(m.header)
455		header.Draw(scr, layout.header)
456
457		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
458			Height(layout.main.Dy()).
459			Background(lipgloss.ANSIColor(rand.Intn(256))).
460			Render(" Compact Chat Messages ")
461		main := uv.NewStyledString(mainView)
462		main.Draw(scr, layout.main)
463
464		editor := uv.NewStyledString(m.textarea.View())
465		editor.Draw(scr, layout.editor)
466	}
467
468	// Add help layer
469	help := uv.NewStyledString(m.help.View(m))
470	help.Draw(scr, layout.help)
471
472	// Debugging rendering (visually see when the tui rerenders)
473	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
474		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
475		debug := uv.NewStyledString(debugView.String())
476		debug.Draw(scr, image.Rectangle{
477			Min: image.Pt(4, 1),
478			Max: image.Pt(8, 3),
479		})
480	}
481
482	// This needs to come last to overlay on top of everything
483	if m.dialog.HasDialogs() {
484		if dialogView := m.dialog.View(); dialogView != "" {
485			dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
486			dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
487			dialog := uv.NewStyledString(dialogView)
488			dialog.Draw(scr, dialogArea)
489		}
490	}
491}
492
493// Cursor returns the cursor position and properties for the UI model. It
494// returns nil if the cursor should not be shown.
495func (m *UI) Cursor() *tea.Cursor {
496	if m.layout.editor.Dy() <= 0 {
497		// Don't show cursor if editor is not visible
498		return nil
499	}
500	if m.focus == uiFocusEditor && m.textarea.Focused() {
501		cur := m.textarea.Cursor()
502		cur.X++ // Adjust for app margins
503		cur.Y += m.layout.editor.Min.Y
504		return cur
505	}
506	return nil
507}
508
509// View renders the UI model's view.
510func (m *UI) View() tea.View {
511	var v tea.View
512	v.AltScreen = true
513	v.BackgroundColor = m.com.Styles.Background
514	v.Cursor = m.Cursor()
515	v.MouseMode = tea.MouseModeCellMotion
516
517	canvas := uv.NewScreenBuffer(m.width, m.height)
518	m.Draw(canvas, canvas.Bounds())
519
520	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
521	contentLines := strings.Split(content, "\n")
522	for i, line := range contentLines {
523		// Trim trailing spaces for concise rendering
524		contentLines[i] = strings.TrimRight(line, " ")
525	}
526
527	content = strings.Join(contentLines, "\n")
528
529	v.Content = content
530	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
531		// HACK: use a random percentage to prevent ghostty from hiding it
532		// after a timeout.
533		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
534	}
535
536	return v
537}
538
539// ShortHelp implements [help.KeyMap].
540func (m *UI) ShortHelp() []key.Binding {
541	var binds []key.Binding
542	k := &m.keyMap
543
544	switch m.state {
545	case uiInitialize:
546		binds = append(binds, k.Quit)
547	default:
548		// TODO: other states
549		// if m.session == nil {
550		// no session selected
551		binds = append(binds,
552			k.Commands,
553			k.Models,
554			k.Editor.Newline,
555			k.Quit,
556			k.Help,
557		)
558		// }
559		// else {
560		// we have a session
561		// }
562
563		// switch m.state {
564		// case uiChat:
565		// case uiEdit:
566		// 	binds = append(binds,
567		// 		k.Editor.AddFile,
568		// 		k.Editor.SendMessage,
569		// 		k.Editor.OpenEditor,
570		// 		k.Editor.Newline,
571		// 	)
572		//
573		// 	if len(m.attachments) > 0 {
574		// 		binds = append(binds,
575		// 			k.Editor.AttachmentDeleteMode,
576		// 			k.Editor.DeleteAllAttachments,
577		// 			k.Editor.Escape,
578		// 		)
579		// 	}
580		// }
581	}
582
583	return binds
584}
585
586// FullHelp implements [help.KeyMap].
587func (m *UI) FullHelp() [][]key.Binding {
588	var binds [][]key.Binding
589	k := &m.keyMap
590	help := k.Help
591	help.SetHelp("ctrl+g", "less")
592
593	switch m.state {
594	case uiInitialize:
595		binds = append(binds,
596			[]key.Binding{
597				k.Quit,
598			})
599	default:
600		if m.session == nil {
601			// no session selected
602			binds = append(binds,
603				[]key.Binding{
604					k.Commands,
605					k.Models,
606					k.Sessions,
607				},
608				[]key.Binding{
609					k.Editor.Newline,
610					k.Editor.AddImage,
611					k.Editor.MentionFile,
612					k.Editor.OpenEditor,
613				},
614				[]key.Binding{
615					help,
616				},
617			)
618		}
619		// else {
620		// we have a session
621		// }
622	}
623
624	// switch m.state {
625	// case uiChat:
626	// case uiEdit:
627	// 	binds = append(binds, m.ShortHelp())
628	// }
629
630	return binds
631}
632
633// updateDialogs updates the dialog overlay with the given message and returns cmds
634func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
635	updatedDialog, cmd := m.dialog.Update(msg)
636	m.dialog = updatedDialog
637	cmds = append(cmds, cmd)
638	return cmds
639}
640
641// updateFocused updates the focused model (chat or editor) with the given message
642// and appends any resulting commands to the cmds slice.
643func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
644	switch m.state {
645	case uiConfigure:
646		return cmds
647	case uiInitialize:
648		return append(cmds, m.updateInitializeView(msg)...)
649	case uiChat, uiLanding, uiChatCompact:
650		switch m.focus {
651		case uiFocusMain:
652		case uiFocusEditor:
653			switch {
654			case key.Matches(msg, m.keyMap.Editor.Newline):
655				m.textarea.InsertRune('\n')
656			}
657
658			ta, cmd := m.textarea.Update(msg)
659			m.textarea = ta
660			cmds = append(cmds, cmd)
661			return cmds
662		}
663	}
664	return cmds
665}
666
667// updateLayoutAndSize updates the layout and sizes of UI components.
668func (m *UI) updateLayoutAndSize() {
669	m.layout = generateLayout(m, m.width, m.height)
670	m.updateSize()
671}
672
673// updateSize updates the sizes of UI components based on the current layout.
674func (m *UI) updateSize() {
675	// Set help width
676	m.help.SetWidth(m.layout.help.Dx())
677
678	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
679	m.textarea.SetWidth(m.layout.editor.Dx())
680	m.textarea.SetHeight(m.layout.editor.Dy())
681
682	// Handle different app states
683	switch m.state {
684	case uiConfigure, uiInitialize, uiLanding:
685		m.renderHeader(false, m.layout.header.Dx())
686
687	case uiChat:
688		m.renderSidebarLogo(m.layout.sidebar.Dx())
689
690	case uiChatCompact:
691		// TODO: set the width and heigh of the chat component
692		m.renderHeader(true, m.layout.header.Dx())
693	}
694}
695
696// generateLayout calculates the layout rectangles for all UI components based
697// on the current UI state and terminal dimensions.
698func generateLayout(m *UI, w, h int) layout {
699	// The screen area we're working with
700	area := image.Rect(0, 0, w, h)
701
702	// The help height
703	helpHeight := 1
704	// The editor height
705	editorHeight := 5
706	// The sidebar width
707	sidebarWidth := 30
708	// The header height
709	// TODO: handle compact
710	headerHeight := 4
711
712	var helpKeyMap help.KeyMap = m
713	if m.help.ShowAll {
714		for _, row := range helpKeyMap.FullHelp() {
715			helpHeight = max(helpHeight, len(row))
716		}
717	}
718
719	// Add app margins
720	appRect := area
721	appRect.Min.X += 1
722	appRect.Min.Y += 1
723	appRect.Max.X -= 1
724	appRect.Max.Y -= 1
725
726	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
727		// extra padding on left and right for these states
728		appRect.Min.X += 1
729		appRect.Max.X -= 1
730	}
731
732	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
733
734	layout := layout{
735		area: area,
736		help: helpRect,
737	}
738
739	// Handle different app states
740	switch m.state {
741	case uiConfigure, uiInitialize:
742		// Layout
743		//
744		// header
745		// ------
746		// main
747		// ------
748		// help
749
750		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
751		layout.header = headerRect
752		layout.main = mainRect
753
754	case uiLanding:
755		// Layout
756		//
757		// header
758		// ------
759		// main
760		// ------
761		// editor
762		// ------
763		// help
764		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
765		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
766		// Remove extra padding from editor (but keep it for header and main)
767		editorRect.Min.X -= 1
768		editorRect.Max.X += 1
769		layout.header = headerRect
770		layout.main = mainRect
771		layout.editor = editorRect
772
773	case uiChat:
774		// Layout
775		//
776		// ------|---
777		// main  |
778		// ------| side
779		// editor|
780		// ----------
781		// help
782
783		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
784		// Add padding left
785		sideRect.Min.X += 1
786		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
787		mainRect.Max.X -= 1 // Add padding right
788		// Add bottom margin to main
789		mainRect.Max.Y -= 1
790		layout.sidebar = sideRect
791		layout.main = mainRect
792		layout.editor = editorRect
793
794	case uiChatCompact:
795		// Layout
796		//
797		// compact-header
798		// ------
799		// main
800		// ------
801		// editor
802		// ------
803		// help
804		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
805		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
806		layout.header = headerRect
807		layout.main = mainRect
808		layout.editor = editorRect
809	}
810
811	if !layout.editor.Empty() {
812		// Add editor margins 1 top and bottom
813		layout.editor.Min.Y += 1
814		layout.editor.Max.Y -= 1
815	}
816
817	return layout
818}
819
820// layout defines the positioning of UI elements.
821type layout struct {
822	// area is the overall available area.
823	area uv.Rectangle
824
825	// header is the header shown in special cases
826	// e.x when the sidebar is collapsed
827	// or when in the landing page
828	// or in init/config
829	header uv.Rectangle
830
831	// main is the area for the main pane. (e.x chat, configure, landing)
832	main uv.Rectangle
833
834	// editor is the area for the editor pane.
835	editor uv.Rectangle
836
837	// sidebar is the area for the sidebar.
838	sidebar uv.Rectangle
839
840	// help is the area for the help view.
841	help uv.Rectangle
842}
843
844// setEditorPrompt configures the textarea prompt function based on whether
845// yolo mode is enabled.
846func (m *UI) setEditorPrompt() {
847	if m.com.App.Permissions.SkipRequests() {
848		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
849		return
850	}
851	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
852}
853
854// normalPromptFunc returns the normal editor prompt style ("  > " on first
855// line, "::: " on subsequent lines).
856func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
857	t := m.com.Styles
858	if info.LineNumber == 0 {
859		return "  > "
860	}
861	if info.Focused {
862		return t.EditorPromptNormalFocused.Render()
863	}
864	return t.EditorPromptNormalBlurred.Render()
865}
866
867// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
868// and colored dots.
869func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
870	t := m.com.Styles
871	if info.LineNumber == 0 {
872		if info.Focused {
873			return t.EditorPromptYoloIconFocused.Render()
874		} else {
875			return t.EditorPromptYoloIconBlurred.Render()
876		}
877	}
878	if info.Focused {
879		return t.EditorPromptYoloDotsFocused.Render()
880	}
881	return t.EditorPromptYoloDotsBlurred.Render()
882}
883
884var readyPlaceholders = [...]string{
885	"Ready!",
886	"Ready...",
887	"Ready?",
888	"Ready for instructions",
889}
890
891var workingPlaceholders = [...]string{
892	"Working!",
893	"Working...",
894	"Brrrrr...",
895	"Prrrrrrrr...",
896	"Processing...",
897	"Thinking...",
898}
899
900// randomizePlaceholders selects random placeholder text for the textarea's
901// ready and working states.
902func (m *UI) randomizePlaceholders() {
903	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
904	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
905}
906
907// renderHeader renders and caches the header logo at the specified width.
908func (m *UI) renderHeader(compact bool, width int) {
909	// TODO: handle the compact case differently
910	m.header = renderLogo(m.com.Styles, compact, width)
911}
912
913// renderSidebarLogo renders and caches the sidebar logo at the specified
914// width.
915func (m *UI) renderSidebarLogo(width int) {
916	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
917}
918
919// renderLogo renders the Crush logo with the given styles and dimensions.
920func renderLogo(t *styles.Styles, compact bool, width int) string {
921	return logo.Render(version.Version, compact, logo.Opts{
922		FieldColor:   t.LogoFieldColor,
923		TitleColorA:  t.LogoTitleColorA,
924		TitleColorB:  t.LogoTitleColorB,
925		CharmColor:   t.LogoCharmColor,
926		VersionColor: t.LogoVersionColor,
927		Width:        width,
928	})
929}