refactor(tui): improve config resilience and scroll navigation
app.go changes: - Add loadConfigOrDefault() helper that returns sensible defaults when config loading fails, ensuring TUI can always start even with corrupted config files - Extract scroll navigation constants (scrollOverhead, minHalfPageScroll, minContentHeight) for clarity and consistency - Apply accent border styling to loading card for visual polish - Replace inline config.Load() calls with loadConfigOrDefault() setup.go changes: - Use loadConfigOrDefault() for consistent error handling during setup wizard initialization and config persistence The loadConfigOrDefault pattern improves user experience by gracefully degrading rather than failing hard when config issues occur. Users can still access the TUI and reconfigure via the Settings tab. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,18 +118,38 @@ const (
|
|||||||
minTerminalWidth = 80
|
minTerminalWidth = 80
|
||||||
compactWidth = 120
|
compactWidth = 120
|
||||||
maxContentWidth = 180
|
maxContentWidth = 180
|
||||||
|
|
||||||
|
// Scroll navigation
|
||||||
|
scrollOverhead = 10 // approximate header + status bar height for half-page calc
|
||||||
|
minHalfPageScroll = 1 // minimum lines for half-page scroll
|
||||||
|
minContentHeight = 5 // minimum content area height
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// loadConfigOrDefault loads config, returning defaults on error.
|
||||||
|
// This ensures the TUI can always start even if config is corrupted.
|
||||||
|
func loadConfigOrDefault() config.Config {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
// Return zero-value config with sensible defaults applied
|
||||||
|
return config.Config{
|
||||||
|
TUI: config.TUIConfig{
|
||||||
|
RefreshIntervalSec: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// NewApp creates a new TUI app model.
|
// NewApp creates a new TUI app model.
|
||||||
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
|
func NewApp(claudeDir string, days int, project, modelFilter string, includeSubagents bool) App {
|
||||||
needSetup := !config.Exists()
|
needSetup := !config.Exists()
|
||||||
|
|
||||||
sp := spinner.New()
|
sp := spinner.New()
|
||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F"))
|
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#3AA99F")).Background(theme.Active.Surface)
|
||||||
|
|
||||||
// Load refresh settings from config
|
// Load refresh settings from config
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
|
refreshInterval := time.Duration(cfg.TUI.RefreshIntervalSec) * time.Second
|
||||||
if refreshInterval < 10*time.Second {
|
if refreshInterval < 10*time.Second {
|
||||||
refreshInterval = 30 * time.Second // minimum 10s, default 30s
|
refreshInterval = 30 * time.Second // minimum 10s, default 30s
|
||||||
@@ -159,7 +179,7 @@ func (a App) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start subscription data fetch if session key is configured
|
// Start subscription data fetch if session key is configured
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
||||||
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
||||||
}
|
}
|
||||||
@@ -387,16 +407,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case "ctrl+d":
|
case "ctrl+d":
|
||||||
halfPage := (a.height - 10) / 2
|
halfPage := (a.height - scrollOverhead) / 2
|
||||||
if halfPage < 1 {
|
if halfPage < minHalfPageScroll {
|
||||||
halfPage = 1
|
halfPage = minHalfPageScroll
|
||||||
}
|
}
|
||||||
a.sessState.detailScroll += halfPage
|
a.sessState.detailScroll += halfPage
|
||||||
return a, nil
|
return a, nil
|
||||||
case "ctrl+u":
|
case "ctrl+u":
|
||||||
halfPage := (a.height - 10) / 2
|
halfPage := (a.height - scrollOverhead) / 2
|
||||||
if halfPage < 1 {
|
if halfPage < minHalfPageScroll {
|
||||||
halfPage = 1
|
halfPage = minHalfPageScroll
|
||||||
}
|
}
|
||||||
a.sessState.detailScroll -= halfPage
|
a.sessState.detailScroll -= halfPage
|
||||||
if a.sessState.detailScroll < 0 {
|
if a.sessState.detailScroll < 0 {
|
||||||
@@ -438,8 +458,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Toggle auto-refresh
|
// Toggle auto-refresh
|
||||||
if key == "R" {
|
if key == "R" {
|
||||||
a.autoRefresh = !a.autoRefresh
|
a.autoRefresh = !a.autoRefresh
|
||||||
// Persist to config
|
// Persist to config (best-effort, ignore errors)
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
cfg.TUI.AutoRefresh = a.autoRefresh
|
cfg.TUI.AutoRefresh = a.autoRefresh
|
||||||
_ = config.Save(cfg)
|
_ = config.Save(cfg)
|
||||||
return a, nil
|
return a, nil
|
||||||
@@ -491,9 +511,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.subData = msg.Data
|
a.subData = msg.Data
|
||||||
a.subFetching = false
|
a.subFetching = false
|
||||||
|
|
||||||
// Cache org ID if we got one
|
// Cache org ID if we got one (best-effort, ignore errors)
|
||||||
if msg.Data != nil && msg.Data.Org.UUID != "" {
|
if msg.Data != nil && msg.Data.Org.UUID != "" {
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID {
|
if cfg.ClaudeAI.OrgID != msg.Data.Org.UUID {
|
||||||
cfg.ClaudeAI.OrgID = msg.Data.Org.UUID
|
cfg.ClaudeAI.OrgID = msg.Data.Org.UUID
|
||||||
_ = config.Save(cfg)
|
_ = config.Save(cfg)
|
||||||
@@ -517,7 +537,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Refresh subscription data every 5 minutes (1200 ticks at 250ms)
|
// Refresh subscription data every 5 minutes (1200 ticks at 250ms)
|
||||||
if a.loaded && !a.subFetching && a.subTicks >= 1200 {
|
if a.loaded && !a.subFetching && a.subTicks >= 1200 {
|
||||||
a.subTicks = 0
|
a.subTicks = 0
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
if sessionKey := config.GetSessionKey(cfg); sessionKey != "" {
|
||||||
a.subFetching = true
|
a.subFetching = true
|
||||||
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
cmds = append(cmds, fetchSubDataCmd(sessionKey))
|
||||||
@@ -635,86 +655,143 @@ func (a App) viewLoading() string {
|
|||||||
w := a.width
|
w := a.width
|
||||||
h := a.height
|
h := a.height
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
// Polished loading card with accent border
|
||||||
Foreground(t.Accent).
|
cardStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(t.BorderAccent).
|
||||||
|
Background(t.Surface).
|
||||||
|
Padding(2, 4)
|
||||||
|
|
||||||
|
// ASCII art logo effect
|
||||||
|
logoStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
mutedStyle := lipgloss.NewStyle().
|
subtitleStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted)
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
spinnerStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.Accent).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
countStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextPrimary).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("\n\n")
|
b.WriteString(logoStyle.Render("◈ cburn"))
|
||||||
b.WriteString(titleStyle.Render(" cburn"))
|
b.WriteString(subtitleStyle.Render(" · Claude Usage Metrics"))
|
||||||
b.WriteString(mutedStyle.Render(" - Claude Usage Metrics"))
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
if a.progressMax > 0 {
|
if a.progressMax > 0 {
|
||||||
barW := w - 20
|
barW := 40
|
||||||
|
if barW > w-30 {
|
||||||
|
barW = w - 30
|
||||||
|
}
|
||||||
if barW < 20 {
|
if barW < 20 {
|
||||||
barW = 20
|
barW = 20
|
||||||
}
|
}
|
||||||
if barW > 60 {
|
|
||||||
barW = 60
|
|
||||||
}
|
|
||||||
pct := float64(a.progress) / float64(a.progressMax)
|
pct := float64(a.progress) / float64(a.progressMax)
|
||||||
fmt.Fprintf(&b, " %s Parsing sessions\n", a.spinner.View())
|
b.WriteString(spinnerStyle.Render(a.spinner.View()))
|
||||||
fmt.Fprintf(&b, " %s %s/%s\n",
|
b.WriteString(subtitleStyle.Render(" Parsing sessions\n\n"))
|
||||||
components.ProgressBar(pct, barW),
|
b.WriteString(components.ProgressBar(pct, barW))
|
||||||
cli.FormatNumber(int64(a.progress)),
|
b.WriteString("\n")
|
||||||
cli.FormatNumber(int64(a.progressMax)))
|
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progress))))
|
||||||
|
b.WriteString(subtitleStyle.Render(" / "))
|
||||||
|
b.WriteString(countStyle.Render(cli.FormatNumber(int64(a.progressMax))))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&b, " %s Scanning sessions\n", a.spinner.View())
|
b.WriteString(spinnerStyle.Render(a.spinner.View()))
|
||||||
|
b.WriteString(subtitleStyle.Render(" Discovering sessions..."))
|
||||||
}
|
}
|
||||||
|
|
||||||
content := b.String()
|
card := cardStyle.Render(b.String())
|
||||||
return padHeight(truncateHeight(content, h), h)
|
|
||||||
|
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
|
||||||
|
lipgloss.WithWhitespaceBackground(t.Background))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a App) viewHelp() string {
|
func (a App) viewHelp() string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
h := a.height
|
h := a.height
|
||||||
|
w := a.width
|
||||||
|
|
||||||
|
// Polished help overlay with accent border
|
||||||
|
cardStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(t.BorderAccent).
|
||||||
|
Background(t.Surface).
|
||||||
|
Padding(1, 3)
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
sectionStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.Accent).
|
Foreground(t.Accent).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
keyStyle := lipgloss.NewStyle().
|
keyStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextPrimary).
|
Foreground(t.Cyan).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
descStyle := lipgloss.NewStyle().
|
descStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted)
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
dimStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.TextDim).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("\n")
|
b.WriteString(titleStyle.Render("◈ Keyboard Shortcuts"))
|
||||||
b.WriteString(titleStyle.Render(" Keybindings"))
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
bindings := []struct{ key, desc string }{
|
// Navigation section
|
||||||
{"o/c/s/b", "Overview / Costs / Sessions / Breakdown"},
|
b.WriteString(sectionStyle.Render("Navigation"))
|
||||||
{"x", "Settings"},
|
b.WriteString("\n")
|
||||||
{"<- / ->", "Previous / Next tab"},
|
navBindings := []struct{ key, desc string }{
|
||||||
{"j / k", "Navigate lists (or mouse wheel)"},
|
{"o c s b x", "Jump to tab"},
|
||||||
{"J / K", "Scroll detail pane"},
|
{"← →", "Previous / Next tab"},
|
||||||
{"^d / ^u", "Scroll detail half-page"},
|
{"j k", "Navigate lists"},
|
||||||
{"/", "Search sessions (Enter apply, Esc cancel)"},
|
{"J K", "Scroll detail pane"},
|
||||||
{"Enter / f", "Expand session full-screen"},
|
{"^d ^u", "Half-page scroll"},
|
||||||
{"Esc", "Clear search / Back to split view"},
|
|
||||||
{"r / R", "Refresh now / Toggle auto-refresh"},
|
|
||||||
{"?", "Toggle this help"},
|
|
||||||
{"q", "Quit (or back from full-screen)"},
|
|
||||||
}
|
}
|
||||||
|
for _, bind := range navBindings {
|
||||||
for _, bind := range bindings {
|
|
||||||
fmt.Fprintf(&b, " %s %s\n",
|
fmt.Fprintf(&b, " %s %s\n",
|
||||||
keyStyle.Render(fmt.Sprintf("%-12s", bind.key)),
|
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
|
||||||
descStyle.Render(bind.desc))
|
descStyle.Render(bind.desc))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(&b, "\n %s\n", descStyle.Render("Press any key to close"))
|
b.WriteString("\n")
|
||||||
|
b.WriteString(sectionStyle.Render("Actions"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
actionBindings := []struct{ key, desc string }{
|
||||||
|
{"/", "Search sessions"},
|
||||||
|
{"Enter", "Expand / Confirm"},
|
||||||
|
{"Esc", "Back / Cancel"},
|
||||||
|
{"r", "Refresh data"},
|
||||||
|
{"R", "Toggle auto-refresh"},
|
||||||
|
{"?", "Toggle help"},
|
||||||
|
{"q", "Quit"},
|
||||||
|
}
|
||||||
|
for _, bind := range actionBindings {
|
||||||
|
fmt.Fprintf(&b, " %s %s\n",
|
||||||
|
keyStyle.Render(fmt.Sprintf("%-10s", bind.key)),
|
||||||
|
descStyle.Render(bind.desc))
|
||||||
|
}
|
||||||
|
|
||||||
content := b.String()
|
b.WriteString("\n")
|
||||||
return padHeight(truncateHeight(content, h), h)
|
b.WriteString(dimStyle.Render("Press any key to close"))
|
||||||
|
|
||||||
|
card := cardStyle.Render(b.String())
|
||||||
|
|
||||||
|
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, card,
|
||||||
|
lipgloss.WithWhitespaceBackground(t.Background))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a App) viewMain() string {
|
func (a App) viewMain() string {
|
||||||
@@ -723,18 +800,33 @@ func (a App) viewMain() string {
|
|||||||
cw := a.contentWidth()
|
cw := a.contentWidth()
|
||||||
h := a.height
|
h := a.height
|
||||||
|
|
||||||
// 1. Render header (tab bar + filter line)
|
// 1. Render header (tab bar + filter pill)
|
||||||
filterStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
filterPillStyle := lipgloss.NewStyle().
|
||||||
filterStr := fmt.Sprintf(" [%dd", a.days)
|
Foreground(t.TextDim).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
|
filterAccentStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(t.Accent).
|
||||||
|
Background(t.Surface).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
filterStr := filterPillStyle.Render(" ") +
|
||||||
|
filterAccentStyle.Render(fmt.Sprintf("%dd", a.days))
|
||||||
if a.project != "" {
|
if a.project != "" {
|
||||||
filterStr += " | " + a.project
|
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.project)
|
||||||
}
|
}
|
||||||
if a.modelFilter != "" {
|
if a.modelFilter != "" {
|
||||||
filterStr += " | " + a.modelFilter
|
filterStr += filterPillStyle.Render(" │ ") + filterAccentStyle.Render(a.modelFilter)
|
||||||
}
|
}
|
||||||
filterStr += "]"
|
filterStr += filterPillStyle.Render(" ")
|
||||||
header := components.RenderTabBar(a.activeTab, w) + "\n" +
|
|
||||||
filterStyle.Render(filterStr) + "\n"
|
// Pad filter line to full width
|
||||||
|
filterRowStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.Surface).
|
||||||
|
Width(w)
|
||||||
|
|
||||||
|
header := components.RenderTabBar(a.activeTab, w) +
|
||||||
|
filterRowStyle.Render(filterStr)
|
||||||
|
|
||||||
// 2. Render status bar
|
// 2. Render status bar
|
||||||
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
|
dataAge := fmt.Sprintf("%.1fs", a.loadTime.Seconds())
|
||||||
@@ -744,8 +836,8 @@ func (a App) viewMain() string {
|
|||||||
headerH := lipgloss.Height(header)
|
headerH := lipgloss.Height(header)
|
||||||
statusH := lipgloss.Height(statusBar)
|
statusH := lipgloss.Height(statusBar)
|
||||||
contentH := h - headerH - statusH
|
contentH := h - headerH - statusH
|
||||||
if contentH < 5 {
|
if contentH < minContentHeight {
|
||||||
contentH = 5
|
contentH = minContentHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Render tab content (pass contentH to sessions)
|
// 4. Render tab content (pass contentH to sessions)
|
||||||
@@ -767,13 +859,20 @@ func (a App) viewMain() string {
|
|||||||
// 5. Truncate + pad to exactly contentH lines
|
// 5. Truncate + pad to exactly contentH lines
|
||||||
content = padHeight(truncateHeight(content, contentH), contentH)
|
content = padHeight(truncateHeight(content, contentH), contentH)
|
||||||
|
|
||||||
// 6. Center horizontally if terminal wider than content cap
|
// 6. Fill each line to full width with background (fixes gaps between cards)
|
||||||
if w > cw {
|
content = fillLinesWithBackground(content, cw, t.Background)
|
||||||
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Stack vertically
|
// 7. Place content with background fill (handles centering when w > cw)
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
|
content = lipgloss.Place(w, contentH, lipgloss.Center, lipgloss.Top, content,
|
||||||
|
lipgloss.WithWhitespaceBackground(t.Background))
|
||||||
|
|
||||||
|
// 8. Stack vertically
|
||||||
|
output := lipgloss.JoinVertical(lipgloss.Left, header, content, statusBar)
|
||||||
|
|
||||||
|
// 9. Ensure entire terminal is filled with background
|
||||||
|
// This handles any edge cases where the calculated heights don't perfectly match
|
||||||
|
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, output,
|
||||||
|
lipgloss.WithWhitespaceBackground(t.Background))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
@@ -1021,6 +1120,25 @@ func padHeight(s string, h int) string {
|
|||||||
return s + padding
|
return s + padding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillLinesWithBackground pads each line to width w with background color.
|
||||||
|
// This ensures gaps between cards and empty lines have proper background fill.
|
||||||
|
func fillLinesWithBackground(s string, w int, bg lipgloss.Color) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for i, line := range lines {
|
||||||
|
// Use PlaceHorizontal to ensure proper width and background fill
|
||||||
|
// This is more reliable than just Background().Render(spaces)
|
||||||
|
placed := lipgloss.PlaceHorizontal(w, lipgloss.Left, line,
|
||||||
|
lipgloss.WithWhitespaceBackground(bg))
|
||||||
|
result.WriteString(placed)
|
||||||
|
if i < len(lines)-1 {
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
// fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine.
|
// fetchSubDataCmd fetches subscription data from claude.ai in a background goroutine.
|
||||||
func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
@@ -1040,25 +1158,23 @@ func fetchSubDataCmd(sessionKey string) tea.Cmd {
|
|||||||
// ─── Mouse Support ──────────────────────────────────────────────
|
// ─── Mouse Support ──────────────────────────────────────────────
|
||||||
|
|
||||||
// tabAtX returns the tab index at the given X coordinate, or -1 if none.
|
// tabAtX returns the tab index at the given X coordinate, or -1 if none.
|
||||||
// Tab layout: " Overview Costs Sessions Breakdown Settings[x]"
|
// Hitboxes are derived from the same width rules used by RenderTabBar.
|
||||||
func (a App) tabAtX(x int) int {
|
func (a App) tabAtX(x int) int {
|
||||||
// Tab bar format: " TabName TabName ..." with 2-space gaps
|
pos := 0
|
||||||
// We approximate positions since exact widths depend on styling.
|
for i, tab := range components.Tabs {
|
||||||
// Each tab name is roughly: name length + optional [k] suffix + gap
|
// Must match RenderTabBar's visual width calculation exactly.
|
||||||
positions := []struct {
|
// Use lipgloss.Width() to handle unicode and styled text correctly.
|
||||||
start, end int
|
tabW := components.TabVisualWidth(tab, i == a.activeTab)
|
||||||
}{
|
|
||||||
{1, 12}, // Overview (0)
|
|
||||||
{14, 22}, // Costs (1)
|
|
||||||
{24, 35}, // Sessions (2)
|
|
||||||
{37, 50}, // Breakdown (3)
|
|
||||||
{52, 68}, // Settings (4)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, p := range positions {
|
if x >= pos && x < pos+tabW {
|
||||||
if x >= p.start && x <= p.end {
|
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
pos += tabW
|
||||||
|
|
||||||
|
// Separator is one column between tabs.
|
||||||
|
if i < len(components.Tabs)-1 {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type setupValues struct {
|
|||||||
|
|
||||||
// newSetupForm builds the huh form for first-run configuration.
|
// newSetupForm builds the huh form for first-run configuration.
|
||||||
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
|
func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.Form {
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
|
|
||||||
// Pre-populate defaults
|
// Pre-populate defaults
|
||||||
vals.days = cfg.General.DefaultDays
|
vals.days = cfg.General.DefaultDays
|
||||||
@@ -98,7 +98,7 @@ func newSetupForm(numSessions int, claudeDir string, vals *setupValues) *huh.For
|
|||||||
|
|
||||||
// saveSetupConfig persists the setup wizard values to the config file.
|
// saveSetupConfig persists the setup wizard values to the config file.
|
||||||
func (a *App) saveSetupConfig() error {
|
func (a *App) saveSetupConfig() error {
|
||||||
cfg, _ := config.Load()
|
cfg := loadConfigOrDefault()
|
||||||
|
|
||||||
if a.setupVals.sessionKey != "" {
|
if a.setupVals.sessionKey != "" {
|
||||||
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
|
cfg.ClaudeAI.SessionKey = a.setupVals.sessionKey
|
||||||
|
|||||||
Reference in New Issue
Block a user