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