summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/common/keys.go2
-rw-r--r--internal/tui/dashboard/model.go56
-rw-r--r--internal/tui/dashboard/model_test.go45
-rw-r--r--internal/tui/help.go3
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",