From de2385c4d9c2a22ebadd636f5681f0a8e70c919e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 13:06:37 -0400 Subject: [PATCH 1/2] Keep "? help" on the top bar while the shortcut bar is hidden Pressing ? hid every shortcut, including the one that brings them back, and the choice survives a restart, so it was easy to end up with no way of knowing how to get help again. While help is hidden the top rule now opens with "? help". It takes no rows, so hiding help still gives the screen back, and it is left out when the terminal is too narrow to fit it beside HEY. --- docs/tui.md | 3 ++- internal/tui/nav.go | 21 ++++++++++++++++----- internal/tui/tui_test.go | 27 ++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index b627485a..711d57ec 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -24,7 +24,8 @@ manually launched TUIs. The app has four sections: Mail, Contacts, Calendar and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is -remembered across restarts. +remembered across restarts. While it is hidden, `? help` at the left of the top bar says +how to bring it back. Mail navigation includes HEY's boxes plus separate Labels and Collections tabs: Shift+L opens Labels directly and Shift+K opens Collections. Previously Seen has its own tab after diff --git a/internal/tui/nav.go b/internal/tui/nav.go index bcbbffa2..ccd6fea6 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -324,10 +324,11 @@ func centerText(text string, width int) string { } // renderTopRule draws the top rule with HEY centered and the account -// aligned to the right, both bold: +// aligned to the right, both bold. A hint, when given, sits at the left end +// if there is room for it beside HEY: // -// ─────────── HEY ─────────── jz@example.com ── -func renderTopRule(width int, account string) string { +// ── ? help ── HEY ─────────── jz@example.com ── +func renderTopRule(width int, hint, account string) string { ruleStyle := lipgloss.NewStyle().Foreground(colorChrome) labelStyle := lipgloss.NewStyle().Foreground(colorChrome).Bold(true) @@ -346,6 +347,12 @@ func renderTopRule(width int, account string) string { } var b strings.Builder + const lead = 2 + if hintWidth := lipgloss.Width(hint) + 2; hint != "" && left >= lead+hintWidth+1 { + b.WriteString(ruleStyle.Render(strings.Repeat("─", lead))) + b.WriteString(" " + labelStyle.Render(hint) + " ") + left -= lead + hintWidth + } b.WriteString(ruleStyle.Render(strings.Repeat("─", left))) b.WriteString(" " + labelStyle.Render("HEY") + " ") b.WriteString(ruleStyle.Render(strings.Repeat("─", mid))) @@ -360,8 +367,12 @@ func renderTopRule(width int, account string) string { func renderHeader(m *model) string { var b strings.Builder - // Row 1: section rule + items - b.WriteString(renderTopRule(m.width, m.mailAccount.label)) + // Row 1: section rule + items. Hidden help leaves the one key that brings it back. + hint := "" + if m.help.hidden && m.canToggleHelp() { + hint = "? help" + } + b.WriteString(renderTopRule(m.width, hint, m.mailAccount.label)) b.WriteString("\n") b.WriteString(renderNavRow(sectionItems, int(m.section), m.focus == rowSection, m.width, true)) b.WriteString("\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6d5c77ee..61208100 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -248,6 +248,9 @@ func TestQuestionMarkTogglesHelpAndResizesContent(t *testing.T) { if !slices.Equal(saved, []bool{true}) { t.Errorf("saved preferences = %v, want [true]", saved) } + if top := lines[0]; !strings.Contains(top, " ? help ") { + t.Errorf("hidden help left no way back on the top rule: %q", top) + } updated, _ = m.Update(keyPress("?")) m = updated.(model) @@ -1436,8 +1439,30 @@ func TestChromeUsesBlueBoldConvention(t *testing.T) { } } +func TestTopRuleShowsAHintBesideHeyWhenThereIsRoom(t *testing.T) { + line := stripANSI(renderTopRule(80, "? help", "frank.castillo@example.com")) + if got := lipgloss.Width(line); got != 80 { + t.Errorf("top rule width = %d, want 80", got) + } + if !strings.HasPrefix(line, "── ? help ─") { + t.Errorf("the hint should open the rule: %q", line) + } + heyColumn := func(rule string) int { return lipgloss.Width(rule[:strings.Index(rule, "HEY")]) } + if want := stripANSI(renderTopRule(80, "", "frank.castillo@example.com")); heyColumn(line) != heyColumn(want) { + t.Errorf("the hint moved HEY: %q, want HEY where it is in %q", line, want) + } + if !strings.HasSuffix(line, "frank.castillo@example.com ──") { + t.Errorf("the account should stay right-aligned: %q", line) + } + + narrow := stripANSI(renderTopRule(40, "? help", "frank.castillo@example.com")) + if strings.Contains(narrow, "help") || lipgloss.Width(narrow) != 40 { + t.Errorf("a rule with no room for the hint = %q, want it left out", narrow) + } +} + func TestTopRuleCentersHeyAndRightAlignsAccount(t *testing.T) { - line := renderTopRule(80, "frank.castillo@example.com") + line := renderTopRule(80, "", "frank.castillo@example.com") if got := lipgloss.Width(line); got != 80 { t.Errorf("top rule width = %d, want 80", got) } From 6b5e6db6361e7b3b4cc027ba9cb457767582cb77 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 13:10:34 -0400 Subject: [PATCH 2/2] Leave "? help" off the top bar while an error box is up An error box takes every key until it is dismissed, so ? did nothing while the header still offered it. canToggleHelp now says no while an error is showing, which keeps the hint and the key in step. --- internal/tui/tui.go | 3 ++- internal/tui/tui_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1e293344..24edd16a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -998,7 +998,8 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m model) canToggleHelp() bool { - if m.mailAccountPicker { + // An error box takes every key until it is dismissed, ? included. + if m.mailAccountPicker || m.err != nil { return false } if m.activeView == m.screenerView { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 61208100..1e7a56ac 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -251,6 +251,16 @@ func TestQuestionMarkTogglesHelpAndResizesContent(t *testing.T) { if top := lines[0]; !strings.Contains(top, " ? help ") { t.Errorf("hidden help left no way back on the top rule: %q", top) } + m.err = errors.New("HEY is not answering") + if top := strings.SplitN(stripANSI(m.View().Content), "\n", 2)[0]; strings.Contains(top, "? help") { + t.Errorf("the top rule offers ? while an error box would swallow it: %q", top) + } + updated, _ = m.Update(keyPress("?")) + m = updated.(model) + if !m.help.hidden { + t.Error("? acted through an error box") + } + m.err = nil updated, _ = m.Update(keyPress("?")) m = updated.(model)