Add summary TUI for browsing checkpoints#954
Conversation
…ation Introduces `entire summary` command with an interactive Bubble Tea TUI that reads session data directly from the checkpoint store (no SQLite/insightsdb dependency). Users can browse checkpoints, filter by time/branch, and press 'g' to generate AI summaries persisted to the checkpoint branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: ffaf93d82b5f
There was a problem hiding this comment.
Pull request overview
This PR adds a new entire summary command that lets users browse checkpoint/session summaries via an interactive Bubble Tea TUI, with JSON and accessible-mode (non-TUI) output paths.
Changes:
- Introduces
cmd/entire/cli/summary_cmd.goto load checkpoint-derived session data, render JSON/text, and launch the TUI (with on-demandgsummary generation/persistence). - Adds the
summarytuipackage implementing the Bubble Tea model/view/update, styling, keybindings, and detail rendering with tests. - Adds a shared
termstylepackage (plus tests) for terminal-aware styling utilities.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
cmd/entire/cli/termstyle/termstyle.go |
New shared terminal styling helpers (color/width detection, rules, token formatting). |
cmd/entire/cli/termstyle/termstyle_test.go |
Unit tests for the new termstyle helpers. |
cmd/entire/cli/summarytui/root.go |
Bubble Tea root model: filtering, pagination, selection, generation workflow, layout. |
cmd/entire/cli/summarytui/root_test.go |
Tests for root model rendering, navigation, filtering, and generation behavior. |
cmd/entire/cli/summarytui/detail_page.go |
Detail-pane rendering (metadata header + summary/code/learnings/signals boxes). |
cmd/entire/cli/summarytui/detail_page_test.go |
Tests for detail rendering helpers and formatting. |
cmd/entire/cli/summarytui/styles.go |
Lipgloss style definitions and box rendering helpers for the TUI. |
cmd/entire/cli/summarytui/keys.go |
Keybinding map for the TUI. |
cmd/entire/cli/summary_cmd.go |
Implements the new summary command: loads sessions from checkpoint stores, outputs JSON/text, launches TUI, generates/persists summaries. |
cmd/entire/cli/summary_cmd_test.go |
Command-level tests for registration, args, JSON/text rendering, limit normalization. |
cmd/entire/cli/root.go |
Registers the new summary command in the root CLI. |
.entire/settings.json |
Enables summarize feature in strategy options. |
| 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, | ||
| } |
There was a problem hiding this comment.
The accessible field is set from the ACCESSIBLE env var but is never read anywhere in the model. This is dead state that can confuse future changes (and suggests accessible-mode handling that isn’t implemented here). Either remove the field and assignment, or use it to adjust rendering/behavior inside the TUI.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
--last/outputLimit isn’t actually applied unless --agent or --branch are set. As written, --json with no filters will emit up to maxSummaryRecentSessions (200) regardless of --last, and the in-code comment says JSON should be capped but the condition only checks hasCLIFilters. Consider applying the outputLimit when opts.OutputJSON is true and/or when opts.Last is explicitly set, so the flag behavior matches its help text.
| 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 |
There was a problem hiding this comment.
generateForSession swallows persistence errors from UpdateSummary (both v1 and v2) and will still mark the session as generated if summary generation succeeded. This can lead to the UI reporting success while the summary was not actually saved. Consider returning an error (or at least surfacing a non-success status) when persisting the summary fails.
| // 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) | ||
| } |
There was a problem hiding this comment.
These width assertions are environment-dependent: termstyle.New(&bytes.Buffer{}) calls GetTerminalWidth, which falls back to os.Stdout/os.Stderr and will return the real terminal width when tests are run interactively (not 60). This makes the test flaky for local runs. Consider asserting a range (e.g., >0 && <=80) or redirecting stdout/stderr to a non-tty within the test (without t.Parallel()).
| // 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) | ||
| } |
There was a problem hiding this comment.
This test assumes GetTerminalWidth returns the fallback (60) for a non-terminal writer, but the implementation explicitly falls back to os.Stdout/os.Stderr and will return the actual terminal width when available. That will fail when running go test in a real terminal. Adjust the assertion to account for the stdout/stderr fallback, or make the environment non-tty for the duration of the test.
| 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") |
There was a problem hiding this comment.
TestRunSummary_AccessibleDoesNotStartTUI doesn’t actually exercise the behavior it claims to test: it never calls runSummary (or the Cobra command), and instead calls renderSummaryText, which cannot start the TUI. This makes the test a false positive. Consider invoking runSummary with ACCESSIBLE=1 (or executing the cobra command) and asserting the stubbed runSummaryTUI is not called while output is produced.
| 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") | |
| cmd := newSummaryCmd() | |
| cmd.SetOut(&buf) | |
| cmd.SetErr(&buf) | |
| require.NoError(t, cmd.RunE(cmd, []string{})) | |
| require.False(t, called, "accessible mode should not launch the TUI") | |
| require.NotEmpty(t, buf.String(), "accessible mode should produce text output") |
| // 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 | ||
| } |
There was a problem hiding this comment.
This package duplicates a lot of logic that already exists in cmd/entire/cli/status_style.go (terminal detection, width fallback, token formatting, token summation, rule rendering). Having two independent implementations risks future drift/inconsistent behavior. Consider refactoring status_style.go to use termstyle (or moving the shared helpers here and reusing them) so there’s a single source of truth.
| 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...), |
There was a problem hiding this comment.
RunWithCurrentBranch accepts a context but discards it (parameter is named _), and newRootModel hard-codes ctx: context.Background(). This prevents cancellation/deadlines from propagating into generate operations and any future ctx-dependent behavior in the TUI. Thread the caller context into the model (e.g., pass ctx into newRootModel and store it on rootModel).
Summary
entire summarycommand with an interactive Bubble Tea TUI for browsing checkpointsgto generate AI summaries persisted to the checkpoint branchsummarytuipackage (model/update/view, detail rendering, styles, keybindings),termstyleshared styling package, and comprehensive testsTest plan
mise run check(fmt + lint + tests)entire summaryin a repo with existing checkpoints — verify list loads, detail pane rendersgon a checkpoint without a summary — verify generation works and persists1key and branch filter with2keyentire summary --jsonoutputs valid JSONACCESSIBLE=1 entire summary🤖 Generated with Claude Code
Note
Medium Risk
Adds a new interactive command that reads and updates checkpoint metadata (including persisting AI-generated summaries) across v1/v2 stores; main risk is regressions in checkpoint I/O and UX edge cases rather than security-critical behavior.
Overview
Adds a new
entire summarycommand that loads recent committed checkpoints and lets users browse session metadata and cached AI summaries via an interactive Bubble Tea split-pane TUI, with filters for time range and branch scope and optional--jsonoutput.Supports on-demand summary generation (
g) that reuses existing transcript-scoping + AI summarization helpers and persists the generatedSummaryback to the checkpoint store (v1 and v2 when available), with an accessible-mode text fallback.Introduces the new
summarytuiUI package (rendering, keybindings, pagination, filtering) and a sharedtermstyleutility for terminal color/width-aware formatting, plus comprehensive unit tests and a settings toggle understrategy_options.summarize.enabled.Reviewed by Cursor Bugbot for commit a456873. Configure here.