summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 09:17:20 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 09:17:20 +0200
commit1cbb2430d027c9d8850bf3a2b79a05338efea3ea (patch)
tree5572c87d76b18ec8ea2956fb9e9a20dc7e69b2ac
parentceb5c392d363f9f6afccd310b0a7a7efb14bb4e3 (diff)
tui: add help overlay behavior and docs update
-rw-r--r--AGENTS.md3
-rw-r--r--internal/tui/tui.go46
-rw-r--r--internal/tui/tui_test.go86
3 files changed, 135 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index db92aa8..ead2d34 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,9 @@ Generator source code:
- **Entry point**: `cmd/ior/main.go` - Linux-only BPF-based I/O syscall tracer
- **Core packages**: `/internal/event/` (BPF event handling), `/internal/flamegraph/` (FlameGraph generation), `/internal/c/` (BPF programs)
- **Output**: Compressed zstd files, collapsed stack format compatible with Inferno FlameGraphs
+- **TUI package**: `/internal/tui/` contains top-level Bubble Tea orchestration (`tui.go`), shared key map (`keys.go`), and styles (`styles.go`).
+- **Dashboard tabs**: `/internal/tui/dashboard/` contains tab renderers (overview/syscalls/files/processes/latency/gaps) and tab framework model.
+- **Export modal**: `/internal/tui/export/model.go` implements the centered modal used for CSV export flow in TUI mode.
## Code Style
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 90b0162..4d7a7dc 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -10,12 +10,14 @@ import (
tuiexport "ior/internal/tui/export"
"ior/internal/tui/pidpicker"
"os"
+ "strings"
"sync"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
)
// Screen identifies the currently active TUI screen.
@@ -85,6 +87,7 @@ type Model struct {
attaching bool
spin spinner.Model
lastErr error
+ showHelp bool
startTrace TraceStarter
traceStop context.CancelFunc
@@ -139,6 +142,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stopTrace()
return m, tea.Quit
}
+ if !m.exporter.Visible() && key.Matches(msg, m.keys.Help) {
+ m.showHelp = !m.showHelp
+ return m, nil
+ }
+ if !m.exporter.Visible() && m.showHelp && key.Matches(msg, m.keys.Esc) {
+ m.showHelp = false
+ return m, nil
+ }
+ if !m.exporter.Visible() && m.showHelp {
+ return m, nil
+ }
if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() {
m.exporter = m.exporter.Open()
return m, nil
@@ -264,12 +278,18 @@ func (m Model) View() string {
if m.exporter.Visible() {
return m.exporter.View(m.width, m.height) + "\n" + base
}
+ if m.showHelp {
+ return renderHelpOverlay(m.width, m.height, [][]key.Binding{m.keys.PickerShortHelp()}) + "\n" + base
+ }
return base
case ScreenDashboard:
base := m.dashboard.View()
if m.exporter.Visible() {
return m.exporter.View(m.width, m.height) + "\n" + base
}
+ if m.showHelp {
+ return renderHelpOverlay(m.width, m.height, m.keys.DashboardFullHelp()) + "\n" + base
+ }
return base
default:
return ""
@@ -407,3 +427,29 @@ func snapValueF(snap *statsengine.Snapshot, get func(*statsengine.Snapshot) floa
func exportFlamegraph() (string, error) {
return "", errors.New("flamegraph export is not yet available in TUI mode")
}
+
+func renderHelpOverlay(width, height int, groups [][]key.Binding) string {
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ lines := []string{"Help"}
+ for _, group := range groups {
+ parts := make([]string, 0, len(group))
+ for _, binding := range group {
+ h := binding.Help()
+ parts = append(parts, fmt.Sprintf("%s %s", h.Key, h.Desc))
+ }
+ lines = append(lines, strings.Join(parts, " • "))
+ }
+ lines = append(lines, "", "Esc/? close")
+
+ box := PanelStyle.Copy().
+ Width(72).
+ Render(strings.Join(lines, "\n"))
+
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index d66ec84..98b249f 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -229,3 +229,89 @@ func TestRunExportCmdCSVWritesFile(t *testing.T) {
t.Fatalf("expected CSV file to exist: %v", err)
}
}
+
+func TestHelpKeyTogglesOverlay(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ if m.showHelp {
+ t.Fatalf("expected help hidden by default")
+ }
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ updated := next.(Model)
+ if !updated.showHelp {
+ t.Fatalf("expected help to be shown after ?")
+ }
+
+ next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ updated = next.(Model)
+ if updated.showHelp {
+ t.Fatalf("expected help to toggle off after second ?")
+ }
+}
+
+func TestViewShowsHelpOverlay(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.showHelp = true
+ m.width = 100
+ m.height = 30
+
+ out := m.View()
+ if !strings.Contains(out, "Help") {
+ t.Fatalf("expected help title in overlay")
+ }
+ if !strings.Contains(out, "tab next tab") {
+ t.Fatalf("expected keybinding text in overlay")
+ }
+}
+
+func TestHelpOverlayBlocksUnderlyingActions(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.showHelp = true
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
+ updated := next.(Model)
+ if updated.exporter.Visible() {
+ t.Fatalf("expected export modal to stay closed while help overlay is active")
+ }
+}
+
+func TestHelpOverlayUsesPickerBindingsOnPickerScreen(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenPIDPicker
+ m.showHelp = true
+ m.width = 100
+ m.height = 30
+
+ out := m.View()
+ if !strings.Contains(out, "enter select") || !strings.Contains(out, "r refresh") {
+ t.Fatalf("expected picker shortcuts in help overlay")
+ }
+ if strings.Contains(out, "e export") {
+ t.Fatalf("did not expect dashboard-only shortcut in picker help overlay")
+ }
+}
+
+func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
+ updated := next.(Model)
+ if !updated.exporter.Visible() {
+ t.Fatalf("expected export modal to open")
+ }
+
+ next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ updated = next.(Model)
+ if updated.showHelp {
+ t.Fatalf("did not expect hidden help flag while export modal is open")
+ }
+
+ next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ updated = next.(Model)
+ if updated.exporter.Visible() {
+ t.Fatalf("expected esc to close export modal")
+ }
+}