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