feat(ui): status: add status bar with info messages and help toggle

Ayman Bagabas created

Change summary

internal/ui/model/status.go  | 106 ++++++++++++++++++++++++++++++++++++++
internal/ui/model/ui.go      |  45 ++++++++-------
internal/ui/styles/styles.go |  29 ++++++++++
internal/uiutil/uiutil.go    |   6 ++
4 files changed, 166 insertions(+), 20 deletions(-)

Detailed changes

internal/ui/model/status.go 🔗

@@ -0,0 +1,106 @@
+package model
+
+import (
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// DefaultStatusTTL is the default time-to-live for status messages.
+const DefaultStatusTTL = 5 * time.Second
+
+// Status is the status bar and help model.
+type Status struct {
+	com    *common.Common
+	help   help.Model
+	helpKm help.KeyMap
+	msg    uiutil.InfoMsg
+}
+
+// NewStatus creates a new status bar and help model.
+func NewStatus(com *common.Common, km help.KeyMap) *Status {
+	s := new(Status)
+	s.com = com
+	s.help = help.New()
+	s.help.Styles = com.Styles.Help
+	s.helpKm = km
+	return s
+}
+
+// SetInfoMsg sets the status info message.
+func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
+	s.msg = msg
+}
+
+// ClearInfoMsg clears the status info message.
+func (s *Status) ClearInfoMsg() {
+	s.msg = uiutil.InfoMsg{}
+}
+
+// SetWidth sets the width of the status bar and help view.
+func (s *Status) SetWidth(width int) {
+	s.help.SetWidth(width)
+}
+
+// ShowingAll returns whether the full help view is shown.
+func (s *Status) ShowingAll() bool {
+	return s.help.ShowAll
+}
+
+// ToggleHelp toggles the full help view.
+func (s *Status) ToggleHelp() {
+	s.help.ShowAll = !s.help.ShowAll
+}
+
+// Draw draws the status bar onto the screen.
+func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
+	helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
+	uv.NewStyledString(helpView).Draw(scr, area)
+
+	// Render notifications
+	if s.msg.IsEmpty() {
+		return
+	}
+
+	var indStyle lipgloss.Style
+	var msgStyle lipgloss.Style
+	switch s.msg.Type {
+	case uiutil.InfoTypeError:
+		indStyle = s.com.Styles.Status.ErrorIndicator
+		msgStyle = s.com.Styles.Status.ErrorMessage
+	case uiutil.InfoTypeWarn:
+		indStyle = s.com.Styles.Status.WarnIndicator
+		msgStyle = s.com.Styles.Status.WarnMessage
+	case uiutil.InfoTypeUpdate:
+		indStyle = s.com.Styles.Status.UpdateIndicator
+		msgStyle = s.com.Styles.Status.UpdateMessage
+	case uiutil.InfoTypeInfo:
+		indStyle = s.com.Styles.Status.InfoIndicator
+		msgStyle = s.com.Styles.Status.InfoMessage
+	case uiutil.InfoTypeSuccess:
+		indStyle = s.com.Styles.Status.SuccessIndicator
+		msgStyle = s.com.Styles.Status.SuccessMessage
+	}
+
+	ind := indStyle.String()
+	messageWidth := area.Dx() - lipgloss.Width(ind)
+	msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
+	info := msgStyle.Width(messageWidth).Render(msg)
+
+	// Draw the info message over the help view
+	uv.NewStyledString(ind+info).Draw(scr, area)
+}
+
+// clearInfoMsgCmd returns a command that clears the info message after the
+// given TTL.
+func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
+		return uiutil.ClearStatusMsg{}
+	})
+}

internal/ui/model/ui.go 🔗

@@ -82,7 +82,7 @@ type UI struct {
 	keyenh tea.KeyboardEnhancementsMsg
 
 	dialog *dialog.Overlay
-	help   help.Model
+	status *Status
 
 	// header is the last cached header logo
 	header string
@@ -137,13 +137,14 @@ func New(com *common.Common) *UI {
 		com:      com,
 		dialog:   dialog.NewOverlay(),
 		keyMap:   DefaultKeyMap(),
-		help:     help.New(),
 		focus:    uiFocusNone,
 		state:    uiConfigure,
 		textarea: ta,
 		chat:     ch,
 	}
 
+	status := NewStatus(com, ui)
+
 	// set onboarding state defaults
 	ui.onboarding.yesInitializeSelected = true
 
@@ -162,7 +163,7 @@ func New(com *common.Common) *UI {
 	ui.setEditorPrompt(false)
 	ui.randomizePlaceholders()
 	ui.textarea.Placeholder = ui.readyPlaceholder
-	ui.help.Styles = com.Styles.Help
+	ui.status = status
 
 	return ui
 }
@@ -338,6 +339,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case openEditorMsg:
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
+	case uiutil.InfoMsg:
+		m.status.SetInfoMsg(msg)
+		ttl := msg.TTL
+		if ttl <= 0 {
+			ttl = DefaultStatusTTL
+		}
+		cmds = append(cmds, clearInfoMsgCmd(ttl))
+	case uiutil.ClearStatusMsg:
+		m.status.ClearInfoMsg()
 	}
 
 	// This logic gets triggered on any message type, but should it?
