refactor(ui): rework init and support different layouts (#1463)

Kujtim Hoxha created

Change summary

internal/ui/common/button.go |  69 +++++
internal/ui/dialog/quit.go   |  26 -
internal/ui/model/keys.go    |  19 +
internal/ui/model/sidebar.go |  16 -
internal/ui/model/ui.go      | 449 +++++++++++++++++++++++++++++--------
internal/ui/styles/styles.go |  23 +
6 files changed, 459 insertions(+), 143 deletions(-)

Detailed changes

internal/ui/common/button.go 🔗

@@ -0,0 +1,69 @@
+package common
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// ButtonOpts defines the configuration for a single button
+type ButtonOpts struct {
+	// Text is the button label
+	Text string
+	// UnderlineIndex is the 0-based index of the character to underline (-1 for none)
+	UnderlineIndex int
+	// Selected indicates whether this button is currently selected
+	Selected bool
+	// Padding inner horizontal padding defaults to 2 if this is 0
+	Padding int
+}
+
+// Button creates a button with an underlined character and selection state
+func Button(t *styles.Styles, opts ButtonOpts) string {
+	// Select style based on selection state
+	style := t.ButtonBlur
+	if opts.Selected {
+		style = t.ButtonFocus
+	}
+
+	text := opts.Text
+	if opts.Padding == 0 {
+		opts.Padding = 2
+	}
+
+	// the index is out of bound
+	if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 {
+		opts.UnderlineIndex = -1
+	}
+
+	text = style.Padding(0, opts.Padding).Render(text)
+
+	if opts.UnderlineIndex != -1 {
+		text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true)))
+	}
+
+	return text
+}
+
+// ButtonGroup creates a row of selectable buttons
+// Spacing is the separator between buttons
+// Use "  " or similar for horizontal layout
+// Use "\n"  for vertical layout
+// Defaults to "  " (horizontal)
+func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string {
+	if len(buttons) == 0 {
+		return ""
+	}
+
+	if spacing == "" {
+		spacing = "  "
+	}
+
+	parts := make([]string, len(buttons))
+	for i, button := range buttons {
+		parts[i] = Button(t, button)
+	}
+
+	return strings.Join(parts, spacing)
+}

internal/ui/dialog/quit.go 🔗

