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