ui.go

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