From 17e66163283638c8de1bf73557e9c0766b38062d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 6 Jan 2026 12:05:08 -0500 Subject: [PATCH] feat(ui): status: add status bar with info messages and help toggle --- 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(-) create mode 100644 internal/ui/model/status.go diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go new file mode 100644 index 0000000000000000000000000000000000000000..a3371d27d2f19f3236734ea8a31602fa5d518e62 --- /dev/null +++ b/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{} + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6072c4292c07aebbd92a1329944bc20b9826bb48..3540a946d095ca356b8a1a21cbb81a6faeb9be0a 100644 --- a/internal/ui/model/ui.go +++ b/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 { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a0c7ad418a0c8d4dbeb041cfd48d5a16fd110622..394889039c0d71602cd07089db53e44165dba489 100644 --- a/internal/ui/styles/styles.go +++ b/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 } diff --git a/internal/uiutil/uiutil.go b/internal/uiutil/uiutil.go index efd89dda69f780b354777916b459675154780372..92ae8c97937793e0643cdcb6a930216116fee2dd 100644 --- a/internal/uiutil/uiutil.go +++ b/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.