@@ -22,6 +22,7 @@ import (
2222 "github.com/charmbracelet/crush/internal/ui/styles"
2323 "github.com/charmbracelet/crush/internal/version"
2424 uv "github.com/charmbracelet/ultraviolet"
25+ "github.com/charmbracelet/ultraviolet/screen"
2526)
2627
2728// uiFocusState represents the current focus state of the UI.
@@ -50,6 +51,11 @@ type UI struct {
5051 com *common.Common
5152 sess *session.Session
5253
54+ // The width and height of the terminal in cells.
55+ width int
56+ height int
57+ layout layout
58+
5359 focus uiFocusState
5460 state uiState
5561
@@ -64,8 +70,6 @@ type UI struct {
6470 // header is the last cached header logo
6571 header string
6672
67− layout layout
68−
6973 // sendProgressBar instructs the TUI to send progress bar updates to the
7074 // terminal.
7175 sendProgressBar bool
@@ -154,7 +158,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154158 }
155159 return m, nil
156160 case tea.WindowSizeMsg:
157− m.updateLayoutAndSize(msg.Width, msg.Height)
161+ m.width, m.height = msg.Width, msg.Height
162+ m.updateLayoutAndSize()
158163 case tea.KeyboardEnhancementsMsg:
159164 m.keyenh = msg
160165 if msg.SupportsKeyDisambiguation() {
@@ -182,7 +187,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182187 }
183188 case key.Matches(msg, m.keyMap.Help):
184189 m.help.ShowAll = !m.help.ShowAll
185− m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
190+ m.updateLayoutAndSize()
186191 case key.Matches(msg, m.keyMap.Quit):
187192 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
188193 m.dialog.AddDialog(dialog.NewQuit(m.com))
@@ -218,111 +223,118 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
218223 return m, tea.Batch(cmds...)
219224}
220225
221−// View renders the UI model's view.
222−func (m *UI) View() tea.View {
223− var v tea.View
224− v.AltScreen = true
225− v.BackgroundColor = m.com.Styles.Background
226+// Draw implements [tea.Layer] and draws the UI model.
227+func (m *UI) Draw(scr tea.Screen, area tea.Rectangle) {
228+ layout := generateLayout(m, area.Dx(), area.Dy())
226229
227− layers := []*lipgloss.Layer{}
230+ // Clear the screen first
231+ screen.Clear(scr)
228232
229− // Determine the help key map based on focus
230− var helpKeyMap help.KeyMap = m
233+ switch m.state {
234+ case uiConfigure:
235+ header := uv.NewStyledString(m.header)
236+ header.Draw(scr, layout.header)
231237
232− // The screen areas we're working with
233− area := m.layout.area
234− headerRect := m.layout.header
235− mainRect := m.layout.main
236− sideRect := m.layout.sidebar
237− editRect := m.layout.editor
238− helpRect := m.layout.help
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)
239244
240− if m.dialog.HasDialogs() {
241− if dialogView := m.dialog.View(); dialogView != "" {
242− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
243− dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
244− layers = append(layers,
245− lipgloss.NewLayer(dialogView).
246− X(dialogArea.Min.X).
247− Y(dialogArea.Min.Y).
248− Z(99),
249− )
250− }
251− }
252−
253− if m.focus == uiFocusEditor && m.textarea.Focused() {
254− cur := m.textarea.Cursor()
255− cur.X++ // Adjust for app margins
256− cur.Y += editRect.Min.Y
257− v.Cursor = cur
258− }
245+ case uiInitialize:
246+ header := uv.NewStyledString(m.header)
247+ header.Draw(scr, layout.header)
259248
260− mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
261− Width(area.Dx()).Height(area.Dy())
249+ main := uv.NewStyledString(m.initializeView())
250+ main.Draw(scr, layout.main)
262251
263− switch m.state {
264− case uiConfigure:
265− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
266− main := lipgloss.NewLayer(
267− lipgloss.NewStyle().Width(mainRect.Dx()).
268− Height(mainRect.Dy()).
269− Background(lipgloss.ANSIColor(rand.Intn(256))).
270− Render(" Configure "),
271− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
272− mainLayer = mainLayer.AddLayers(header, main)
273− case uiInitialize:
274− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
275− main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y)
276− mainLayer = mainLayer.AddLayers(header, main)
277252 case uiLanding:
278− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
279− main := lipgloss.NewLayer(
280− lipgloss.NewStyle().Width(mainRect.Dx()).
281− Height(mainRect.Dy()).
282− Background(lipgloss.ANSIColor(rand.Intn(256))).
283− Render(" Landing Page "),
284− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
285− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
286− mainLayer = mainLayer.AddLayers(header, main, editor)
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+
287266 case uiChat:
288− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
289− side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y)
290− main := lipgloss.NewLayer(
291− lipgloss.NewStyle().Width(mainRect.Dx()).
292− Height(mainRect.Dy()).
293− Background(lipgloss.ANSIColor(rand.Intn(256))).
294− Render(" Chat Messages "),
295− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
296− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
297− mainLayer = mainLayer.AddLayers(header, main, side, editor)
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+
298283 case uiChatCompact:
299− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
300− main := lipgloss.NewLayer(
301− lipgloss.NewStyle().Width(mainRect.Dx()).
302− Height(mainRect.Dy()).
303− Background(lipgloss.ANSIColor(rand.Intn(256))).
304− Render(" Compact Chat Messages "),
305− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
306− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
307− mainLayer = mainLayer.AddLayers(header, main, editor)
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)
308296 }
309297
310298 // Add help layer
311− help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y)
312− mainLayer = mainLayer.AddLayers(help)
313−
314− layers = append(layers, mainLayer)
299+ help := uv.NewStyledString(m.help.View(m))
300+ help.Draw(scr, layout.help)
315301
316302 // Debugging rendering (visually see when the tui rerenders)
317303 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
318− content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
319− debugLayer := lipgloss.NewLayer(content).
320− X(4).
321− Y(1)
322− layers = append(layers, debugLayer)
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+ })
323310 }
324311
325− v.Content = lipgloss.NewCanvas(layers...)
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.
324+func (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
326338 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
327339 // HACK: use a random percentage to prevent ghostty from hiding it
328340 // after a timeout.
@@ -465,9 +477,44 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
465477 }
466478}
467479
468−// updateLayoutAndSize updates the layout and sub-models sizes based on the
469−// given terminal width and height given in cells.
470−func (m *UI) updateLayoutAndSize(w, h int) {
480+// updateLayoutAndSize updates the layout and sizes of UI components.
481+func (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.
487+func (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.
517+func generateLayout(m *UI, w, h int) layout {
471518 // The screen area we're working with
472519 area := image.Rect(0, 0, w, h)
473520
@@ -503,14 +550,11 @@ func (m *UI) updateLayoutAndSize(w, h int) {
503550
504551 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
505552
506− m.layout = layout{
553+ layout := layout{
507554 area: area,
508555 help: helpRect,
509556 }
510557
511− // Set help width
512− m.help.SetWidth(m.layout.help.Dx())
513−
514558 // Handle different app states
515559 switch m.state {
516560 case uiConfigure, uiInitialize:
@@ -523,9 +567,8 @@ func (m *UI) updateLayoutAndSize(w, h int) {
523567 // help
524568
525569 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
526− m.layout.header = headerRect
527− m.layout.main = mainRect
528− m.renderHeader(false, m.layout.header.Dx())
570+ layout.header = headerRect
571+ layout.main = mainRect
529572
530573 case uiLanding:
531574 // Layout
@@ -539,13 +582,9 @@ func (m *UI) updateLayoutAndSize(w, h int) {
539582 // help
540583 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
541584 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
542− m.layout.header = headerRect
543− m.layout.main = mainRect
544− m.layout.editor = editorRect
545− // TODO: set the width and heigh of the chat component
546− m.renderHeader(false, m.layout.header.Dx())
547− m.textarea.SetWidth(m.layout.editor.Dx())
548− m.textarea.SetHeight(m.layout.editor.Dy())
585+ layout.header = headerRect
586+ layout.main = mainRect
587+ layout.editor = editorRect
549588
550589 case uiChat:
551590 // Layout
@@ -559,13 +598,10 @@ func (m *UI) updateLayoutAndSize(w, h int) {
559598
560599 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
561600 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
562− m.layout.sidebar = sideRect
563− m.layout.main = mainRect
564− m.layout.editor = editorRect
565− // TODO: set the width and heigh of the chat component
566− m.side.SetWidth(m.layout.sidebar.Dx())
567− m.textarea.SetWidth(m.layout.editor.Dx())
568− m.textarea.SetHeight(m.layout.editor.Dy())
601+ layout.sidebar = sideRect
602+ layout.main = mainRect
603+ layout.editor = editorRect
604+
569605 case uiChatCompact:
570606 // Layout
571607 //
@@ -578,14 +614,18 @@ func (m *UI) updateLayoutAndSize(w, h int) {
578614 // help
579615 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
580616 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
581− m.layout.header = headerRect
582− m.layout.main = mainRect
583− m.layout.editor = editorRect
584− // TODO: set the width and heigh of the chat component
585− m.renderHeader(true, m.layout.header.Dx())
586− m.textarea.SetWidth(m.layout.editor.Dx())
587− m.textarea.SetHeight(m.layout.editor.Dy())
617+ layout.header = headerRect
618+ layout.main = mainRect
619+ layout.editor = editorRect
588620 }
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
589629}
590630
591631// layout defines the positioning of UI elements.