From 93e343f657786ce714ad1515278e964a949dc8a9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Mon, 23 Feb 2026 09:36:38 -0500 Subject: [PATCH] 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 --- internal/config/config.go | 11 ++++ internal/tui/app.go | 96 +++++++++++++++++++++++++++- internal/tui/components/statusbar.go | 18 ++++-- internal/tui/tab_settings.go | 34 ++++++++++ 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 4102db9..386fb21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { ClaudeAI ClaudeAIConfig `toml:"claude_ai"` Budget BudgetConfig `toml:"budget"` Appearance AppearanceConfig `toml:"appearance"` + TUI TUIConfig `toml:"tui"` Pricing PricingOverrides `toml:"pricing"` } @@ -48,6 +49,12 @@ type AppearanceConfig struct { Theme string `toml:"theme"` } +// TUIConfig holds TUI-specific settings. +type TUIConfig struct { + AutoRefresh bool `toml:"auto_refresh"` + RefreshIntervalSec int `toml:"refresh_interval_sec"` +} + // PricingOverrides allows user-defined pricing for specific models. type PricingOverrides struct { Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"` @@ -72,6 +79,10 @@ func DefaultConfig() Config { Appearance: AppearanceConfig{ Theme: "flexoki-dark", }, + TUI: TUIConfig{ + AutoRefresh: true, + RefreshIntervalSec: 30, + }, } } diff --git a/internal/tui/app.go b/internal/tui/app.go index f78dcc8..b5f73d9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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. diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go index 2b32b86..88967c1 100644 --- a/internal/tui/components/statusbar.go +++ b/internal/tui/components/statusbar.go @@ -12,21 +12,29 @@ import ( ) // RenderStatusBar renders the bottom status bar with optional rate limit indicators. -func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData) string { +func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string { t := theme.Active style := lipgloss.NewStyle(). Foreground(t.TextMuted). Width(width) - left := " [?]help [q]uit" + left := " [?]help [r]efresh [q]uit" // Build rate limit indicators for the middle section ratePart := renderStatusRateLimits(subData) - right := "" - if dataAge != "" { - right = fmt.Sprintf("Data: %s ", dataAge) + // Build right side with refresh status + var right string + if refreshing { + refreshStyle := lipgloss.NewStyle().Foreground(t.Accent) + right = refreshStyle.Render("↻ refreshing ") + } else if dataAge != "" { + autoStr := "" + if autoRefresh { + autoStr = "↻ " + } + right = fmt.Sprintf("%sData: %s ", autoStr, dataAge) } // Layout: left + ratePart + right, with padding distributed diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go index 09a6e21..204e339 100644 --- a/internal/tui/tab_settings.go +++ b/internal/tui/tab_settings.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" "strings" + "time" "cburn/internal/cli" "cburn/internal/config" @@ -21,6 +22,8 @@ const ( settingsFieldTheme settingsFieldDays settingsFieldBudget + settingsFieldAutoRefresh + settingsFieldRefreshInterval settingsFieldCount // sentinel ) @@ -80,6 +83,19 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) { ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD)) } ti.EchoMode = textinput.EchoNormal + case settingsFieldAutoRefresh: + ti.Placeholder = "true or false" + ti.SetValue(strconv.FormatBool(a.autoRefresh)) + ti.EchoMode = textinput.EchoNormal + case settingsFieldRefreshInterval: + ti.Placeholder = "30 (seconds, minimum 10)" + // Use effective value from App state to match display + intervalSec := int(a.refreshInterval.Seconds()) + if intervalSec < 10 { + intervalSec = 30 + } + ti.SetValue(strconv.Itoa(intervalSec)) + ti.EchoMode = textinput.EchoNormal } ti.Focus() @@ -144,6 +160,15 @@ func (a *App) settingsSave() { cfg.Budget.MonthlyUSD = &b } } + case settingsFieldAutoRefresh: + cfg.TUI.AutoRefresh = val == "true" || val == "1" || val == "yes" + a.autoRefresh = cfg.TUI.AutoRefresh + case settingsFieldRefreshInterval: + var interval int + if _, err := fmt.Sscanf(val, "%d", &interval); err == nil && interval >= 10 { + cfg.TUI.RefreshIntervalSec = interval + a.refreshInterval = time.Duration(interval) * time.Second + } } a.settings.saveErr = config.Save(cfg) @@ -184,6 +209,13 @@ func (a App) renderSettingsTab(cw int) string { } } + // Use live App state for TUI-specific settings (auto-refresh, interval) + // to ensure display matches actual behavior after R toggle + refreshIntervalSec := int(a.refreshInterval.Seconds()) + if refreshIntervalSec < 10 { + refreshIntervalSec = 30 // match the effective default + } + fields := []field{ {"Admin API Key", apiKeyDisplay}, {"Session Key", sessionKeyDisplay}, @@ -195,6 +227,8 @@ func (a App) renderSettingsTab(cw int) string { } return "(not set)" }()}, + {"Auto Refresh", strconv.FormatBool(a.autoRefresh)}, + {"Refresh Interval", fmt.Sprintf("%ds", refreshIntervalSec)}, } var formBody strings.Builder