ui.go

  1package model
  2
  3import (
  4	"fmt"
  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/config"
 17	"github.com/charmbracelet/crush/internal/home"
 18	"github.com/charmbracelet/crush/internal/session"
 19	"github.com/charmbracelet/crush/internal/ui/common"
 20	"github.com/charmbracelet/crush/internal/ui/dialog"
 21	"github.com/charmbracelet/crush/internal/ui/logo"
 22	"github.com/charmbracelet/crush/internal/ui/styles"
 23	"github.com/charmbracelet/crush/internal/version"
 24	uv "github.com/charmbracelet/ultraviolet"
 25	"github.com/charmbracelet/ultraviolet/screen"
 26)
 27
 28// uiFocusState represents the current focus state of the UI.
 29type uiFocusState uint8
 30
 31// Possible uiFocusState values.
 32const (
 33	uiFocusNone uiFocusState = iota
 34	uiFocusEditor
 35	uiFocusMain
 36)
 37
 38type uiState uint8
 39
 40// Possible uiState values.
 41const (
 42	uiConfigure uiState = iota
 43	uiInitialize
 44	uiLanding
 45	uiChat
 46	uiChatCompact
 47)
 48
 49// UI represents the main user interface model.
 50type UI struct {
 51	com  *common.Common
 52	sess *session.Session
 53
 54	// The width and height of the terminal in cells.
 55	width  int
 56	height int
 57	layout layout
 58
 59	focus uiFocusState
 60	state uiState
 61
 62	keyMap KeyMap
 63	keyenh tea.KeyboardEnhancementsMsg
 64
 65	chat   *ChatModel
 66	side   *SidebarModel
 67	dialog *dialog.Overlay
 68	help   help.Model
 69
 70	// header is the last cached header logo
 71	header string
 72
 73	// sendProgressBar instructs the TUI to send progress bar updates to the
 74	// terminal.
 75	sendProgressBar bool
 76
 77	// QueryVersion instructs the TUI to query for the terminal version when it
 78	// starts.
 79	QueryVersion bool
 80
 81	// Editor components
 82	textarea textarea.Model
 83
 84	attachments []any // TODO: Implement attachments
 85
 86	readyPlaceholder   string
 87	workingPlaceholder string
 88
 89	// Initialize state
 90	yesInitializeSelected bool
 91}
 92
 93// New creates a new instance of the [UI] model.
 94func New(com *common.Common) *UI {
 95	// Editor components
 96	ta := textarea.New()
 97	ta.SetStyles(com.Styles.TextArea)
 98	ta.ShowLineNumbers = false
 99	ta.CharLimit = -1
100	ta.SetVirtualCursor(false)
101	ta.Focus()
102
103	ui := &UI{
104		com:      com,
105		dialog:   dialog.NewOverlay(),
106		keyMap:   DefaultKeyMap(),
107		side:     NewSidebarModel(com),
108		help:     help.New(),
109		focus:    uiFocusNone,
110		state:    uiConfigure,
111		textarea: ta,
112
113		// initialize
114		yesInitializeSelected: true,
115	}
116	// If no provider is configured show the user the provider list
117	if !com.Config().IsConfigured() {
118		ui.state = uiConfigure
119		// if the project needs initialization show the user the question
120	} else if n, _ := config.ProjectNeedsInitialization(); n {
121		ui.state = uiInitialize
122		// otherwise go to the landing UI
123	} else {
124		ui.state = uiLanding
125		ui.focus = uiFocusEditor
126	}
127
128	ui.setEditorPrompt()
129	ui.randomizePlaceholders()
130	ui.textarea.Placeholder = ui.readyPlaceholder
131
132	return ui
133}
134
135// Init initializes the UI model.
136func (m *UI) Init() tea.Cmd {
137	if m.QueryVersion {
138		return tea.RequestTerminalVersion
139	}
140	return nil
141}
142
143// Update handles updates to the UI model.
144func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145	var cmds []tea.Cmd
146	hasDialogs := m.dialog.HasDialogs()
147	switch msg := msg.(type) {
148	case tea.EnvMsg:
149		// Is this Windows Terminal?
150		if !m.sendProgressBar {
151			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
152		}
153	case tea.TerminalVersionMsg:
154		termVersion := strings.ToLower(msg.Name)
155		// Only enable progress bar for the following terminals.
156		if !m.sendProgressBar {
157			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
158		}
159		return m, nil
160	case tea.WindowSizeMsg:
161		m.width, m.height = msg.Width, msg.Height
162		m.updateLayoutAndSize()
163	case tea.KeyboardEnhancementsMsg:
164		m.keyenh = msg
165		if msg.SupportsKeyDisambiguation() {
166			m.keyMap.Models.SetHelp("ctrl+m", "models")
167			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
168		}
169	case tea.KeyPressMsg:
170		if hasDialogs {
171			m.updateDialogs(msg, &cmds)
172		}
173	}
174
175	if !hasDialogs {
176		// This branch only handles UI elements when there's no dialog shown.
177		switch msg := msg.(type) {
178		case tea.KeyPressMsg:
179			switch {
180			case key.Matches(msg, m.keyMap.Tab):
181				if m.focus == uiFocusMain {
182					m.focus = uiFocusEditor
183					cmds = append(cmds, m.textarea.Focus())
184				} else {
185					m.focus = uiFocusMain
186					m.textarea.Blur()
187				}
188			case key.Matches(msg, m.keyMap.Help):
189				m.help.ShowAll = !m.help.ShowAll
190				m.updateLayoutAndSize()
191			case key.Matches(msg, m.keyMap.Quit):
192				if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
193					m.dialog.AddDialog(dialog.NewQuit(m.com))
194					return m, nil
195				}
196			case key.Matches(msg, m.keyMap.Commands):
197				// TODO: Implement me
198			case key.Matches(msg, m.keyMap.Models):
199				// TODO: Implement me
200			case key.Matches(msg, m.keyMap.Sessions):
201				// TODO: Implement me
202			default:
203				m.updateFocused(msg, &cmds)
204			}
205		}
206
207		// This logic gets triggered on any message type, but should it?
208		switch m.focus {
209		case uiFocusMain:
210		case uiFocusEditor:
211			// Textarea placeholder logic
212			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
213				m.textarea.Placeholder = m.workingPlaceholder
214			} else {
215				m.textarea.Placeholder = m.readyPlaceholder
216			}
217			if m.com.App.Permissions.SkipRequests() {
218				m.textarea.Placeholder = "Yolo mode!"
219			}
220		}
221	}
222
223	return m, tea.Batch(cmds...)
224}
225
226// Draw implements [tea.Layer] and draws the UI model.
227func (m *UI) Draw(scr tea.Screen, area tea.Rectangle) {
228	layout := generateLayout(m, area.Dx(), area.Dy())
229
230	// Clear the screen first
231	screen.Clear(scr)
232
233	switch m.state {
234	case uiConfigure:
235		header := uv.NewStyledString(m.header)
236		header.Draw(scr, layout.header)
237
238		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
239			Height(layout.main.Dy()).
240			Background(lipgloss.ANSIColor(rand.Intn(256))).
241			Render(" Configure ")
242		main := uv.NewStyledString(mainView)
243		main.Draw(scr, layout.main)
244
245	case uiInitialize:
246		header := uv.NewStyledString(m.header)
247		header.Draw(scr, layout.header)
248
249		main := uv.NewStyledString(m.initializeView())
250		main.Draw(scr, layout.main)
251
252	case uiLanding:
253		header := uv.NewStyledString(m.header)
254		header.Draw(scr, layout.header)
255
256		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
257			Height(layout.main.Dy()).
258			Background(lipgloss.ANSIColor(rand.Intn(256))).
259			Render(" Landing Page ")
260		main := uv.NewStyledString(mainView)
261		main.Draw(scr, layout.main)
262
263		editor := uv.NewStyledString(m.textarea.View())
264		editor.Draw(scr, layout.editor)
265
266	case uiChat:
267		header := uv.NewStyledString(m.header)
268		header.Draw(scr, layout.header)
269
270		side := uv.NewStyledString(m.side.View())
271		side.Draw(scr, layout.sidebar)
272
273		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
274			Height(layout.main.Dy()).
275			Background(lipgloss.ANSIColor(rand.Intn(256))).
276			Render(" Chat Messages ")
277		main := uv.NewStyledString(mainView)
278		main.Draw(scr, layout.main)
279
280		editor := uv.NewStyledString(m.textarea.View())
281		editor.Draw(scr, layout.editor)
282
283	case uiChatCompact:
284		header := uv.NewStyledString(m.header)
285		header.Draw(scr, layout.header)
286
287		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
288			Height(layout.main.Dy()).
289			Background(lipgloss.ANSIColor(rand.Intn(256))).
290			Render(" Compact Chat Messages ")
291		main := uv.NewStyledString(mainView)
292		main.Draw(scr, layout.main)
293
294		editor := uv.NewStyledString(m.textarea.View())
295		editor.Draw(scr, layout.editor)
296	}
297
298	// Add help layer
299	help := uv.NewStyledString(m.help.View(m))
300	help.Draw(scr, layout.help)
301
302	// Debugging rendering (visually see when the tui rerenders)
303	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
304		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
305		debug := uv.NewStyledString(debugView.String())
306		debug.Draw(scr, image.Rectangle{
307			Min: image.Pt(4, 1),
308			Max: image.Pt(8, 3),
309		})
310	}
311
312	// This needs to come last to overlay on top of everything
313	if m.dialog.HasDialogs() {
314		if dialogView := m.dialog.View(); dialogView != "" {
315			dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
316			dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
317			dialog := uv.NewStyledString(dialogView)
318			dialog.Draw(scr, dialogArea)
319		}
320	}
321}
322
323// View renders the UI model's view.
324func (m *UI) View() tea.View {
325	var v tea.View
326	v.AltScreen = true
327	v.BackgroundColor = m.com.Styles.Background
328
329	layout := generateLayout(m, m.width, m.height)
330	if m.focus == uiFocusEditor && m.textarea.Focused() {
331		cur := m.textarea.Cursor()
332		cur.X++ // Adjust for app margins
333		cur.Y += layout.editor.Min.Y
334		v.Cursor = cur
335	}
336
337	v.Content = m
338	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
339		// HACK: use a random percentage to prevent ghostty from hiding it
340		// after a timeout.
341		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
342	}
343
344	return v
345}
346
347// ShortHelp implements [help.KeyMap].
348func (m *UI) ShortHelp() []key.Binding {
349	var binds []key.Binding
350	k := &m.keyMap
351
352	switch m.state {
353	case uiInitialize:
354		binds = append(binds, k.Quit)
355	default:
356		// TODO: other states
357		if m.sess == nil {
358			// no session selected
359			binds = append(binds,
360				k.Commands,
361				k.Models,
362				k.Editor.Newline,
363				k.Quit,
364				k.Help,
365			)
366		} else {
367			// we have a session
368		}
369
370		// switch m.state {
371		// case uiChat:
372		// case uiEdit:
373		// 	binds = append(binds,
374		// 		k.Editor.AddFile,
375		// 		k.Editor.SendMessage,
376		// 		k.Editor.OpenEditor,
377		// 		k.Editor.Newline,
378		// 	)
379		//
380		// 	if len(m.attachments) > 0 {
381		// 		binds = append(binds,
382		// 			k.Editor.AttachmentDeleteMode,
383		// 			k.Editor.DeleteAllAttachments,
384		// 			k.Editor.Escape,
385		// 		)
386		// 	}
387		// }
388
389	}
390
391	return binds
392}
393
394// FullHelp implements [help.KeyMap].
395func (m *UI) FullHelp() [][]key.Binding {
396	var binds [][]key.Binding
397	k := &m.keyMap
398	help := k.Help
399	help.SetHelp("ctrl+g", "less")
400
401	switch m.state {
402	case uiInitialize:
403		binds = append(binds,
404			[]key.Binding{
405				k.Quit,
406			})
407	default:
408		if m.sess == nil {
409			// no session selected
410			binds = append(binds,
411				[]key.Binding{
412					k.Commands,
413					k.Models,
414					k.Sessions,
415				},
416				[]key.Binding{
417					k.Editor.Newline,
418					k.Editor.AddImage,
419					k.Editor.MentionFile,
420					k.Editor.OpenEditor,
421				},
422				[]key.Binding{
423					help,
424				},
425			)
426		} else {
427			// we have a session
428		}
429	}
430
431	// switch m.state {
432	// case uiChat:
433	// case uiEdit:
434	// 	binds = append(binds, m.ShortHelp())
435	// }
436
437	return binds
438}
439
440// updateDialogs updates the dialog overlay with the given message and appends
441// any resulting commands to the cmds slice.
442func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
443	updatedDialog, cmd := m.dialog.Update(msg)
444	m.dialog = updatedDialog
445	if cmd != nil {
446		*cmds = append(*cmds, cmd)
447	}
448}
449
450// updateFocused updates the focused model (chat or editor) with the given message
451// and appends any resulting commands to the cmds slice.
452func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
453	switch m.focus {
454	case uiFocusMain:
455		m.updateChat(msg, cmds)
456	case uiFocusEditor:
457		switch {
458		case key.Matches(msg, m.keyMap.Editor.Newline):
459			m.textarea.InsertRune('\n')
460		}
461
462		ta, cmd := m.textarea.Update(msg)
463		m.textarea = ta
464		if cmd != nil {
465			*cmds = append(*cmds, cmd)
466		}
467	}
468}
469
470// updateChat updates the chat model with the given message and appends any
471// resulting commands to the cmds slice.
472func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
473	updatedChat, cmd := m.chat.Update(msg)
474	m.chat = updatedChat
475	if cmd != nil {
476		*cmds = append(*cmds, cmd)
477	}
478}
479
480// updateLayoutAndSize updates the layout and sizes of UI components.
481func (m *UI) updateLayoutAndSize() {
482	m.layout = generateLayout(m, m.width, m.height)
483	m.updateSize()
484}
485
486// updateSize updates the sizes of UI components based on the current layout.
487func (m *UI) updateSize() {
488	// Set help width
489	m.help.SetWidth(m.layout.help.Dx())
490
491	// Handle different app states
492	switch m.state {
493	case uiConfigure, uiInitialize:
494		m.renderHeader(false, m.layout.header.Dx())
495
496	case uiLanding:
497		// TODO: set the width and heigh of the chat component
498		m.renderHeader(false, m.layout.header.Dx())
499		m.textarea.SetWidth(m.layout.editor.Dx())
500		m.textarea.SetHeight(m.layout.editor.Dy())
501
502	case uiChat:
503		// TODO: set the width and heigh of the chat component
504		m.side.SetWidth(m.layout.sidebar.Dx())
505		m.textarea.SetWidth(m.layout.editor.Dx())
506		m.textarea.SetHeight(m.layout.editor.Dy())
507
508	case uiChatCompact:
509		// TODO: set the width and heigh of the chat component
510		m.renderHeader(true, m.layout.header.Dx())
511		m.textarea.SetWidth(m.layout.editor.Dx())
512		m.textarea.SetHeight(m.layout.editor.Dy())
513	}
514}
515
516// generateLayout generates a [layout] for the given rectangle.
517func generateLayout(m *UI, w, h int) layout {
518	// The screen area we're working with
519	area := image.Rect(0, 0, w, h)
520
521	// The help height
522	helpHeight := 1
523	// The editor height
524	editorHeight := 5
525	// The sidebar width
526	sidebarWidth := 40
527	// The header height
528	// TODO: handle compact
529	headerHeight := 4
530
531	var helpKeyMap help.KeyMap = m
532	if m.help.ShowAll {
533		for _, row := range helpKeyMap.FullHelp() {
534			helpHeight = max(helpHeight, len(row))
535		}
536	}
537
538	// Add app margins
539	appRect := area
540	appRect.Min.X += 1
541	appRect.Min.Y += 1
542	appRect.Max.X -= 1
543	appRect.Max.Y -= 1
544
545	if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
546		// extra padding on left and right for these states
547		appRect.Min.X += 1
548		appRect.Max.X -= 1
549	}
550
551	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
552
553	layout := layout{
554		area: area,
555		help: helpRect,
556	}
557
558	// Handle different app states
559	switch m.state {
560	case uiConfigure, uiInitialize:
561		// Layout
562		//
563		// header
564		// ------
565		// main
566		// ------
567		// help
568
569		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
570		layout.header = headerRect
571		layout.main = mainRect
572
573	case uiLanding:
574		// Layout
575		//
576		// header
577		// ------
578		// main
579		// ------
580		// editor
581		// ------
582		// help
583		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
584		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
585		layout.header = headerRect
586		layout.main = mainRect
587		layout.editor = editorRect
588
589	case uiChat:
590		// Layout
591		//
592		// ------|---
593		// main  |
594		// ------| side
595		// editor|
596		// ----------
597		// help
598
599		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
600		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
601		layout.sidebar = sideRect
602		layout.main = mainRect
603		layout.editor = editorRect
604
605	case uiChatCompact:
606		// Layout
607		//
608		// compact-header
609		// ------
610		// main
611		// ------
612		// editor
613		// ------
614		// help
615		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
616		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
617		layout.header = headerRect
618		layout.main = mainRect
619		layout.editor = editorRect
620	}
621
622	if !layout.editor.Empty() {
623		// Add editor margins 1 top and bottom
624		layout.editor.Min.Y += 1
625		layout.editor.Max.Y -= 1
626	}
627
628	return layout
629}
630
631// layout defines the positioning of UI elements.
632type layout struct {
633	// area is the overall available area.
634	area uv.Rectangle
635
636	// header is the header shown in special cases
637	// e.x when the sidebar is collapsed
638	// or when in the landing page
639	// or in init/config
640	header uv.Rectangle
641
642	// main is the area for the main pane. (e.x chat, configure, landing)
643	main uv.Rectangle
644
645	// editor is the area for the editor pane.
646	editor uv.Rectangle
647
648	// sidebar is the area for the sidebar.
649	sidebar uv.Rectangle
650
651	// help is the area for the help view.
652	help uv.Rectangle
653}
654
655func (m *UI) setEditorPrompt() {
656	if m.com.App.Permissions.SkipRequests() {
657		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
658		return
659	}
660	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
661}
662
663func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
664	t := m.com.Styles
665	if info.LineNumber == 0 {
666		return "  > "
667	}
668	if info.Focused {
669		return t.EditorPromptNormalFocused.Render()
670	}
671	return t.EditorPromptNormalBlurred.Render()
672}
673
674func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
675	t := m.com.Styles
676	if info.LineNumber == 0 {
677		if info.Focused {
678			return t.EditorPromptYoloIconFocused.Render()
679		} else {
680			return t.EditorPromptYoloIconBlurred.Render()
681		}
682	}
683	if info.Focused {
684		return t.EditorPromptYoloDotsFocused.Render()
685	}
686	return t.EditorPromptYoloDotsBlurred.Render()
687}
688
689var readyPlaceholders = [...]string{
690	"Ready!",
691	"Ready...",
692	"Ready?",
693	"Ready for instructions",
694}
695
696var workingPlaceholders = [...]string{
697	"Working!",
698	"Working...",
699	"Brrrrr...",
700	"Prrrrrrrr...",
701	"Processing...",
702	"Thinking...",
703}
704
705func (m *UI) randomizePlaceholders() {
706	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
707	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
708}
709
710func (m *UI) initializeView() string {
711	cfg := m.com.Config()
712	s := m.com.Styles.Initialize
713	cwd := home.Short(cfg.WorkingDir())
714	initFile := cfg.Options.InitializeAs
715
716	header := s.Header.Render("Would you like to initialize this project?")
717	path := s.Accent.PaddingLeft(2).Render(cwd)
718	desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile))
719	hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
720	prompt := s.Content.Render("Would you like to initialize now?")
721
722	buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
723		{Text: "Yep!", Selected: m.yesInitializeSelected},
724		{Text: "Nope", Selected: !m.yesInitializeSelected},
725	}, " ")
726
727	// max width 60 so the text is compact
728	width := min(m.layout.main.Dx(), 60)
729
730	return lipgloss.NewStyle().
731		Width(width).
732		Height(m.layout.main.Dy()).
733		PaddingBottom(1).
734		AlignVertical(lipgloss.Bottom).
735		Render(strings.Join(
736			[]string{
737				header,
738				path,
739				desc,
740				hint,
741				prompt,
742				buttons,
743			},
744			"\n\n",
745		))
746}
747
748func (m *UI) renderHeader(compact bool, width int) {
749	// TODO: handle the compact case differently
750	m.header = renderLogo(m.com.Styles, compact, width)
751}
752
753func renderLogo(t *styles.Styles, compact bool, width int) string {
754	return logo.Render(version.Version, compact, logo.Opts{
755		FieldColor:   t.LogoFieldColor,
756		TitleColorA:  t.LogoTitleColorA,
757		TitleColorB:  t.LogoTitleColorB,
758		CharmColor:   t.LogoCharmColor,
759		VersionColor: t.LogoVersionColor,
760		Width:        max(0, width-2),
761	})
762}