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.SessionsID)
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.QuitID) {
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.SessionsID) {
374				// Bring to front
375				m.dialog.BringToFront(dialog.SessionsID)
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		m.dialog.Draw(scr, area)
539	}
540}
541
542// Cursor returns the cursor position and properties for the UI model. It
543// returns nil if the cursor should not be shown.
544func (m *UI) Cursor() *tea.Cursor {
545	if m.layout.editor.Dy() <= 0 {
546		// Don't show cursor if editor is not visible
547		return nil
548	}
549	if m.focus == uiFocusEditor && m.textarea.Focused() {
550		cur := m.textarea.Cursor()
551		cur.X++ // Adjust for app margins
552		cur.Y += m.layout.editor.Min.Y
553		return cur
554	}
555	return nil
556}
557
558// View renders the UI model's view.
559func (m *UI) View() tea.View {
560	var v tea.View
561	v.AltScreen = true
562	v.BackgroundColor = m.com.Styles.Background
563	v.Cursor = m.Cursor()
564	v.MouseMode = tea.MouseModeCellMotion
565
566	canvas := uv.NewScreenBuffer(m.width, m.height)
567	m.Draw(canvas, canvas.Bounds())
568
569	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
570	contentLines := strings.Split(content, "\n")
571	for i, line := range contentLines {
572		// Trim trailing spaces for concise rendering
573		contentLines[i] = strings.TrimRight(line, " ")
574	}
575
576	content = strings.Join(contentLines, "\n")
577
578	v.Content = content
579	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
580		// HACK: use a random percentage to prevent ghostty from hiding it
581		// after a timeout.
582		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
583	}
584
585	return v
586}
587
588// ShortHelp implements [help.KeyMap].
589func (m *UI) ShortHelp() []key.Binding {
590	var binds []key.Binding
591	k := &m.keyMap
592
593	switch m.state {
594	case uiInitialize:
595		binds = append(binds, k.Quit)
596	default:
597		// TODO: other states
598		// if m.session == nil {
599		// no session selected
600		binds = append(binds,
601			k.Commands,
602			k.Models,
603			k.Editor.Newline,
604			k.Quit,
605			k.Help,
606		)
607		// }
608		// else {
609		// we have a session
610		// }
611
612		// switch m.state {
613		// case uiChat:
614		// case uiEdit:
615		// 	binds = append(binds,
616		// 		k.Editor.AddFile,
617		// 		k.Editor.SendMessage,
618		// 		k.Editor.OpenEditor,
619		// 		k.Editor.Newline,
620		// 	)
621		//
622		// 	if len(m.attachments) > 0 {
623		// 		binds = append(binds,
624		// 			k.Editor.AttachmentDeleteMode,
625		// 			k.Editor.DeleteAllAttachments,
626		// 			k.Editor.Escape,
627		// 		)
628		// 	}
629		// }
630	}
631
632	return binds
633}
634
635// FullHelp implements [help.KeyMap].
636func (m *UI) FullHelp() [][]key.Binding {
637	var binds [][]key.Binding
638	k := &m.keyMap
639	help := k.Help
640	help.SetHelp("ctrl+g", "less")
641
642	switch m.state {
643	case uiInitialize:
644		binds = append(binds,
645			[]key.Binding{
646				k.Quit,
647			})
648	default:
649		if m.session == nil {
650			// no session selected
651			binds = append(binds,
652				[]key.Binding{
653					k.Commands,
654					k.Models,
655					k.Sessions,
656				},
657				[]key.Binding{
658					k.Editor.Newline,
659					k.Editor.AddImage,
660					k.Editor.MentionFile,
661					k.Editor.OpenEditor,
662				},
663				[]key.Binding{
664					help,
665				},
666			)
667		}
668		// else {
669		// we have a session
670		// }
671	}
672
673	// switch m.state {
674	// case uiChat:
675	// case uiEdit:
676	// 	binds = append(binds, m.ShortHelp())
677	// }
678
679	return binds
680}
681
682// updateFocused updates the focused model (chat or editor) with the given message
683// and appends any resulting commands to the cmds slice.
684func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
685	switch m.state {
686	case uiConfigure:
687		return cmds
688	case uiInitialize:
689		return append(cmds, m.updateInitializeView(msg)...)
690	case uiChat, uiLanding, uiChatCompact:
691		switch m.focus {
692		case uiFocusMain:
693		case uiFocusEditor:
694			switch {
695			case key.Matches(msg, m.keyMap.Editor.Newline):
696				m.textarea.InsertRune('\n')
697			}
698
699			ta, cmd := m.textarea.Update(msg)
700			m.textarea = ta
701			cmds = append(cmds, cmd)
702			return cmds
703		}
704	}
705	return cmds
706}
707
708// updateLayoutAndSize updates the layout and sizes of UI components.
709func (m *UI) updateLayoutAndSize() {
710	m.layout = generateLayout(m, m.width, m.height)
711	m.updateSize()
712}
713
714// updateSize updates the sizes of UI components based on the current layout.
715func (m *UI) updateSize() {
716	// Set help width
717	m.help.SetWidth(m.layout.help.Dx())
718
719	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
720	m.textarea.SetWidth(m.layout.editor.Dx())
721	m.textarea.SetHeight(m.layout.editor.Dy())
722
723	// Handle different app states
724	switch m.state {
725	case uiConfigure, uiInitialize, uiLanding:
726		m.renderHeader(false, m.layout.header.Dx())
727
728	case uiChat:
729		m.renderSidebarLogo(m.layout.sidebar.Dx())
730
731	case uiChatCompact:
732		// TODO: set the width and heigh of the chat component
733		m.renderHeader(true, m.layout.header.Dx())
734	}
735}
736
737// generateLayout calculates the layout rectangles for all UI components based
738// on the current UI state and terminal dimensions.
739func generateLayout(m *UI, w, h int) layout {
740	// The screen area we're working with
741	area := image.Rect(0, 0, w, h)
742
743	// The help height
744	helpHeight := 1
745	// The editor height
746	editorHeight := 5
747	// The sidebar width
748	sidebarWidth := 30
749	// The header height
750	// TODO: handle compact
751	headerHeight := 4
752
753	var helpKeyMap help.KeyMap = m
754	if m.help.ShowAll {
755		for _, row := range helpKeyMap.FullHelp() {
756			helpHeight = max(helpHeight, len(row))
757		}
758	}
759
760	// Add app margins
761	appRect := area
762	appRect.Min.X += 1
763	appRect.Min.Y += 1
764	appRect.Max.X -= 1
765	appRect.Max.Y -= 1
766
767	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
768		// extra padding on left and right for these states
769		appRect.Min.X += 1
770		appRect.Max.X -= 1
771	}
772
773	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
774
775	layout := layout{
776		area: area,
777		help: helpRect,
778	}
779
780	// Handle different app states
781	switch m.state {
782	case uiConfigure, uiInitialize:
783		// Layout
784		//
785		// header
786		// ------
787		// main
788		// ------
789		// help
790
791		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
792		layout.header = headerRect
793		layout.main = mainRect
794
795	case uiLanding:
796		// Layout
797		//
798		// header
799		// ------
800		// main
801		// ------
802		// editor
803		// ------
804		// help
805		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
806		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
807		// Remove extra padding from editor (but keep it for header and main)
808		editorRect.Min.X -= 1
809		editorRect.Max.X += 1
810		layout.header = headerRect
811		layout.main = mainRect
812		layout.editor = editorRect
813
814	case uiChat:
815		// Layout
816		//
817		// ------|---
818		// main  |
819		// ------| side
820		// editor|
821		// ----------
822		// help
823
824		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
825		// Add padding left
826		sideRect.Min.X += 1
827		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
828		mainRect.Max.X -= 1 // Add padding right
829		// Add bottom margin to main
830		mainRect.Max.Y -= 1
831		layout.sidebar = sideRect
832		layout.main = mainRect
833		layout.editor = editorRect
834
835	case uiChatCompact:
836		// Layout
837		//
838		// compact-header
839		// ------
840		// main
841		// ------
842		// editor
843		// ------
844		// help
845		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
846		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
847		layout.header = headerRect
848		layout.main = mainRect
849		layout.editor = editorRect
850	}
851
852	if !layout.editor.Empty() {
853		// Add editor margins 1 top and bottom
854		layout.editor.Min.Y += 1
855		layout.editor.Max.Y -= 1
856	}
857
858	return layout
859}
860
861// layout defines the positioning of UI elements.
862type layout struct {
863	// area is the overall available area.
864	area uv.Rectangle
865
866	// header is the header shown in special cases
867	// e.x when the sidebar is collapsed
868	// or when in the landing page
869	// or in init/config
870	header uv.Rectangle
871
872	// main is the area for the main pane. (e.x chat, configure, landing)
873	main uv.Rectangle
874
875	// editor is the area for the editor pane.
876	editor uv.Rectangle
877
878	// sidebar is the area for the sidebar.
879	sidebar uv.Rectangle
880
881	// help is the area for the help view.
882	help uv.Rectangle
883}
884
885// setEditorPrompt configures the textarea prompt function based on whether
886// yolo mode is enabled.
887func (m *UI) setEditorPrompt() {
888	if m.com.App.Permissions.SkipRequests() {
889		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
890		return
891	}
892	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
893}
894
895// normalPromptFunc returns the normal editor prompt style ("  > " on first
896// line, "::: " on subsequent lines).
897func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
898	t := m.com.Styles
899	if info.LineNumber == 0 {
900		return "  > "
901	}
902	if info.Focused {
903		return t.EditorPromptNormalFocused.Render()
904	}
905	return t.EditorPromptNormalBlurred.Render()
906}
907
908// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
909// and colored dots.
910func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
911	t := m.com.Styles
912	if info.LineNumber == 0 {
913		if info.Focused {
914			return t.EditorPromptYoloIconFocused.Render()
915		} else {
916			return t.EditorPromptYoloIconBlurred.Render()
917		}
918	}
919	if info.Focused {
920		return t.EditorPromptYoloDotsFocused.Render()
921	}
922	return t.EditorPromptYoloDotsBlurred.Render()
923}
924
925var readyPlaceholders = [...]string{
926	"Ready!",
927	"Ready...",
928	"Ready?",
929	"Ready for instructions",
930}
931
932var workingPlaceholders = [...]string{
933	"Working!",
934	"Working...",
935	"Brrrrr...",
936	"Prrrrrrrr...",
937	"Processing...",
938	"Thinking...",
939}
940
941// randomizePlaceholders selects random placeholder text for the textarea's
942// ready and working states.
943func (m *UI) randomizePlaceholders() {
944	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
945	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
946}
947
948// renderHeader renders and caches the header logo at the specified width.
949func (m *UI) renderHeader(compact bool, width int) {
950	// TODO: handle the compact case differently
951	m.header = renderLogo(m.com.Styles, compact, width)
952}
953
954// renderSidebarLogo renders and caches the sidebar logo at the specified
955// width.
956func (m *UI) renderSidebarLogo(width int) {
957	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
958}
959
960// loadSessionsCmd loads the list of sessions and returns a command that sends
961// a sessionFilesLoadedMsg when done.
962func (m *UI) loadSessionsCmd() tea.Msg {
963	allSessions, _ := m.com.App.Sessions.List(context.TODO())
964	return sessionsLoadedMsg{sessions: allSessions}
965}
966
967// renderLogo renders the Crush logo with the given styles and dimensions.
968func renderLogo(t *styles.Styles, compact bool, width int) string {
969	return logo.Render(version.Version, compact, logo.Opts{
970		FieldColor:   t.LogoFieldColor,
971		TitleColorA:  t.LogoTitleColorA,
972		TitleColorB:  t.LogoTitleColorB,
973		CharmColor:   t.LogoCharmColor,
974		VersionColor: t.LogoVersionColor,
975		Width:        width,
976	})
977}