on main help sign in
feat(ui): status: add status bar with info messages and help toggle
commit17e66163283638c8de1bf73557e9c0766b38062d authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 6 Jan 2026, 12:05 (9 months ago) parente671774
4 files changed+166−20
internal/ui/model/status.go+106 internal/ui/model/ui.go+25−20 internal/ui/styles/styles.go+29 internal/uiutil/uiutil.go+6
internal/ui/model/status.gonew file+106
@@ -0,0 +1,106 @@
1+package model
2+
3+import (
4+ "time"
5+
6+ "charm.land/bubbles/v2/help"
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/uiutil"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+// DefaultStatusTTL is the default time-to-live for status messages.
16+const DefaultStatusTTL = 5 * time.Second
17+
18+// Status is the status bar and help model.
19+type Status struct {
20+ com *common.Common
21+ help help.Model
22+ helpKm help.KeyMap
23+ msg uiutil.InfoMsg
24+}
25+
26+// NewStatus creates a new status bar and help model.
27+func NewStatus(com *common.Common, km help.KeyMap) *Status {
28+ s := new(Status)
29+ s.com = com
30+ s.help = help.New()
31+ s.help.Styles = com.Styles.Help
32+ s.helpKm = km
33+ return s
34+}
35+
36+// SetInfoMsg sets the status info message.
37+func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
38+ s.msg = msg
39+}
40+
41+// ClearInfoMsg clears the status info message.
42+func (s *Status) ClearInfoMsg() {
43+ s.msg = uiutil.InfoMsg{}
44+}
45+
46+// SetWidth sets the width of the status bar and help view.
47+func (s *Status) SetWidth(width int) {
48+ s.help.SetWidth(width)
49+}
50+
51+// ShowingAll returns whether the full help view is shown.
52+func (s *Status) ShowingAll() bool {
53+ return s.help.ShowAll
54+}
55+
56+// ToggleHelp toggles the full help view.
57+func (s *Status) ToggleHelp() {
58+ s.help.ShowAll = !s.help.ShowAll
59+}
60+
61+// Draw draws the status bar onto the screen.
62+func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
63+ helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
64+ uv.NewStyledString(helpView).Draw(scr, area)
65+
66+ // Render notifications
67+ if s.msg.IsEmpty() {
68+ return
69+ }
70+
71+ var indStyle lipgloss.Style
72+ var msgStyle lipgloss.Style
73+ switch s.msg.Type {
74+ case uiutil.InfoTypeError:
75+ indStyle = s.com.Styles.Status.ErrorIndicator
76+ msgStyle = s.com.Styles.Status.ErrorMessage
77+ case uiutil.InfoTypeWarn:
78+ indStyle = s.com.Styles.Status.WarnIndicator
79+ msgStyle = s.com.Styles.Status.WarnMessage
80+ case uiutil.InfoTypeUpdate:
81+ indStyle = s.com.Styles.Status.UpdateIndicator
82+ msgStyle = s.com.Styles.Status.UpdateMessage
83+ case uiutil.InfoTypeInfo:
84+ indStyle = s.com.Styles.Status.InfoIndicator
85+ msgStyle = s.com.Styles.Status.InfoMessage
86+ case uiutil.InfoTypeSuccess:
87+ indStyle = s.com.Styles.Status.SuccessIndicator
88+ msgStyle = s.com.Styles.Status.SuccessMessage
89+ }
90+
91+ ind := indStyle.String()
92+ messageWidth := area.Dx() - lipgloss.Width(ind)
93+ msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
94+ info := msgStyle.Width(messageWidth).Render(msg)
95+
96+ // Draw the info message over the help view
97+ uv.NewStyledString(ind+info).Draw(scr, area)
98+}
99+
100+// clearInfoMsgCmd returns a command that clears the info message after the
101+// given TTL.
102+func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
103+ return tea.Tick(ttl, func(time.Time) tea.Msg {
104+ return uiutil.ClearStatusMsg{}
105+ })
106+}
internal/ui/model/ui.go+25−20
@@ -82,7 +82,7 @@ type UI struct {
8282 keyenh tea.KeyboardEnhancementsMsg
8383
8484 dialog *dialog.Overlay
85− help help.Model
85+ status *Status
8686
8787 // header is the last cached header logo
8888 header string
@@ -137,13 +137,14 @@ func New(com *common.Common) *UI {
137137 com: com,
138138 dialog: dialog.NewOverlay(),
139139 keyMap: DefaultKeyMap(),
140− help: help.New(),
141140 focus: uiFocusNone,
142141 state: uiConfigure,
143142 textarea: ta,
144143 chat: ch,
145144 }
146145
146+ status := NewStatus(com, ui)
147+
147148 // set onboarding state defaults
148149 ui.onboarding.yesInitializeSelected = true
149150
@@ -162,7 +163,7 @@ func New(com *common.Common) *UI {
162163 ui.setEditorPrompt(false)
163164 ui.randomizePlaceholders()
164165 ui.textarea.Placeholder = ui.readyPlaceholder
165− ui.help.Styles = com.Styles.Help
166+ ui.status = status
166167
167168 return ui
168169}
@@ -338,6 +339,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
338339 case openEditorMsg:
339340 m.textarea.SetValue(msg.Text)
340341 m.textarea.MoveToEnd()
342+ case uiutil.InfoMsg:
343+ m.status.SetInfoMsg(msg)
344+ ttl := msg.TTL
345+ if ttl <= 0 {
346+ ttl = DefaultStatusTTL
347+ }
348+ cmds = append(cmds, clearInfoMsgCmd(ttl))
349+ case uiutil.ClearStatusMsg:
350+ m.status.ClearInfoMsg()
341351 }
342352
343353 // This logic gets triggered on any message type, but should it?
@@ -480,7 +490,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
480490 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
481491 switch {
482492 case key.Matches(msg, m.keyMap.Help):
483− m.help.ShowAll = !m.help.ShowAll
493+ m.status.ToggleHelp()
484494 m.updateLayoutAndSize()
485495 return true
486496 case key.Matches(msg, m.keyMap.Commands):
@@ -569,7 +579,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
569579 cmds = append(cmds, uiutil.ReportError(err))
570580 }
571581 case dialog.ToggleHelpMsg:
572− m.help.ShowAll = !m.help.ShowAll
582+ m.status.ToggleHelp()
573583 m.dialog.CloseDialog(dialog.CommandsID)
574584 case dialog.QuitMsg:
575585 cmds = append(cmds, tea.Quit)
@@ -815,9 +825,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
815825 editor.Draw(scr, layout.editor)
816826 }
817827
818− // Add help layer
819− help := uv.NewStyledString(m.help.View(m))
820− help.Draw(scr, layout.help)
828+ // Add status and help layer
829+ m.status.Draw(scr, layout.status)
821830
822831 // Debugging rendering (visually see when the tui rerenders)
823832 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
@@ -1077,8 +1086,8 @@ func (m *UI) updateLayoutAndSize() {
10771086
10781087// updateSize updates the sizes of UI components based on the current layout.
10791088func (m *UI) updateSize() {
1080− // Set help width
1081− m.help.SetWidth(m.layout.help.Dx())
1089+ // Set status width
1090+ m.status.SetWidth(m.layout.status.Dx())
10821091
10831092 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
10841093 m.textarea.SetWidth(m.layout.editor.Dx())
@@ -1115,18 +1124,16 @@ func (m *UI) generateLayout(w, h int) layout {
11151124 headerHeight := 4
11161125
11171126 var helpKeyMap help.KeyMap = m
1118− if m.help.ShowAll {
1127+ if m.status.ShowingAll() {
11191128 for _, row := range helpKeyMap.FullHelp() {
11201129 helpHeight = max(helpHeight, len(row))
11211130 }
11221131 }
11231132
11241133 // Add app margins
1125− appRect := area
1134+ appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
11261135 appRect.Min.X += 1
1127− appRect.Min.Y += 1
11281136 appRect.Max.X -= 1
1129− appRect.Max.Y -= 1
11301137
11311138 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
11321139 // extra padding on left and right for these states
@@ -1134,11 +1141,9 @@ func (m *UI) generateLayout(w, h int) layout {
11341141 appRect.Max.X -= 1
11351142 }
11361143
1137− appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1138−
11391144 layout := layout{
1140− area: area,
1141− help: helpRect,
1145+ area: area,
1146+ status: helpRect,
11421147 }
11431148
11441149 // Handle different app states
@@ -1242,8 +1247,8 @@ type layout struct {
12421247 // sidebar is the area for the sidebar.
12431248 sidebar uv.Rectangle
12441249
1245− // help is the area for the help view.
1246− help uv.Rectangle
1250+ // status is the area for the status view.
1251+ status uv.Rectangle
12471252}
12481253
12491254func (m *UI) openEditor(value string) tea.Cmd {
internal/ui/styles/styles.go+29
@@ -303,6 +303,23 @@ type Styles struct {
303303
304304 Commands struct{}
305305 }
306+
307+ // Status bar and help
308+ Status struct {
309+ Help lipgloss.Style
310+
311+ ErrorIndicator lipgloss.Style
312+ WarnIndicator lipgloss.Style
313+ InfoIndicator lipgloss.Style
314+ UpdateIndicator lipgloss.Style
315+ SuccessIndicator lipgloss.Style
316+
317+ ErrorMessage lipgloss.Style
318+ WarnMessage lipgloss.Style
319+ InfoMessage lipgloss.Style
320+ UpdateMessage lipgloss.Style
321+ SuccessMessage lipgloss.Style
322+ }
306323}
307324
308325// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1110,6 +1127,18 @@ func DefaultStyles() Styles {
11101127
11111128 s.Dialog.List = base.Margin(0, 0, 1, 0)
11121129
1130+ s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1131+ s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1132+ s.Status.InfoIndicator = s.Status.SuccessIndicator
1133+ s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
1134+ s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING")
1135+ s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR")
1136+ s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1)
1137+ s.Status.InfoMessage = s.Status.SuccessMessage
1138+ s.Status.UpdateMessage = s.Status.SuccessMessage
1139+ s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
1140+ s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
1141+
11131142 return s
11141143}
11151144
internal/uiutil/uiutil.go+6
@@ -65,6 +65,12 @@ type (
6565 ClearStatusMsg struct{}
6666)
6767
68+// IsEmpty checks if the [InfoMsg] is empty.
69+func (m InfoMsg) IsEmpty() bool {
70+ var zero InfoMsg
71+ return m == zero
72+}
73+
6874// ExecShell parses a shell command string and executes it with exec.Command.
6975// Uses shell.Fields for proper handling of shell syntax like quotes and
7076// arguments while preserving TTY handling for terminal editors.