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:
teernisse
2026-02-23 09:36:38 -05:00
parent 5b9edc7702
commit 93e343f657
4 changed files with 153 additions and 6 deletions

View File

@@ -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,
},
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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