ui.go

  1package model
  2
  3import (
  4	"image"
  5	"math/rand"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	"charm.land/bubbles/v2/textarea"
 12	tea "charm.land/bubbletea/v2"
 13	"charm.land/lipgloss/v2"
 14	"github.com/charmbracelet/crush/internal/session"
 15	"github.com/charmbracelet/crush/internal/ui/common"
 16	"github.com/charmbracelet/crush/internal/ui/dialog"
 17	uv "github.com/charmbracelet/ultraviolet"
 18)
 19
 20// uiState represents the current focus state of the UI.
 21type uiState uint8
 22
 23// Possible uiState values.
 24const (
 25	uiEdit uiState = iota
 26	uiChat
 27)
 28
 29// UI represents the main user interface model.
 30type UI struct {
 31	com  *common.Common
 32	sess *session.Session
 33
 34	state uiState
 35
 36	keyMap KeyMap
 37	keyenh tea.KeyboardEnhancementsMsg
 38
 39	chat   *ChatModel
 40	side   *SidebarModel
 41	dialog *dialog.Overlay
 42	help   help.Model
 43
 44	layout layout
 45
 46	// sendProgressBar instructs the TUI to send progress bar updates to the
 47	// terminal.
 48	sendProgressBar bool
 49
 50	// QueryVersion instructs the TUI to query for the terminal version when it
 51	// starts.
 52	QueryVersion bool
 53
 54	// Editor components
 55	textarea textarea.Model
 56
 57	attachments []any // TODO: Implement attachments
 58
 59	readyPlaceholder   string
 60	workingPlaceholder string
 61}
 62
 63// New creates a new instance of the [UI] model.
 64func New(com *common.Common) *UI {
 65	// Editor components
 66	ta := textarea.New()
 67	ta.SetStyles(com.Styles.TextArea)
 68	ta.ShowLineNumbers = false
 69	ta.CharLimit = -1
 70	ta.SetVirtualCursor(false)
 71	ta.Focus()
 72
 73	ui := &UI{
 74		com:      com,
 75		dialog:   dialog.NewOverlay(),
 76		keyMap:   DefaultKeyMap(),
 77		side:     NewSidebarModel(com),
 78		help:     help.New(),
 79		textarea: ta,
 80	}
 81
 82	ui.setEditorPrompt()
 83	ui.randomizePlaceholders()
 84	ui.textarea.Placeholder = ui.readyPlaceholder
 85
 86	return ui
 87}
 88
 89// Init initializes the UI model.
 90func (m *UI) Init() tea.Cmd {
 91	if m.QueryVersion {
 92		return tea.RequestTerminalVersion
 93	}
 94
 95	return nil
 96}
 97
 98// Update handles updates to the UI model.
 99func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100	var cmds []tea.Cmd
101	hasDialogs := m.dialog.HasDialogs()
102	switch msg := msg.(type) {
103	case tea.EnvMsg:
104		// Is this Windows Terminal?
105		if !m.sendProgressBar {
106			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
107		}
108	case tea.TerminalVersionMsg:
109		termVersion := strings.ToLower(msg.Name)
110		// Only enable progress bar for the following terminals.
111		if !m.sendProgressBar {
112			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
113		}
114		return m, nil
115	case tea.WindowSizeMsg:
116		m.updateLayoutAndSize(msg.Width, msg.Height)
117	case tea.KeyboardEnhancementsMsg:
118		m.keyenh = msg
119		if msg.SupportsKeyDisambiguation() {
120			m.keyMap.Models.SetHelp("ctrl+m", "models")
121			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
122		}
123	case tea.KeyPressMsg:
124		if hasDialogs {
125			m.updateDialogs(msg, &cmds)
126		}
127	}
128
129	if !hasDialogs {
130		// This branch only handles UI elements when there's no dialog shown.
131		switch msg := msg.(type) {
132		case tea.KeyPressMsg:
133			switch {
134			case key.Matches(msg, m.keyMap.Tab):
135				if m.state == uiChat {
136					m.state = uiEdit
137					cmds = append(cmds, m.textarea.Focus())
138				} else {
139					m.state = uiChat
140					m.textarea.Blur()
141				}
142			case key.Matches(msg, m.keyMap.Help):
143				m.help.ShowAll = !m.help.ShowAll
144				m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
145			case key.Matches(msg, m.keyMap.Quit):
146				if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
147					m.dialog.AddDialog(dialog.NewQuit(m.com))
148					return m, nil
149				}
150			case key.Matches(msg, m.keyMap.Commands):
151				// TODO: Implement me
152			case key.Matches(msg, m.keyMap.Models):
153				// TODO: Implement me
154			case key.Matches(msg, m.keyMap.Sessions):
155				// TODO: Implement me
156			default:
157				m.updateFocused(msg, &cmds)
158			}
159		}
160
161		// This logic gets triggered on any message type, but should it?
162		switch m.state {
163		case uiChat:
164		case uiEdit:
165			// Textarea placeholder logic
166			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167				m.textarea.Placeholder = m.workingPlaceholder
168			} else {
169				m.textarea.Placeholder = m.readyPlaceholder
170			}
171			if m.com.App.Permissions.SkipRequests() {
172				m.textarea.Placeholder = "Yolo mode!"
173			}
174		}
175	}
176
177	return m, tea.Batch(cmds...)
178}
179
180// View renders the UI model's view.
181func (m *UI) View() tea.View {
182	var v tea.View
183	v.AltScreen = true
184
185	layers := []*lipgloss.Layer{}
186
187	// Determine the help key map based on focus
188	var helpKeyMap help.KeyMap = m
189
190	// The screen areas we're working with
191	area := m.layout.area
192	chatRect := m.layout.chat
193	sideRect := m.layout.sidebar
194	editRect := m.layout.editor
195	helpRect := m.layout.help
196
197	if m.dialog.HasDialogs() {
198		if dialogView := m.dialog.View(); dialogView != "" {
199			dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
200			dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
201			layers = append(layers,
202				lipgloss.NewLayer(dialogView).
203					X(dialogArea.Min.X).
204					Y(dialogArea.Min.Y).
205					Z(99),
206			)
207		}
208	}
209
210	if m.state == uiEdit && m.textarea.Focused() {
211		cur := m.textarea.Cursor()
212		cur.X++ // Adjust for app margins
213		cur.Y += editRect.Min.Y
214		v.Cursor = cur
215	}
216
217	mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
218		Width(area.Dx()).Height(area.Dy()).
219		AddLayers(
220			lipgloss.NewLayer(
221				lipgloss.NewStyle().Width(chatRect.Dx()).
222					Height(chatRect.Dy()).
223					Background(lipgloss.ANSIColor(rand.Intn(256))).
224					Render(" Main View "),
225			).X(chatRect.Min.X).Y(chatRect.Min.Y),
226			lipgloss.NewLayer(m.side.View()).
227				X(sideRect.Min.X).Y(sideRect.Min.Y),
228			lipgloss.NewLayer(m.textarea.View()).
229				X(editRect.Min.X).Y(editRect.Min.Y),
230			lipgloss.NewLayer(m.help.View(helpKeyMap)).
231				X(helpRect.Min.X).Y(helpRect.Min.Y),
232		)
233
234	layers = append(layers, mainLayer)
235
236	v.Content = lipgloss.NewCanvas(layers...)
237	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
238		// HACK: use a random percentage to prevent ghostty from hiding it
239		// after a timeout.
240		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
241	}
242
243	return v
244}
245
246// ShortHelp implements [help.KeyMap].
247func (m *UI) ShortHelp() []key.Binding {
248	var binds []key.Binding
249	k := &m.keyMap
250
251	if m.sess == nil {
252		// no session selected
253		binds = append(binds,
254			k.Commands,
255			k.Models,
256			k.Editor.Newline,
257			k.Quit,
258			k.Help,
259		)
260	} else {
261		// we have a session
262	}
263
264	// switch m.state {
265	// case uiChat:
266	// case uiEdit:
267	// 	binds = append(binds,
268	// 		k.Editor.AddFile,
269	// 		k.Editor.SendMessage,
270	// 		k.Editor.OpenEditor,
271	// 		k.Editor.Newline,
272	// 	)
273	//
274	// 	if len(m.attachments) > 0 {
275	// 		binds = append(binds,
276	// 			k.Editor.AttachmentDeleteMode,
277	// 			k.Editor.DeleteAllAttachments,
278	// 			k.Editor.Escape,
279	// 		)
280	// 	}
281	// }
282
283	return binds
284}
285
286// FullHelp implements [help.KeyMap].
287func (m *UI) FullHelp() [][]key.Binding {
288	var binds [][]key.Binding
289	k := &m.keyMap
290	help := k.Help
291	help.SetHelp("ctrl+g", "less")
292
293	if m.sess == nil {
294		// no session selected
295		binds = append(binds,
296			[]key.Binding{
297				k.Commands,
298				k.Models,
299				k.Sessions,
300			},
301			[]key.Binding{
302				k.Editor.Newline,
303				k.Editor.AddImage,
304				k.Editor.MentionFile,
305				k.Editor.OpenEditor,
306			},
307			[]key.Binding{
308				help,
309			},
310		)
311	} else {
312		// we have a session
313	}
314
315	// switch m.state {
316	// case uiChat:
317	// case uiEdit:
318	// 	binds = append(binds, m.ShortHelp())
319	// }
320
321	return binds
322}
323
324// updateDialogs updates the dialog overlay with the given message and appends
325// any resulting commands to the cmds slice.
326func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
327	updatedDialog, cmd := m.dialog.Update(msg)
328	m.dialog = updatedDialog
329	if cmd != nil {
330		*cmds = append(*cmds, cmd)
331	}
332}
333
334// updateFocused updates the focused model (chat or editor) with the given message
335// and appends any resulting commands to the cmds slice.
336func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
337	switch m.state {
338	case uiChat:
339		m.updateChat(msg, cmds)
340	case uiEdit:
341		switch {
342		case key.Matches(msg, m.keyMap.Editor.Newline):
343			m.textarea.InsertRune('\n')
344		}
345
346		ta, cmd := m.textarea.Update(msg)
347		m.textarea = ta
348		if cmd != nil {
349			*cmds = append(*cmds, cmd)
350		}
351	}
352}
353
354// updateChat updates the chat model with the given message and appends any
355// resulting commands to the cmds slice.
356func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
357	updatedChat, cmd := m.chat.Update(msg)
358	m.chat = updatedChat
359	if cmd != nil {
360		*cmds = append(*cmds, cmd)
361	}
362}
363
364// updateLayoutAndSize updates the layout and sub-models sizes based on the
365// given terminal width and height given in cells.
366func (m *UI) updateLayoutAndSize(w, h int) {
367	// The screen area we're working with
368	area := image.Rect(0, 0, w, h)
369	var helpKeyMap help.KeyMap = m
370	helpHeight := 1
371	if m.help.ShowAll {
372		for _, row := range helpKeyMap.FullHelp() {
373			helpHeight = max(helpHeight, len(row))
374		}
375	}
376
377	// Add app margins
378	mainRect := area
379	mainRect.Min.X += 1
380	mainRect.Min.Y += 1
381	mainRect.Max.X -= 1
382	mainRect.Max.Y -= 1
383
384	mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
385	chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
386	chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
387
388	// Add 1 line margin bottom of chatRect
389	chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
390	// Add 1 line margin bottom of editRect
391	editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
392
393	m.layout = layout{
394		area:    area,
395		main:    mainRect,
396		chat:    chatRect,
397		editor:  editRect,
398		sidebar: sideRect,
399		help:    helpRect,
400	}
401
402	// Update sub-model sizes
403	m.side.SetWidth(m.layout.sidebar.Dx())
404	m.textarea.SetWidth(m.layout.editor.Dx())
405	m.textarea.SetHeight(m.layout.editor.Dy())
406	m.help.SetWidth(m.layout.help.Dx())
407}
408
409// layout defines the positioning of UI elements.
410type layout struct {
411	// area is the overall available area.
412	area uv.Rectangle
413
414	// main is the main area excluding help.
415	main uv.Rectangle
416
417	// chat is the area for the chat pane.
418	chat uv.Rectangle
419
420	// editor is the area for the editor pane.
421	editor uv.Rectangle
422
423	// sidebar is the area for the sidebar.
424	sidebar uv.Rectangle
425
426	// help is the area for the help view.
427	help uv.Rectangle
428}
429
430func (m *UI) setEditorPrompt() {
431	if m.com.App.Permissions.SkipRequests() {
432		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
433		return
434	}
435	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
436}
437
438func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
439	t := m.com.Styles
440	if info.LineNumber == 0 {
441		return "  > "
442	}
443	if info.Focused {
444		return t.EditorPromptNormalFocused.Render()
445	}
446	return t.EditorPromptNormalBlurred.Render()
447}
448
449func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
450	t := m.com.Styles
451	if info.LineNumber == 0 {
452		if info.Focused {
453			return t.EditorPromptYoloIconFocused.Render()
454		} else {
455			return t.EditorPromptYoloIconBlurred.Render()
456		}
457	}
458	if info.Focused {
459		return t.EditorPromptYoloDotsFocused.Render()
460	}
461	return t.EditorPromptYoloDotsBlurred.Render()
462}
463
464var readyPlaceholders = [...]string{
465	"Ready!",
466	"Ready...",
467	"Ready?",
468	"Ready for instructions",
469}
470
471var workingPlaceholders = [...]string{
472	"Working!",
473	"Working...",
474	"Brrrrr...",
475	"Prrrrrrrr...",
476	"Processing...",
477	"Thinking...",
478}
479
480func (m *UI) randomizePlaceholders() {
481	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
483}