@@ -22,6 +22,7 @@ import (
22 "github.com/charmbracelet/crush/internal/ui/styles"
22 "github.com/charmbracelet/crush/internal/ui/styles"
23 "github.com/charmbracelet/crush/internal/version"
23 "github.com/charmbracelet/crush/internal/version"
24 uv "github.com/charmbracelet/ultraviolet"
24 uv "github.com/charmbracelet/ultraviolet"
25+ "github.com/charmbracelet/ultraviolet/screen"
27// uiFocusState represents the current focus state of the UI.
28// uiFocusState represents the current focus state of the UI.
@@ -50,6 +51,11 @@ type UI struct {
50 com *common.Common
51 com *common.Common
51 sess *session.Session
52 sess *session.Session
54+ // The width and height of the terminal in cells.
53 focus uiFocusState
59 focus uiFocusState
54 state uiState
60 state uiState
@@ -64,8 +70,6 @@ type UI struct {
64 // header is the last cached header logo
70 // header is the last cached header logo
65 header string
71 header string
69 // sendProgressBar instructs the TUI to send progress bar updates to the
73 // sendProgressBar instructs the TUI to send progress bar updates to the
70 // terminal.
74 // terminal.
71 sendProgressBar bool
75 sendProgressBar bool
@@ -154,7 +158,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155 return m, nil
159 return m, nil
156 case tea.WindowSizeMsg:
160 case tea.WindowSizeMsg:
161+ m.width, m.height = msg.Width, msg.Height
157− m.updateLayoutAndSize(msg.Width, msg.Height)
162+ m.updateLayoutAndSize()
158 case tea.KeyboardEnhancementsMsg:
163 case tea.KeyboardEnhancementsMsg:
159 m.keyenh = msg
164 m.keyenh = msg
160 if msg.SupportsKeyDisambiguation() {
165 if msg.SupportsKeyDisambiguation() {
@@ -182,7 +187,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183 case key.Matches(msg, m.keyMap.Help):
188 case key.Matches(msg, m.keyMap.Help):
184 m.help.ShowAll = !m.help.ShowAll
189 m.help.ShowAll = !m.help.ShowAll
185− m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
190+ m.updateLayoutAndSize()
186 case key.Matches(msg, m.keyMap.Quit):
191 case key.Matches(msg, m.keyMap.Quit):
187 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
192 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
188 m.dialog.AddDialog(dialog.NewQuit(m.com))
193 m.dialog.AddDialog(dialog.NewQuit(m.com))
@@ -218,111 +223,118 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
218 return m, tea.Batch(cmds...)
223 return m, tea.Batch(cmds...)
221−// View renders the UI model's view.
226+// Draw implements [tea.Layer] and draws the UI model.
222−func (m *UI) View() tea.View {
227+func (m *UI) Draw(scr tea.Screen, area tea.Rectangle) {
223− var v tea.View
228+ layout := generateLayout(m, area.Dx(), area.Dy())
225− v.BackgroundColor = m.com.Styles.Background
227− layers := []*lipgloss.Layer{}
230+ // Clear the screen first
229− // Determine the help key map based on focus
233+ switch m.state {
230− var helpKeyMap help.KeyMap = m
234+ case uiConfigure:
235+ header := uv.NewStyledString(m.header)
236+ header.Draw(scr, layout.header)
232− // The screen areas we're working with
238+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
233− area := m.layout.area
234− headerRect := m.layout.header
235− mainRect := m.layout.main
239+ Height(layout.main.Dy()).
236− sideRect := m.layout.sidebar
240+ Background(lipgloss.ANSIColor(rand.Intn(256))).
237− editRect := m.layout.editor
241+ Render(" Configure ")
238− helpRect := m.layout.help
242+ main := uv.NewStyledString(mainView)
243+ main.Draw(scr, layout.main)
240− if m.dialog.HasDialogs() {
245+ case uiInitialize:
241− if dialogView := m.dialog.View(); dialogView != "" {
246+ header := uv.NewStyledString(m.header)
242− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
247+ header.Draw(scr, layout.header)
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).
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
260− mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
249+ main := uv.NewStyledString(m.initializeView())
261− Width(area.Dx()).Height(area.Dy())
250+ main.Draw(scr, layout.main)
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)
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)
277 case uiLanding:
252 case uiLanding:
278− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
253+ header := uv.NewStyledString(m.header)
279− main := lipgloss.NewLayer(
254+ header.Draw(scr, layout.header)
280− lipgloss.NewStyle().Width(mainRect.Dx()).
256+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
281− Height(mainRect.Dy()).
257+ Height(layout.main.Dy()).
282− Background(lipgloss.ANSIColor(rand.Intn(256))).
258+ Background(lipgloss.ANSIColor(rand.Intn(256))).
283− Render(" Landing Page "),
259+ Render(" Landing Page ")
284− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
260+ main := uv.NewStyledString(mainView)
285− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
261+ main.Draw(scr, layout.main)
286− mainLayer = mainLayer.AddLayers(header, main, editor)
262+
263+ editor := uv.NewStyledString(m.textarea.View())
264+ editor.Draw(scr, layout.editor)
287 case uiChat:
266 case uiChat:
288− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
267+ header := uv.NewStyledString(m.header)
289− side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y)
268+ header.Draw(scr, layout.header)
290− main := lipgloss.NewLayer(
269+
270+ side := uv.NewStyledString(m.side.View())
271+ side.Draw(scr, layout.sidebar)
291− lipgloss.NewStyle().Width(mainRect.Dx()).
273+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
292− Height(mainRect.Dy()).
274+ Height(layout.main.Dy()).
293− Background(lipgloss.ANSIColor(rand.Intn(256))).
275+ Background(lipgloss.ANSIColor(rand.Intn(256))).
294− Render(" Chat Messages "),
276+ Render(" Chat Messages ")
295− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
277+ main := uv.NewStyledString(mainView)
296− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
278+ main.Draw(scr, layout.main)
297− mainLayer = mainLayer.AddLayers(header, main, side, editor)
279+
280+ editor := uv.NewStyledString(m.textarea.View())
281+ editor.Draw(scr, layout.editor)
298 case uiChatCompact:
283 case uiChatCompact:
299− header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
284+ header := uv.NewStyledString(m.header)
300− main := lipgloss.NewLayer(
285+ header.Draw(scr, layout.header)
301− lipgloss.NewStyle().Width(mainRect.Dx()).
287+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
302− Height(mainRect.Dy()).
288+ Height(layout.main.Dy()).
303− Background(lipgloss.ANSIColor(rand.Intn(256))).
289+ Background(lipgloss.ANSIColor(rand.Intn(256))).
304− Render(" Compact Chat Messages "),
290+ Render(" Compact Chat Messages ")
305− ).X(mainRect.Min.X).Y(mainRect.Min.Y)
291+ main := uv.NewStyledString(mainView)
306− editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
292+ main.Draw(scr, layout.main)
307− mainLayer = mainLayer.AddLayers(header, main, editor)
293+
294+ editor := uv.NewStyledString(m.textarea.View())
295+ editor.Draw(scr, layout.editor)
310 // Add help layer
298 // Add help layer
311− help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y)
299+ help := uv.NewStyledString(m.help.View(m))
312− mainLayer = mainLayer.AddLayers(help)
300+ help.Draw(scr, layout.help)
314− layers = append(layers, mainLayer)
316 // Debugging rendering (visually see when the tui rerenders)
302 // Debugging rendering (visually see when the tui rerenders)
317 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
303 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
318− content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
304+ debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
319− debugLayer := lipgloss.NewLayer(content).
305+ debug := uv.NewStyledString(debugView.String())
320− X(4).
306+ debug.Draw(scr, image.Rectangle{
321− Y(1)
307+ Min: image.Pt(4, 1),
322− layers = append(layers, debugLayer)
308+ Max: image.Pt(8, 3),
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)
323+// View renders the UI model's view.
324+func (m *UI) View() tea.View {
327+ v.BackgroundColor = m.com.Styles.Background
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
325− v.Content = lipgloss.NewCanvas(layers...)
337+ v.Content = m
326 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
338 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
327 // HACK: use a random percentage to prevent ghostty from hiding it
339 // HACK: use a random percentage to prevent ghostty from hiding it
328 // after a timeout.
340 // after a timeout.
@@ -465,9 +477,44 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
468−// updateLayoutAndSize updates the layout and sub-models sizes based on the
480+// updateLayoutAndSize updates the layout and sizes of UI components.
469−// given terminal width and height given in cells.
481+func (m *UI) updateLayoutAndSize() {
470−func (m *UI) updateLayoutAndSize(w, h int) {
482+ m.layout = generateLayout(m, m.width, m.height)
486+// updateSize updates the sizes of UI components based on the current layout.
487+func (m *UI) updateSize() {
489+ m.help.SetWidth(m.layout.help.Dx())
491+ // Handle different app states
493+ case uiConfigure, uiInitialize:
494+ m.renderHeader(false, m.layout.header.Dx())
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())
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())
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())
516+// generateLayout generates a [layout] for the given rectangle.
517+func generateLayout(m *UI, w, h int) layout {
471 // The screen area we're working with
518 // The screen area we're working with
472 area := image.Rect(0, 0, w, h)
519 area := image.Rect(0, 0, w, h)
@@ -503,14 +550,11 @@ func (m *UI) updateLayoutAndSize(w, h int) {
504 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
551 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
506− m.layout = layout{
553+ layout := layout{
507 area: area,
554 area: area,
508 help: helpRect,
555 help: helpRect,
512− m.help.SetWidth(m.layout.help.Dx())
514 // Handle different app states
558 // Handle different app states
515 switch m.state {
559 switch m.state {
516 case uiConfigure, uiInitialize:
560 case uiConfigure, uiInitialize:
@@ -523,9 +567,8 @@ func (m *UI) updateLayoutAndSize(w, h int) {
525 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
569 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
526− m.layout.header = headerRect
570+ layout.header = headerRect
527− m.layout.main = mainRect
571+ layout.main = mainRect
528− m.renderHeader(false, m.layout.header.Dx())
530 case uiLanding:
573 case uiLanding:
531 // Layout
574 // Layout
@@ -539,13 +582,9 @@ func (m *UI) updateLayoutAndSize(w, h int) {
540 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
583 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
541 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
584 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
542− m.layout.header = headerRect
585+ layout.header = headerRect
543− m.layout.main = mainRect
586+ layout.main = mainRect
544− m.layout.editor = editorRect
587+ 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())
550 case uiChat:
589 case uiChat:
551 // Layout
590 // Layout
@@ -559,13 +598,10 @@ func (m *UI) updateLayoutAndSize(w, h int) {
560 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
599 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
561 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
600 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
562− m.layout.sidebar = sideRect
601+ layout.sidebar = sideRect
563− m.layout.main = mainRect
602+ layout.main = mainRect
564− m.layout.editor = editorRect
603+ layout.editor = editorRect
565− // TODO: set the width and heigh of the chat component
604+
566− m.side.SetWidth(m.layout.sidebar.Dx())
567− m.textarea.SetWidth(m.layout.editor.Dx())
568− m.textarea.SetHeight(m.layout.editor.Dy())
569 case uiChatCompact:
605 case uiChatCompact:
570 // Layout
606 // Layout
@@ -578,14 +614,18 @@ func (m *UI) updateLayoutAndSize(w, h int) {
579 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
615 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
580 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
616 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
581− m.layout.header = headerRect
617+ layout.header = headerRect
582− m.layout.main = mainRect
618+ layout.main = mainRect
583− m.layout.editor = editorRect
619+ 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())
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
591// layout defines the positioning of UI elements.
631// layout defines the positioning of UI elements.