summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 14:51:27 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 14:51:27 +0200
commit4737786fd4a417ff94e22e4f72a1e924d4e033dd (patch)
tree70a17e892e5367cb53737776b00551b06684e7da
parent479f399aae8d3b28d9714214ea624d4a8cc0e886 (diff)
tui: add full-screen help overlay with H and esc close
-rw-r--r--internal/tui/tui.go133
-rw-r--r--internal/tui/tui_test.go66
2 files changed, 199 insertions, 0 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 19e164f..4006d84 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -187,6 +187,8 @@ type Model struct {
keys KeyMap
+ helpOverlayVisible bool
+
width int
height int
quitting bool
@@ -326,6 +328,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stopTrace()
return m, tea.Quit
}
+ if m.helpOverlayVisible {
+ if isHelpOverlayCloseKey(msg) || isHelpOverlayOpenKey(msg) {
+ m.helpOverlayVisible = false
+ }
+ return m, nil
+ }
+ if isHelpOverlayOpenKey(msg) && !m.attaching && m.lastErr == nil {
+ m.helpOverlayVisible = true
+ return m, nil
+ }
if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) {
m.exporter = m.exporter.Open()
return m, nil
@@ -662,6 +674,10 @@ func (m Model) View() tea.View {
if m.lastErr != nil {
return altScreenView(placeToViewport(width, height, ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))), title)
}
+ if m.helpOverlayVisible {
+ helpView := renderGlobalHelpOverlay(width, height, m.helpSections())
+ return altScreenView(helpView, title)
+ }
switch m.screen {
case ScreenPIDPicker:
@@ -684,6 +700,14 @@ func (m Model) View() tea.View {
}
}
+func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool {
+ return msg.String() == "H"
+}
+
+func isHelpOverlayCloseKey(msg tea.KeyPressMsg) bool {
+ return msg.Code == tea.KeyEsc || msg.String() == "esc" || msg.String() == "?"
+}
+
func runExportCmd(exportEnabled bool, option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd {
return func() tea.Msg {
if !exportEnabled {
@@ -840,6 +864,115 @@ func renderHelpOverlay(width, height int, groups [][]key.Binding) string {
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
}
+type helpSection struct {
+ title string
+ lines []string
+}
+
+func (m Model) helpSections() []helpSection {
+ globalLines := []string{
+ "H help esc close help q quit",
+ "tab/shift+tab cycle tabs 1..7 jump tab",
+ "p pid picker t tid picker o probes r refresh",
+ }
+ if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" {
+ globalLines = append(globalLines, "e snapshot export")
+ }
+
+ return []helpSection{
+ {
+ title: "Global",
+ lines: globalLines,
+ },
+ {
+ title: "Flame Tab",
+ lines: []string{
+ "arrows/hjkl navigate pgup top pgdn root",
+ "enter zoom u/backspace/esc undo",
+ "/ filter n/N match next/prev",
+ "space/p pause o order r reset baseline",
+ },
+ },
+ {
+ title: "Stream Tab",
+ lines: []string{
+ "space pause/live f add filter esc undo filter",
+ "enter apply filter / or ? search n/N next/prev",
+ "j/k/up/down scroll pgup/pgdn page g/G top/tail",
+ "left/right or h/l switch columns",
+ "c clear x export X export-as E open last",
+ },
+ },
+ {
+ title: "PID/TID Picker",
+ lines: []string{
+ "enter select r refresh esc back",
+ },
+ },
+ }
+}
+
+func renderGlobalHelpOverlay(width, height int, sections []helpSection) string {
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ boxWidth := width - 4
+ if boxWidth > 100 {
+ boxWidth = 100
+ }
+ if boxWidth < 74 {
+ boxWidth = 74
+ }
+ contentWidth := boxWidth - 4
+ if contentWidth < 20 {
+ contentWidth = boxWidth
+ }
+
+ lines := make([]string, 0, 24)
+ lines = append(lines, "Help")
+ for _, section := range sections {
+ lines = append(lines, "")
+ lines = append(lines, section.title)
+ for _, line := range section.lines {
+ lines = append(lines, " "+truncateHelpLine(line, contentWidth-2))
+ }
+ }
+ lines = append(lines, "", "Esc close")
+
+ maxLines := height - 4
+ if maxLines < 6 {
+ maxLines = 6
+ }
+ if len(lines) > maxLines {
+ lines = lines[:maxLines-1]
+ lines = append(lines, truncateHelpLine("... (resize for full help)", contentWidth))
+ }
+
+ box := PanelStyle.Copy().Width(boxWidth).Render(strings.Join(lines, "\n"))
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
+}
+
+func truncateHelpLine(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if lipgloss.Width(s) <= width {
+ return s
+ }
+ if width == 1 {
+ return "…"
+ }
+ r := []rune(s)
+ if len(r) >= width {
+ return string(r[:width-1]) + "…"
+ }
+ return s
+}
+
func placeToViewport(width, height int, content string) string {
if width <= 0 || height <= 0 {
return content
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 946d0e3..87ad340 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -730,6 +730,49 @@ func TestViewShowsDashboardWithoutHelpOverlay(t *testing.T) {
}
}
+func TestHelpOverlayOpensWithUppercaseHAndClosesWithEsc(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 100
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})})
+ m = next.(Model)
+ if !m.helpOverlayVisible {
+ t.Fatalf("expected help overlay to become visible after H")
+ }
+ view := m.View().Content
+ if !strings.Contains(view, "Help") || !strings.Contains(view, "Global") || !strings.Contains(view, "Esc close") {
+ t.Fatalf("expected global help overlay content, got %q", view)
+ }
+
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = next.(Model)
+ if m.helpOverlayVisible {
+ t.Fatalf("expected esc to close help overlay")
+ }
+ if !strings.Contains(m.View().Content, "press H for help") {
+ t.Fatalf("expected dashboard help hint after closing overlay")
+ }
+}
+
+func TestHelpOverlayCanOpenFromPIDPicker(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenPIDPicker
+ m.width = 100
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})})
+ m = next.(Model)
+ if !m.helpOverlayVisible {
+ t.Fatalf("expected help overlay to open on pid picker screen")
+ }
+ if !strings.Contains(m.View().Content, "PID/TID Picker") {
+ t.Fatalf("expected picker shortcuts in help overlay")
+ }
+}
+
func TestQuestionMarkDoesNotBlockUnderlyingActions(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard
@@ -931,3 +974,26 @@ func TestRenderHelpOverlayUsesWideViewport(t *testing.T) {
t.Fatalf("expected wide help overlay to exceed previous 110-col cap, got %d", maxWidth)
}
}
+
+func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ out := renderGlobalHelpOverlay(80, 24, m.helpSections())
+
+ lines := strings.Split(out, "\n")
+ if len(lines) > 24 {
+ t.Fatalf("expected help overlay to fit within 24 lines, got %d", len(lines))
+ }
+
+ maxWidth := 0
+ for _, line := range lines {
+ if w := lipgloss.Width(line); w > maxWidth {
+ maxWidth = w
+ }
+ }
+ if maxWidth > 80 {
+ t.Fatalf("expected help overlay width <= 80, got %d", maxWidth)
+ }
+ if !strings.Contains(out, "Flame Tab") || !strings.Contains(out, "Stream Tab") {
+ t.Fatalf("expected overlay to include tab-specific help sections")
+ }
+}