diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index dda4903a47..698b61e688 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -101,6 +101,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newAttachCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) cmd.AddCommand(newMigrateCmd()) + cmd.AddCommand(newSummaryCmd()) cmd.SetVersionTemplate(versionString()) diff --git a/cmd/entire/cli/summary_cmd.go b/cmd/entire/cli/summary_cmd.go new file mode 100644 index 0000000000..385ce6efeb --- /dev/null +++ b/cmd/entire/cli/summary_cmd.go @@ -0,0 +1,412 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/summarytui" + "github.com/entireio/cli/cmd/entire/cli/termstyle" + "github.com/spf13/cobra" +) + +type summaryOptions struct { + Last int + Agent string + Branch string + OutputJSON bool + CheckpointPrefix string +} + +const ( + defaultSummarySessionLimit = 10 + maxSummaryRecentSessions = 200 +) + +type summarySessionView struct { + CheckpointID string `json:"checkpoint_id"` + SessionID string `json:"session_id"` + Agent string `json:"agent"` + Model string `json:"model,omitempty"` + Branch string `json:"branch,omitempty"` + CreatedAt string `json:"created_at"` + Tokens int `json:"tokens"` + Turns int `json:"turns"` + HasSummary bool `json:"has_summary"` + Summary *checkpoint.Summary `json:"summary,omitempty"` +} + +var runSummaryTUI = summarytui.RunWithCurrentBranch //nolint:gochecknoglobals // injectable for testing + +func newSummaryCmd() *cobra.Command { + opts := summaryOptions{Last: defaultSummarySessionLimit} + + cmd := &cobra.Command{ + Use: "summary [checkpoint-id]", + Short: "Browse source sessions with summaries", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + w := cmd.OutOrStdout() + + if checkDisabledGuard(ctx, w) { + return nil + } + + if len(args) > 0 { + opts.CheckpointPrefix = args[0] + } + + return runSummary(ctx, w, opts) + }, + } + + cmd.Flags().IntVar(&opts.Last, "last", opts.Last, "number of recent sessions to browse") + cmd.Flags().StringVar(&opts.Agent, "agent", "", "filter by agent name") + cmd.Flags().StringVar(&opts.Branch, "branch", "", "filter by branch") + cmd.Flags().BoolVar(&opts.OutputJSON, "json", false, "output sessions as JSON instead of launching the browser") + return cmd +} + +func runSummary(ctx context.Context, w io.Writer, opts summaryOptions) error { + rows, err := loadSummarySessions(ctx, opts) + if err != nil { + return err + } + + if opts.OutputJSON { + return renderSummaryJSON(w, rows) + } + + if IsAccessibleMode() { + renderSummaryText(w, rows) + return nil + } + + currentBranch := "" + if opts.CheckpointPrefix == "" { + if branch, err := GetCurrentBranch(ctx); err == nil { + currentBranch = branch + } + } + + // For the "repo" view, show all loaded sessions (no branch filter). + // The TUI filters by current branch vs all in the UI. + return runSummaryTUI(ctx, rows, currentBranch, rows, generateForSession) +} + +func loadSummarySessions(ctx context.Context, opts summaryOptions) ([]summarytui.SessionData, error) { + repo, err := openRepository(ctx) + if err != nil { + return nil, fmt.Errorf("not in a git repository: %w", err) + } + + v1Store := checkpoint.NewGitStore(repo) + v2Store := checkpoint.NewV2GitStore(repo, strategy.ResolveCheckpointURL(ctx, "origin")) + preferV2 := settings.IsCheckpointsV2Enabled(ctx) + + committed, err := listCommittedForExplain(ctx, v1Store, v2Store, preferV2) + if err != nil { + return nil, fmt.Errorf("list checkpoints: %w", err) + } + + // Checkpoint prefix lookup: filter by prefix. + if opts.CheckpointPrefix != "" { + var filtered []checkpoint.CommittedInfo + for _, info := range committed { + if strings.HasPrefix(info.CheckpointID.String(), opts.CheckpointPrefix) { + filtered = append(filtered, info) + } + } + if len(filtered) == 0 { + return nil, fmt.Errorf("checkpoint not found: %s", opts.CheckpointPrefix) + } + committed = filtered + } + + // Limit to most recent sessions. + limit := min(len(committed), maxSummaryRecentSessions) + committed = committed[:limit] + + // Bulk-load metadata for each checkpoint. + sessions := make([]summarytui.SessionData, 0, len(committed)) + for _, info := range committed { + session, loadErr := loadSessionFromCheckpoint(ctx, v1Store, v2Store, preferV2, info) + if loadErr != nil { + logging.Debug(ctx, "summary: skipping unreadable checkpoint", + slog.String("checkpoint_id", info.CheckpointID.String()), + slog.String("error", loadErr.Error())) + continue + } + sessions = append(sessions, session) + } + + outputLimit := normalizedSummarySessionLimit(opts.Last) + hasCLIFilters := opts.Agent != "" || opts.Branch != "" + + // Apply CLI filters. + filtered := make([]summarytui.SessionData, 0, len(sessions)) + for _, s := range sessions { + if opts.Agent != "" && !strings.EqualFold(strings.TrimSpace(s.Agent), strings.TrimSpace(opts.Agent)) { + continue + } + if opts.Branch != "" && !strings.EqualFold(strings.TrimSpace(s.Branch), strings.TrimSpace(opts.Branch)) { + continue + } + filtered = append(filtered, s) + // Only cap results when CLI filters or JSON output are in use. + // The TUI has its own branch filter, so it needs the full result set. + if hasCLIFilters && len(filtered) == outputLimit { + break + } + } + + return filtered, nil +} + +// loadSessionFromCheckpoint reads checkpoint metadata and converts to SessionData. +func loadSessionFromCheckpoint(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, preferV2 bool, info checkpoint.CommittedInfo) (summarytui.SessionData, error) { + reader, summary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, info.CheckpointID, v1Store, v2Store, preferV2) + if err != nil { + return summarytui.SessionData{}, fmt.Errorf("resolve checkpoint: %w", err) + } + + latestIndex := 0 + if summary != nil && len(summary.Sessions) > 0 { + latestIndex = len(summary.Sessions) - 1 + } + + content, err := reader.ReadSessionContent(ctx, info.CheckpointID, latestIndex) + if err != nil { + return summarytui.SessionData{}, fmt.Errorf("read session content: %w", err) + } + + return committedToSessionData(info, latestIndex, &content.Metadata), nil +} + +// committedToSessionData converts checkpoint metadata into TUI SessionData. +func committedToSessionData(info checkpoint.CommittedInfo, sessionIndex int, meta *checkpoint.CommittedMetadata) summarytui.SessionData { + s := summarytui.SessionData{ + CheckpointID: info.CheckpointID.String(), + SessionID: meta.SessionID, + SessionIndex: sessionIndex, + Agent: string(meta.Agent), + Model: meta.Model, + Branch: meta.Branch, + CreatedAt: meta.CreatedAt, + FilesTouched: meta.FilesTouched, + Summary: meta.Summary, + } + + if meta.TokenUsage != nil { + s.InputTokens = meta.TokenUsage.InputTokens + s.CacheTokens = meta.TokenUsage.CacheCreationTokens + meta.TokenUsage.CacheReadTokens + s.OutputTokens = meta.TokenUsage.OutputTokens + s.TotalTokens = meta.TokenUsage.InputTokens + meta.TokenUsage.CacheCreationTokens + meta.TokenUsage.CacheReadTokens + meta.TokenUsage.OutputTokens + } + + if meta.SessionMetrics != nil { + s.DurationMs = meta.SessionMetrics.DurationMs + s.TurnCount = meta.SessionMetrics.TurnCount + } + + return s +} + +func normalizedSummarySessionLimit(last int) int { + if last <= 0 { + return defaultSummarySessionLimit + } + return min(last, maxSummaryRecentSessions) +} + +func renderSummaryJSON(w io.Writer, rows []summarytui.SessionData) error { + views := make([]summarySessionView, 0, len(rows)) + for _, row := range rows { + views = append(views, sessionDataToView(row)) + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(views); err != nil { + return fmt.Errorf("encode summary sessions: %w", err) + } + return nil +} + +func renderSummaryText(w io.Writer, rows []summarytui.SessionData) { + s := termstyle.New(w) + fmt.Fprintln(w, s.Render(s.Bold, "Session Summary")) + if len(rows) == 0 { + fmt.Fprintln(w, "No sessions found.") + return + } + + for _, row := range rows { + fmt.Fprintf(w, "\n%s %s %s\n", row.CreatedAt.Format("2006-01-02 15:04"), row.Agent, row.SessionID) + if row.Branch != "" { + fmt.Fprintf(w, "Branch: %s\n", row.Branch) + } + fmt.Fprintf(w, "Checkpoint: %s\n", row.CheckpointID) + + hasSummary := row.Summary != nil + fmt.Fprintf(w, "Summary: %s\n", yesNo(hasSummary)) + + if hasSummary { + fmt.Fprintln(w, "Intent:") + fmt.Fprintf(w, " %s\n", fallbackText(row.Summary.Intent, "No summary cached")) + fmt.Fprintln(w, "Outcome:") + fmt.Fprintf(w, " %s\n", fallbackText(row.Summary.Outcome, "No summary cached")) + renderStringListSection(w, "Friction", row.Summary.Friction, "No friction recorded") + renderStringListSection(w, "Open Items", row.Summary.OpenItems, "No open items") + renderLearningSection(w, row.Summary.Learnings) + } else { + fmt.Fprintln(w, "No summary cached") + } + } +} + +func renderLearningSection(w io.Writer, learnings checkpoint.LearningsSummary) { + hasLearnings := len(learnings.Repo) > 0 || len(learnings.Code) > 0 || len(learnings.Workflow) > 0 + if !hasLearnings { + fmt.Fprintln(w, "Learnings:") + fmt.Fprintln(w, " No learnings recorded") + return + } + + fmt.Fprintln(w, "Learnings:") + for _, item := range learnings.Repo { + fmt.Fprintf(w, " - [repo] %s\n", item) + } + for _, item := range learnings.Code { + if item.Path != "" { + fmt.Fprintf(w, " - [code] %s (%s)\n", item.Finding, item.Path) + continue + } + fmt.Fprintf(w, " - [code] %s\n", item.Finding) + } + for _, item := range learnings.Workflow { + fmt.Fprintf(w, " - [workflow] %s\n", item) + } +} + +func renderStringListSection(w io.Writer, title string, items []string, empty string) { + fmt.Fprintf(w, "%s:\n", title) + if len(items) == 0 { + fmt.Fprintf(w, " %s\n", empty) + return + } + for _, item := range items { + fmt.Fprintf(w, " - %s\n", item) + } +} + +func sessionDataToView(row summarytui.SessionData) summarySessionView { + return summarySessionView{ + CheckpointID: row.CheckpointID, + SessionID: row.SessionID, + Agent: row.Agent, + Model: row.Model, + Branch: row.Branch, + CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + Tokens: row.TotalTokens, + Turns: row.TurnCount, + HasSummary: row.Summary != nil, + Summary: row.Summary, + } +} + +func yesNo(v bool) string { + if v { + return "yes" + } + return "no" +} + +func fallbackText(value, fb string) string { + if strings.TrimSpace(value) == "" { + return fb + } + return value +} + +// generateForSession generates summary and facets for a single session on demand. +// Persists the summary to the checkpoint store; facets are kept in-memory only. +func generateForSession(ctx context.Context, session summarytui.SessionData) (summarytui.SessionData, error) { + repo, err := openRepository(ctx) + if err != nil { + return session, fmt.Errorf("open repository: %w", err) + } + + v1Store := checkpoint.NewGitStore(repo) + v2Store := checkpoint.NewV2GitStore(repo, strategy.ResolveCheckpointURL(ctx, "origin")) + preferV2 := settings.IsCheckpointsV2Enabled(ctx) + + cpID, err := checkpointid.NewCheckpointID(session.CheckpointID) + if err != nil { + return session, fmt.Errorf("invalid checkpoint ID: %w", err) + } + + reader, cpSummary, err := checkpoint.ResolveCommittedReaderForCheckpoint(ctx, cpID, v1Store, v2Store, preferV2) + if err != nil { + return session, fmt.Errorf("resolve checkpoint: %w", err) + } + + content, err := reader.ReadSessionContent(ctx, cpID, session.SessionIndex) + if err != nil { + return session, fmt.Errorf("read session content: %w", err) + } + if len(content.Transcript) == 0 { + return session, fmt.Errorf("empty transcript for checkpoint %s", session.CheckpointID) + } + + // Scope transcript to this checkpoint's portion. + scopedTranscript := scopeTranscriptForCheckpoint(content.Transcript, content.Metadata.GetTranscriptStart(), content.Metadata.Agent) + if len(scopedTranscript) == 0 { + return session, fmt.Errorf("scoped transcript empty for checkpoint %s", session.CheckpointID) + } + + // Determine files touched for summary input. + filesTouched := session.FilesTouched + if cpSummary != nil && len(cpSummary.FilesTouched) > 0 { + filesTouched = cpSummary.FilesTouched + } + + // Generate summary (reuses explain.go's helper). + summary, genErr := generateCheckpointAISummary(ctx, scopedTranscript, filesTouched, content.Metadata.Agent) + if genErr != nil { + logging.Debug(ctx, "generateForSession: summary generation failed", + "checkpoint_id", session.CheckpointID, "error", genErr) + } + if summary != nil { + // Persist to checkpoint store (same as explain --generate). + v1Err := v1Store.UpdateSummary(ctx, cpID, summary) + if v1Err != nil { + logging.Debug(ctx, "generateForSession: v1 UpdateSummary failed", + slog.String("checkpoint_id", session.CheckpointID), + slog.String("error", v1Err.Error())) + } + if v2Store != nil { + v2Err := v2Store.UpdateSummary(ctx, cpID, summary) + if v2Err != nil { + logging.Debug(ctx, "generateForSession: v2 UpdateSummary failed", + slog.String("checkpoint_id", session.CheckpointID), + slog.String("error", v2Err.Error())) + } + } + session.Summary = summary + } + + if genErr != nil { + return session, fmt.Errorf("summary generation: %w", genErr) + } + return session, nil +} diff --git a/cmd/entire/cli/summary_cmd_test.go b/cmd/entire/cli/summary_cmd_test.go new file mode 100644 index 0000000000..9b9bb31a6b --- /dev/null +++ b/cmd/entire/cli/summary_cmd_test.go @@ -0,0 +1,197 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/summarytui" + "github.com/stretchr/testify/require" +) + +func TestNewRootCmd_RegistersSummaryCommand(t *testing.T) { + t.Parallel() + + root := NewRootCmd() + + var found bool + for _, cmd := range root.Commands() { + if cmd.Name() == "summary" { + found = true + break + } + } + + require.True(t, found, "expected 'summary' command to be registered") +} + +func TestSummaryCmd_HasExpectedMetadata(t *testing.T) { + t.Parallel() + + cmd := newSummaryCmd() + require.Equal(t, "summary [checkpoint-id]", cmd.Use) + require.NotEmpty(t, cmd.Short) + require.NotNil(t, cmd.RunE) +} + +func TestSummaryCmd_AcceptsCheckpointArg(t *testing.T) { + t.Parallel() + + cmd := newSummaryCmd() + // Should accept 0 or 1 args + require.NoError(t, cmd.Args(cmd, []string{})) + require.NoError(t, cmd.Args(cmd, []string{"a1b2c3"})) + require.Error(t, cmd.Args(cmd, []string{"a1b2c3", "extra"})) +} + +func TestRenderSummaryJSON_EncodesSessionData(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + rows := []summarytui.SessionData{sampleSummarySessionData()} + + require.NoError(t, renderSummaryJSON(&buf, rows)) + + var decoded []summarySessionView + require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded)) + require.Len(t, decoded, 1) + require.Equal(t, "chk-summary", decoded[0].CheckpointID) + require.Equal(t, "Fix flaky tests", decoded[0].Summary.Intent) +} + +func TestRenderSummaryText_ShowsSummarySection(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderSummaryText(&buf, []summarytui.SessionData{sampleSummarySessionData()}) + + out := buf.String() + require.Contains(t, out, "Session Summary") + require.Contains(t, out, "Fix flaky tests") + require.Contains(t, out, "Stabilized the failing integration test") + require.Contains(t, out, "Friction") + require.Contains(t, out, "Fixture setup was duplicated across tests") + require.Contains(t, out, "Learnings") + require.Contains(t, out, "[repo]") + require.Contains(t, out, "Canary tests must run after prompt wording changes") +} + +func TestRenderSummaryText_ShowsBranchAndOpenItems(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderSummaryText(&buf, []summarytui.SessionData{sampleSummarySessionData()}) + + out := buf.String() + require.Contains(t, out, "Branch: feature/summary-browser") + require.Contains(t, out, "Open Items") + require.Contains(t, out, "Run focused tests before broad verification") +} + +func TestRunSummary_AccessibleDoesNotStartTUI(t *testing.T) { + t.Setenv("ACCESSIBLE", "1") + + originalRun := runSummaryTUI + t.Cleanup(func() { runSummaryTUI = originalRun }) + + var called bool + runSummaryTUI = func(_ context.Context, _ []summarytui.SessionData, _ string, _ []summarytui.SessionData, _ summarytui.GenerateFunc) error { + called = true + return nil + } + + var buf bytes.Buffer + rows := []summarytui.SessionData{sampleSummarySessionData()} + renderSummaryText(&buf, rows) + + require.False(t, called, "accessible mode should not launch the TUI") + require.Contains(t, buf.String(), "Session Summary") +} + +func TestRenderSummaryText_EmptySessionsShowsMessage(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderSummaryText(&buf, nil) + + out := buf.String() + require.Contains(t, out, "No sessions found.") +} + +func TestRenderSummaryText_NoSummaryShowsMessage(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderSummaryText(&buf, []summarytui.SessionData{ + { + CheckpointID: "chk-nosummary", + SessionID: "sess-nosummary", + Agent: "Claude Code", + CreatedAt: time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC), + }, + }) + + out := buf.String() + require.Contains(t, out, "No summary cached") +} + +func TestSessionDataToView_ConvertsProperly(t *testing.T) { + t.Parallel() + + data := sampleSummarySessionData() + view := sessionDataToView(data) + + require.Equal(t, "chk-summary", view.CheckpointID) + require.Equal(t, "sess-summary", view.SessionID) + require.Equal(t, "Claude Code", view.Agent) + require.Equal(t, "sonnet", view.Model) + require.Equal(t, 3210, view.Tokens) + require.Equal(t, 7, view.Turns) + require.True(t, view.HasSummary) + require.Equal(t, "Fix flaky tests", view.Summary.Intent) +} + +func TestNormalizedSummarySessionLimit(t *testing.T) { + t.Parallel() + + require.Equal(t, defaultSummarySessionLimit, normalizedSummarySessionLimit(0)) + require.Equal(t, defaultSummarySessionLimit, normalizedSummarySessionLimit(-1)) + require.Equal(t, 5, normalizedSummarySessionLimit(5)) + require.Equal(t, maxSummaryRecentSessions, normalizedSummarySessionLimit(500)) +} + +func sampleSummarySessionData() summarytui.SessionData { + now := time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC) + return summarytui.SessionData{ + CheckpointID: "chk-summary", + SessionID: "sess-summary", + SessionIndex: 0, + Agent: "Claude Code", + Model: "sonnet", + Branch: "feature/summary-browser", + CreatedAt: now, + TotalTokens: 3210, + TurnCount: 7, + FilesTouched: []string{"cmd/cli/strategy/common.go"}, + Summary: &checkpoint.Summary{ + Intent: "Fix flaky tests", + Outcome: "Stabilized the failing integration test", + Friction: []string{ + "Fixture setup was duplicated across tests", + }, + Learnings: checkpoint.LearningsSummary{ + Repo: []string{"Canary tests must run after prompt wording changes"}, + Workflow: []string{"Write the regression test before adjusting helper code"}, + Code: []checkpoint.CodeLearning{ + {Path: "cmd/entire/cli/summary_cmd.go", Finding: "Keep loader and rendering separate"}, + }, + }, + OpenItems: []string{ + "Run focused tests before broad verification", + }, + }, + } +} diff --git a/cmd/entire/cli/summarytui/detail_page.go b/cmd/entire/cli/summarytui/detail_page.go new file mode 100644 index 0000000000..7ffd486682 --- /dev/null +++ b/cmd/entire/cli/summarytui/detail_page.go @@ -0,0 +1,239 @@ +package summarytui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// renderMetadataHeader renders the fixed metadata header with two side-by-side boxes. +func renderMetadataHeader(s styles, row SessionData, width int) string { + tokensWidth := 14 + sessionWidth := max(0, width-tokensWidth-3) // gap between boxes + + // SESSION box content — two lines for readability + sep := s.render(s.dim, " · ") + + var line1 []string + if row.CheckpointID != "" { + line1 = append(line1, s.render(s.detailLabel, "checkpoint:")+s.render(s.detailValue, " "+row.CheckpointID)) + } + if row.Agent != "" { + line1 = append(line1, s.render(s.detailLabel, "agent:")+s.render(s.detailValue, " "+row.Agent)) + } + + var line2 []string + if row.Branch != "" { + line2 = append(line2, s.render(s.detailLabel, "branch:")+s.render(s.detailValue, " "+row.Branch)) + } + if row.Model != "" { + line2 = append(line2, s.render(s.detailLabel, "model:")+s.render(s.detailValue, " "+row.Model)) + } + if row.TurnCount > 0 { + line2 = append(line2, s.render(s.detailLabel, "turns:")+s.render(s.detailValue, " "+strconv.Itoa(row.TurnCount))) + } + + var lines []string + if len(line1) > 0 { + lines = append(lines, strings.Join(line1, sep)) + } + if len(line2) > 0 { + lines = append(lines, strings.Join(line2, sep)) + } + sessionContent := strings.Join(lines, "\n") + sessionBox := s.renderBox("SESSION", sessionContent, sessionWidth) + + // TOKENS box content + tokensContent := s.render(s.detailValue, formatTokensForDetail(row.TotalTokens)) + tokensBox := s.renderBox("TOKENS", tokensContent, tokensWidth) + + return lipgloss.JoinHorizontal(lipgloss.Top, sessionBox, " ", tokensBox) +} + +// renderDetailContent renders the scrollable detail content with bordered boxes. +func renderDetailContent(s styles, row SessionData, width int) string { + var sections []string + + // Box 1: SUMMARY (intent, outcome, token stats) + if box := renderSummaryBox(s, row, width); box != "" { + sections = append(sections, box) + } + + // Box 2: CODE (files touched) + if box := renderCodeBox(s, row, width); box != "" { + sections = append(sections, box) + } + + // Box 3: LEARNINGS + if row.Summary != nil { + if box := renderLearningsBox(s, row, width); box != "" { + sections = append(sections, box) + } + } + + // Box 4: SIGNALS (friction, open items) + if box := renderSignalsBox(s, row, width); box != "" { + sections = append(sections, box) + } + + if len(sections) == 0 { + return s.render(s.emptyState, " No summary or insights cached. Press g to generate.") + } + + return strings.Join(sections, "\n\n") +} + +// renderSubSection renders a dim uppercase sub-section header followed by content lines. +// Returns nil if lines is empty. Prepends a blank line separator when appending to +// existing content (caller passes current length so first sub-section has no leading gap). +func renderSubSection(s styles, currentLen int, title string, lines []string) []string { + if len(lines) == 0 { + return nil + } + var result []string + if currentLen > 0 { + result = append(result, "") // blank line separator between sub-sections + } + result = append(result, s.render(s.dim, strings.ToUpper(title))) + result = append(result, lines...) + return result +} + +func renderCodeBox(s styles, row SessionData, width int) string { + var allLines []string + + // Files touched + var fileLines []string + for _, f := range row.FilesTouched { + fileLines = append(fileLines, s.render(s.bullet, "• ")+f) + } + allLines = append(allLines, renderSubSection(s, len(allLines), "Files Touched", fileLines)...) + + if len(allLines) == 0 { + return "" + } + return s.renderBox("CODE", strings.Join(allLines, "\n"), width) +} + +func renderSummaryBox(s styles, row SessionData, width int) string { + var lines []string + + if row.Summary != nil { + if row.Summary.Intent != "" { + lines = append(lines, s.render(s.detailLabel, "Intent: ")+s.render(s.detailValue, row.Summary.Intent)) + } + if row.Summary.Outcome != "" { + lines = append(lines, s.render(s.detailLabel, "Outcome: ")+s.render(s.detailValue, row.Summary.Outcome)) + } + } + + // Stats line + hasStats := row.InputTokens > 0 || row.OutputTokens > 0 || row.DurationMs > 0 + if hasStats { + if len(lines) > 0 { + lines = append(lines, "") + } + var parts []string + parts = append(parts, s.render(s.detailLabel, "Tokens: ")+ + s.render(s.detailValue, formatTokensForDetail(row.InputTokens)+" in · "+ + formatTokensForDetail(row.CacheTokens)+" cache · "+ + formatTokensForDetail(row.OutputTokens)+" out")) + if row.DurationMs > 0 { + parts = append(parts, s.render(s.detailLabel, "Time: ")+ + s.render(s.detailValue, formatDuration(row.DurationMs))) + } + lines = append(lines, strings.Join(parts, " ")) + } + + if len(lines) == 0 { + return "" + } + return s.renderBox("SUMMARY", strings.Join(lines, "\n"), width) +} + +func renderLearningsBox(s styles, row SessionData, width int) string { + if row.Summary == nil { + return "" + } + var lines []string + + // Repo learnings + for _, item := range row.Summary.Learnings.Repo { + lines = append(lines, s.render(s.bullet, "• ")+item+s.render(s.dim, " (repo)")) + } + + // Code learnings + for _, item := range row.Summary.Learnings.Code { + text := item.Finding + if item.Path != "" { + text += s.render(s.dim, " ("+item.Path+")") + } + lines = append(lines, s.render(s.bullet, "• ")+text) + } + + // Workflow learnings + for _, item := range row.Summary.Learnings.Workflow { + lines = append(lines, s.render(s.bullet, "• ")+item+s.render(s.dim, " (workflow)")) + } + + if len(lines) == 0 { + return "" + } + return s.renderBox("LEARNINGS", strings.Join(lines, "\n"), width) +} + +func renderSignalsBox(s styles, row SessionData, width int) string { + if row.Summary == nil { + return "" + } + + var allLines []string + + // Friction + var frictionLines []string + for _, item := range row.Summary.Friction { + frictionLines = append(frictionLines, s.render(s.bullet, "• ")+item) + } + allLines = append(allLines, renderSubSection(s, len(allLines), "Friction", frictionLines)...) + + // Open items + var openLines []string + for _, item := range row.Summary.OpenItems { + openLines = append(openLines, s.render(s.bullet, "• ")+item) + } + allLines = append(allLines, renderSubSection(s, len(allLines), "Open Items", openLines)...) + + if len(allLines) == 0 { + return "" + } + return s.renderBox("SIGNALS", strings.Join(allLines, "\n"), width) +} + +func formatTokensForDetail(tokens int) string { + switch { + case tokens >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + return fmt.Sprintf("%.1fk", float64(tokens)/1_000) + default: + return strconv.Itoa(tokens) + } +} + +func formatDuration(ms int64) string { + totalSec := ms / 1000 + switch { + case totalSec >= 3600: + h := totalSec / 3600 + m := (totalSec % 3600) / 60 + return fmt.Sprintf("%dh %dm", h, m) + case totalSec >= 60: + m := totalSec / 60 + s := totalSec % 60 + return fmt.Sprintf("%dm %ds", m, s) + default: + return fmt.Sprintf("%ds", totalSec) + } +} diff --git a/cmd/entire/cli/summarytui/detail_page_test.go b/cmd/entire/cli/summarytui/detail_page_test.go new file mode 100644 index 0000000000..5e80cdf01e --- /dev/null +++ b/cmd/entire/cli/summarytui/detail_page_test.go @@ -0,0 +1,267 @@ +package summarytui + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/stretchr/testify/require" +) + +func TestRenderDetailContent_ShowsSummaryAndCode(t *testing.T) { + t.Parallel() + + row := sampleRowsForTest()[0] + s := newStyles() + content := renderDetailContent(s, row, 80) + + // Summary box + require.Contains(t, content, "Fix flaky tests") + require.Contains(t, content, "Stabilized the failing integration test") + // Code box + require.Contains(t, content, "CODE") + require.Contains(t, content, "cmd/cli/strategy/common.go") + // Learnings box + require.Contains(t, content, "LEARNINGS") + require.Contains(t, content, "Always use repo root for git-relative paths") + // Signals box + require.Contains(t, content, "SIGNALS") + require.Contains(t, content, "Fixture setup was duplicated across tests") +} + +func TestRenderDetailContent_EmptyState(t *testing.T) { + t.Parallel() + + row := SessionData{ + SessionID: "empty", + } + s := newStyles() + content := renderDetailContent(s, row, 60) + + require.Contains(t, content, "No summary or insights cached") +} + +func TestRenderDetailContent_SummaryOnly(t *testing.T) { + t.Parallel() + + row := SessionData{ + SessionID: "summary-only", + Summary: &checkpoint.Summary{ + Intent: "Test intent", + Outcome: "Test outcome", + }, + } + s := newStyles() + content := renderDetailContent(s, row, 60) + + require.Contains(t, content, "Test intent") + require.Contains(t, content, "Test outcome") + require.NotContains(t, content, "CODE") +} + +func TestRenderDetailContent_OmitsEmptyBoxes(t *testing.T) { + t.Parallel() + + row := SessionData{ + SessionID: "no-friction", + Summary: &checkpoint.Summary{ + Intent: "Some intent", + OpenItems: []string{"finish tests"}, + }, + } + s := newStyles() + content := renderDetailContent(s, row, 80) + + require.Contains(t, content, "SUMMARY") + require.NotContains(t, content, "CODE") + require.NotContains(t, content, "LEARNINGS") + require.Contains(t, content, "SIGNALS") + require.Contains(t, content, "finish tests") +} + +func TestRenderMetadataHeader_ShowsSessionInfo(t *testing.T) { + t.Parallel() + + row := sampleRowsForTest()[0] + s := newStyles() + header := renderMetadataHeader(s, row, 60) + + require.Contains(t, header, "SESSION") + require.Contains(t, header, "TOKENS") + require.Contains(t, header, "feature/summary-browser") + require.Contains(t, header, "sonnet") + require.Contains(t, header, "3.2k") +} + +func TestFormatTokensForDetail(t *testing.T) { + t.Parallel() + + require.Equal(t, "0", formatTokensForDetail(0)) + require.Equal(t, "500", formatTokensForDetail(500)) + require.Equal(t, "3.2k", formatTokensForDetail(3200)) + require.Equal(t, "1.5M", formatTokensForDetail(1500000)) +} + +func TestRenderSummaryBox_WithStats(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{ + Intent: "Fix flaky tests", + Outcome: "Stabilized 3 tests", + }, + InputTokens: 45200, + CacheTokens: 12100, + OutputTokens: 8300, + DurationMs: 272000, + } + s := newStyles() + content := renderSummaryBox(s, row, 80) + + require.Contains(t, content, "SUMMARY") + require.Contains(t, content, "Fix flaky tests") + require.Contains(t, content, "Stabilized 3 tests") + require.Contains(t, content, "45.2k in") + require.Contains(t, content, "12.1k cache") + require.Contains(t, content, "8.3k out") + require.Contains(t, content, "4m 32s") +} + +func TestRenderSummaryBox_IntentOnlyNoStats(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{ + Intent: "Quick fix", + }, + } + s := newStyles() + content := renderSummaryBox(s, row, 80) + + require.Contains(t, content, "Quick fix") + require.NotContains(t, content, "Tokens:") +} + +func TestRenderSummaryBox_EmptyWhenNoData(t *testing.T) { + t.Parallel() + + row := SessionData{} + s := newStyles() + content := renderSummaryBox(s, row, 80) + + require.Empty(t, content) +} + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + require.Equal(t, "0s", formatDuration(0)) + require.Equal(t, "5s", formatDuration(5000)) + require.Equal(t, "59s", formatDuration(59000)) + require.Equal(t, "1m 0s", formatDuration(60000)) + require.Equal(t, "4m 32s", formatDuration(272000)) + require.Equal(t, "59m 59s", formatDuration(3599000)) + require.Equal(t, "1h 0m", formatDuration(3600000)) + require.Equal(t, "2h 15m", formatDuration(8100000)) +} + +func TestRenderCodeBox(t *testing.T) { + t.Parallel() + + row := SessionData{ + FilesTouched: []string{"cmd/cli/foo.go", "cmd/cli/bar.go"}, + } + s := newStyles() + content := renderCodeBox(s, row, 80) + + require.Contains(t, content, "CODE") + require.Contains(t, content, "FILES TOUCHED") + require.Contains(t, content, "cmd/cli/foo.go") + require.Contains(t, content, "cmd/cli/bar.go") +} + +func TestRenderCodeBox_ReturnsEmptyWhenNoFiles(t *testing.T) { + t.Parallel() + + row := SessionData{} + s := newStyles() + content := renderCodeBox(s, row, 80) + + require.Empty(t, content) +} + +func TestRenderSignalsBox_ShowsFrictionAndOpenItems(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{ + Friction: []string{"Fixture setup duplicated"}, + OpenItems: []string{"Add pre-commit hook for tests"}, + }, + } + s := newStyles() + content := renderSignalsBox(s, row, 80) + + require.Contains(t, content, "SIGNALS") + require.Contains(t, content, "FRICTION") + require.Contains(t, content, "Fixture setup duplicated") + require.Contains(t, content, "OPEN ITEMS") + require.Contains(t, content, "Add pre-commit hook for tests") +} + +func TestRenderSignalsBox_ReturnsEmptyForNoSignals(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{}, + } + s := newStyles() + content := renderSignalsBox(s, row, 80) + + require.Empty(t, content) +} + +func TestRenderSignalsBox_ReturnsEmptyWhenNoSummary(t *testing.T) { + t.Parallel() + + row := SessionData{} + s := newStyles() + content := renderSignalsBox(s, row, 80) + + require.Empty(t, content) +} + +func TestRenderLearningsBox_GroupsByScope(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{ + Learnings: checkpoint.LearningsSummary{ + Repo: []string{"Use repo root for paths"}, + Code: []checkpoint.CodeLearning{{Finding: "Handler needs context", Path: "cmd/handler.go"}}, + Workflow: []string{"Run tests before committing"}, + }, + }, + } + s := newStyles() + content := renderLearningsBox(s, row, 80) + + require.Contains(t, content, "LEARNINGS") + require.Contains(t, content, "Use repo root for paths") + require.Contains(t, content, "(repo)") + require.Contains(t, content, "Handler needs context") + require.Contains(t, content, "(cmd/handler.go)") + require.Contains(t, content, "Run tests before committing") + require.Contains(t, content, "(workflow)") +} + +func TestRenderLearningsBox_EmptyWhenNoLearnings(t *testing.T) { + t.Parallel() + + row := SessionData{ + Summary: &checkpoint.Summary{}, + } + s := newStyles() + content := renderLearningsBox(s, row, 80) + + require.Empty(t, content) +} diff --git a/cmd/entire/cli/summarytui/keys.go b/cmd/entire/cli/summarytui/keys.go new file mode 100644 index 0000000000..9ee0e83c38 --- /dev/null +++ b/cmd/entire/cli/summarytui/keys.go @@ -0,0 +1,49 @@ +package summarytui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + cursorUp key.Binding + cursorDown key.Binding + cycleTimeFilter key.Binding + cycleBranchFilter key.Binding + nextPage key.Binding + prevPage key.Binding + generate key.Binding + quit key.Binding +} + +var keys = keyMap{ + cursorUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + cursorDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + cycleTimeFilter: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "time filter"), + ), + cycleBranchFilter: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "branch filter"), + ), + nextPage: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next page"), + ), + prevPage: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "prev page"), + ), + generate: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "generate"), + ), + quit: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ), +} diff --git a/cmd/entire/cli/summarytui/root.go b/cmd/entire/cli/summarytui/root.go new file mode 100644 index 0000000000..701e3f2c15 --- /dev/null +++ b/cmd/entire/cli/summarytui/root.go @@ -0,0 +1,642 @@ +package summarytui + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" +) + +// SessionData is the TUI's internal data model, populated from checkpoint store data. +type SessionData struct { + CheckpointID string + SessionID string + SessionIndex int + Agent string + Model string + Branch string + CreatedAt time.Time + FilesTouched []string + // Token usage (from CommittedMetadata.TokenUsage) + InputTokens int + CacheTokens int + OutputTokens int + TotalTokens int + // Session metrics (when available from agent hooks) + DurationMs int64 + TurnCount int + // Summary (nil when not yet generated) + Summary *checkpoint.Summary +} + +const defaultPageSize = 10 + +// Chrome: filter bar (1) + gap (1) + separator (1) + list header (1) + separator (1) + status bar (1) + padding (1) = 7 +const verticalChrome = 7 + +// GenerateFunc generates summary and facets for a single session on demand. +type GenerateFunc func(ctx context.Context, session SessionData) (SessionData, error) + +type branchFilter int + +const ( + filterCurrentBranch branchFilter = iota // current working branch + filterRepo // all branches in this repo +) + +type timeFilter int + +const ( + timeFilter24h timeFilter = iota + timeFilter7d + timeFilter30d + timeFilterAll +) + +//nolint:recvcheck // bubbletea pattern: value receiver for interface, pointer receivers for mutation helpers +type rootModel struct { + ctx context.Context + branchRows []SessionData // rows for "current branch" view + repoRows []SessionData // rows for "repo" view + filteredRows []SessionData + currentBranch string + branchFilter branchFilter + timeFilter timeFilter + cursor int + paginator paginator.Model + pageSize int + detailVP viewport.Model + width int + height int + styles styles + generateFn GenerateFunc + generating bool + genStatus string // status message for generate operation + accessible bool // accessible mode fallback +} + +type generateDoneMsg struct { + row SessionData +} + +type generateErrMsg struct { + err error +} + +func Run(ctx context.Context, rows []SessionData) error { + return RunWithCurrentBranch(ctx, rows, "", nil, nil) +} + +func RunWithCurrentBranch(_ context.Context, rows []SessionData, currentBranch string, repoRows []SessionData, generateFn GenerateFunc) error { + p := tea.NewProgram(newRootModel(rows, currentBranch, repoRows, generateFn), tea.WithAltScreen()) + _, err := p.Run() + if err != nil { + return fmt.Errorf("run summary TUI: %w", err) + } + return nil +} + +func newRootModel(rows []SessionData, currentBranch string, repoRows []SessionData, generateFn GenerateFunc) rootModel { + s := newStyles() + p := paginator.New() + p.PerPage = defaultPageSize + + accessible := os.Getenv("ACCESSIBLE") != "" + + vp := viewport.New(60, 20) + vp.MouseWheelEnabled = false + + m := rootModel{ + ctx: context.Background(), + branchRows: append([]SessionData(nil), rows...), + repoRows: append([]SessionData(nil), repoRows...), + currentBranch: currentBranch, + branchFilter: filterCurrentBranch, + timeFilter: timeFilterAll, + paginator: p, + pageSize: defaultPageSize, + styles: s, + width: 100, + height: 30, + detailVP: vp, + generateFn: generateFn, + accessible: accessible, + } + m.rebuildFilteredRows() + m.updateDetailViewport() + return m +} + +func (m rootModel) Init() tea.Cmd { + return nil +} + +//nolint:cyclop,ireturn // required by bubbletea Model interface; state machine has inherent branching +func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.resize(msg.Width, msg.Height) + return m, nil + + case tea.KeyMsg: + return m.updateKeys(msg) + + case generateDoneMsg: + m.generating = false + m.genStatus = "Generated" + m.updateRowData(msg.row) + m.updateDetailViewport() + return m, nil + + case generateErrMsg: + m.generating = false + m.genStatus = "Error: " + msg.err.Error() + return m, nil + } + + return m, nil +} + +//nolint:cyclop,ireturn // required by bubbletea pattern +func (m rootModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.quit): + return m, tea.Quit + case key.Matches(msg, keys.cycleTimeFilter): + m.cycleTime() + return m, nil + case key.Matches(msg, keys.cycleBranchFilter): + m.cycleBranch() + return m, nil + case key.Matches(msg, keys.cursorDown): + m.moveCursor(1) + return m, nil + case key.Matches(msg, keys.cursorUp): + m.moveCursor(-1) + return m, nil + case key.Matches(msg, keys.nextPage): + m.nextPage() + return m, nil + case key.Matches(msg, keys.prevPage): + m.prevPage() + return m, nil + case key.Matches(msg, keys.generate): + return m.handleGenerate() + } + + //nolint:exhaustive // only Ctrl+C needs special handling here + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + default: + } + + // Scroll detail viewport + var cmd tea.Cmd + m.detailVP, cmd = m.detailVP.Update(msg) + return m, cmd +} + +//nolint:ireturn // required by bubbletea pattern +func (m rootModel) handleGenerate() (tea.Model, tea.Cmd) { + if m.generateFn == nil || m.generating { + return m, nil + } + row := m.selectedRow() + if row == nil { + return m, nil + } + m.generating = true + m.genStatus = "Generating..." + selected := *row + ctx := m.ctx + fn := m.generateFn + return m, func() tea.Msg { + updated, err := fn(ctx, selected) + if err != nil { + return generateErrMsg{err: err} + } + return generateDoneMsg{row: updated} + } +} + +func (m rootModel) View() string { + var b strings.Builder + + // Filter bar + b.WriteString(m.renderFilterBar()) + b.WriteString("\n\n") + + listWidth := m.listWidth() + detailWidth := m.detailWidth() + contentHeight := m.contentHeight() + + // List pane + listPane := m.renderListPane(listWidth, contentHeight) + + // Detail pane + detailPane := m.renderDetailPane(detailWidth, contentHeight) + + // Join horizontally + split := lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) + b.WriteString(split) + b.WriteString("\n") + + // Status bar + b.WriteString(m.renderStatusBar()) + + return b.String() +} + +// --- Filter bar --- + +func (m rootModel) renderFilterBar() string { + sep := m.styles.render(m.styles.filterSeparator, " │ ") + + // TIME filter + timeLabels := []struct { + filter timeFilter + label string + }{ + {timeFilter24h, "24h"}, + {timeFilter7d, "7d"}, + {timeFilter30d, "30d"}, + {timeFilterAll, "all"}, + } + var timeParts []string + for _, item := range timeLabels { + if item.filter == m.timeFilter { + timeParts = append(timeParts, m.styles.render(m.styles.filterActive, item.label)) + } else { + timeParts = append(timeParts, m.styles.render(m.styles.filterInactive, item.label)) + } + } + timeStr := m.styles.render(m.styles.filterLabel, "TIME: ") + strings.Join(timeParts, sep) + + // BRANCH filter + branchLabels := []struct { + filter branchFilter + label string + }{ + {filterCurrentBranch, "current"}, + {filterRepo, "repo"}, + } + var branchParts []string + for _, item := range branchLabels { + if item.filter == m.branchFilter { + branchParts = append(branchParts, m.styles.render(m.styles.filterActive, item.label)) + } else { + branchParts = append(branchParts, m.styles.render(m.styles.filterInactive, item.label)) + } + } + branchStr := m.styles.render(m.styles.filterLabel, "BRANCH: ") + strings.Join(branchParts, sep) + + count := m.styles.render(m.styles.sessionCount, fmt.Sprintf("%d sessions", len(m.filteredRows))) + + return timeStr + " " + branchStr + " " + count +} + +// --- List pane --- + +func (m rootModel) renderListPane(width, height int) string { + var b strings.Builder + + // Column header + header := m.formatListRow("TIME", "CKPT", "AGENT", width) + b.WriteString(m.styles.render(m.styles.listHeader, header)) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", width)) + b.WriteString("\n") + + pageRows := m.currentPageRows() + listHeight := height - 2 // header + separator + + for i, row := range pageRows { + if i >= listHeight { + break + } + timeStr := row.CreatedAt.Format("01-02 15:04") + checkpoint := row.CheckpointID + if len(checkpoint) > 6 { + checkpoint = checkpoint[:6] + } + agent := truncate(row.Agent, 14) + line := m.formatListRow(timeStr, checkpoint, agent, width) + + if i == m.cursor { + // Selected: amber text + dark background + left accent + accent := m.styles.render(m.styles.listAccent, "▸ ") + styled := m.styles.render(m.styles.listSelected, line) + if m.styles.colorEnabled { + styled = m.styles.listSelectedBg.Render(styled) + } + b.WriteString(accent + styled) + } else { + b.WriteString(" " + m.styles.render(m.styles.listNormal, line)) + } + b.WriteString("\n") + } + + // Fill remaining lines + for i := len(pageRows); i < listHeight; i++ { + b.WriteString("\n") + } + + // Page indicator + pageInfo := m.styles.render(m.styles.dim, fmt.Sprintf("%d/%d", m.paginator.Page+1, max(1, m.paginator.TotalPages))) + b.WriteString(m.styles.render(m.styles.dim, "←→ ") + pageInfo) + + return b.String() +} + +func (m rootModel) formatListRow(time, ckpt, agent string, _ int) string { + return fmt.Sprintf("%-11s %-6s %-14s", time, ckpt, agent) +} + +// --- Detail pane --- + +func (m rootModel) renderDetailPane(width, _ int) string { + var b strings.Builder + + // Column header aligned with list pane header + b.WriteString(m.styles.render(m.styles.listHeader, "Details")) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", width)) + b.WriteString("\n") + + row := m.selectedRow() + if row == nil { + b.WriteString(m.styles.render(m.styles.emptyState, " No sessions to display")) + return b.String() + } + + // Fixed metadata header + header := renderMetadataHeader(m.styles, *row, width) + b.WriteString(header) + b.WriteString("\n\n") + + // Scrollable viewport (sized in resize()) + b.WriteString(m.detailVP.View()) + + return b.String() +} + +func (m rootModel) renderStatusBar() string { + var parts []string + parts = append(parts, "j/k navigate") + parts = append(parts, "pgup/dn scroll") + parts = append(parts, "1 time") + parts = append(parts, "2 branch") + parts = append(parts, "←→ page") + if m.generateFn != nil { + parts = append(parts, "g generate") + } + parts = append(parts, "q quit") + status := strings.Join(parts, " ") + + if m.genStatus != "" { + style := m.styles.filterActive + if strings.HasPrefix(m.genStatus, "Error:") { + style = m.styles.errorText + } + status = m.styles.render(style, m.genStatus) + " " + status + } + + return m.styles.render(m.styles.statusBar, status) +} + +// --- Layout helpers --- + +func (m rootModel) listWidth() int { + return max(30, m.width*30/100) +} + +func (m rootModel) detailWidth() int { + return max(30, m.width-m.listWidth()-1) +} + +func (m rootModel) contentHeight() int { + return max(5, m.height-verticalChrome) +} + +// --- Data management --- + +func (m *rootModel) updateRowData(updated SessionData) { + for i, row := range m.branchRows { + if row.SessionID == updated.SessionID { + m.branchRows[i] = updated + break + } + } + for i, row := range m.repoRows { + if row.SessionID == updated.SessionID { + m.repoRows[i] = updated + break + } + } + for i, row := range m.filteredRows { + if row.SessionID == updated.SessionID { + m.filteredRows[i] = updated + break + } + } +} + +func (m *rootModel) rebuildFilteredRows() { + m.filteredRows = m.applyFilter() + m.paginator.PerPage = m.pageSize + totalPages := m.paginator.SetTotalPages(len(m.filteredRows)) + if totalPages == 0 { + m.paginator.TotalPages = 1 + } + if m.paginator.Page >= m.paginator.TotalPages { + m.paginator.Page = max(0, m.paginator.TotalPages-1) + } + if m.paginator.Page < 0 { + m.paginator.Page = 0 + } + if m.cursor >= len(m.currentPageRows()) { + m.cursor = max(0, len(m.currentPageRows())-1) + } +} + +func (m rootModel) applyFilter() []SessionData { + // Select source rows based on branch filter scope. + var source []SessionData + switch m.branchFilter { + case filterCurrentBranch: + source = m.branchRows + case filterRepo: + source = m.repoRows + } + + filtered := make([]SessionData, 0, len(source)) + now := time.Now() + for _, row := range source { + // Time filter + switch m.timeFilter { + case timeFilter24h: + if now.Sub(row.CreatedAt) > 24*time.Hour { + continue + } + case timeFilter7d: + if now.Sub(row.CreatedAt) > 7*24*time.Hour { + continue + } + case timeFilter30d: + if now.Sub(row.CreatedAt) > 30*24*time.Hour { + continue + } + case timeFilterAll: + // no time filtering + } + + // Current-branch view still filters by branch name. + if m.branchFilter == filterCurrentBranch && m.currentBranch != "" && row.Branch != m.currentBranch { + continue + } + + filtered = append(filtered, row) + } + return filtered +} + +func (m rootModel) currentPageRows() []SessionData { + if len(m.filteredRows) == 0 { + return nil + } + start, end := m.paginator.GetSliceBounds(len(m.filteredRows)) + if start >= len(m.filteredRows) { + return nil + } + if end > len(m.filteredRows) { + end = len(m.filteredRows) + } + return m.filteredRows[start:end] +} + +func (m rootModel) selectedRow() *SessionData { + rows := m.currentPageRows() + if m.cursor < 0 || m.cursor >= len(rows) { + return nil + } + return &rows[m.cursor] +} + +// --- Navigation --- + +func (m *rootModel) moveCursor(delta int) { + pageRows := m.currentPageRows() + if len(pageRows) == 0 { + return + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(pageRows) { + m.cursor = len(pageRows) - 1 + } + m.genStatus = "" // clear status on navigation + m.updateDetailViewport() +} + +func (m *rootModel) cycleTime() { + m.timeFilter = (m.timeFilter + 1) % 4 + m.paginator.Page = 0 + m.cursor = 0 + m.genStatus = "" + m.rebuildFilteredRows() + m.updateDetailViewport() +} + +func (m *rootModel) cycleBranch() { + m.branchFilter = (m.branchFilter + 1) % 2 + m.paginator.Page = 0 + m.cursor = 0 + m.genStatus = "" + m.rebuildFilteredRows() + m.updateDetailViewport() +} + +func (m *rootModel) nextPage() { + if m.paginator.OnLastPage() { + return + } + m.paginator.NextPage() + m.cursor = 0 + m.genStatus = "" + m.updateDetailViewport() +} + +func (m *rootModel) prevPage() { + if m.paginator.OnFirstPage() { + return + } + m.paginator.PrevPage() + m.cursor = 0 + m.genStatus = "" + m.updateDetailViewport() +} + +func (m *rootModel) updateDetailViewport() { + row := m.selectedRow() + if row == nil { + m.detailVP.SetContent("") + return + } + content := renderDetailContent(m.styles, *row, m.detailWidth()) + m.detailVP.SetContent(content) +} + +func (m *rootModel) resize(width, height int) { + selectedSessionID := "" + if selected := m.selectedRow(); selected != nil { + selectedSessionID = selected.SessionID + } + + m.width = width + m.height = height + + contentHeight := m.contentHeight() + listHeight := contentHeight - 2 // header + separator + m.pageSize = max(1, listHeight) + + // Detail viewport: subtract detail header (1) + separator (1) + metadata header (~3) + blank line (1) = 6 + vp := viewport.New(m.detailWidth(), max(3, contentHeight-6)) + vp.MouseWheelEnabled = false + m.detailVP = vp + + m.rebuildFilteredRows() + m.restoreSelection(selectedSessionID) + m.updateDetailViewport() +} + +func (m *rootModel) restoreSelection(sessionID string) { + if sessionID == "" || len(m.filteredRows) == 0 { + return + } + + for idx, row := range m.filteredRows { + if row.SessionID != sessionID { + continue + } + + m.paginator.Page = idx / m.pageSize + cursor := idx % m.pageSize + pageRows := m.currentPageRows() + if len(pageRows) > 0 && cursor >= len(pageRows) { + cursor = len(pageRows) - 1 + } + m.cursor = cursor + return + } +} diff --git a/cmd/entire/cli/summarytui/root_test.go b/cmd/entire/cli/summarytui/root_test.go new file mode 100644 index 0000000000..23b0d28690 --- /dev/null +++ b/cmd/entire/cli/summarytui/root_test.go @@ -0,0 +1,443 @@ +package summarytui + +import ( + "context" + "strconv" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/stretchr/testify/require" +) + +func TestRootView_RendersSplitPaneLayout(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + out := root.View() + + // Filter bar present + require.Contains(t, out, "TIME:") + require.Contains(t, out, "BRANCH:") + require.Contains(t, out, "sessions") + + // List header columns + require.Contains(t, out, "TIME") + require.Contains(t, out, "CKPT") + require.Contains(t, out, "AGENT") + + // Status bar + require.Contains(t, out, "j/k navigate") + require.Contains(t, out, "1 time") + require.Contains(t, out, "2 branch") + require.Contains(t, out, "q quit") + + // List pane header should not contain detail-only columns + listHeader := root.formatListRow("TIME", "CKPT", "AGENT", root.listWidth()) + require.NotContains(t, listHeader, "TOKENS") + require.NotContains(t, listHeader, "BRANCH") +} + +func TestRootView_ShowsDetailForSelectedRow(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + root.branchFilter = filterRepo + root.rebuildFilteredRows() + root.updateDetailViewport() + + out := root.View() + + // Detail pane should show the selected row's data + // First row is sess-1 with intent "Fix flaky tests" + require.Contains(t, out, "Fix flaky tests") +} + +func TestNewRootModel_DefaultsToCurrentBranchFilter(t *testing.T) { + t.Parallel() + + root := newRootModel(sampleRowsForTest(), "feature/summary-browser", nil, nil) + + require.Equal(t, filterCurrentBranch, root.branchFilter) + require.Len(t, root.filteredRows, 1) + require.Equal(t, "sess-1", root.filteredRows[0].SessionID) +} + +func TestRootUpdate_CycleBranchFilterRebuildsVisibleRows(t *testing.T) { + t.Parallel() + + rows := sampleRowsForTest() + root := newRootModel(rows, "feature/summary-browser", rows, nil) + require.Equal(t, filterCurrentBranch, root.branchFilter) + require.Len(t, root.filteredRows, 1) + + // First cycle: Current Branch → Repo (key "2") + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + root = requireRootModel(t, next) + require.Equal(t, filterRepo, root.branchFilter) + require.Len(t, root.filteredRows, 2) + + // Second cycle: Repo → Current Branch (wraps around) + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + root = requireRootModel(t, next) + require.Equal(t, filterCurrentBranch, root.branchFilter) + require.Len(t, root.filteredRows, 1) + require.Equal(t, "sess-1", root.filteredRows[0].SessionID) +} + +func TestRootUpdate_CycleTimeFilter(t *testing.T) { + t.Parallel() + + now := time.Now() + rows := []SessionData{ + {SessionID: "recent", Agent: "Claude", Branch: "main", CreatedAt: now.Add(-1 * time.Hour)}, + {SessionID: "old-week", Agent: "Claude", Branch: "main", CreatedAt: now.Add(-3 * 24 * time.Hour)}, + {SessionID: "old-month", Agent: "Claude", Branch: "main", CreatedAt: now.Add(-15 * 24 * time.Hour)}, + } + + root := newRootModel(rows, "", nil, nil) + require.Equal(t, timeFilterAll, root.timeFilter) + require.Len(t, root.filteredRows, 3) + + // Cycle to 24h + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + root = requireRootModel(t, next) + require.Equal(t, timeFilter24h, root.timeFilter) + require.Len(t, root.filteredRows, 1) + require.Equal(t, "recent", root.filteredRows[0].SessionID) + + // Cycle to 7d + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + root = requireRootModel(t, next) + require.Equal(t, timeFilter7d, root.timeFilter) + require.Len(t, root.filteredRows, 2) + + // Cycle to 30d + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + root = requireRootModel(t, next) + require.Equal(t, timeFilter30d, root.timeFilter) + require.Len(t, root.filteredRows, 3) + + // Cycle to all + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + root = requireRootModel(t, next) + require.Equal(t, timeFilterAll, root.timeFilter) + require.Len(t, root.filteredRows, 3) +} + +func TestRootUpdate_FilterChangeResetsCursor(t *testing.T) { + t.Parallel() + + root := newRootModel(paginatedRowsForTest(), "feature/summary-browser", paginatedRowsForTest(), nil) + root.branchFilter = filterRepo + root.rebuildFilteredRows() + root.moveCursor(2) // move to third row + + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + root = requireRootModel(t, next) + + require.Equal(t, 0, root.cursor) + require.Equal(t, 0, root.paginator.Page) +} + +func TestRootUpdate_CursorMovement(t *testing.T) { + t.Parallel() + + root := newRootModel(paginatedRowsForTest(), "feature/summary-browser", paginatedRowsForTest(), nil) + root.branchFilter = filterRepo + root.rebuildFilteredRows() + require.Equal(t, 0, root.cursor) + + // Move down + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + root = requireRootModel(t, next) + require.Equal(t, 1, root.cursor) + + // Move down again + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + root = requireRootModel(t, next) + require.Equal(t, 2, root.cursor) + + // Move up + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + root = requireRootModel(t, next) + require.Equal(t, 1, root.cursor) +} + +func TestRootUpdate_CursorDoesNotExceedBounds(t *testing.T) { + t.Parallel() + + root := newRootModel(sampleRowsForTest(), "feature/summary-browser", nil, nil) + require.Len(t, root.filteredRows, 1) // repo filter, only main row + + // Move up from 0 — should stay at 0 + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + root = requireRootModel(t, next) + require.Equal(t, 0, root.cursor) + + // Move down past end — should stay at last + next, _ = root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + root = requireRootModel(t, next) + require.Equal(t, 0, root.cursor) // only 1 row +} + +func TestRootUpdate_CursorChangeUpdatesDetail(t *testing.T) { + t.Parallel() + + rows := sampleRowsForTest() + root := newRootModel(rows, "feature/summary-browser", rows, nil) + root.branchFilter = filterRepo + root.cursor = 0 + root.rebuildFilteredRows() + root.resize(100, 30) + // Force cursor to 0 after resize (resize may restore to previous selection) + root.cursor = 0 + root.updateDetailViewport() + + // Initially shows first row's content (sess-1: Fix flaky tests) + require.Equal(t, "sess-1", root.selectedRow().SessionID) + content := renderDetailContent(root.styles, *root.selectedRow(), root.detailWidth()) + require.Contains(t, content, "Fix flaky tests") + + // Move to second row + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + root = requireRootModel(t, next) + + require.Equal(t, "sess-2", root.selectedRow().SessionID) + content = renderDetailContent(root.styles, *root.selectedRow(), root.detailWidth()) + require.Contains(t, content, "Update docs") +} + +func TestRootUpdate_PageNavigation(t *testing.T) { + t.Parallel() + + root := newRootModel(paginatedRowsForTest(), "feature/summary-browser", paginatedRowsForTest(), nil) + root.branchFilter = filterRepo + root.pageSize = 2 + root.rebuildFilteredRows() + root.updateDetailViewport() + + require.Equal(t, 3, root.paginator.TotalPages) + require.Equal(t, 0, root.paginator.Page) + require.Equal(t, "sess-1", root.selectedRow().SessionID) + + // Next page + next, _ := root.Update(tea.KeyMsg{Type: tea.KeyRight}) + root = requireRootModel(t, next) + require.Equal(t, 1, root.paginator.Page) + require.Equal(t, "sess-3", root.selectedRow().SessionID) +} + +func TestRootUpdate_WindowSizeRecalculates(t *testing.T) { + t.Parallel() + + root := newRootModel(sampleRowsForTest(), "feature/summary-browser", nil, nil) + + next, _ := root.Update(tea.WindowSizeMsg{Width: 120, Height: 32}) + root = requireRootModel(t, next) + + require.Equal(t, 120, root.width) + require.Equal(t, 32, root.height) + require.Equal(t, 36, root.listWidth()) // 30% of 120 + require.Equal(t, 83, root.detailWidth()) // 120 - 36 - 1 +} + +func TestRootUpdate_WindowSizePreservesSelection(t *testing.T) { + t.Parallel() + + root := newRootModel(manyCurrentBranchRowsForTest(12), "feature/summary-browser", nil, nil) + root.branchFilter = filterCurrentBranch + root.pageSize = 3 + root.rebuildFilteredRows() + root.nextPage() + root.moveCursor(1) + + selected := root.selectedRow() + require.NotNil(t, selected) + require.Equal(t, "sess-5", selected.SessionID) + + next, _ := root.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) + root = requireRootModel(t, next) + + selected = root.selectedRow() + require.NotNil(t, selected) + require.Equal(t, "sess-5", selected.SessionID) +} + +func TestRootUpdate_QKeyQuits(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + + _, cmd := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + require.NotNil(t, cmd) + msg := cmd() + require.IsType(t, tea.QuitMsg{}, msg) +} + +func TestRootUpdate_Generate(t *testing.T) { + t.Parallel() + + generateCalled := false + generateFn := func(_ context.Context, session SessionData) (SessionData, error) { + generateCalled = true + session.Summary = &checkpoint.Summary{ + Intent: "Generated intent", + Outcome: "Generated outcome", + } + return session, nil + } + + rows := sampleRowsForTest() + root := newRootModel(rows, "feature/summary-browser", rows, generateFn) + root.branchFilter = filterRepo + root.rebuildFilteredRows() + root.updateDetailViewport() + + // Press g to generate + next, cmd := root.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + root = requireRootModel(t, next) + + require.True(t, root.generating) + require.Equal(t, "Generating...", root.genStatus) + require.NotNil(t, cmd) + + // Execute the command and feed result back + msg := cmd() + next, _ = root.Update(msg) + root = requireRootModel(t, next) + + require.True(t, generateCalled) + require.False(t, root.generating) + require.Equal(t, "Generated", root.genStatus) + + // Detail should reflect updated data + content := renderDetailContent(root.styles, *root.selectedRow(), root.detailWidth()) + require.Contains(t, content, "Generated intent") +} + +func TestRootView_FilterBarShowsActiveValues(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + out := root.renderFilterBar() + + // Default: timeFilterAll active, filterCurrentBranch active + require.Contains(t, out, "all") // time + require.Contains(t, out, "current") // branch +} + +func TestRootView_GenerateStatusShown(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + root.genStatus = "Generating..." + out := root.renderStatusBar() + + require.Contains(t, out, "Generating...") +} + +func TestRootView_EmptyFilteredRows(t *testing.T) { + t.Parallel() + + root := newRootModel(nil, "", nil, nil) + out := root.View() + + require.Contains(t, out, "No sessions to display") +} + +// --- Helpers --- + +func newRootModelForTest() rootModel { + rows := sampleRowsForTest() + m := newRootModel(rows, "feature/summary-browser", rows, nil) + m.width = 100 + m.height = 30 + m.pageSize = 10 + m.rebuildFilteredRows() + m.updateDetailViewport() + return m +} + +func requireRootModel(t *testing.T, model tea.Model) rootModel { + t.Helper() + + root, ok := model.(rootModel) + require.True(t, ok) + return root +} + +func sampleRowsForTest() []SessionData { + now := time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC) + return []SessionData{ + { + CheckpointID: "chk-1", + SessionID: "sess-1", + Agent: "Claude Code", + Model: "sonnet", + Branch: "feature/summary-browser", + CreatedAt: now, + TotalTokens: 3200, + TurnCount: 7, + InputTokens: 2000, + CacheTokens: 500, + OutputTokens: 700, + DurationMs: 180000, + FilesTouched: []string{"cmd/cli/strategy/common.go", "cmd/cli/lifecycle.go"}, + Summary: &checkpoint.Summary{ + Intent: "Fix flaky tests", + Outcome: "Stabilized the failing integration test", + Friction: []string{ + "Fixture setup was duplicated across tests", + }, + Learnings: checkpoint.LearningsSummary{ + Repo: []string{"Always use repo root for git-relative paths"}, + }, + OpenItems: []string{ + "Run focused tests before broad verification", + }, + }, + }, + { + CheckpointID: "chk-2", + SessionID: "sess-2", + Agent: "Cursor", + Branch: "main", + CreatedAt: now.Add(-time.Hour), + TotalTokens: 1500, + TurnCount: 4, + Summary: &checkpoint.Summary{ + Intent: "Update docs", + Outcome: "Docs updated", + }, + }, + } +} + +func paginatedRowsForTest() []SessionData { + now := time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC) + return []SessionData{ + {CheckpointID: "chk-1", SessionID: "sess-1", Agent: "Claude Code", Branch: "feature/summary-browser", CreatedAt: now}, + {CheckpointID: "chk-2", SessionID: "sess-2", Agent: "Cursor", Branch: "main", CreatedAt: now.Add(-time.Minute)}, + {CheckpointID: "chk-3", SessionID: "sess-3", Agent: "OpenCode", Branch: "feature/summary-browser", CreatedAt: now.Add(-2 * time.Minute)}, + {CheckpointID: "chk-4", SessionID: "sess-4", Agent: "Codex", Branch: "main", CreatedAt: now.Add(-3 * time.Minute)}, + {CheckpointID: "chk-5", SessionID: "sess-5", Agent: "Claude Code", Branch: "feature/summary-browser", CreatedAt: now.Add(-4 * time.Minute)}, + {CheckpointID: "chk-6", SessionID: "sess-6", Agent: "Cursor", Branch: "main", CreatedAt: now.Add(-5 * time.Minute)}, + } +} + +func manyCurrentBranchRowsForTest(count int) []SessionData { + now := time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC) + rows := make([]SessionData, 0, count) + for i := range count { + rows = append(rows, SessionData{ + CheckpointID: "chk-" + strconv.Itoa(i+1), + SessionID: "sess-" + strconv.Itoa(i+1), + Agent: "Claude Code", + Branch: "feature/summary-browser", + CreatedAt: now.Add(-time.Duration(i) * time.Minute), + }) + } + return rows +} diff --git a/cmd/entire/cli/summarytui/styles.go b/cmd/entire/cli/summarytui/styles.go new file mode 100644 index 0000000000..6677e92dfa --- /dev/null +++ b/cmd/entire/cli/summarytui/styles.go @@ -0,0 +1,107 @@ +package summarytui + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/stringutil" + "github.com/entireio/cli/cmd/entire/cli/termstyle" +) + +type styles struct { + colorEnabled bool + appTitle lipgloss.Style + dim lipgloss.Style + statusBar lipgloss.Style + filterLabel lipgloss.Style + filterActive lipgloss.Style + filterInactive lipgloss.Style + filterSeparator lipgloss.Style + listHeader lipgloss.Style + listSelected lipgloss.Style + listSelectedBg lipgloss.Style + listNormal lipgloss.Style + listAccent lipgloss.Style + boxStyle lipgloss.Style + boxTitle lipgloss.Style + detailLabel lipgloss.Style + detailValue lipgloss.Style + bullet lipgloss.Style + emptyState lipgloss.Style + errorText lipgloss.Style + sessionCount lipgloss.Style +} + +func newStyles() styles { + useColor := termstyle.ShouldUseColor(os.Stdout) + s := styles{colorEnabled: useColor} + if !useColor { + return s + } + + amber := lipgloss.Color("214") + gray := lipgloss.Color("245") + dimGray := lipgloss.Color("239") + darkBg := lipgloss.Color("236") + borderGray := lipgloss.Color("240") + + s.appTitle = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.dim = lipgloss.NewStyle().Faint(true) + s.statusBar = lipgloss.NewStyle().Faint(true) + + // Filter bar + s.filterLabel = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.filterActive = lipgloss.NewStyle().Foreground(amber).Underline(true) + s.filterInactive = lipgloss.NewStyle().Foreground(dimGray) + s.filterSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + s.sessionCount = lipgloss.NewStyle().Foreground(dimGray) + + // List pane + s.listHeader = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.listSelected = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.listSelectedBg = lipgloss.NewStyle().Background(darkBg) + s.listNormal = lipgloss.NewStyle().Foreground(gray) + s.listAccent = lipgloss.NewStyle().Foreground(amber).Background(darkBg) + + // Detail pane boxes + s.boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderGray). + Padding(0, 1) + s.boxTitle = lipgloss.NewStyle().Bold(true).Foreground(amber) + + // Detail content + s.detailLabel = lipgloss.NewStyle().Foreground(gray) + s.detailValue = lipgloss.NewStyle() + s.bullet = lipgloss.NewStyle().Foreground(gray) + s.emptyState = lipgloss.NewStyle().Foreground(dimGray).Italic(true) + s.errorText = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + return s +} + +func (s styles) render(style lipgloss.Style, text string) string { + if !s.colorEnabled { + return text + } + return style.Render(text) +} + +func (s styles) renderBox(title, content string, width int) string { + box := s.boxStyle.Width(max(0, width-2)) // account for border + titleStr := s.render(s.boxTitle, title) + if !s.colorEnabled { + return titleStr + "\n" + content + } + box = box.BorderTop(true). + BorderBottom(true). + BorderLeft(true). + BorderRight(true) + return lipgloss.JoinVertical(lipgloss.Left, + titleStr, + box.Render(content), + ) +} + +func truncate(value string, limit int) string { + return stringutil.TruncateRunes(value, limit, "...") +} diff --git a/cmd/entire/cli/termstyle/termstyle.go b/cmd/entire/cli/termstyle/termstyle.go new file mode 100644 index 0000000000..49028baa4f --- /dev/null +++ b/cmd/entire/cli/termstyle/termstyle.go @@ -0,0 +1,154 @@ +// Package termstyle provides shared terminal styling utilities for CLI output. +// It wraps lipgloss styles with color/width detection so callers don't need +// to handle terminal detection themselves. +package termstyle + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/agent" + "golang.org/x/term" +) + +// Styles holds pre-built lipgloss styles and terminal metadata. +// ColorEnabled and Width are exported so callers can read them, but +// mutation of individual style fields should be done via assignment to the +// whole Styles value (returned from New). +type Styles struct { + ColorEnabled bool + Width int + + Green lipgloss.Style + Red lipgloss.Style + Yellow lipgloss.Style + Gray lipgloss.Style + Bold lipgloss.Style + Dim lipgloss.Style + Agent lipgloss.Style // amber/orange for agent names + Cyan lipgloss.Style +} + +// New creates a Styles value appropriate for the given output writer. +// Color is disabled when the writer is not a terminal or when NO_COLOR is set. +// Width is capped at 80 with a fallback of 60 when no terminal size is available. +func New(w io.Writer) Styles { + useColor := ShouldUseColor(w) + width := GetTerminalWidth(w) + + s := Styles{ + ColorEnabled: useColor, + Width: width, + } + + if useColor { + s.Green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + s.Red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + s.Yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + s.Gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + s.Bold = lipgloss.NewStyle().Bold(true) + s.Dim = lipgloss.NewStyle().Faint(true) + s.Agent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + s.Cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + } + + return s +} + +// Render applies the given style to text only when color is enabled. +// When color is disabled the text is returned unchanged so output stays +// machine-readable (e.g. in CI or when piped). +func (s Styles) Render(style lipgloss.Style, text string) string { + if !s.ColorEnabled { + return text + } + return style.Render(text) +} + +// ShouldUseColor returns true if the writer supports color output. +// Color is suppressed when the NO_COLOR environment variable is non-empty, +// or when the writer is not an *os.File connected to a terminal. +func ShouldUseColor(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if f, ok := w.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + } + return false +} + +// GetTerminalWidth returns the terminal width, capped at 80 with a fallback of 60. +// It first checks the writer itself, then falls back to Stdout/Stderr. +func GetTerminalWidth(w io.Writer) int { + if f, ok := w.(*os.File); ok { + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + return min(width, 80) + } + } + + for _, f := range []*os.File{os.Stdout, os.Stderr} { + if f == nil { + continue + } + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + return min(width, 80) + } + } + + return 60 +} + +// FormatTokenCount formats a token count for compact display. +// Values below 1000 are rendered as plain integers; larger values use a +// one-decimal-place "k" suffix with the trailing ".0" trimmed +// (e.g. 0→"0", 500→"500", 1000→"1k", 1200→"1.2k", 14300→"14.3k"). +func FormatTokenCount(n int) string { + if n < 1000 { + return strconv.Itoa(n) + } + f := float64(n) / 1000.0 + s := fmt.Sprintf("%.1f", f) + s = strings.TrimSuffix(s, ".0") + return s + "k" +} + +// TotalTokens recursively sums all token fields in a TokenUsage value, +// including any subagent tokens. Returns 0 for a nil pointer. +func TotalTokens(tu *agent.TokenUsage) int { + if tu == nil { + return 0 + } + total := tu.InputTokens + tu.CacheCreationTokens + tu.CacheReadTokens + tu.OutputTokens + total += TotalTokens(tu.SubagentTokens) + return total +} + +// HorizontalRule renders a dimmed horizontal rule spanning the stored width. +func (s Styles) HorizontalRule() string { + rule := strings.Repeat("─", s.Width) + return s.Render(s.Dim, rule) +} + +// SectionRule renders a section header of the form: ── Label ──────────── +// The trailing dashes fill the remaining width; trailing is at least 1. +func (s Styles) SectionRule(label string) string { + prefix := "── " + content := label + " " + usedWidth := len([]rune(prefix)) + len([]rune(content)) + trailing := s.Width - usedWidth + if trailing < 1 { + trailing = 1 + } + + var b strings.Builder + b.WriteString(s.Render(s.Dim, "── ")) + b.WriteString(s.Render(s.Dim, label)) + b.WriteString(" ") + b.WriteString(s.Render(s.Dim, strings.Repeat("─", trailing))) + return b.String() +} diff --git a/cmd/entire/cli/termstyle/termstyle_test.go b/cmd/entire/cli/termstyle/termstyle_test.go new file mode 100644 index 0000000000..ea9e3ddd23 --- /dev/null +++ b/cmd/entire/cli/termstyle/termstyle_test.go @@ -0,0 +1,174 @@ +package termstyle_test + +import ( + "bytes" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/termstyle" +) + +// TestNew_NoColor verifies that New returns a Styles with ColorEnabled=false +// when the writer is not a terminal (e.g. bytes.Buffer). +func TestNew_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + if s.ColorEnabled { + t.Error("expected ColorEnabled=false for non-terminal writer") + } +} + +// TestNew_Width verifies that New returns a fallback width when no terminal is present. +func TestNew_Width(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + if s.Width != 60 { + t.Errorf("expected Width=60 fallback, got %d", s.Width) + } +} + +// TestShouldUseColor_NoColorEnv verifies that NO_COLOR env disables color. +func TestShouldUseColor_NoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := termstyle.ShouldUseColor(&bytes.Buffer{}) + if got { + t.Error("expected false when NO_COLOR is set") + } +} + +// TestShouldUseColor_NonTerminal verifies that a buffer writer returns false. +func TestShouldUseColor_NonTerminal(t *testing.T) { + t.Parallel() + got := termstyle.ShouldUseColor(&bytes.Buffer{}) + if got { + t.Error("expected false for non-terminal writer") + } +} + +// TestGetTerminalWidth_Fallback verifies the fallback width of 60. +func TestGetTerminalWidth_Fallback(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + got := termstyle.GetTerminalWidth(&buf) + if got != 60 { + t.Errorf("expected fallback width 60, got %d", got) + } +} + +// TestFormatTokenCount covers the token count formatting rules. +func TestFormatTokenCount(t *testing.T) { + t.Parallel() + tests := []struct { + input int + want string + }{ + {0, "0"}, + {1, "1"}, + {999, "999"}, + {1000, "1k"}, + {1200, "1.2k"}, + {14300, "14.3k"}, + {100000, "100k"}, + } + for _, tt := range tests { + got := termstyle.FormatTokenCount(tt.input) + if got != tt.want { + t.Errorf("FormatTokenCount(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// TestTotalTokens_Nil verifies nil input returns 0. +func TestTotalTokens_Nil(t *testing.T) { + t.Parallel() + if got := termstyle.TotalTokens(nil); got != 0 { + t.Errorf("TotalTokens(nil) = %d, want 0", got) + } +} + +// TestTotalTokens_Basic verifies basic token summation. +func TestTotalTokens_Basic(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 10, + CacheCreationTokens: 5, + CacheReadTokens: 3, + OutputTokens: 2, + } + want := 20 + if got := termstyle.TotalTokens(tu); got != want { + t.Errorf("TotalTokens = %d, want %d", got, want) + } +} + +// TestTotalTokens_Recursive verifies subagent tokens are included. +func TestTotalTokens_Recursive(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 10, + OutputTokens: 5, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 3, + OutputTokens: 2, + }, + } + want := 20 // 10+5 + 3+2 + if got := termstyle.TotalTokens(tu); got != want { + t.Errorf("TotalTokens = %d, want %d", got, want) + } +} + +// TestRender_NoColor verifies Render returns plain text when color is disabled. +func TestRender_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + got := s.Render(s.Bold, "hello") + if got != "hello" { + t.Errorf("Render with no color = %q, want %q", got, "hello") + } +} + +// TestHorizontalRule_NoColor verifies HorizontalRule returns plain dashes when no color. +func TestHorizontalRule_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + // Override width for deterministic output + s.Width = 5 + got := s.HorizontalRule() + want := "─────" + if got != want { + t.Errorf("HorizontalRule() = %q, want %q", got, want) + } +} + +// TestSectionRule_NoColor verifies SectionRule output format without color. +func TestSectionRule_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + s.Width = 20 + got := s.SectionRule("Foo") + // "── Foo " = 7 runes used, trailing = 13 + want := "── Foo " + "─────────────" + if got != want { + t.Errorf("SectionRule(%q) = %q, want %q", "Foo", got, want) + } +} + +// TestSectionRule_ShortWidth verifies trailing is at least 1 even when label is long. +func TestSectionRule_ShortWidth(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + s.Width = 1 + got := s.SectionRule("Very Long Label") + // trailing forced to 1 minimum + want := "── Very Long Label " + "─" + if got != want { + t.Errorf("SectionRule short width = %q, want %q", got, want) + } +}