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	dialog *dialog.Overlay
 40	help   help.Model
 41
 42	layout layout
 43
 44	// sendProgressBar instructs the TUI to send progress bar updates to the
 45	// terminal.
 46	sendProgressBar bool
 47
 48	// QueryVersion instructs the TUI to query for the terminal version when it
 49	// starts.
 50	QueryVersion bool
 51}
 52
 53// New creates a new instance of the [UI] model.
 54func New(com *common.Common, app *app.App) *UI {
 55	return &UI{
 56		app:    app,
 57		com:    com,
 58		dialog: dialog.NewOverlay(),
 59		keyMap: DefaultKeyMap(),
 60		editor: NewEditorModel(com, app),
 61		help:   help.New(),
 62	}
 63}
 64
 65// Init initializes the UI model.
 66func (m *UI) Init() tea.Cmd {
 67	if m.QueryVersion {
 68		return tea.RequestTerminalVersion
 69	}
 70
 71	return nil
 72}
 73
 74// Update handles updates to the UI model.
 75func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 76	var cmds []tea.Cmd
 77	switch msg := msg.(type) {
 78	case tea.EnvMsg:
 79		// Is this Windows Terminal?
 80		if !m.sendProgressBar {
 81			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 82		}
 83	case tea.TerminalVersionMsg:
 84		termVersion := strings.ToLower(string(msg))
 85		// Only enable progress bar for the following terminals.
 86		if !m.sendProgressBar {
 87			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 88		}
 89		return m, nil
 90	case tea.WindowSizeMsg:
 91		m.updateLayout(msg.Width, msg.Height)
 92		m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
 93		m.help.Width = m.layout.help.Dx()
 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.updateLayout(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(
176				lipgloss.NewStyle().Width(sideRect.Dx()).
177					Height(sideRect.Dy()).
178					Background(lipgloss.ANSIColor(rand.Intn(256))).
179					Render(" Side View "),
180			).X(sideRect.Min.X).Y(sideRect.Min.Y),
181			lipgloss.NewLayer(m.editor.View()).
182				X(editRect.Min.X).Y(editRect.Min.Y),
183			lipgloss.NewLayer(m.help.View(helpKeyMap)).
184				X(helpRect.Min.X).Y(helpRect.Min.Y),
185		)
186
187	layers = append(layers, mainLayer)
188
189	v.Layer = lipgloss.NewCanvas(layers...)
190	if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
191		// HACK: use a random percentage to prevent ghostty from hiding it
192		// after a timeout.
193		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
194	}
195
196	return v
197}
198
199func (m *UI) focusedKeyMap() help.KeyMap {
200	if m.state == uiChat {
201		return m.chat
202	}
203	return m.editor
204}
205
206// updateDialogs updates the dialog overlay with the given message and appends
207// any resulting commands to the cmds slice.
208func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
209	updatedDialog, cmd := m.dialog.Update(msg)
210	m.dialog = updatedDialog
211	if cmd != nil {
212		*cmds = append(*cmds, cmd)
213	}
214}
215
216// updateFocused updates the focused model (chat or editor) with the given message
217// and appends any resulting commands to the cmds slice.
218func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
219	switch m.state {
220	case uiChat:
221		m.updateChat(msg, cmds)
222	case uiEdit:
223		m.updateEditor(msg, cmds)
224	}
225}
226
227// updateChat updates the chat model with the given message and appends any
228// resulting commands to the cmds slice.
229func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
230	updatedChat, cmd := m.chat.Update(msg)
231	m.chat = updatedChat
232	if cmd != nil {
233		*cmds = append(*cmds, cmd)
234	}
235}
236
237// updateEditor updates the editor model with the given message and appends any
238// resulting commands to the cmds slice.
239func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
240	updatedEditor, cmd := m.editor.Update(msg)
241	m.editor = updatedEditor
242	if cmd != nil {
243		*cmds = append(*cmds, cmd)
244	}
245}
246
247// updateLayout updates the layout based on the given terminal width and
248// height given in cells.
249func (m *UI) updateLayout(w, h int) {
250	// The screen area we're working with
251	area := image.Rect(0, 0, w, h)
252	helpKeyMap := m.focusedKeyMap()
253	helpHeight := 1
254	if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
255		helpKeyMap = m.dialog
256	}
257	if m.help.ShowAll {
258		for _, row := range helpKeyMap.FullHelp() {
259			helpHeight = max(helpHeight, len(row))
260		}
261	}
262
263	// Add app margins
264	mainRect := area
265	mainRect.Min.X += 1
266	mainRect.Min.Y += 1
267	mainRect.Max.X -= 1
268	mainRect.Max.Y -= 1
269
270	mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
271	chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
272	chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
273
274	// Add 1 line margin bottom of chatRect
275	chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
276	// Add 1 line margin bottom of editRect
277	editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
278
279	m.layout = layout{
280		area:    area,
281		main:    mainRect,
282		chat:    chatRect,
283		editor:  editRect,
284		sidebar: sideRect,
285		help:    helpRect,
286	}
287}
288
289// layout defines the positioning of UI elements.
290type layout struct {
291	// area is the overall available area.
292	area uv.Rectangle
293
294	// main is the main area excluding help.
295	main uv.Rectangle
296
297	// chat is the area for the chat pane.
298	chat uv.Rectangle
299
300	// editor is the area for the editor pane.
301	editor uv.Rectangle
302
303	// sidebar is the area for the sidebar.
304	sidebar uv.Rectangle
305
306	// help is the area for the help view.
307	help uv.Rectangle
308}