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:
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
|
ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
|
||||||
Budget BudgetConfig `toml:"budget"`
|
Budget BudgetConfig `toml:"budget"`
|
||||||
Appearance AppearanceConfig `toml:"appearance"`
|
Appearance AppearanceConfig `toml:"appearance"`
|
||||||
|
TUI TUIConfig `toml:"tui"`
|
||||||
Pricing PricingOverrides `toml:"pricing"`
|
Pricing PricingOverrides `toml:"pricing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +49,12 @@ type AppearanceConfig struct {
|
|||||||
Theme string `toml:"theme"`
|
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.
|
// PricingOverrides allows user-defined pricing for specific models.
|
||||||
type PricingOverrides struct {
|
type PricingOverrides struct {
|
||||||
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"`
|
||||||
@@ -72,6 +79,10 @@ func DefaultConfig() Config {
|
|||||||
Appearance: AppearanceConfig{
|
Appearance: AppearanceConfig{
|
||||||
Theme: "flexoki-dark",
|
Theme: "flexoki-dark",
|
||||||
},
|
},
|
||||||
|
TUI: TUIConfig{
|
||||||
|
AutoRefresh: true,
|
||||||
|
RefreshIntervalSec: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ type SubDataMsg struct {
|
|||||||
Data *claudeai.SubscriptionData
|
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.
|
// App is the root Bubble Tea model.
|
||||||
type App struct {
|
type App struct {
|
||||||
// Data
|
// Data
|
||||||
@@ -49,6 +55,12 @@ type App struct {
|
|||||||
loaded bool
|
loaded bool
|
||||||
loadTime time.Duration
|
loadTime time.Duration
|
||||||
|
|
||||||
|
// Auto-refresh state
|
||||||
|
autoRefresh bool
|
||||||
|
refreshInterval time.Duration
|
||||||
|
lastRefresh time.Time
|
||||||
|
refreshing bool
|
||||||
|
|
||||||
// Subscription data from claude.ai
|
// Subscription data from claude.ai
|
||||||
subData *claudeai.SubscriptionData
|
subData *claudeai.SubscriptionData
|
||||||
subFetching bool
|
subFetching bool
|
||||||
@@ -62,6 +74,10 @@ type App struct {
|
|||||||
models []model.ModelStats
|
models []model.ModelStats
|
||||||
projects []model.ProjectStats
|
projects []model.ProjectStats
|
||||||
|
|
||||||
|
// Live activity charts (today + last hour)
|
||||||
|
todayHourly []model.HourlyStats
|
||||||
|
lastHour []model.MinuteStats
|
||||||
|
|
||||||
// Subagent grouping: parent session ID -> subagent sessions
|
// Subagent grouping: parent session ID -> subagent sessions
|
||||||
subagentMap map[string][]model.SessionStats
|
subagentMap map[string][]model.SessionStats
|
||||||
|
|
||||||
@@ -110,6 +126,13 @@ func NewApp(claudeDir string, days int, project, modelFilter string, includeSuba
|
|||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F"))
|
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{
|
return App{
|
||||||
claudeDir: claudeDir,
|
claudeDir: claudeDir,
|
||||||
days: days,
|
days: days,
|
||||||
@@ -117,6 +140,8 @@ func NewApp(claudeDir string, days int, project, modelFilter string, includeSuba
|
|||||||
project: project,
|
project: project,
|
||||||
modelFilter: modelFilter,
|
modelFilter: modelFilter,
|
||||||
includeSubagents: includeSubagents,
|
includeSubagents: includeSubagents,
|
||||||
|
autoRefresh: cfg.TUI.AutoRefresh,
|
||||||
|
refreshInterval: refreshInterval,
|
||||||
spinner: sp,
|
spinner: sp,
|
||||||
loadSub: make(chan tea.Msg, 1),
|
loadSub: make(chan tea.Msg, 1),
|
||||||
}
|
}
|
||||||
@@ -157,6 +182,10 @@ func (a *App) recompute() {
|
|||||||
a.models = pipeline.AggregateModels(filtered, since, now)
|
a.models = pipeline.AggregateModels(filtered, since, now)
|
||||||
a.projects = pipeline.AggregateProjects(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)
|
// Previous period for comparison (same duration, immediately before)
|
||||||
prevSince := since.AddDate(0, 0, -a.days)
|
prevSince := since.AddDate(0, 0, -a.days)
|
||||||
a.prevStats = pipeline.Aggregate(filtered, prevSince, since)
|
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
|
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
|
// Tab navigation
|
||||||
switch key {
|
switch key {
|
||||||
case "o":
|
case "o":
|
||||||
@@ -358,6 +403,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.sessions = msg.Sessions
|
a.sessions = msg.Sessions
|
||||||
a.loaded = true
|
a.loaded = true
|
||||||
a.loadTime = msg.LoadTime
|
a.loadTime = msg.LoadTime
|
||||||
|
a.lastRefresh = time.Now()
|
||||||
a.recompute()
|
a.recompute()
|
||||||
|
|
||||||
// Activate first-run setup after data loads
|
// 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...)
|
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.)
|
// 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"},
|
{"^d / ^u", "Scroll detail half-page"},
|
||||||
{"Enter / f", "Expand session full-screen"},
|
{"Enter / f", "Expand session full-screen"},
|
||||||
{"Esc", "Back to split view"},
|
{"Esc", "Back to split view"},
|
||||||
|
{"r / R", "Refresh now / Toggle auto-refresh"},
|
||||||
{"?", "Toggle this help"},
|
{"?", "Toggle this help"},
|
||||||
{"q", "Quit (or back from full-screen)"},
|
{"q", "Quit (or back from full-screen)"},
|
||||||
}
|
}
|
||||||
@@ -607,7 +672,7 @@ func (a App) viewMain() string {
|
|||||||
|
|
||||||
// 2. Render status bar
|
// 2. Render status bar
|
||||||
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
|
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
|
// 3. Calculate content zone height
|
||||||
headerH := lipgloss.Height(header)
|
headerH := lipgloss.Height(header)
|
||||||
@@ -794,6 +859,35 @@ func storeOpen() (*store.Cache, error) {
|
|||||||
return store.Open(pipeline.CachePath())
|
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.
|
// chartDateLabels builds compact X-axis labels for a chronological date series.
|
||||||
// First label: month abbreviation (e.g. "Jan"). Month boundaries: "Feb 1".
|
// First label: month abbreviation (e.g. "Jan"). Month boundaries: "Feb 1".
|
||||||
// Everything else (including last): just the day number.
|
// Everything else (including last): just the day number.
|
||||||
|
|||||||
@@ -12,21 +12,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RenderStatusBar renders the bottom status bar with optional rate limit indicators.
|
// 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
|
t := theme.Active
|
||||||
|
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted).
|
Foreground(t.TextMuted).
|
||||||
Width(width)
|
Width(width)
|
||||||
|
|
||||||
left := " [?]help [q]uit"
|
left := " [?]help [r]efresh [q]uit"
|
||||||
|
|
||||||
// Build rate limit indicators for the middle section
|
// Build rate limit indicators for the middle section
|
||||||
ratePart := renderStatusRateLimits(subData)
|
ratePart := renderStatusRateLimits(subData)
|
||||||
|
|
||||||
right := ""
|
// Build right side with refresh status
|
||||||
if dataAge != "" {
|
var right string
|
||||||
right = fmt.Sprintf("Data: %s ", dataAge)
|
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
|
// Layout: left + ratePart + right, with padding distributed
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cburn/internal/cli"
|
"cburn/internal/cli"
|
||||||
"cburn/internal/config"
|
"cburn/internal/config"
|
||||||
@@ -21,6 +22,8 @@ const (
|
|||||||
settingsFieldTheme
|
settingsFieldTheme
|
||||||
settingsFieldDays
|
settingsFieldDays
|
||||||
settingsFieldBudget
|
settingsFieldBudget
|
||||||
|
settingsFieldAutoRefresh
|
||||||
|
settingsFieldRefreshInterval
|
||||||
settingsFieldCount // sentinel
|
settingsFieldCount // sentinel
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +83,19 @@ func (a App) settingsStartEdit() (tea.Model, tea.Cmd) {
|
|||||||
ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD))
|
ti.SetValue(fmt.Sprintf("%.0f", *cfg.Budget.MonthlyUSD))
|
||||||
}
|
}
|
||||||
ti.EchoMode = textinput.EchoNormal
|
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()
|
ti.Focus()
|
||||||
@@ -144,6 +160,15 @@ func (a *App) settingsSave() {
|
|||||||
cfg.Budget.MonthlyUSD = &b
|
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)
|
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{
|
fields := []field{
|
||||||
{"Admin API Key", apiKeyDisplay},
|
{"Admin API Key", apiKeyDisplay},
|
||||||
{"Session Key", sessionKeyDisplay},
|
{"Session Key", sessionKeyDisplay},
|
||||||
@@ -195,6 +227,8 @@ func (a App) renderSettingsTab(cw int) string {
|
|||||||
}
|
}
|
||||||
return "(not set)"
|
return "(not set)"
|
||||||
}()},
|
}()},
|
||||||
|
{"Auto Refresh", strconv.FormatBool(a.autoRefresh)},
|
||||||
|
{"Refresh Interval", fmt.Sprintf("%ds", refreshIntervalSec)},
|
||||||
}
|
}
|
||||||
|
|
||||||
var formBody strings.Builder
|
var formBody strings.Builder
|
||||||
|
|||||||
Reference in New Issue
Block a user