diff options
| -rw-r--r-- | internal/tui/common/keys.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 56 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 45 | ||||
| -rw-r--r-- | internal/tui/help.go | 3 |
4 files changed, 93 insertions, 13 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 315edef..d1f26cf 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -78,7 +78,7 @@ func DefaultKeyMap() KeyMap { Enter: keyBinding("select", "enter"), Esc: keyBinding("back", "esc"), Refresh: keyBinding("reset baseline", "r"), - AutoReset: keyBinding("auto-reset", "I"), + AutoReset: keyBinding("cycle auto-reset", "I"), } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 478e735..79e3b38 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -78,6 +78,12 @@ type Model struct { // autoResetGen is incremented every time autoResetEvery changes so // in-flight ticks scheduled under the previous cadence can be ignored. autoResetGen uint64 + // autoResetArmedAt is the wall-clock instant the current tick was + // scheduled. The next reset is expected at autoResetArmedAt + + // autoResetEvery; autoResetStatus uses this to render the live + // countdown ("12s/30s") in the chrome. Updated on every arm + // (SetAutoResetInterval, focus regain, tick re-arm). + autoResetArmedAt time.Time keys common.KeyMap globalFilter globalfilter.Filter filterStack []string @@ -939,6 +945,7 @@ func (m Model) handleAutoResetTick(msg autoResetTickMsg) (tea.Model, tea.Cmd) { if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 || !m.focused { return m, nil } + m.autoResetArmedAt = time.Now() resetCmd := m.resetBaselineCmd() nextTick := m.autoResetTickCmd() switch { @@ -963,6 +970,11 @@ func (m *Model) SetAutoResetInterval(d time.Duration) tea.Cmd { } m.autoResetEvery = d m.autoResetGen++ + if d > 0 { + m.autoResetArmedAt = time.Now() + } else { + m.autoResetArmedAt = time.Time{} + } return m.autoResetTickCmd() } @@ -1069,6 +1081,9 @@ func (m *Model) SetFocused(focused bool) tea.Cmd { if !focused { return nil } + if m.autoResetEvery > 0 { + m.autoResetArmedAt = time.Now() + } return m.autoResetTickCmd() } @@ -1123,11 +1138,15 @@ func (m Model) filterSummary() string { } // autoResetStatus is the human-readable label for the current -// auto-reset cadence shown in the dashboard chrome. "off" when the -// timer is disabled, otherwise the configured interval (e.g. "30s"). -// When the timer is enabled but the TUI has lost focus, a "(paused)" -// suffix is appended so the user knows the timer will not fire until -// focus returns. Disabled timers stay "off" regardless of focus. +// auto-reset cadence shown in the dashboard chrome. +// - "off" when the timer is disabled. +// - "<remaining>/<total>" while running and focused, e.g. "12s/30s". +// The countdown updates on every render (driven by the periodic +// refresh tick) so users can see when the next reset will fire. +// - "<total> (paused)" when enabled but the TUI has lost focus, so +// the user knows the timer will not fire until focus returns. +// +// Disabled timers stay "off" regardless of focus. func (m Model) autoResetStatus() string { if m.autoResetEvery <= 0 { return "auto-reset: off" @@ -1135,7 +1154,32 @@ func (m Model) autoResetStatus() string { if !m.focused { return "auto-reset: " + m.autoResetEvery.String() + " (paused)" } - return "auto-reset: " + m.autoResetEvery.String() + return "auto-reset: " + formatAutoResetRemaining(m.autoResetArmedAt, m.autoResetEvery) + "/" + m.autoResetEvery.String() +} + +// formatAutoResetRemaining renders the time left until the next +// scheduled tick as a compact whole-second duration string ("12s", +// "1m23s"). When armedAt is the zero value (e.g. just after enabling) +// or the deadline has already elapsed, it returns "0s" so the chrome +// always shows a value rather than an empty placeholder. +func formatAutoResetRemaining(armedAt time.Time, every time.Duration) string { + if armedAt.IsZero() || every <= 0 { + return "0s" + } + remaining := time.Until(armedAt.Add(every)) + if remaining < 0 { + remaining = 0 + } + seconds := int(remaining.Round(time.Second).Seconds()) + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) + } + minutes := seconds / 60 + secs := seconds % 60 + if secs == 0 { + return fmt.Sprintf("%dm", minutes) + } + return fmt.Sprintf("%dm%ds", minutes, secs) } func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model, flameModel *flamegraphtui.Model) string { diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 2fb59f5..a6c4455 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -1699,14 +1699,20 @@ func TestSetFocusedReturnsNilWhenTimerDisabled(t *testing.T) { } // TestAutoResetStatusAddsPausedSuffixWhenBlurred locks in the chrome -// label contract: enabled+focused -> "auto-reset: 30s", -// enabled+blurred -> "auto-reset: 30s (paused)", disabled stays "off" -// regardless of focus. +// label contract: +// - enabled+focused -> "auto-reset: <remaining>/30s" (countdown). +// - enabled+blurred -> "auto-reset: 30s (paused)". +// - disabled stays "auto-reset: off" regardless of focus. +// +// The countdown value can fluctuate by a second between SetAutoResetInterval +// and the status read, so we accept "30s/30s" or "29s/30s" rather than +// pinning an exact remaining string. func TestAutoResetStatusAddsPausedSuffixWhenBlurred(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.SetAutoResetInterval(30 * time.Second) - if got, want := m.autoResetStatus(), "auto-reset: 30s"; got != want { - t.Fatalf("focused enabled status = %q, want %q", got, want) + got := m.autoResetStatus() + if got != "auto-reset: 30s/30s" && got != "auto-reset: 29s/30s" { + t.Fatalf("focused enabled status = %q, want auto-reset: 30s/30s or 29s/30s", got) } m.SetFocused(false) @@ -1724,3 +1730,32 @@ func TestAutoResetStatusAddsPausedSuffixWhenBlurred(t *testing.T) { t.Fatalf("focused disabled status = %q, want %q", got, want) } } + +// TestFormatAutoResetRemainingFormats exercises the duration formatter +// used by the chrome countdown: sub-minute durations stay in seconds, +// whole minutes drop the trailing "0s", and mixed values use "MmSs". +// Zero/negative remaining (deadline elapsed before the next tick) and +// the zero armedAt sentinel both render "0s" so the status line never +// shows an empty placeholder. +func TestFormatAutoResetRemainingFormats(t *testing.T) { + now := time.Now() + cases := []struct { + name string + armedAt time.Time + every time.Duration + want string + }{ + {"sub-minute", now, 12 * time.Second, "12s"}, + {"whole minute", now, 2 * time.Minute, "2m"}, + {"mixed", now, time.Minute + 23*time.Second, "1m23s"}, + {"zero armedAt", time.Time{}, 30 * time.Second, "0s"}, + {"elapsed deadline", now.Add(-5 * time.Second), 1 * time.Second, "0s"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := formatAutoResetRemaining(tc.armedAt, tc.every); got != tc.want { + t.Fatalf("formatAutoResetRemaining(%v, %v) = %q, want %q", tc.armedAt, tc.every, got, tc.want) + } + }) + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 21a78ac..ba4ed02 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -63,7 +63,8 @@ func (m Model) helpSections() []helpSection { { title: "Dashboard Tabs", lines: []string{ - "tab/shift+tab tabs 1..7 jump tab r reset baseline I auto-reset R parquet rec", + "tab/shift+tab tabs 1..7 jump tab r reset baseline R parquet rec", + "I cycle auto-reset (off → 10s → 30s → 1m → 2m → 5m); status shows remaining/total", "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom", "sys/files/proc tables: s sort S reverse sort", "sys/proc: v bubbles b metric events/bytes", |
