ui.go

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