diff --git a/internal/tui/tab_breakdown.go b/internal/tui/tab_breakdown.go index 429e38b..1ac8b39 100644 --- a/internal/tui/tab_breakdown.go +++ b/internal/tui/tab_breakdown.go @@ -23,8 +23,18 @@ func (a App) renderModelsTab(cw int) string { nameW = 14 } - headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true) + rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface) + shareStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface) + + // Model colors for visual interest - pre-compute styles to avoid allocation in loops + modelColors := []lipgloss.Color{t.BlueBright, t.Cyan, t.Magenta, t.Yellow, t.Green} + nameStyles := make([]lipgloss.Style, len(modelColors)) + for i, color := range modelColors { + nameStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface) + } var tableBody strings.Builder if a.isCompactLayout() { @@ -37,29 +47,30 @@ func (a App) renderModelsTab(cw int) string { } tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %6s", nameW, "Model", "Calls", "Cost", "Share"))) tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+shareW+costW+callW+3))) + tableBody.WriteString("\n") - for _, ms := range models { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %5.1f%%", - nameW, - truncStr(shortModel(ms.Model), nameW), - cli.FormatNumber(int64(ms.APICalls)), - cli.FormatCost(ms.EstimatedCost), - ms.SharePercent))) + for i, ms := range models { + tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW)))) + tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s", cli.FormatNumber(int64(ms.APICalls))))) + tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost)))) + tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent))) tableBody.WriteString("\n") } } else { tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %6s", nameW, "Model", "Calls", "Input", "Output", "Cost", "Share"))) tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW))) + tableBody.WriteString("\n") - for _, ms := range models { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %8s %10s %10s %10s %5.1f%%", - nameW, - truncStr(shortModel(ms.Model), nameW), + for i, ms := range models { + tableBody.WriteString(nameStyles[i%len(modelColors)].Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(ms.Model), nameW)))) + tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %8s %10s %10s", cli.FormatNumber(int64(ms.APICalls)), cli.FormatTokens(ms.InputTokens), - cli.FormatTokens(ms.OutputTokens), - cli.FormatCost(ms.EstimatedCost), - ms.SharePercent))) + cli.FormatTokens(ms.OutputTokens)))) + tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ms.EstimatedCost)))) + tableBody.WriteString(shareStyle.Render(fmt.Sprintf(" %5.1f%%", ms.SharePercent))) tableBody.WriteString("\n") } } @@ -79,8 +90,11 @@ func (a App) renderProjectsTab(cw int) string { nameW = 18 } - headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true) + rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + nameStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface) + costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface) var tableBody strings.Builder if a.isCompactLayout() { @@ -92,27 +106,28 @@ func (a App) renderProjectsTab(cw int) string { } tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %10s", nameW, "Project", "Sess.", "Cost"))) tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+costW+sessW+2))) + tableBody.WriteString("\n") for _, ps := range projects { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %10s", - nameW, - truncStr(ps.Project, nameW), - ps.Sessions, - cli.FormatCost(ps.EstimatedCost)))) + tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW)))) + tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d", ps.Sessions))) + tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost)))) tableBody.WriteString("\n") } } else { tableBody.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %6s %8s %10s %10s", nameW, "Project", "Sess.", "Prompts", "Tokens", "Cost"))) tableBody.WriteString("\n") + tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", innerW))) + tableBody.WriteString("\n") for _, ps := range projects { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %6d %8s %10s %10s", - nameW, - truncStr(ps.Project, nameW), + tableBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(ps.Project, nameW)))) + tableBody.WriteString(rowStyle.Render(fmt.Sprintf(" %6d %8s %10s", ps.Sessions, cli.FormatNumber(int64(ps.Prompts)), - cli.FormatTokens(ps.TotalTokens), - cli.FormatCost(ps.EstimatedCost)))) + cli.FormatTokens(ps.TotalTokens)))) + tableBody.WriteString(costStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(ps.EstimatedCost)))) tableBody.WriteString("\n") } } diff --git a/internal/tui/tab_costs.go b/internal/tui/tab_costs.go index a395eb9..f2b9493 100644 --- a/internal/tui/tab_costs.go +++ b/internal/tui/tab_costs.go @@ -51,11 +51,14 @@ func (a App) renderCostsTab(cw int) string { nameW = 14 } - headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) + headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + costValueStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface) + modelNameStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface) + tokenCostStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface) + spaceStyle := lipgloss.NewStyle().Background(t.Surface) var tableBody strings.Builder if a.isCompactLayout() { @@ -70,10 +73,8 @@ func (a App) renderCostsTab(cw int) string { tableBody.WriteString("\n") for _, mc := range modelCosts { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s", - nameW, - truncStr(shortModel(mc.Model), nameW), - cli.FormatCost(mc.TotalCost)))) + tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW)))) + tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost)))) tableBody.WriteString("\n") } tableBody.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+totalW+1))) @@ -84,13 +85,12 @@ func (a App) renderCostsTab(cw int) string { tableBody.WriteString("\n") for _, mc := range modelCosts { - tableBody.WriteString(rowStyle.Render(fmt.Sprintf("%-*s %10s %10s %10s %10s", - nameW, - truncStr(shortModel(mc.Model), nameW), + tableBody.WriteString(modelNameStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(shortModel(mc.Model), nameW)))) + tableBody.WriteString(tokenCostStyle.Render(fmt.Sprintf(" %10s %10s %10s", cli.FormatCost(mc.InputCost), cli.FormatCost(mc.OutputCost), - cli.FormatCost(mc.CacheCost), - cli.FormatCost(mc.TotalCost)))) + cli.FormatCost(mc.CacheCost)))) + tableBody.WriteString(costValueStyle.Render(fmt.Sprintf(" %10s", cli.FormatCost(mc.TotalCost)))) tableBody.WriteString("\n") } @@ -126,12 +126,16 @@ func (a App) renderCostsTab(cw int) string { var body strings.Builder body.WriteString(bar.ViewAs(pct)) - fmt.Fprintf(&body, " %.0f%%\n", pct*100) - fmt.Fprintf(&body, "%s %s / %s %s", - labelStyle.Render("Used"), - valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits)), - valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit)), - labelStyle.Render(ol.Currency)) + body.WriteString(spaceStyle.Render(" ")) + body.WriteString(valueStyle.Render(fmt.Sprintf("%.0f%%", pct*100))) + body.WriteString("\n") + body.WriteString(labelStyle.Render("Used")) + body.WriteString(spaceStyle.Render(" ")) + body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.UsedCredits))) + body.WriteString(spaceStyle.Render(" / ")) + body.WriteString(valueStyle.Render(fmt.Sprintf("$%.2f", ol.MonthlyCreditLimit))) + body.WriteString(spaceStyle.Render(" ")) + body.WriteString(labelStyle.Render(ol.Currency)) progressCard = components.ContentCard("Overage Spend", body.String(), halves[0]) } else { @@ -159,12 +163,14 @@ func (a App) renderCostsTab(cw int) string { return topDays[i].Date.After(topDays[j].Date) }) for _, d := range topDays { - fmt.Fprintf(&spendBody, "%s %s\n", - valueStyle.Render(d.Date.Format("Jan 02")), - lipgloss.NewStyle().Foreground(t.Green).Render(cli.FormatCost(d.EstimatedCost))) + spendBody.WriteString(valueStyle.Render(d.Date.Format("Jan 02"))) + spendBody.WriteString(spaceStyle.Render(" ")) + spendBody.WriteString(lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Render(cli.FormatCost(d.EstimatedCost))) + spendBody.WriteString("\n") } } else { - spendBody.WriteString("No data\n") + spendBody.WriteString(labelStyle.Render("No data")) + spendBody.WriteString("\n") } spendCard := components.ContentCard("Top Spend Days", spendBody.String(), halves[1]) @@ -189,16 +195,21 @@ func (a App) renderCostsTab(cw int) string { promptsPerSess = float64(stats.TotalPrompts) / float64(stats.TotalSessions) } - effMetrics := []struct{ name, value string }{ - {"Tokens/Prompt", cli.FormatTokens(tokPerPrompt)}, - {"Output/Prompt", cli.FormatTokens(outPerPrompt)}, - {"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess)}, - {"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay)}, + effMetrics := []struct { + name string + value string + color lipgloss.Color + }{ + {"Tokens/Prompt", cli.FormatTokens(tokPerPrompt), t.Cyan}, + {"Output/Prompt", cli.FormatTokens(outPerPrompt), t.Cyan}, + {"Prompts/Session", fmt.Sprintf("%.1f", promptsPerSess), t.Magenta}, + {"Minutes/Day", fmt.Sprintf("%.0f", stats.MinutesPerDay), t.Yellow}, } var effBody strings.Builder for _, m := range effMetrics { - effBody.WriteString(rowStyle.Render(fmt.Sprintf("%-20s %10s", m.name, m.value))) + effBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s", m.name))) + effBody.WriteString(lipgloss.NewStyle().Foreground(m.color).Background(t.Surface).Render(fmt.Sprintf(" %10s", m.value))) effBody.WriteString("\n") } @@ -210,11 +221,11 @@ func (a App) renderCostsTab(cw int) string { // renderSubscriptionCard renders the rate limit + overage card at the top of the costs tab. func (a App) renderSubscriptionCard(cw int) string { t := theme.Active - hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) + hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) // No session key configured if a.subData == nil && !a.subFetching { - cfg, _ := config.Load() + cfg := loadConfigOrDefault() if config.GetSessionKey(cfg) == "" { return components.ContentCard("Subscription", hintStyle.Render("Configure session key in Settings to see rate limits"), @@ -235,7 +246,7 @@ func (a App) renderSubscriptionCard(cw int) string { // Error with no usable data if a.subData.Usage == nil && a.subData.Error != nil { - warnStyle := lipgloss.NewStyle().Foreground(t.Orange) + warnStyle := lipgloss.NewStyle().Foreground(t.Orange).Background(t.Surface) return components.ContentCard("Subscription", warnStyle.Render(fmt.Sprintf("Error: %s", a.subData.Error)), cw) + "\n" @@ -285,12 +296,12 @@ func (a App) renderSubscriptionCard(cw int) string { if ol := a.subData.Overage; ol != nil && ol.IsEnabled && ol.MonthlyCreditLimit > 0 { pct := ol.UsedCredits / ol.MonthlyCreditLimit body.WriteString("\n") - body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render(strings.Repeat("─", innerW))) + body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface).Render(strings.Repeat("─", innerW))) body.WriteString("\n") body.WriteString(components.RateLimitBar("Overage", pct, time.Time{}, labelW, barW)) - spendStyle := lipgloss.NewStyle().Foreground(t.TextDim) + spendStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) body.WriteString(spendStyle.Render( fmt.Sprintf(" $%.2f / $%.2f", ol.UsedCredits, ol.MonthlyCreditLimit))) } @@ -298,7 +309,7 @@ func (a App) renderSubscriptionCard(cw int) string { // Fetch timestamp if !a.subData.FetchedAt.IsZero() { body.WriteString("\n") - tsStyle := lipgloss.NewStyle().Foreground(t.TextDim) + tsStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) body.WriteString(tsStyle.Render("Updated " + a.subData.FetchedAt.Format("3:04 PM"))) } diff --git a/internal/tui/tab_overview.go b/internal/tui/tab_overview.go index beecd4d..5b608ab 100644 --- a/internal/tui/tab_overview.go +++ b/internal/tui/tab_overview.go @@ -21,7 +21,7 @@ func (a App) renderOverviewTab(cw int) string { models := a.models var b strings.Builder - // Row 1: Metric cards + // Row 1: Metric cards with colored values costDelta := "" if prev.CostPerDay > 0 { costDelta = fmt.Sprintf("%s/day (%s)", cli.FormatCost(stats.CostPerDay), cli.FormatDelta(stats.CostPerDay, prev.CostPerDay)) @@ -54,7 +54,7 @@ func (a App) renderOverviewTab(cw int) string { b.WriteString(components.MetricCardRow(cards, cw)) b.WriteString("\n") - // Row 2: Daily token usage chart + // Row 2: Daily token usage chart - use PanelCard for emphasis if len(days) > 0 { chartVals := make([]float64, len(days)) chartLabels := chartDateLabels(days) @@ -62,9 +62,9 @@ func (a App) renderOverviewTab(cw int) string { chartVals[len(days)-1-i] = float64(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h) } chartInnerW := components.CardInnerWidth(cw) - b.WriteString(components.ContentCard( + b.WriteString(components.PanelCard( fmt.Sprintf("Daily Token Usage (%dd)", a.days), - components.BarChart(chartVals, chartLabels, t.Blue, chartInnerW, 10), + components.BarChart(chartVals, chartLabels, t.BlueBright, chartInnerW, 10), cw, )) b.WriteString("\n") @@ -88,7 +88,7 @@ func (a App) renderOverviewTab(cw int) string { } todayCard = components.ContentCard( fmt.Sprintf("Today (%s)", cli.FormatTokens(todayTotal)), - components.BarChart(hourVals, hourLabels24(), t.Blue, components.CardInnerWidth(liveHalves[0]), liveChartH), + components.BarChart(hourVals, hourLabels24(), t.Cyan, components.CardInnerWidth(liveHalves[0]), liveChartH), liveHalves[0], ) } @@ -104,7 +104,7 @@ func (a App) renderOverviewTab(cw int) string { } lastHourCard = components.ContentCard( fmt.Sprintf("Last Hour (%s)", cli.FormatTokens(hourTotal)), - components.BarChart(minVals, minuteLabels(), t.Accent, components.CardInnerWidth(liveHalves[1]), liveChartH), + components.BarChart(minVals, minuteLabels(), t.Magenta, components.CardInnerWidth(liveHalves[1]), liveChartH), liveHalves[1], ) } @@ -127,10 +127,7 @@ func (a App) renderOverviewTab(cw int) string { halves := components.LayoutRow(cw, 2) innerW := components.CardInnerWidth(halves[0]) - nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - barStyle := lipgloss.NewStyle().Foreground(t.Accent) - pctStyle := lipgloss.NewStyle().Foreground(t.TextDim) - + // Model split with colored bars per model var modelBody strings.Builder limit := 5 if len(models) < limit { @@ -150,18 +147,36 @@ func (a App) renderOverviewTab(cw int) string { if barMaxLen < 1 { barMaxLen = 1 } - for _, ms := range models[:limit] { + + // Color palette for models - pre-compute styles to avoid allocation in loop + modelColors := []lipgloss.Color{t.BlueBright, t.Cyan, t.Magenta, t.Yellow, t.Green} + sepStyle := lipgloss.NewStyle().Background(t.Surface) + nameStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + + // Pre-compute bar and percent styles for each color + barStyles := make([]lipgloss.Style, len(modelColors)) + pctStyles := make([]lipgloss.Style, len(modelColors)) + for i, color := range modelColors { + barStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface) + pctStyles[i] = lipgloss.NewStyle().Foreground(color).Background(t.Surface).Bold(true) + } + + for i, ms := range models[:limit] { barLen := 0 if maxShare > 0 { barLen = int(ms.SharePercent / maxShare * float64(barMaxLen)) } - fmt.Fprintf(&modelBody, "%s %s %s\n", - nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model))), - barStyle.Render(strings.Repeat("█", barLen)), - pctStyle.Render(fmt.Sprintf("%.0f%%", ms.SharePercent))) + + colorIdx := i % len(modelColors) + modelBody.WriteString(nameStyle.Render(fmt.Sprintf("%-*s", nameW, shortModel(ms.Model)))) + modelBody.WriteString(sepStyle.Render(" ")) + modelBody.WriteString(barStyles[colorIdx].Render(strings.Repeat("█", barLen))) + modelBody.WriteString(sepStyle.Render(" ")) + modelBody.WriteString(pctStyles[colorIdx].Render(fmt.Sprintf("%3.0f%%", ms.SharePercent))) + modelBody.WriteString("\n") } - // Compact activity: aggregate prompts into 4-hour buckets + // Activity patterns with time-of-day coloring now := time.Now() since := now.AddDate(0, 0, -a.days) hours := pipeline.AggregateHourly(a.filtered, since, now) @@ -172,11 +187,11 @@ func (a App) renderOverviewTab(cw int) string { color lipgloss.Color } buckets := []actBucket{ - {"Night 00-03", 0, t.Red}, - {"Early 04-07", 0, t.Yellow}, - {"Morning 08-11", 0, t.Green}, + {"Night 00-03", 0, t.Magenta}, + {"Early 04-07", 0, t.Orange}, + {"Morning 08-11", 0, t.GreenBright}, {"Midday 12-15", 0, t.Green}, - {"Evening 16-19", 0, t.Green}, + {"Evening 16-19", 0, t.Cyan}, {"Late 20-23", 0, t.Yellow}, } for _, h := range hours { @@ -196,31 +211,34 @@ func (a App) renderOverviewTab(cw int) string { actInnerW := components.CardInnerWidth(halves[1]) - // Compute number column width from actual data so bars never overflow. + // Compute number column width maxNumW := 5 for _, bk := range buckets { if nw := len(cli.FormatNumber(int64(bk.total))); nw > maxNumW { maxNumW = nw } } - // prefix = 13 (label) + 1 (space) + maxNumW (number) + 1 (space) actBarMax := actInnerW - 15 - maxNumW if actBarMax < 1 { actBarMax = 1 } - numStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + numStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + var actBody strings.Builder for _, bk := range buckets { bl := 0 if maxBucket > 0 { bl = bk.total * actBarMax / maxBucket } - bar := lipgloss.NewStyle().Foreground(bk.color).Render(strings.Repeat("█", bl)) - fmt.Fprintf(&actBody, "%s %s %s\n", - numStyle.Render(bk.label), - numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total)))), - bar) + barStyle := lipgloss.NewStyle().Foreground(bk.color).Background(t.Surface) + actBody.WriteString(labelStyle.Render(bk.label)) + actBody.WriteString(sepStyle.Render(" ")) + actBody.WriteString(numStyle.Render(fmt.Sprintf("%*s", maxNumW, cli.FormatNumber(int64(bk.total))))) + actBody.WriteString(sepStyle.Render(" ")) + actBody.WriteString(barStyle.Render(strings.Repeat("█", bl))) + actBody.WriteString("\n") } modelCard := components.ContentCard("Model Split", modelBody.String(), halves[0]) @@ -236,7 +254,7 @@ func (a App) renderOverviewTab(cw int) string { return b.String() } -// hourLabels24 returns X-axis labels for 24 hourly buckets (one per hour). +// hourLabels24 returns X-axis labels for 24 hourly buckets. func hourLabels24() []string { labels := make([]string, 24) for i := 0; i < 24; i++ { @@ -253,8 +271,7 @@ func hourLabels24() []string { return labels } -// minuteLabels returns X-axis labels for 12 five-minute buckets (one per bucket). -// Bucket 0 is oldest (55-60 min ago), bucket 11 is newest (0-5 min ago). +// minuteLabels returns X-axis labels for 12 five-minute buckets. func minuteLabels() []string { return []string{"-55", "-50", "-45", "-40", "-35", "-30", "-25", "-20", "-15", "-10", "-5", "now"} } diff --git a/internal/tui/tab_sessions.go b/internal/tui/tab_sessions.go index cb11e09..b7c59f2 100644 --- a/internal/tui/tab_sessions.go +++ b/internal/tui/tab_sessions.go @@ -21,6 +21,13 @@ const ( sessViewDetail // Full-screen detail ) +// Layout constants for sessions tab height calculations. +const ( + sessListOverhead = 6 // card border (2) + header row (2) + footer hint (2) + sessDetailOverhead = 4 // card border (2) + title (1) + gap (1) + sessMinVisible = 5 // minimum visible rows in any pane +) + // sessionsState holds the sessions tab state. type sessionsState struct { cursor int @@ -79,17 +86,21 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str // Show search input when in search mode if ss.searching { var b strings.Builder - searchStyle := lipgloss.NewStyle().Foreground(t.Accent) + searchStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface).Bold(true) + spaceStyle := lipgloss.NewStyle().Background(t.Surface) b.WriteString(searchStyle.Render(" Search: ")) b.WriteString(ss.searchInput.View()) b.WriteString("\n") - hintStyle := lipgloss.NewStyle().Foreground(t.TextDim) - b.WriteString(hintStyle.Render(" [Enter] apply [Esc] cancel")) + hintStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) + keyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface) + b.WriteString(spaceStyle.Render(" ") + hintStyle.Render("[") + keyStyle.Render("Enter") + hintStyle.Render("] apply [") + + keyStyle.Render("Esc") + hintStyle.Render("] cancel")) b.WriteString("\n\n") // Show preview of filtered results previewFiltered := filterSessionsBySearch(a.filtered, ss.searchInput.Value()) - b.WriteString(hintStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered)))) + countStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + b.WriteString(countStyle.Render(fmt.Sprintf(" %d sessions match", len(previewFiltered)))) return b.String() } @@ -102,10 +113,10 @@ func (a App) renderSessionsContent(filtered []model.SessionStats, cw, h int) str if len(filtered) == 0 { var body strings.Builder - body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Render("No sessions found")) + body.WriteString(lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface).Render("No sessions found")) if ss.searchQuery != "" { body.WriteString("\n\n") - body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render("[Esc] clear search [/] new search")) + body.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface).Render("[Esc] clear search [/] new search")) } return components.ContentCard(title, body.String(), cw) } @@ -157,15 +168,15 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin // Left pane: condensed session list leftInner := components.CardInnerWidth(leftW) - headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true) - mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + rowStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + costStyle := lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface) var leftBody strings.Builder - visible := h - 6 // card border (2) + header row (2) + footer hint (2) - if visible < 5 { - visible = 5 + visible := h - sessListOverhead + if visible < sessMinVisible { + visible = sessMinVisible } offset := ss.offset @@ -198,18 +209,22 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin } if i == cursor { - fullLine := leftPart + strings.Repeat(" ", padN) + costStr - // Pad to full width for continuous highlight background - if len(fullLine) < leftInner { - fullLine += strings.Repeat(" ", leftInner-len(fullLine)) - } - leftBody.WriteString(selectedStyle.Render(fullLine)) + // Selected row with bright background and accent marker + selectedCostStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.SurfaceBright).Bold(true) + marker := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright).Render("▸ ") + leftBody.WriteString(marker + selectedStyle.Render(leftPart) + + lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(1, padN-2))) + + selectedCostStyle.Render(costStr) + + lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", max(0, leftInner-len(leftPart)-padN-len(costStr))))) } else { + // Normal row leftBody.WriteString( - mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + " " + + lipgloss.NewStyle().Background(t.Surface).Render(" ") + + mutedStyle.Render(fmt.Sprintf("%-13s", startStr)) + + lipgloss.NewStyle().Background(t.Surface).Render(" ") + rowStyle.Render(dur) + - strings.Repeat(" ", padN) + - mutedStyle.Render(costStr)) + lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padN-2)) + + costStyle.Render(costStr)) } leftBody.WriteString("\n") } @@ -223,10 +238,10 @@ func (a App) renderSessionsSplit(sessions []model.SessionStats, cw, h int) strin // Right pane: full session detail with scroll support sel := sessions[cursor] - rightBody := a.renderDetailBody(sel, rightW, headerStyle, mutedStyle) + rightBody := a.renderDetailBody(sel, rightW, mutedStyle) // Apply detail scroll offset - rightBody = a.applyDetailScroll(rightBody, h-4) // card border (2) + title (1) + gap (1) + rightBody = a.applyDetailScroll(rightBody, h-sessDetailOverhead) titleStr := "Session " + shortID(sel.SessionID) rightCard := components.ContentCard(titleStr, rightBody, rightW) @@ -248,11 +263,10 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin } sel := sessions[cursor] - headerStyle := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + mutedStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) - body := a.renderDetailBody(sel, cw, headerStyle, mutedStyle) - body = a.applyDetailScroll(body, h-4) + body := a.renderDetailBody(sel, cw, mutedStyle) + body = a.applyDetailScroll(body, h-sessDetailOverhead) title := "Session " + shortID(sel.SessionID) return components.ContentCard(title, body, cw) @@ -260,21 +274,28 @@ func (a App) renderSessionDetail(sessions []model.SessionStats, cw, h int) strin // renderDetailBody generates the full detail content for a session. // Used by both the split right pane and the full-screen detail view. -func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedStyle lipgloss.Style) string { +func (a App) renderDetailBody(sel model.SessionStats, w int, mutedStyle lipgloss.Style) string { t := theme.Active innerW := components.CardInnerWidth(w) - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - greenStyle := lipgloss.NewStyle().Foreground(t.Green) + // Rich color palette for different data types + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + tokenStyle := lipgloss.NewStyle().Foreground(t.Cyan).Background(t.Surface) + costStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface) + savingsStyle := lipgloss.NewStyle().Foreground(t.Green).Background(t.Surface).Bold(true) + timeStyle := lipgloss.NewStyle().Foreground(t.Magenta).Background(t.Surface) + modelStyle := lipgloss.NewStyle().Foreground(t.BlueBright).Background(t.Surface) + accentStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) var body strings.Builder - body.WriteString(mutedStyle.Render(sel.Project)) + body.WriteString(accentStyle.Render(sel.Project)) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", innerW))) + body.WriteString(dimStyle.Render(strings.Repeat("─", innerW))) body.WriteString("\n\n") - // Duration line + // Duration line with colored values if !sel.StartTime.IsZero() { durStr := cli.FormatDuration(sel.DurationSecs) timeStr := sel.StartTime.Local().Format("15:04:05") @@ -282,28 +303,42 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS timeStr += " - " + sel.EndTime.Local().Format("15:04:05") } timeStr += " " + sel.StartTime.Local().Format("MST") - fmt.Fprintf(&body, "%s %s (%s)\n", - labelStyle.Render("Duration:"), - valueStyle.Render(durStr), - mutedStyle.Render(timeStr)) + body.WriteString(labelStyle.Render("Duration: ")) + body.WriteString(timeStyle.Render(durStr)) + body.WriteString(dimStyle.Render(" (")) + body.WriteString(mutedStyle.Render(timeStr)) + body.WriteString(dimStyle.Render(")")) + body.WriteString("\n") } ratio := 0.0 if sel.UserMessages > 0 { ratio = float64(sel.APICalls) / float64(sel.UserMessages) } - fmt.Fprintf(&body, "%s %s %s %s %s %.1fx\n\n", - labelStyle.Render("Prompts:"), valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages))), - labelStyle.Render("API Calls:"), valueStyle.Render(cli.FormatNumber(int64(sel.APICalls))), - labelStyle.Render("Ratio:"), ratio) + body.WriteString(labelStyle.Render("Prompts: ")) + body.WriteString(valueStyle.Render(cli.FormatNumber(int64(sel.UserMessages)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(labelStyle.Render("API Calls: ")) + body.WriteString(tokenStyle.Render(cli.FormatNumber(int64(sel.APICalls)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(labelStyle.Render("Ratio: ")) + body.WriteString(accentStyle.Render(fmt.Sprintf("%.1fx", ratio))) + body.WriteString("\n\n") - // Token breakdown table - body.WriteString(headerStyle.Render("TOKEN BREAKDOWN")) + // Token breakdown table with section header + sectionStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface).Bold(true) + tableHeaderStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface) + + body.WriteString(sectionStyle.Render("TOKEN BREAKDOWN")) body.WriteString("\n") typeW, tokW, costW, tableW := tokenTableLayout(innerW) - body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %*s %*s", typeW, "Type", tokW, "Tokens", costW, "Cost"))) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s", typeW, "Type"))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%*s", tokW, "Tokens"))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%*s", costW, "Cost"))) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW))) + body.WriteString(dimStyle.Render(strings.Repeat("─", tableW))) body.WriteString("\n") // Calculate per-type costs (aggregate across models) @@ -342,37 +377,35 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS if r.tokens == 0 { continue } - body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %*s %*s", - typeW, - truncStr(r.typ, typeW), - tokW, - cli.FormatTokens(r.tokens), - costW, - cli.FormatCost(r.cost)))) + body.WriteString(labelStyle.Render(fmt.Sprintf("%-*s", typeW, truncStr(r.typ, typeW)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(tokenStyle.Render(fmt.Sprintf("%*s", tokW, cli.FormatTokens(r.tokens)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(costStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(r.cost)))) body.WriteString("\n") } - body.WriteString(mutedStyle.Render(strings.Repeat("─", tableW))) + body.WriteString(dimStyle.Render(strings.Repeat("─", tableW))) + body.WriteString("\n") + // Net Cost row - highlighted + body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", typeW, "Net Cost"))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(dimStyle.Render(fmt.Sprintf("%*s", tokW, ""))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(savingsStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(sel.EstimatedCost)))) + body.WriteString("\n") + // Cache Savings row + body.WriteString(labelStyle.Render(fmt.Sprintf("%-*s", typeW, "Cache Savings"))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(dimStyle.Render(fmt.Sprintf("%*s", tokW, ""))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(savingsStyle.Render(fmt.Sprintf("%*s", costW, cli.FormatCost(savings)))) body.WriteString("\n") - fmt.Fprintf(&body, "%-*s %*s %*s\n", - typeW, - valueStyle.Render("Net Cost"), - tokW, - "", - costW, - greenStyle.Render(cli.FormatCost(sel.EstimatedCost))) - fmt.Fprintf(&body, "%-*s %*s %*s\n", - typeW, - labelStyle.Render("Cache Savings"), - tokW, - "", - costW, - greenStyle.Render(cli.FormatCost(savings))) - // Model breakdown + // Model breakdown with colored data if len(sel.Models) > 0 { body.WriteString("\n") - body.WriteString(headerStyle.Render("API CALLS BY MODEL")) + body.WriteString(sectionStyle.Render("API CALLS BY MODEL")) body.WriteString("\n") compactModelTable := innerW < 60 if compactModelTable { @@ -380,14 +413,14 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS if modelW < 8 { modelW = 8 } - body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost"))) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %8s", modelW, "Model", "Calls", "Cost"))) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+8+2))) + body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+8+2))) } else { modelW := 14 - body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost"))) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", modelW, "Model", "Calls", "Input", "Output", "Cost"))) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4))) + body.WriteString(dimStyle.Render(strings.Repeat("─", modelW+7+10+10+8+4))) } body.WriteString("\n") @@ -405,20 +438,22 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS if modelW < 8 { modelW = 8 } - body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %8s", - modelW, - truncStr(shortModel(modelName), modelW), - cli.FormatNumber(int64(mu.APICalls)), - cli.FormatCost(mu.EstimatedCost)))) + body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls))))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost)))) } else { modelW := 14 - body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %7s %10s %10s %8s", - modelW, - truncStr(shortModel(modelName), modelW), - cli.FormatNumber(int64(mu.APICalls)), - cli.FormatTokens(mu.InputTokens), - cli.FormatTokens(mu.OutputTokens), - cli.FormatCost(mu.EstimatedCost)))) + body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", modelW, truncStr(shortModel(modelName), modelW)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(valueStyle.Render(fmt.Sprintf("%7s", cli.FormatNumber(int64(mu.APICalls))))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.InputTokens)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(tokenStyle.Render(fmt.Sprintf("%10s", cli.FormatTokens(mu.OutputTokens)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(costStyle.Render(fmt.Sprintf("%8s", cli.FormatCost(mu.EstimatedCost)))) } body.WriteString("\n") } @@ -426,23 +461,23 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS if sel.IsSubagent { body.WriteString("\n") - body.WriteString(mutedStyle.Render("(subagent session)")) + body.WriteString(dimStyle.Render("(subagent session)")) body.WriteString("\n") } - // Subagent drill-down + // Subagent drill-down with colors if subs := a.subagentMap[sel.SessionID]; len(subs) > 0 { body.WriteString("\n") - body.WriteString(headerStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs)))) + body.WriteString(sectionStyle.Render(fmt.Sprintf("SUBAGENTS (%d)", len(subs)))) body.WriteString("\n") nameW := innerW - 8 - 10 - 2 if nameW < 10 { nameW = 10 } - body.WriteString(headerStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost"))) + body.WriteString(tableHeaderStyle.Render(fmt.Sprintf("%-*s %8s %10s", nameW, "Agent", "Duration", "Cost"))) body.WriteString("\n") - body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) + body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2))) body.WriteString("\n") var totalSubCost float64 @@ -455,31 +490,41 @@ func (a App) renderDetailBody(sel model.SessionStats, w int, headerStyle, mutedS } agentName = strings.TrimPrefix(agentName, "agent-") - body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", - nameW, - truncStr(agentName, nameW), - cli.FormatDuration(sub.DurationSecs), - cli.FormatCost(sub.EstimatedCost)))) + body.WriteString(modelStyle.Render(fmt.Sprintf("%-*s", nameW, truncStr(agentName, nameW)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(sub.DurationSecs)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(costStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(sub.EstimatedCost)))) body.WriteString("\n") totalSubCost += sub.EstimatedCost totalSubDur += sub.DurationSecs } - body.WriteString(mutedStyle.Render(strings.Repeat("─", nameW+8+10+2))) + body.WriteString(dimStyle.Render(strings.Repeat("─", nameW+8+10+2))) body.WriteString("\n") - body.WriteString(valueStyle.Render(fmt.Sprintf("%-*s %8s %10s", - nameW, - "Combined", - cli.FormatDuration(totalSubDur), - cli.FormatCost(totalSubCost)))) + body.WriteString(accentStyle.Render(fmt.Sprintf("%-*s", nameW, "Combined"))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(timeStyle.Render(fmt.Sprintf("%8s", cli.FormatDuration(totalSubDur)))) + body.WriteString(dimStyle.Render(" ")) + body.WriteString(savingsStyle.Render(fmt.Sprintf("%10s", cli.FormatCost(totalSubCost)))) body.WriteString("\n") } + // Footer hints with styled keys body.WriteString("\n") + hintKeyStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.Surface) + hintTextStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) if w < compactWidth { - body.WriteString(mutedStyle.Render("[/] search [j/k] navigate [J/K] scroll [q] quit")) + body.WriteString(hintTextStyle.Render("[") + hintKeyStyle.Render("/") + hintTextStyle.Render("] search [") + + hintKeyStyle.Render("j/k") + hintTextStyle.Render("] navigate [") + + hintKeyStyle.Render("J/K") + hintTextStyle.Render("] scroll [") + + hintKeyStyle.Render("q") + hintTextStyle.Render("] quit")) } else { - body.WriteString(mutedStyle.Render("[/] search [Enter] expand [j/k] navigate [J/K/^d/^u] scroll [q] quit")) + body.WriteString(hintTextStyle.Render("[") + hintKeyStyle.Render("/") + hintTextStyle.Render("] search [") + + hintKeyStyle.Render("Enter") + hintTextStyle.Render("] expand [") + + hintKeyStyle.Render("j/k") + hintTextStyle.Render("] navigate [") + + hintKeyStyle.Render("J/K/^d/^u") + hintTextStyle.Render("] scroll [") + + hintKeyStyle.Render("q") + hintTextStyle.Render("] quit")) } return body.String() @@ -495,8 +540,8 @@ func shortID(id string) string { // applyDetailScroll applies the detail pane scroll offset to a rendered body string. // visibleH is the number of lines that fit in the card body area. func (a App) applyDetailScroll(body string, visibleH int) string { - if visibleH < 5 { - visibleH = 5 + if visibleH < sessMinVisible { + visibleH = sessMinVisible } lines := strings.Split(body, "\n") @@ -526,7 +571,7 @@ func (a App) applyDetailScroll(body string, visibleH int) string { // Count includes the line we're replacing + lines past the viewport. if endIdx < len(lines) { unseen := len(lines) - endIdx + 1 - dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim) + dimStyle := lipgloss.NewStyle().Foreground(theme.Active.TextDim).Background(theme.Active.Surface) visible[len(visible)-1] = dimStyle.Render(fmt.Sprintf("... %d more", unseen)) } diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go index db5ee80..31ac1ff 100644 --- a/internal/tui/tab_settings.go +++ b/internal/tui/tab_settings.go @@ -46,7 +46,7 @@ func newSettingsInput() textinput.Model { } func (a App) settingsStartEdit() (tea.Model, tea.Cmd) { - cfg, _ := config.Load() + cfg := loadConfigOrDefault() a.settings.editing = true a.settings.saved = false @@ -123,7 +123,7 @@ func (a App) updateSettingsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (a *App) settingsSave() { - cfg, _ := config.Load() + cfg := loadConfigOrDefault() val := strings.TrimSpace(a.settings.input.Value()) switch a.settings.cursor { @@ -176,13 +176,15 @@ func (a *App) settingsSave() { func (a App) renderSettingsTab(cw int) string { t := theme.Active - cfg, _ := config.Load() + cfg := loadConfigOrDefault() - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary) - selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface).Bold(true) - accentStyle := lipgloss.NewStyle().Foreground(t.Accent) - greenStyle := lipgloss.NewStyle().Foreground(t.Green) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + valueStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.Surface) + selectedStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Background(t.SurfaceBright).Bold(true) + selectedLabelStyle := lipgloss.NewStyle().Foreground(t.Accent).Background(t.SurfaceBright).Bold(true) + accentStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.Surface) + greenStyle := lipgloss.NewStyle().Foreground(t.GreenBright).Background(t.Surface) + markerStyle := lipgloss.NewStyle().Foreground(t.AccentBright).Background(t.SurfaceBright) type field struct { label string @@ -235,23 +237,39 @@ func (a App) renderSettingsTab(cw int) string { for i, f := range fields { // Show text input if currently editing this field if a.settings.editing && i == a.settings.cursor { - formBody.WriteString(accentStyle.Render(fmt.Sprintf("> %-18s ", f.label))) + formBody.WriteString(markerStyle.Render("▸ ")) + formBody.WriteString(accentStyle.Render(fmt.Sprintf("%-18s ", f.label))) formBody.WriteString(a.settings.input.View()) formBody.WriteString("\n") continue } - line := fmt.Sprintf("%-20s %s", f.label+":", f.value) if i == a.settings.cursor { - formBody.WriteString(selectedStyle.Render(line)) + // Selected row with marker and highlight + marker := markerStyle.Render("▸ ") + label := selectedLabelStyle.Render(fmt.Sprintf("%-18s ", f.label+":")) + value := selectedStyle.Render(f.value) + formBody.WriteString(marker) + formBody.WriteString(label) + formBody.WriteString(value) + // Use lipgloss.Width() for correct visual width calculation + usedWidth := lipgloss.Width(marker) + lipgloss.Width(label) + lipgloss.Width(value) + innerW := components.CardInnerWidth(cw) + padLen := innerW - usedWidth + if padLen > 0 { + formBody.WriteString(lipgloss.NewStyle().Background(t.SurfaceBright).Render(strings.Repeat(" ", padLen))) + } } else { - formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-20s ", f.label+":")) + valueStyle.Render(f.value)) + // Normal row + formBody.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(" ")) + formBody.WriteString(labelStyle.Render(fmt.Sprintf("%-18s ", f.label+":"))) + formBody.WriteString(valueStyle.Render(f.value)) } formBody.WriteString("\n") } if a.settings.saveErr != nil { - warnStyle := lipgloss.NewStyle().Foreground(t.Orange) + warnStyle := lipgloss.NewStyle().Foreground(t.Orange).Background(t.Surface) formBody.WriteString("\n") formBody.WriteString(warnStyle.Render(fmt.Sprintf("Save failed: %s", a.settings.saveErr))) } else if a.settings.saved {