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