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