@@ -480,7 +490,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
 		switch {
 		case key.Matches(msg, m.keyMap.Help):
-			m.help.ShowAll = !m.help.ShowAll
+			m.status.ToggleHelp()
 			m.updateLayoutAndSize()
 			return true
 		case key.Matches(msg, m.keyMap.Commands):
@@ -569,7 +579,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, uiutil.ReportError(err))
 			}
 		case dialog.ToggleHelpMsg:
-			m.help.ShowAll = !m.help.ShowAll
+			m.status.ToggleHelp()
 			m.dialog.CloseDialog(dialog.CommandsID)
 		case dialog.QuitMsg:
 			cmds = append(cmds, tea.Quit)
@@ -815,9 +825,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		editor.Draw(scr, layout.editor)
 	}
 
-	// Add help layer
-	help := uv.NewStyledString(m.help.View(m))
-	help.Draw(scr, layout.help)
+	// Add status and help layer
+	m.status.Draw(scr, layout.status)
 
 	// Debugging rendering (visually see when the tui rerenders)
 	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
@@ -1077,8 +1086,8 @@ func (m *UI) updateLayoutAndSize() {
 
 // updateSize updates the sizes of UI components based on the current layout.
 func (m *UI) updateSize() {
-	// Set help width
-	m.help.SetWidth(m.layout.help.Dx())
+	// Set status width
+	m.status.SetWidth(m.layout.status.Dx())
 
 	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 	m.textarea.SetWidth(m.layout.editor.Dx())
@@ -1115,18 +1124,16 @@ func (m *UI) generateLayout(w, h int) layout {
 	headerHeight := 4
 
 	var helpKeyMap help.KeyMap = m
-	if m.help.ShowAll {
+	if m.status.ShowingAll() {
 		for _, row := range helpKeyMap.FullHelp() {
 			helpHeight = max(helpHeight, len(row))
 		}
 	}
 
 	// Add app margins
-	appRect := area
+	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
 	appRect.Min.X += 1
-	appRect.Min.Y += 1
 	appRect.Max.X -= 1
-	appRect.Max.Y -= 1
 
 	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
 		// extra padding on left and right for these states
@@ -1134,11 +1141,9 @@ func (m *UI) generateLayout(w, h int) layout {
 		appRect.Max.X -= 1
 	}
 
-	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
-
 	layout := layout{
-		area: area,
-		help: helpRect,
+		area:   area,
+		status: helpRect,
 	}
 
 	// Handle different app states
@@ -1242,8 +1247,8 @@ type layout struct {
 	// sidebar is the area for the sidebar.
 	sidebar uv.Rectangle
 
-	// help is the area for the help view.
-	help uv.Rectangle
+	// status is the area for the status view.
+	status uv.Rectangle
 }
 
 func (m *UI) openEditor(value string) tea.Cmd {

internal/ui/styles/styles.go 🔗

@@ -303,6 +303,23 @@ type Styles struct {
 
 		Commands struct{}
 	}
+
+	// Status bar and help
+	Status struct {
+		Help lipgloss.Style
+
+		ErrorIndicator   lipgloss.Style
+		WarnIndicator    lipgloss.Style
+		InfoIndicator    lipgloss.Style
+		UpdateIndicator  lipgloss.Style
+		SuccessIndicator lipgloss.Style
+
+		ErrorMessage   lipgloss.Style
+		WarnMessage    lipgloss.Style
+		InfoMessage    lipgloss.Style
+		UpdateMessage  lipgloss.Style
+		SuccessMessage lipgloss.Style
+	}
 }
 
 // ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1110,6 +1127,18 @@ func DefaultStyles() Styles {
 
 	s.Dialog.List = base.Margin(0, 0, 1, 0)
 
+	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
+	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
+	s.Status.InfoIndicator = s.Status.SuccessIndicator
+	s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
+	s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING")
+	s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR")
+	s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1)
+	s.Status.InfoMessage = s.Status.SuccessMessage
+	s.Status.UpdateMessage = s.Status.SuccessMessage
+	s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
+	s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
+
 	return s
 }
 

internal/uiutil/uiutil.go 🔗

@@ -65,6 +65,12 @@ type (
 	ClearStatusMsg struct{}
 )
 
+// IsEmpty checks if the [InfoMsg] is empty.
+func (m InfoMsg) IsEmpty() bool {
+	var zero InfoMsg
+	return m == zero
+}
+
 // ExecShell parses a shell command string and executes it with exec.Command.
 // Uses shell.Fields for proper handling of shell syntax like quotes and
 // arguments while preserving TTY handling for terminal editors.