@@ -60,8 +60,9 @@ type Quit struct {
 // NewQuit creates a new quit confirmation dialog.
 func NewQuit(com *common.Common) *Quit {
 	q := &Quit{
-		com:    com,
-		keyMap: DefaultQuitKeyMap(),
+		com:        com,
+		keyMap:     DefaultQuitKeyMap(),
+		selectedNo: true,
 	}
 	return q
 }
@@ -98,24 +99,11 @@ func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) {
 func (q *Quit) View() string {
 	const question = "Are you sure you want to quit?"
 	baseStyle := q.com.Styles.Base
-	yesStyle := q.com.Styles.ButtonSelected
-	noStyle := q.com.Styles.ButtonUnselected
-
-	if q.selectedNo {
-		noStyle = q.com.Styles.ButtonSelected
-		yesStyle = q.com.Styles.ButtonUnselected
+	buttonOpts := []common.ButtonOpts{
+		{Text: "Yep!", Selected: !q.selectedNo, Padding: 3},
+		{Text: "Nope", Selected: q.selectedNo, Padding: 3},
 	}
-
-	const horizontalPadding = 3
-	yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
-		yesStyle.PaddingRight(horizontalPadding).Render("ep!")
-	noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
-		noStyle.PaddingRight(horizontalPadding).Render("ope")
-
-	buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
-		lipgloss.JoinHorizontal(lipgloss.Center, yesButton, "  ", noButton),
-	)
-
+	buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ")
 	content := baseStyle.Render(
 		lipgloss.JoinVertical(
 			lipgloss.Center,

internal/ui/model/keys.go 🔗

@@ -17,6 +17,12 @@ type KeyMap struct {
 		DeleteAllAttachments key.Binding
 	}
 
+	Initialize struct {
+		Yes,
+		No,
+		Switch key.Binding
+	}
+
 	// Global key maps
 	Quit     key.Binding
 	Help     key.Binding
@@ -99,5 +105,18 @@ func DefaultKeyMap() KeyMap {
 		key.WithHelp("ctrl+r+r", "delete all attachments"),
 	)
 
+	km.Initialize.Yes = key.NewBinding(
+		key.WithKeys("y", "Y"),
+		key.WithHelp("y", "yes"),
+	)
+	km.Initialize.No = key.NewBinding(
+		key.WithKeys("n", "N", "esc", "alt+esc"),
+		key.WithHelp("n", "no"),
+	)
+	km.Initialize.Switch = key.NewBinding(
+		key.WithKeys("left", "right", "tab"),
+		key.WithHelp("tab", "switch"),
+	)
+
 	return km
 }

internal/ui/model/sidebar.go 🔗

@@ -4,9 +4,6 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/ui/logo"
-	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/version"
 )
 
 // SidebarModel is the model for the sidebar UI component.
@@ -65,17 +62,6 @@ func (m *SidebarModel) View() string {
 
 // SetWidth sets the width of the sidebar and updates the logo accordingly.
 func (m *SidebarModel) SetWidth(width int) {
-	m.logo = logoBlock(m.com.Styles, width)
+	m.logo = renderLogo(m.com.Styles, true, width)
 	m.width = width
 }
-
-func logoBlock(t *styles.Styles, width int) string {
-	return logo.Render(version.Version, true, logo.Opts{
-		FieldColor:   t.LogoFieldColor,
-		TitleColorA:  t.LogoTitleColorA,
-		TitleColorB:  t.LogoTitleColorB,
-		CharmColor:   t.LogoCharmColor,
-		VersionColor: t.LogoVersionColor,
-		Width:        max(0, width-2),
-	})
-}

internal/ui/model/ui.go 🔗

@@ -1,8 +1,10 @@
 package model
 
 import (
+	"fmt"
 	"image"
 	"math/rand"
+	"os"
 	"slices"
 	"strings"
 
@@ -11,19 +13,36 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
+	"github.com/charmbracelet/crush/internal/ui/logo"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/version"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
-// uiState represents the current focus state of the UI.
+// uiFocusState represents the current focus state of the UI.
+type uiFocusState uint8
+
+// Possible uiFocusState values.
+const (
+	uiFocusNone uiFocusState = iota
+	uiFocusEditor
+	uiFocusMain
+)
+
 type uiState uint8
 
 // Possible uiState values.
 const (
-	uiEdit uiState = iota
+	uiConfigure uiState = iota
+	uiInitialize
+	uiLanding
 	uiChat
+	uiChatCompact
 )
 
 // UI represents the main user interface model.
@@ -31,6 +50,7 @@ type UI struct {
 	com  *common.Common
 	sess *session.Session
 
+	focus uiFocusState
 	state uiState
 
 	keyMap KeyMap
@@ -41,6 +61,9 @@ type UI struct {
 	dialog *dialog.Overlay
 	help   help.Model
 
+	// header is the last cached header logo
+	header string
+
 	layout layout
 
 	// sendProgressBar instructs the TUI to send progress bar updates to the
@@ -58,6 +81,9 @@ type UI struct {
 
 	readyPlaceholder   string
 	workingPlaceholder string
+
+	// Initialize state
+	yesInitializeSelected bool
 }
 
 // New creates a new instance of the [UI] model.
@@ -76,7 +102,23 @@ func New(com *common.Common) *UI {
 		keyMap:   DefaultKeyMap(),
 		side:     NewSidebarModel(com),
 		help:     help.New(),
+		focus:    uiFocusNone,
+		state:    uiConfigure,
 		textarea: ta,
+
+		// initialize
+		yesInitializeSelected: true,
+	}
+	// If no provider is configured show the user the provider list
+	if !com.Config().IsConfigured() {
+		ui.state = uiConfigure
+		// if the project needs initialization show the user the question
+	} else if n, _ := config.ProjectNeedsInitialization(); n {
+		ui.state = uiInitialize
+		// otherwise go to the landing UI
+	} else {
+		ui.state = uiLanding
+		ui.focus = uiFocusEditor
 	}
 
 	ui.setEditorPrompt()
@@ -91,7 +133,6 @@ func (m *UI) Init() tea.Cmd {
 	if m.QueryVersion {
 		return tea.RequestTerminalVersion
 	}
-
 	return nil
 }
 
@@ -132,11 +173,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case tea.KeyPressMsg:
 			switch {
 			case key.Matches(msg, m.keyMap.Tab):
-				if m.state == uiChat {
-					m.state = uiEdit
+				if m.focus == uiFocusMain {
+					m.focus = uiFocusEditor
 					cmds = append(cmds, m.textarea.Focus())
 				} else {
-					m.state = uiChat
+					m.focus = uiFocusMain
 					m.textarea.Blur()
 				}
 			case key.Matches(msg, m.keyMap.Help):
@@ -159,9 +200,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		// This logic gets triggered on any message type, but should it?
-		switch m.state {
-		case uiChat:
-		case uiEdit:
+		switch m.focus {
+		case uiFocusMain:
+		case uiFocusEditor:
 			// Textarea placeholder logic
 			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 				m.textarea.Placeholder = m.workingPlaceholder
@@ -181,6 +222,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *UI) View() tea.View {
 	var v tea.View
 	v.AltScreen = true
+	v.BackgroundColor = m.com.Styles.Background
 
 	layers := []*lipgloss.Layer{}
 
@@ -189,7 +231,8 @@ func (m *UI) View() tea.View {
 
 	// The screen areas we're working with
 	area := m.layout.area
-	chatRect := m.layout.chat
+	headerRect := m.layout.header
+	mainRect := m.layout.main
 	sideRect := m.layout.sidebar
 	editRect := m.layout.editor
 	helpRect := m.layout.help
@@ -207,7 +250,7 @@ func (m *UI) View() tea.View {
 		}
 	}
 
-	if m.state == uiEdit && m.textarea.Focused() {
+	if m.focus == uiFocusEditor && m.textarea.Focused() {
 		cur := m.textarea.Cursor()
 		cur.X++ // Adjust for app margins
 		cur.Y += editRect.Min.Y
@@ -215,24 +258,70 @@ func (m *UI) View() tea.View {
 	}
 
 	mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
-		Width(area.Dx()).Height(area.Dy()).
-		AddLayers(
-			lipgloss.NewLayer(
-				lipgloss.NewStyle().Width(chatRect.Dx()).
-					Height(chatRect.Dy()).
-					Background(lipgloss.ANSIColor(rand.Intn(256))).
-					Render(" Main View "),
-			).X(chatRect.Min.X).Y(chatRect.Min.Y),
-			lipgloss.NewLayer(m.side.View()).
-				X(sideRect.Min.X).Y(sideRect.Min.Y),
-			lipgloss.NewLayer(m.textarea.View()).
-				X(editRect.Min.X).Y(editRect.Min.Y),
-			lipgloss.NewLayer(m.help.View(helpKeyMap)).
-				X(helpRect.Min.X).Y(helpRect.Min.Y),
-		)
+		Width(area.Dx()).Height(area.Dy())
+
+	switch m.state {
+	case uiConfigure:
+		header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
+		main := lipgloss.NewLayer(
+			lipgloss.NewStyle().Width(mainRect.Dx()).
+				Height(mainRect.Dy()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
+				Render(" Configure "),
+		).X(mainRect.Min.X).Y(mainRect.Min.Y)
+		mainLayer = mainLayer.AddLayers(header, main)
+	case uiInitialize:
+		header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
+		main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y)
+		mainLayer = mainLayer.AddLayers(header, main)
+	case uiLanding:
+		header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
+		main := lipgloss.NewLayer(
+			lipgloss.NewStyle().Width(mainRect.Dx()).
+				Height(mainRect.Dy()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
+				Render(" Landing Page "),
+		).X(mainRect.Min.X).Y(mainRect.Min.Y)
+		editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
+		mainLayer = mainLayer.AddLayers(header, main, editor)
+	case uiChat:
+		header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
+		side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y)
+		main := lipgloss.NewLayer(
+			lipgloss.NewStyle().Width(mainRect.Dx()).
+				Height(mainRect.Dy()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
+				Render(" Chat Messages "),
+		).X(mainRect.Min.X).Y(mainRect.Min.Y)
+		editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
+		mainLayer = mainLayer.AddLayers(header, main, side, editor)
+	case uiChatCompact:
+		header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
+		main := lipgloss.NewLayer(
+			lipgloss.NewStyle().Width(mainRect.Dx()).
+				Height(mainRect.Dy()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
+				Render(" Compact Chat Messages "),
+		).X(mainRect.Min.X).Y(mainRect.Min.Y)
+		editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
+		mainLayer = mainLayer.AddLayers(header, main, editor)
+	}
+
+	// Add help layer
+	help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y)
+	mainLayer = mainLayer.AddLayers(help)
 
 	layers = append(layers, mainLayer)
 
+	// Debugging rendering (visually see when the tui rerenders)
+	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
+		content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
+		debugLayer := lipgloss.NewLayer(content).
+			X(4).
+			Y(1)
+		layers = append(layers, debugLayer)
+	}
+
 	v.Content = lipgloss.NewCanvas(layers...)
 	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
@@ -248,37 +337,44 @@ func (m *UI) ShortHelp() []key.Binding {
 	var binds []key.Binding
 	k := &m.keyMap
 
-	if m.sess == nil {
-		// no session selected
-		binds = append(binds,
-			k.Commands,
-			k.Models,
-			k.Editor.Newline,
-			k.Quit,
-			k.Help,
-		)
-	} else {
-		// we have a session
-	}
+	switch m.state {
+	case uiInitialize:
+		binds = append(binds, k.Quit)
+	default:
+		// TODO: other states
+		if m.sess == nil {
+			// no session selected
+			binds = append(binds,
+				k.Commands,
+				k.Models,
+				k.Editor.Newline,
+				k.Quit,
+				k.Help,
+			)
+		} else {
+			// we have a session
+		}
 
-	// switch m.state {
-	// case uiChat:
-	// case uiEdit:
-	// 	binds = append(binds,
-	// 		k.Editor.AddFile,
-	// 		k.Editor.SendMessage,
-	// 		k.Editor.OpenEditor,
-	// 		k.Editor.Newline,
-	// 	)
-	//
-	// 	if len(m.attachments) > 0 {
-	// 		binds = append(binds,
-	// 			k.Editor.AttachmentDeleteMode,
-	// 			k.Editor.DeleteAllAttachments,
-	// 			k.Editor.Escape,
-	// 		)
-	// 	}
-	// }
+		// switch m.state {
+		// case uiChat:
+		// case uiEdit:
+		// 	binds = append(binds,
+		// 		k.Editor.AddFile,
+		// 		k.Editor.SendMessage,
+		// 		k.Editor.OpenEditor,
+		// 		k.Editor.Newline,
+		// 	)
+		//
+		// 	if len(m.attachments) > 0 {
+		// 		binds = append(binds,
+		// 			k.Editor.AttachmentDeleteMode,
+		// 			k.Editor.DeleteAllAttachments,
+		// 			k.Editor.Escape,
+		// 		)
+		// 	}
+		// }
+
+	}
 
 	return binds
 }
@@ -290,26 +386,34 @@ func (m *UI) FullHelp() [][]key.Binding {
 	help := k.Help
 	help.SetHelp("ctrl+g", "less")
 
-	if m.sess == nil {
-		// no session selected
+	switch m.state {
+	case uiInitialize:
 		binds = append(binds,
 			[]key.Binding{
-				k.Commands,
-				k.Models,
-				k.Sessions,
-			},
-			[]key.Binding{
-				k.Editor.Newline,
-				k.Editor.AddImage,
-				k.Editor.MentionFile,
-				k.Editor.OpenEditor,
-			},
-			[]key.Binding{
-				help,
-			},
-		)
-	} else {
-		// we have a session
+				k.Quit,
+			})
+	default:
+		if m.sess == nil {
+			// no session selected
+			binds = append(binds,
+				[]key.Binding{
+					k.Commands,
+					k.Models,
+					k.Sessions,
+				},
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+				[]key.Binding{
+					help,
+				},
+			)
+		} else {
+			// we have a session
+		}
 	}
 
 	// switch m.state {
@@ -334,10 +438,10 @@ func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
 // updateFocused updates the focused model (chat or editor) with the given message
 // and appends any resulting commands to the cmds slice.
 func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
-	switch m.state {
-	case uiChat:
+	switch m.focus {
+	case uiFocusMain:
 		m.updateChat(msg, cmds)
-	case uiEdit:
+	case uiFocusEditor:
 		switch {
 		case key.Matches(msg, m.keyMap.Editor.Newline):
 			m.textarea.InsertRune('\n')
@@ -366,8 +470,18 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
 func (m *UI) updateLayoutAndSize(w, h int) {
 	// The screen area we're working with
 	area := image.Rect(0, 0, w, h)
-	var helpKeyMap help.KeyMap = m
+
+	// The help height
 	helpHeight := 1
+	// The editor height
+	editorHeight := 5
+	// The sidebar width
+	sidebarWidth := 40
+	// The header height
+	// TODO: handle compact
+	headerHeight := 4
+
+	var helpKeyMap help.KeyMap = m
 	if m.help.ShowAll {
 		for _, row := range helpKeyMap.FullHelp() {
 			helpHeight = max(helpHeight, len(row))
@@ -375,35 +489,103 @@ func (m *UI) updateLayoutAndSize(w, h int) {
 	}
 
 	// Add app margins
-	mainRect := area
-	mainRect.Min.X += 1
-	mainRect.Min.Y += 1
-	mainRect.Max.X -= 1
-	mainRect.Max.Y -= 1
-
-	mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
-	chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
-	chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
+	appRect := area
+	appRect.Min.X += 1
+	appRect.Min.Y += 1
+	appRect.Max.X -= 1
+	appRect.Max.Y -= 1
+
+	if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
+		// extra padding on left and right for these states
+		appRect.Min.X += 1
+		appRect.Max.X -= 1
+	}
 
-	// Add 1 line margin bottom of chatRect
-	chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
-	// Add 1 line margin bottom of editRect
-	editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
+	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
 
 	m.layout = layout{
-		area:    area,
-		main:    mainRect,
-		chat:    chatRect,
-		editor:  editRect,
-		sidebar: sideRect,
-		help:    helpRect,
+		area: area,
+		help: helpRect,
 	}
 
-	// Update sub-model sizes
-	m.side.SetWidth(m.layout.sidebar.Dx())
-	m.textarea.SetWidth(m.layout.editor.Dx())
-	m.textarea.SetHeight(m.layout.editor.Dy())
+	// Set help width
 	m.help.SetWidth(m.layout.help.Dx())
+
+	// Handle different app states
+	switch m.state {
+	case uiConfigure, uiInitialize:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// help
+
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		m.layout.header = headerRect
+		m.layout.main = mainRect
+		m.renderHeader(false, m.layout.header.Dx())
+
+	case uiLanding:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// editor
+		// ------
+		// help
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		m.layout.header = headerRect
+		m.layout.main = mainRect
+		m.layout.editor = editorRect
+		// TODO: set the width and heigh of the chat component
+		m.renderHeader(false, m.layout.header.Dx())
+		m.textarea.SetWidth(m.layout.editor.Dx())
+		m.textarea.SetHeight(m.layout.editor.Dy())
+
+	case uiChat:
+		// Layout
+		//
+		// ------|---
+		// main  |
+		// ------| side
+		// editor|
+		// ----------
+		// help
+
+		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		m.layout.sidebar = sideRect
+		m.layout.main = mainRect
+		m.layout.editor = editorRect
+		// TODO: set the width and heigh of the chat component
+		m.side.SetWidth(m.layout.sidebar.Dx())
+		m.textarea.SetWidth(m.layout.editor.Dx())
+		m.textarea.SetHeight(m.layout.editor.Dy())
+	case uiChatCompact:
+		// Layout
+		//
+		// compact-header
+		// ------
+		// main
+		// ------
+		// editor
+		// ------
+		// help
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
+		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		m.layout.header = headerRect
+		m.layout.main = mainRect
+		m.layout.editor = editorRect
+		// TODO: set the width and heigh of the chat component
+		m.renderHeader(true, m.layout.header.Dx())
+		m.textarea.SetWidth(m.layout.editor.Dx())
+		m.textarea.SetHeight(m.layout.editor.Dy())
+	}
 }
 
 // layout defines the positioning of UI elements.
@@ -411,11 +593,14 @@ type layout struct {
 	// area is the overall available area.
 	area uv.Rectangle
 
-	// main is the main area excluding help.
-	main uv.Rectangle
+	// header is the header shown in special cases
+	// e.x when the sidebar is collapsed
+	// or when in the landing page
+	// or in init/config
+	header uv.Rectangle
 
-	// chat is the area for the chat pane.
-	chat uv.Rectangle
+	// main is the area for the main pane. (e.x chat, configure, landing)
+	main uv.Rectangle
 
 	// editor is the area for the editor pane.
 	editor uv.Rectangle
@@ -481,3 +666,57 @@ func (m *UI) randomizePlaceholders() {
 	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
 	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
 }
+
+func (m *UI) initializeView() string {
+	cfg := m.com.Config()
+	s := m.com.Styles.Initialize
+	cwd := home.Short(cfg.WorkingDir())
+	initFile := cfg.Options.InitializeAs
+
+	header := s.Header.Render("Would you like to initialize this project?")
+	path := s.Accent.PaddingLeft(2).Render(cwd)
+	desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile))
+	hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
+	prompt := s.Content.Render("Would you like to initialize now?")
+
+	buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
+		{Text: "Yep!", Selected: m.yesInitializeSelected},
+		{Text: "Nope", Selected: !m.yesInitializeSelected},
+	}, " ")
+
+	// max width 60 so the text is compact
+	width := min(m.layout.main.Dx(), 60)
+
+	return lipgloss.NewStyle().
+		Width(width).
+		Height(m.layout.main.Dy()).
+		PaddingBottom(1).
+		AlignVertical(lipgloss.Bottom).
+		Render(strings.Join(
+			[]string{
+				header,
+				path,
+				desc,
+				hint,
+				prompt,
+				buttons,
+			},
+			"\n\n",
+		))
+}
+
+func (m *UI) renderHeader(compact bool, width int) {
+	// TODO: handle the compact case differently
+	m.header = renderLogo(m.com.Styles, compact, width)
+}
+
+func renderLogo(t *styles.Styles, compact bool, width int) string {
+	return logo.Render(version.Version, compact, logo.Opts{
+		FieldColor:   t.LogoFieldColor,
+		TitleColorA:  t.LogoTitleColorA,
+		TitleColorB:  t.LogoTitleColorB,
+		CharmColor:   t.LogoCharmColor,
+		VersionColor: t.LogoVersionColor,
+		Width:        max(0, width-2),
+	})
+}

internal/ui/styles/styles.go 🔗

@@ -98,8 +98,8 @@ type Styles struct {
 	FilePicker filepicker.Styles
 
 	// Buttons
-	ButtonSelected   lipgloss.Style
-	ButtonUnselected lipgloss.Style
+	ButtonFocus lipgloss.Style
+	ButtonBlur  lipgloss.Style
 
 	// Borders
 	BorderFocus lipgloss.Style
@@ -113,6 +113,8 @@ type Styles struct {
 	EditorPromptYoloDotsFocused lipgloss.Style
 	EditorPromptYoloDotsBlurred lipgloss.Style
 
+	// Background
+	Background color.Color
 	// Logo
 	LogoFieldColor   color.Color
 	LogoTitleColorA  color.Color
@@ -123,6 +125,13 @@ type Styles struct {
 	// Sidebar
 	SidebarFull    lipgloss.Style
 	SidebarCompact lipgloss.Style
+
+	// Initialize
+	Initialize struct {
+		Header  lipgloss.Style
+		Content lipgloss.Style
+		Accent  lipgloss.Style
+	}
 }
 
 func DefaultStyles() Styles {
@@ -178,6 +187,8 @@ func DefaultStyles() Styles {
 
 	s := Styles{}
 
+	s.Background = bgBase
+
 	s.TextInput = textinput.Styles{
 		Focused: textinput.StyleState{
 			Text:        base,
@@ -537,8 +548,8 @@ func DefaultStyles() Styles {
 	s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
 
 	// Buttons
-	s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary)
-	s.ButtonUnselected = s.Base.Background(bgSubtle)
+	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
+	s.ButtonBlur = s.Base.Background(bgSubtle)
 
 	// Borders
 	s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
@@ -562,6 +573,10 @@ func DefaultStyles() Styles {
 	s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
 	s.SidebarCompact = s.SidebarFull.PaddingTop(0)
 
+	// Initialize
+	s.Initialize.Header = s.Base
+	s.Initialize.Content = s.Muted
+	s.Initialize.Accent = s.Base.Foreground(greenDark)
 	return s
 }