Skip to content

Add summary TUI for browsing checkpoints#954

Draft
alishakawaguchi wants to merge 2 commits intomainfrom
summary-tui
Draft

Add summary TUI for browsing checkpoints#954
alishakawaguchi wants to merge 2 commits intomainfrom
summary-tui

Conversation

@alishakawaguchi
Copy link
Copy Markdown
Contributor

@alishakawaguchi alishakawaguchi commented Apr 14, 2026

Summary

  • Adds entire summary command with an interactive Bubble Tea TUI for browsing checkpoints
  • Reads session data directly from the checkpoint store (no SQLite/insightsdb dependency)
  • Users can filter by time (24h/7d/30d/all) and branch (current/repo), and press g to generate AI summaries persisted to the checkpoint branch
  • Includes summarytui package (model/update/view, detail rendering, styles, keybindings), termstyle shared styling package, and comprehensive tests

Test plan

  • Run mise run check (fmt + lint + tests)
  • Run entire summary in a repo with existing checkpoints — verify list loads, detail pane renders
  • Press g on a checkpoint without a summary — verify generation works and persists
  • Test time filter cycling with 1 key and branch filter with 2 key
  • Test entire summary --json outputs valid JSON
  • Test accessible mode with ACCESSIBLE=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 summary command 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 --json output.

Supports on-demand summary generation (g) that reuses existing transcript-scoping + AI summarization helpers and persists the generated Summary back to the checkpoint store (v1 and v2 when available), with an accessible-mode text fallback.

Introduces the new summarytui UI package (rendering, keybindings, pagination, filtering) and a shared termstyle utility for terminal color/width-aware formatting, plus comprehensive unit tests and a settings toggle under strategy_options.summarize.enabled.

Reviewed by Cursor Bugbot for commit a456873. Configure here.

…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
Copilot AI review requested due to automatic review settings April 14, 2026 19:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.go to load checkpoint-derived session data, render JSON/text, and launch the TUI (with on-demand g summary generation/persistence).
  • Adds the summarytui package implementing the Bubble Tea model/view/update, styling, keybindings, and detail rendering with tests.
  • Adds a shared termstyle package (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.

Comment on lines +112 to +132
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,
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +170
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
}
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Copilot uses AI. Check for mistakes.
Comment on lines +389 to +405
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +29
// 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)
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()).

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +57
// 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)
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +107 to +111
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")
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +129
// 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
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +120
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...),
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants