ui.go

  1package model
  2
  3import (
  4	"image"
  5	"math/rand"
  6	"slices"
  7	"strings"
  8
  9	"github.com/charmbracelet/bubbles/v2/help"
 10	"github.com/charmbracelet/bubbles/v2/key"
 11	tea "github.com/charmbracelet/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/app"
 13	"github.com/charmbracelet/crush/internal/ui/common"
 14	"github.com/charmbracelet/crush/internal/ui/dialog"
 15	"github.com/charmbracelet/lipgloss/v2"
 16	uv "github.com/charmbracelet/ultraviolet"
 17)
 18
 19// uiState represents the current focus state of the UI.
 20type uiState uint8
 21
 22// Possible uiState values.
 23const (
 24	uiChat uiState = iota
 25	uiEdit
 26)
 27
 28// UI represents the main user interface model.
 29type UI struct {
 30	app *app.App
 31	com *common.Common
 32
 33	state uiState
 34
 35	keyMap KeyMap
 36
 37	chat   *ChatModel
 38	editor *EditorModel
 39	side   *SidebarModel
 40	dialog *dialog.Overlay
 41	help   help.Model
 42
 43	layout layout
 44
 45	// sendProgressBar instructs the TUI to send progress bar updates to the
 46	// terminal.
 47	sendProgressBar bool
 48
 49	// QueryVersion instructs the TUI to query for the terminal version when it
 50	// starts.
 51	QueryVersion bool
 52}
 53
 54// New creates a new instance of the [UI] model.
 55func New(com *common.Common, app *app.App) *UI {
 56	return &UI{
 57		app:    app,
 58		com:    com,
 59		dialog: dialog.NewOverlay(),
 60		keyMap: DefaultKeyMap(),
 61		editor: NewEditorModel(com, app),
 62		side:   NewSidebarModel(com),
 63		help:   help.New(),
 64	}
 65}
 66
 67// Init initializes the UI model.
 68func (m *UI) Init() tea.Cmd {
 69	if m.QueryVersion {
 70		return tea.RequestTerminalVersion
 71	}
 72
 73	return nil
 74}
 75
 76// Update handles updates to the UI model.
 77func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 78	var cmds []tea.Cmd
 79	switch msg := msg.(type) {
 80	case tea.EnvMsg:
 81		// Is this Windows Terminal?
 82		if !m.sendProgressBar {
 83			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 84		}
 85	case tea.TerminalVersionMsg:
 86		termVersion := strings.ToLower(string(msg))
 87		// Only enable progress bar for the following terminals.
 88		if !m.sendProgressBar {
 89			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 90		}
 91		return m, nil
 92	case tea.WindowSizeMsg:
 93		m.updateLayoutAndSize(msg.Width, msg.Height)
 94	case tea.KeyPressMsg:
 95		if m.dialog.HasDialogs() {
 96			m.updateDialogs(msg, &cmds)
 97		} else {
 98			switch {
 99			case key.Matches(msg, m.keyMap.Tab):
100				if m.state == uiChat {
101					m.state = uiEdit
102					cmds = append(cmds, m.editor.Focus())
103				} else {
104					m.state = uiChat
105					cmds = append(cmds, m.editor.Blur())
106				}
107			case key.Matches(msg, m.keyMap.Help):
108				m.help.ShowAll = !m.help.ShowAll
109				m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
110			case key.Matches(msg, m.keyMap.Quit):
111				if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
112					m.dialog.AddDialog(dialog.NewQuit(m.com))
113					return m, nil
114				}
115			default:
116				m.updateFocused(msg, &cmds)
117			}
118		}
119	}
120
121	return m, tea.Batch(cmds...)
122}
123
124// View renders the UI model's view.
125func (m *UI) View() tea.View {
126	var v tea.View
127	v.AltScreen = true
128
129	layers := []*lipgloss.Layer{}
130
131	// Determine the help key map based on focus
132	helpKeyMap := m.focusedKeyMap()
133
134	// The screen areas we're working with
135	area := m.layout.area
136	chatRect := m.layout.chat
137	sideRect := m.layout.sidebar
138	editRect := m.layout.editor
139	helpRect := m.layout.help
140
141	if m.dialog.HasDialogs() {
142		if dialogView := m.dialog.View(); dialogView != "" {
143			// If the dialog has its own help, use that instead
144			if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 {
145				helpKeyMap = m.dialog
146			}
147
148			dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
149			dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
150			layers = append(layers,
151				lipgloss.NewLayer(dialogView).
152					X(dialogArea.Min.X).
153					Y(dialogArea.Min.Y).
154					Z(99),
155			)
156		}
157	}
158
159	if m.state == uiEdit && m.editor.Focused() {
160		cur := m.editor.Cursor()
161		cur.X++ // Adjust for app margins
162		cur.Y += editRect.Min.Y
163		v.Cursor = cur
164	}
165
166	mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
167		Width(area.Dx()).Height(area.Dy()).
168		AddLayers(
169			lipgloss.NewLayer(
170				lipgloss.NewStyle().Width(chatRect.Dx()).
171					Height(chatRect.Dy()).
172					Background(lipgloss.ANSIColor(rand.Intn(256))).
173					Render(" Main View "),
174			).X(chatRect.Min.X).Y(chatRect.Min.Y),
175			lipgloss.NewLayer(m.side.View()).
176				X(sideRect.Min.X).Y(sideRect.Min.Y),
177			lipgloss.NewLayer(m.editor.View()).
178				X(editRect.Min.X).Y(editRect.Min.Y),
179			lipgloss.NewLayer(m.help.View(helpKeyMap)).
180				X(helpRect.Min.X).Y(helpRect.Min.Y),
181		)
182
183	layers = append(layers, mainLayer)
184
185	v.Layer = lipgloss.NewCanvas(layers...)
186	if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
187		// HACK: use a random percentage to prevent ghostty from hiding it
188		// after a timeout.
189		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
190	}
191
192	return v
193}
194
195func (m *UI) focusedKeyMap() help.KeyMap {
196	if m.state == uiChat {
197		return m.chat
198	}
199	return m.editor
200}
201
202// updateDialogs updates the dialog overlay with the given message and appends
203// any resulting commands to the cmds slice.
204func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
205	updatedDialog, cmd := m.dialog.Update(msg)
206	m.dialog = updatedDialog
207	if cmd != nil {
208		*cmds = append(*cmds, cmd)
209	}
210}
211
212// updateFocused updates the focused model (chat or editor) with the given message
213// and appends any resulting commands to the cmds slice.
214func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
215	switch m.state {
216	case uiChat:
217		m.updateChat(msg, cmds)
218	case uiEdit:
219		m.updateEditor(msg, cmds)
220	}
221}
222
223// updateChat updates the chat model with the given message and appends any
224// resulting commands to the cmds slice.
225func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
226	updatedChat, cmd := m.chat.Update(msg)
227	m.chat = updatedChat
228	if cmd != nil {
229		*cmds = append(*cmds, cmd)
230	}
231}
232
233// updateEditor updates the editor model with the given message and appends any
234// resulting commands to the cmds slice.
235func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
236	updatedEditor, cmd := m.editor.Update(msg)
237	m.editor = updatedEditor
238	if cmd != nil {
239		*cmds = append(*cmds, cmd)
240	}
241}
242
243// updateLayoutAndSize updates the layout and sub-models sizes based on the
244// given terminal width and height given in cells.
245func (m *UI) updateLayoutAndSize(w, h int) {
246	// The screen area we're working with
247	area := image.Rect(0, 0, w, h)
248	helpKeyMap := m.focusedKeyMap()
249	helpHeight := 1
250	if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
251		helpKeyMap = m.dialog
252	}
253	if m.help.ShowAll {
254		for _, row := range helpKeyMap.FullHelp() {
255			helpHeight = max(helpHeight, len(row))
256		}
257	}
258
259	// Add app margins
260	mainRect := area
261	mainRect.Min.X += 1
262	mainRect.Min.Y += 1
263	mainRect.Max.X -= 1
264	mainRect.Max.Y -= 1
265
266	mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
267	chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
268	chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
269
270	// Add 1 line margin bottom of chatRect
271	chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
272	// Add 1 line margin bottom of editRect
273	editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
274
275	m.layout = layout{
276		area:    area,
277		main:    mainRect,
278		chat:    chatRect,
279		editor:  editRect,
280		sidebar: sideRect,
281		help:    helpRect,
282	}
283
284	// Update sub-model sizes
285	m.side.SetWidth(m.layout.sidebar.Dx())
286	m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
287	m.help.Width = m.layout.help.Dx()
288}
289
290// layout defines the positioning of UI elements.
291type layout struct {
292	// area is the overall available area.
293	area uv.Rectangle
294
295	// main is the main area excluding help.
296	main uv.Rectangle
297
298	// chat is the area for the chat pane.
299	chat uv.Rectangle
300
301	// editor is the area for the editor pane.
302	editor uv.Rectangle
303
304	// sidebar is the area for the sidebar.
305	sidebar uv.Rectangle
306
307	// help is the area for the help view.
308	help uv.Rectangle
309}