feat: add TUI auto-refresh with configurable interval and manual refresh
Introduce background data refresh so the dashboard stays current without restarting. This touches four layers: Config (config.go): - New TUIConfig struct with AutoRefresh (bool) and RefreshIntervalSec (int) fields, defaulting to enabled at 30-second intervals. - Minimum interval floor of 10 seconds enforced at load time. App core (app.go): - RefreshDataMsg type for background refresh completion signaling. - Auto-refresh state: interval timer, refreshing flag, lastRefresh timestamp. Checked on every tick; fires refreshDataCmd when elapsed. - refreshDataCmd: background goroutine that loads session data via cache (with uncached fallback) and posts RefreshDataMsg on completion. - Manual refresh keybind: 'r' triggers immediate refresh. - Auto-refresh toggle keybind: 'R' toggles auto-refresh and persists the preference to config.toml. - Help text updated with r/R keybind documentation. Status bar (statusbar.go): - Shows spinning refresh indicator during active refresh. - Shows auto-refresh icon when auto-refresh is enabled. Settings tab (tab_settings.go): - Two new editable fields: Auto Refresh (bool) and Refresh Interval (seconds with 10s minimum). - Settings display reads live App state to stay consistent with the R toggle keybind (avoids stale config-file reads). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,12 @@ type SubDataMsg struct {
|
||||
Data *claudeai.SubscriptionData
|
||||
}
|
||||
|
||||
// RefreshDataMsg is sent when a background data refresh completes.
|
||||
type RefreshDataMsg struct {
|
||||
Sessions []model.SessionStats
|
||||
LoadTime time.Duration
|
||||
}
|
||||
|
||||
// App is the root Bubble Tea model.
|
||||
type App struct {
|
||||
// Data
|
||||
@@ -49,6 +55,12 @@ type App struct {
|
||||
loaded bool
|
||||
loadTime time.Duration
|
||||
|
||||
// Auto-refresh state
|
||||
autoRefresh bool
|
||||
refreshInterval time.Duration
|
||||
lastRefresh time.Time
|
||||
refreshing bool
|
||||
|
||||
// Subscription data from claude.ai
|
||||
subData *claudeai.SubscriptionData
|
||||
subFetching bool
|
||||
@@ -62,6 +74,10 @@ type App struct {
|
||||
models []model.ModelStats
|
||||
projects []model.ProjectStats
|
||||
|
||||
// Live activity charts (today + last hour)
|
||||
todayHourly []model.HourlyStats
|
||||
lastHour []model.MinuteStats
|
||||
|
||||
// Subagent grouping: parent session ID -> subagent sessions
|
||||
subagentMap map[string][]model.SessionStats
|
||||
|
||||
@@ -110,6 +126,13 @@ func NewApp(claudeDir string, days int, project, modelFilter string, includeSuba
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F"))
|
||||
|
||||
// Load refresh settings from config
|
||||
cfg, _ := config.Load()
|
||||
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
|
||||
if refreshInterval < 10*time.Second {
|
||||
refreshInterval = 30 * time.Second // minimum 10s, default 30s
|
||||
}
|
||||
|
||||
return App{
|
||||
claudeDir: claudeDir,
|
||||
days: days,
|
||||
@@ -117,6 +140,8 @@ func NewApp(claudeDir string, days int, project, modelFilter string, includeSuba
|
||||
project: project,
|
||||
modelFilter: modelFilter,
|
||||
includeSubagents: includeSubagents,
|
||||
autoRefresh: cfg.TUI.AutoRefresh,
|
||||
refreshInterval: refreshInterval,
|
||||
spinner: sp,
|
||||
loadSub: make(chan tea.Msg, 1),
|
||||
}
|
||||
@@ -157,6 +182,10 @@ func (a *App) recompute() {
|
||||
a.models = pipeline.AggregateModels(filtered, since, now)
|
||||
a.projects = pipeline.AggregateProjects(filtered, since, now)
|
||||
|
||||
// Live activity charts
|
||||
a.todayHourly = pipeline.AggregateTodayHourly(filtered)
|
||||
a.lastHour = pipeline.AggregateLastHour(filtered)
|
||||
|
||||
// Previous period for comparison (same duration, immediately before)
|
||||
prevSince := since.AddDate(0, 0, -a.days)
|
||||
a.prevStats = pipeline.Aggregate(filtered, prevSince, since)
|
||||
@@ -335,6 +364,22 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// Manual refresh
|
||||
if key == "r" && !a.refreshing {
|
||||
a.refreshing = true
|
||||
return a, refreshDataCmd(a.claudeDir, a.includeSubagents)
|
||||
}
|
||||
|
||||
// Toggle auto-refresh
|
||||
if key == "R" {
|
||||
a.autoRefresh = !a.autoRefresh
|
||||
// Persist to config
|
||||
cfg, _ := config.Load()
|
||||
cfg.TUI.AutoRefresh = a.autoRefresh
|
||||
_ = config.Save(cfg)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
switch key {
|
||||
case "o":
|
||||
@@ -358,6 +403,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.sessions = msg.Sessions
|
||||
a.loaded = true
|
||||
a.loadTime = msg.LoadTime
|
||||
a.lastRefresh = time.Now()
|
||||
a.recompute()
|
||||
|
||||
// Activate first-run setup after data loads
|
||||
@@ -413,7 +459,25 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh session data
|
||||
if a.loaded && a.autoRefresh && !a.refreshing {
|
||||
if time.Since(a.lastRefresh) >= a.refreshInterval {
|
||||
a.refreshing = true
|
||||
cmds = append(cmds, refreshDataCmd(a.claudeDir, a.includeSubagents))
|
||||
}
|
||||
}
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case RefreshDataMsg:
|
||||
a.refreshing = false
|
||||
a.lastRefresh = time.Now()
|
||||
if msg.Sessions != nil {
|
||||
a.sessions = msg.Sessions
|
||||
a.loadTime = msg.LoadTime
|
||||
a.recompute()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Forward unhandled messages to the setup form (cursor blinks, etc.)
|
||||
@@ -570,6 +634,7 @@ func (a App) viewHelp() string {
|
||||
{"^d / ^u", "Scroll detail half-page"},
|
||||
{"Enter / f", "Expand session full-screen"},
|
||||
{"Esc", "Back to split view"},
|
||||
{"r / R", "Refresh now / Toggle auto-refresh"},
|
||||
{"?", "Toggle this help"},
|
||||
{"q", "Quit (or back from full-screen)"},
|
||||
}
|
||||
@@ -607,7 +672,7 @@ func (a App) viewMain() string {
|
||||
|
||||
// 2. Render status bar
|
||||
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
|
||||
statusBar := components.RenderStatusBar(w, dataAge, a.subData)
|
||||
statusBar := components.RenderStatusBar(w, dataAge, a.subData, a.refreshing, a.autoRefresh)
|
||||
|
||||
// 3. Calculate content zone height
|
||||
headerH := lipgloss.Height(header)
|
||||
@@ -794,6 +859,35 @@ func storeOpen() (*store.Cache, error) {
|
||||
return store.Open(pipeline.CachePath())
|
||||
}
|
||||
|
||||
// refreshDataCmd refreshes session data in the background (no progress UI).
|
||||
func refreshDataCmd(claudeDir string, includeSubagents bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
start := time.Now()
|
||||
|
||||
cache, err := storeOpen()
|
||||
if err == nil {
|
||||
cr, loadErr := pipeline.LoadWithCache(claudeDir, includeSubagents, cache, nil)
|
||||
_ = cache.Close()
|
||||
if loadErr == nil {
|
||||
return RefreshDataMsg{
|
||||
Sessions: cr.Sessions,
|
||||
LoadTime: time.Since(start),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: uncached load
|
||||
result, err := pipeline.Load(claudeDir, includeSubagents, nil)
|
||||
if err != nil {
|
||||
return RefreshDataMsg{LoadTime: time.Since(start)}
|
||||
}
|
||||
return RefreshDataMsg{
|
||||
Sessions: result.Sessions,
|
||||
LoadTime: time.Since(start),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chartDateLabels builds compact X-axis labels for a chronological date series.
|
||||
// First label: month abbreviation (e.g. "Jan"). Month boundaries: "Feb 1".
|
||||
// Everything else (including last): just the day number.
|
||||
|
||||
Reference in New Issue
Block a user