diff --git a/cmd/entire/cli/attribution.go b/cmd/entire/cli/attribution.go index a2d9c47403..6e167d4b92 100644 --- a/cmd/entire/cli/attribution.go +++ b/cmd/entire/cli/attribution.go @@ -18,6 +18,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" + "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/entireio/cli/cmd/entire/cli/trailers" @@ -171,6 +173,7 @@ func newBlameCmd() *cobra.Command { func newWhyCmd() *cobra.Command { var jsonFlag bool var lineFlag string + var tuiFlag bool cmd := &cobra.Command{ Use: "why [:line]", @@ -179,18 +182,20 @@ func newWhyCmd() *cobra.Command { // --help` keep working normally. Hidden: true, Short: "Show why a line exists", - Long: "Explain the commit, checkpoint, prompt, and session behind a file or line.\n\nTarget a specific line with :12 or the --line flag.", + Long: "Explain the commit, checkpoint, prompt, and session behind a file or line.\n\nTarget a specific line with :12 or the --line flag.\n\nUse --tui to browse the file interactively (TTY only; non-interactive runs fall back to the plain output).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runAttributionWhy(cmd.Context(), cmd.OutOrStdout(), args[0], attributionWhyOptions{ LineFlag: lineFlag, JSON: jsonFlag, + TUI: tuiFlag, }) }, } cmd.Flags().StringVar(&lineFlag, "line", "", "Explain a specific line, for example 12 (same as :12)") cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output explanation as JSON") + cmd.Flags().BoolVar(&tuiFlag, "tui", false, "Browse line attribution in an interactive viewer (TTY only)") return cmd } @@ -203,6 +208,7 @@ type attributionBlameOptions struct { type attributionWhyOptions struct { LineFlag string JSON bool + TUI bool } func runAttributionBlame(ctx context.Context, w io.Writer, file string, opts attributionBlameOptions) error { @@ -263,6 +269,18 @@ func runAttributionWhy(ctx context.Context, w io.Writer, target string, opts att return err } + // Interactive browsing is strictly opt-in AND TTY-gated (agent-safe CLI + // fallbacks): a non-interactive run with --tui falls through to the same + // deterministic plain/JSON output below, which carries the full + // information. --json wins over --tui so scripted callers never block. + if opts.TUI && !opts.JSON && interactive.IsTerminalWriter(w) && !IsAccessibleMode() { + startLine, err := whyTUIStartLine(result, hasLine, line) + if err != nil { + return err + } + return runWhyTUI(result, attributionRepoFullName(ctx), shouldUseColor(w), startLine) + } + if !hasLine { if opts.JSON { return printJSON(w, result) @@ -271,13 +289,7 @@ func runAttributionWhy(ctx context.Context, w io.Writer, target string, opts att return nil } - var selected *attributionLine - for i := range result.Lines { - if result.Lines[i].LineNumber == line { - selected = &result.Lines[i] - break - } - } + selected := findAttributionLine(result.Lines, line) if selected == nil { return fmt.Errorf("line %d is outside %s", line, result.File) } @@ -298,6 +310,32 @@ func runAttributionWhy(ctx context.Context, w io.Writer, target string, opts att return nil } +// findAttributionLine returns the attribution entry for the given 1-based line +// number, or nil when the file has no such line. Both the plain and TUI why +// paths rely on this so an out-of-range line is reported consistently. +func findAttributionLine(lines []attributionLine, lineNumber int) *attributionLine { + for i := range lines { + if lines[i].LineNumber == lineNumber { + return &lines[i] + } + } + return nil +} + +// whyTUIStartLine resolves the cursor start line for the interactive why +// viewer. When a specific line was requested it must exist in the file: +// otherwise we return the same "line N is outside " error the plain +// path emits, rather than silently opening the viewer at line 1. +func whyTUIStartLine(result *fileAttributionResult, hasLine bool, line int) (int, error) { + if !hasLine { + return 0, nil + } + if findAttributionLine(result.Lines, line) == nil { + return 0, fmt.Errorf("line %d is outside %s", line, result.File) + } + return line, nil +} + func resolveFileAttribution(ctx context.Context, file string, fetchOnMiss bool) (*fileAttributionResult, error) { repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { @@ -1405,6 +1443,21 @@ func shortSessionID(sessionID string) string { return sessionID[:8] } +// attributionRepoFullName resolves owner/repo from the origin remote for +// building session web links in the why viewer. Best-effort: an empty result +// disables the links without failing the command. +func attributionRepoFullName(ctx context.Context) string { + originURL, err := remote.GetRemoteURL(ctx, "origin") + if err != nil || originURL == "" { + return "" + } + info, err := remote.ParseURL(originURL) + if err != nil || info.Owner == "" || info.Repo == "" { + return "" + } + return info.Owner + "/" + info.Repo +} + func shortSHA(sha string) string { if len(sha) <= 8 { return sha diff --git a/cmd/entire/cli/attribution_tui.go b/cmd/entire/cli/attribution_tui.go new file mode 100644 index 0000000000..9a5b7116a1 --- /dev/null +++ b/cmd/entire/cli/attribution_tui.go @@ -0,0 +1,577 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + xansi "github.com/charmbracelet/x/ansi" + + "github.com/entireio/cli/cmd/entire/cli/stringutil" +) + +// Layout budget for the why viewer. The header is a title line plus a blank +// line; the footer is a marker legend line plus a help line. The remaining rows +// are split between the file-line list (left) and the selected line's +// explanation (right), separated by a thin vertical rule. +const ( + whyHeaderHeight = 2 + whyFooterHeight = 2 + whyListMinWidth = 34 + whyListWidthRatio = 0.55 // list gets slightly more room than the detail + whyPaneGap = 3 // " │ " +) + +// whyMarkerLegend is the one-line explanation of the attribution tags shown in +// the footer and in the plain-text views. Wording is deliberate — the tags are +// a PER-COMMIT inference, not a per-line truth: [AI] means the commit's +// checkpointed work was fully agent-authored; [MX] means the commit mixed +// agent work with human edits (so any given line may be either); [HU] means no +// agent checkpoint is recorded for the commit. Kept within the blame table's +// 80-column budget (with its 2-space indent); full sentences live in the why +// detail views, and [??]/~/? markers are explained by their own legend line. +const whyMarkerLegend = "per commit: [AI] all agent · [MX] mixed — line may be either · [HU] no agent" + +// whyTUIStyles holds the interactive viewer's palette. Empty styles render as +// plain text when color is off, which also keeps tests assertable. +type whyTUIStyles struct { + colorEnabled bool + + title lipgloss.Style + file lipgloss.Style + dim lipgloss.Style + selected lipgloss.Style + section lipgloss.Style + tagAI lipgloss.Style + tagMX lipgloss.Style + tagHU lipgloss.Style + warn lipgloss.Style + helpKey lipgloss.Style + helpDesc lipgloss.Style + sepBar lipgloss.Style +} + +func newWhyTUIStyles(useColor bool) whyTUIStyles { + s := whyTUIStyles{colorEnabled: useColor} + if !useColor { + return s + } + s.title = lipgloss.NewStyle().Bold(true) + s.file = lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")).Bold(true) + s.dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + s.selected = lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")).Bold(true) + s.section = lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")).Bold(true) + s.tagAI = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + s.tagMX = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + s.tagHU = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.warn = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + s.helpKey = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Bold(true) + s.helpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + s.sepBar = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return s +} + +func (s whyTUIStyles) render(style lipgloss.Style, text string) string { + if !s.colorEnabled { + return text + } + return style.Render(text) +} + +// link renders text with an OSC 8 hyperlink when styling is enabled; plain +// styled text otherwise, so dumb terminals and piped output are unaffected. +func (s whyTUIStyles) link(style lipgloss.Style, linkURL, text string) string { + if !s.colorEnabled || strings.TrimSpace(linkURL) == "" { + return s.render(style, text) + } + return style.Hyperlink(linkURL).Render(text) +} + +func (s whyTUIStyles) tagStyle(tag string) lipgloss.Style { + switch tag { + case "[AI]": + return s.tagAI + case "[MX]": + return s.tagMX + default: + return s.tagHU + } +} + +// whyTUIModel renders a master-detail view over a pre-resolved file +// attribution: the file's lines on the left (with attribution markers) and the +// selected line's full explanation on the right. All data is resolved before +// the program starts — no git or network I/O happens inside the TUI. +type whyTUIModel struct { + result *fileAttributionResult + styles whyTUIStyles + + // repoFullName ("owner/repo") enables entire.io session hyperlinks; empty + // disables them (links are best-effort decoration, never required). + repoFullName string + + cursor int + listTop int // first visible list row (manual scroll window) + expanded bool + width int + height int + ready bool + vp viewport.Model + statusMsg string +} + +func newWhyTUIModel(result *fileAttributionResult, repoFullName string, useColor bool, startLine int) whyTUIModel { + m := whyTUIModel{ + result: result, + styles: newWhyTUIStyles(useColor), + repoFullName: repoFullName, + } + if startLine > 0 { + for i := range result.Lines { + if result.Lines[i].LineNumber == startLine { + m.cursor = i + break + } + } + } + return m +} + +func runWhyTUI(result *fileAttributionResult, repoFullName string, useColor bool, startLine int) error { + p := tea.NewProgram(newWhyTUIModel(result, repoFullName, useColor, startLine)) + if _, err := p.Run(); err != nil { + return fmt.Errorf("why TUI: %w", err) + } + return nil +} + +func (m whyTUIModel) Init() tea.Cmd { return nil } + +func (m whyTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m = m.layout() + return m, nil + case tea.KeyPressMsg: + return m.handleKey(msg) + } + if m.ready { + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + return m, nil +} + +func (m whyTUIModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Quit), key.Matches(msg, keys.Back): + return m, tea.Quit + case key.Matches(msg, keys.Up): + return m.moveCursor(m.cursor - 1), nil + case key.Matches(msg, keys.Down): + return m.moveCursor(m.cursor + 1), nil + // Paging is bound to pgup/pgdown explicitly: the shared keymap's + // NextPage/PrevPage claim n/p, which this viewer uses for the more useful + // next/previous agent-attributed line jumps. + case msg.String() == "pgup": + return m.moveCursor(m.cursor - m.bodyHeight()), nil + case msg.String() == "pgdown": + return m.moveCursor(m.cursor + m.bodyHeight()), nil + case key.Matches(msg, keys.Home): + return m.moveCursor(0), nil + case key.Matches(msg, keys.End): + return m.moveCursor(len(m.result.Lines) - 1), nil + case key.Matches(msg, keys.Confirm): + m.expanded = !m.expanded + return m.refreshDetail(), nil + case msg.String() == "n": + return m.jumpAgentLine(1), nil + case msg.String() == "p", msg.String() == "N": + return m.jumpAgentLine(-1), nil + } + if m.ready { + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + return m, nil +} + +func (m whyTUIModel) moveCursor(to int) whyTUIModel { + if len(m.result.Lines) == 0 { + return m + } + if to < 0 { + to = 0 + } + if to > len(m.result.Lines)-1 { + to = len(m.result.Lines) - 1 + } + if to == m.cursor { + return m + } + m.cursor = to + m.expanded = false + m = m.scrollListToCursor() + return m.refreshDetail() +} + +// scrollListToCursor adjusts the persisted list window so the cursor stays +// visible. Kept separate from rendering so the scroll state survives value- +// receiver renders. +func (m whyTUIModel) scrollListToCursor() whyTUIModel { + height := m.bodyHeight() + if m.cursor < m.listTop { + m.listTop = m.cursor + } + if m.cursor >= m.listTop+height { + m.listTop = m.cursor - height + 1 + } + if m.listTop < 0 { + m.listTop = 0 + } + return m +} + +// jumpAgentLine moves the cursor to the next/previous line with agent +// involvement ([AI] or [MX]) — browsing straight to the interesting lines. +func (m whyTUIModel) jumpAgentLine(dir int) whyTUIModel { + lines := m.result.Lines + for i := m.cursor + dir; i >= 0 && i < len(lines); i += dir { + if lines[i].Authorship == attributionAI || lines[i].Authorship == attributionMixed { + return m.moveCursor(i) + } + } + m.statusMsg = "no more agent-attributed lines" + return m +} + +func (m whyTUIModel) layout() whyTUIModel { + if m.width <= 0 || m.height <= 0 { + return m + } + bodyH := m.bodyHeight() + rightW := m.rightPaneWidth() + if !m.ready { + m.vp = viewport.New(viewport.WithWidth(rightW), viewport.WithHeight(bodyH)) + m.ready = true + } else { + m.vp.SetWidth(rightW) + m.vp.SetHeight(bodyH) + } + return m.refreshDetail() +} + +func (m whyTUIModel) bodyHeight() int { + h := m.height - whyHeaderHeight - whyFooterHeight + if h < 1 { + h = 1 + } + return h +} + +func (m whyTUIModel) listWidth() int { + w := int(float64(m.width) * whyListWidthRatio) + if w < whyListMinWidth { + w = whyListMinWidth + } + if limit := m.width - whyPaneGap - 20; w > limit { + w = limit + } + if w < 1 { + w = 1 + } + return w +} + +func (m whyTUIModel) rightPaneWidth() int { + w := m.width - m.listWidth() - whyPaneGap + if w < 1 { + w = 1 + } + return w +} + +func (m whyTUIModel) refreshDetail() whyTUIModel { + m.statusMsg = "" + if !m.ready { + return m + } + m.vp.SetContent(m.renderDetail(m.rightPaneWidth())) + m.vp.GotoTop() + return m +} + +// selectedLine returns the line under the cursor, or nil for an empty file. +func (m whyTUIModel) selectedLine() *attributionLine { + if m.cursor < 0 || m.cursor >= len(m.result.Lines) { + return nil + } + return &m.result.Lines[m.cursor] +} + +// sessionWebURL builds the session URL for the selected line, or "" when there +// is nothing to link. Delegates to expertsSessionURL so the host honors +// ENTIRE_WEB_BASE_URL / the active API origin (staging, self-hosted, dev) +// instead of hardcoding production. +func (m whyTUIModel) sessionWebURL(line *attributionLine) string { + if line == nil { + return "" + } + return expertsSessionURL(m.repoFullName, line.SessionID) +} + +func (m whyTUIModel) View() tea.View { + if !m.ready { + return tea.NewView("") + } + var b strings.Builder + summary := m.result.Summary + fmt.Fprintf(&b, "%s %s %s\n\n", + m.styles.render(m.styles.title, "why"), + m.styles.render(m.styles.file, m.result.File), + m.styles.render(m.styles.dim, fmt.Sprintf("%d lines · %d%% AI · %d%% human · %d%% mixed", + summary.TotalLines, summary.AIPercentage, summary.HumanPercentage, summary.MixedPercentage))) + + bodyH := m.bodyHeight() + left := m.renderList(m.listWidth(), bodyH) + sep := m.verticalSep(bodyH) + right := m.vp.View() + // JoinHorizontal is ANSI-aware: it measures each pane's VISIBLE width, so + // colored list rows stay aligned and the separator column doesn't drift + // (a raw %-*s pads on byte length and breaks once escape codes are present). + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)) + b.WriteString("\n") + + legend := whyMarkerLegend + if m.statusMsg != "" { + legend = m.statusMsg + } + b.WriteString(m.styles.render(m.styles.dim, legend) + "\n") + b.WriteString(m.renderHelp()) + return tea.NewView(b.String()) +} + +// renderList renders the visible window of file lines as a single block (rows +// joined by newlines), each row fit to width and the block padded to height. +// Lines are windowed manually (a file can be thousands of lines). Rows are +// width-fit with fitLine (ANSI-aware) so JoinHorizontal keeps the columns and +// separator aligned once color escape codes are present. +func (m whyTUIModel) renderList(width, height int) string { + lines := m.result.Lines + if len(lines) == 0 { + return m.fitLine(m.styles.render(m.styles.dim, "(empty file)"), width) + } + + // Read-only clamp: the persisted window is maintained by + // scrollListToCursor; this only guards against a resize shrinking the + // window after the last cursor move. + top := m.listTop + if m.cursor < top { + top = m.cursor + } + if m.cursor >= top+height { + top = m.cursor - height + 1 + } + if top < 0 { + top = 0 + } + + numW := len(strconv.Itoa(lines[len(lines)-1].LineNumber)) + rows := make([]string, 0, height) + for i := top; i < len(lines) && len(rows) < height; i++ { + line := lines[i] + tag := attributionTag(line.Authorship) + marker := attributionLineMarker(line) + if marker == "" { + marker = " " + } + content := strings.ReplaceAll(line.Content, "\t", " ") + var row string + if i == m.cursor { + // One outer style over the whole row so the selection reads as a + // single unit (no nested tag styling to unbalance the escapes). + row = m.styles.render(m.styles.selected, + fmt.Sprintf("%*d %s%s %s", numW, line.LineNumber, tag, marker, content)) + } else { + row = fmt.Sprintf("%*d %s%s %s", numW, line.LineNumber, + m.styles.render(m.styles.tagStyle(tag), tag), marker, content) + } + rows = append(rows, m.fitLine(row, width)) + } + // Pad to a full-height block so JoinHorizontal aligns the panes. + for len(rows) < height { + rows = append(rows, strings.Repeat(" ", width)) + } + return strings.Join(rows, "\n") +} + +// fitLine truncates s to width and right-pads it to exactly width, measuring +// VISIBLE width (xansi.Truncate / lipgloss.Width ignore escape codes) so +// colored rows occupy the same column span as plain ones. +func (m whyTUIModel) fitLine(s string, width int) string { + if width <= 0 { + return "" + } + out := xansi.Truncate(s, width, "…") + if pad := width - lipgloss.Width(out); pad > 0 { + out += strings.Repeat(" ", pad) + } + return out +} + +// verticalSep is the " │ " column between the list and detail panes, h rows tall +// so JoinHorizontal has a full-height middle block to align against. +func (m whyTUIModel) verticalSep(h int) string { + bar := " " + m.styles.render(m.styles.sepBar, "│") + " " + lines := make([]string, h) + for i := range lines { + lines[i] = bar + } + return strings.Join(lines, "\n") +} + +// renderDetail builds the explanation pane for the selected line — the same +// facts as the plain-text `why :` output, formatted for a pane. +func (m whyTUIModel) renderDetail(width int) string { + line := m.selectedLine() + if line == nil { + return m.styles.render(m.styles.dim, "No lines.") + } + wrap := func(s string) string { + return lipgloss.NewStyle().Width(width).Render(s) + } + var b strings.Builder + sec := func(title string) { b.WriteString(m.styles.render(m.styles.section, title) + "\n") } + + sec(fmt.Sprintf("LINE %d %s", line.LineNumber, attributionTag(line.Authorship))) + b.WriteString(wrap(m.authorshipSentence(line)) + "\n\n") + + if line.ShortCommitSHA != "" { + commit := "Commit: " + line.ShortCommitSHA + if line.Author != "" { + commit += " by " + line.Author + } + if line.AuthorTime != nil { + commit += " " + line.AuthorTime.Format("2006-01-02 15:04") + } + b.WriteString(wrap(commit) + "\n") + } + if line.Agent != "" { + agentLine := "Agent: " + line.Agent + if line.Model != "" { + agentLine += " · " + line.Model + } + b.WriteString(wrap(agentLine) + "\n") + } + if line.SessionID != "" { + sessionText := "Session: " + shortSessionID(line.SessionID) + if u := m.sessionWebURL(line); u != "" { + sessionText = m.link(m.styles.dim, u, sessionText) // dim styled + clickable + } + b.WriteString(wrap(sessionText) + "\n") + } + if line.CheckpointID != "" { + b.WriteString(wrap("Checkpoint: "+line.CheckpointID) + "\n") + } + b.WriteString("\n") + + if line.Prompt != "" { + label := "PROMPT" + if line.PromptSessionLevel { + label = "SESSION PROMPT" + } + sec(label) + prompt := line.Prompt + if !m.expanded { + prompt = stringutil.TruncateRunes(stringutil.CollapseWhitespace(prompt), 240, "… (enter to expand)") + } + b.WriteString(wrap(prompt) + "\n") + if line.PromptSessionLevel { + b.WriteString(wrap(m.styles.render(m.styles.dim, "session-level prompt — may not appear in this checkpoint's transcript")) + "\n") + } + b.WriteString("\n") + } + if line.Intent != "" && line.Intent != line.Prompt { + sec("INTENT") + b.WriteString(wrap(line.Intent) + "\n\n") + } + + if line.MetadataMissing { + msg := "Checkpoint metadata was not found locally; showing trailer-level attribution only." + if line.MetadataMissingReason != "" { + msg = line.MetadataMissingReason + } + b.WriteString(wrap(m.styles.render(m.styles.warn, msg)) + "\n\n") + } + if line.SessionFallback { + b.WriteString(wrap(m.styles.render(m.styles.warn, + "~ best-effort: this file is not in the checkpoint session's recorded paths; the agent and prompt shown are a guess")) + "\n\n") + } + + if len(line.Candidates) > 1 { + sec(fmt.Sprintf("CANDIDATE CHECKPOINTS (%d)", len(line.Candidates))) + for _, c := range line.Candidates { + row := "- " + c.CheckpointID + if c.Agent != "" { + row += " · " + c.Agent + } + if m.expanded && c.Prompt != "" { + row += " · " + stringutil.TruncateRunes(stringutil.CollapseWhitespace(c.Prompt), 120, "…") + } + b.WriteString(wrap(row) + "\n") + } + b.WriteString("\n") + } + + if line.CheckpointID != "" && !line.MetadataMissing { + b.WriteString(wrap(m.styles.render(m.styles.dim, "Full context: entire checkpoint explain "+line.CheckpointID)) + "\n") + } + return b.String() +} + +// authorshipSentence spells out what the tag means for THIS line, with the +// wording the attribution actually supports: [AI] = the commit's checkpointed +// work was fully agent-authored; [MX] = agent work with human edits mixed in; +// [HU] = no agent checkpoint recorded for the commit. +func (m whyTUIModel) authorshipSentence(line *attributionLine) string { + switch line.Authorship { + case attributionAI: + return "Agent-authored: the checkpoint work behind this commit was fully agent-authored." + case attributionMixed: + return "Mixed: this commit combined agent work with human edits, so this line may be either." + case attributionUncommitted: + return "Uncommitted: this line has no commit yet." + case attributionHuman: + return "Human: no agent checkpoint is recorded for this commit." + default: + return string(line.Authorship) + } +} + +// link is a tiny alias so renderDetail reads naturally. +func (m whyTUIModel) link(style lipgloss.Style, url, text string) string { + return m.styles.link(style, url, text) +} + +func (m whyTUIModel) renderHelp() string { + parts := []struct{ k, d string }{ + {"↑/↓", "line"}, {"n/p", "next/prev agent line"}, {"enter", "expand"}, + {"pgup/pgdn", "page"}, {"g/G", "top/bottom"}, {"q", "quit"}, + } + var b strings.Builder + for i, p := range parts { + if i > 0 { + b.WriteString(m.styles.render(m.styles.helpDesc, " · ")) + } + b.WriteString(m.styles.render(m.styles.helpKey, p.k) + " " + m.styles.render(m.styles.helpDesc, p.d)) + } + return b.String() +} diff --git a/cmd/entire/cli/attribution_tui_test.go b/cmd/entire/cli/attribution_tui_test.go new file mode 100644 index 0000000000..4bda641105 --- /dev/null +++ b/cmd/entire/cli/attribution_tui_test.go @@ -0,0 +1,305 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func whyTUIFixture() *fileAttributionResult { + when := time.Date(2026, 1, 2, 15, 4, 0, 0, time.UTC) + lines := []attributionLine{ + { + LineNumber: 1, Authorship: attributionHuman, Tag: "[HU]", + CommitSHA: "1111111111111111111111111111111111111111", ShortCommitSHA: "1111111", + Author: "Ada", AuthorTime: &when, Content: "package main", + }, + { + LineNumber: 2, Authorship: attributionAI, Tag: "[AI]", + CommitSHA: "2222222222222222222222222222222222222222", ShortCommitSHA: "2222222", + Author: "Ada", AuthorTime: &when, + CheckpointID: "a1b2c3d4e5f6", SessionID: "session-agent-12345678", + Agent: "Claude Code", Model: "claude-test", + Prompt: "Fix the authentication bug in login flow please", + Intent: "Fix auth bug", + Content: "func login() {}", + }, + { + LineNumber: 3, Authorship: attributionMixed, Tag: "[MX]", + CommitSHA: "3333333333333333333333333333333333333333", ShortCommitSHA: "3333333", + CheckpointID: "b1b2c3d4e5f6", SessionID: "session-mixed-12345678", + Agent: "Claude Code", Prompt: "Refactor helpers", PromptSessionLevel: true, + Candidates: []attributionCandidate{ + {CheckpointID: "b1b2c3d4e5f6", Agent: "Claude Code", Prompt: "Refactor helpers"}, + {CheckpointID: "c1b2c3d4e5f6", Agent: "Codex", Prompt: "Tidy up"}, + }, + Content: "func helper() {}", + }, + { + LineNumber: 4, Authorship: attributionAI, Tag: "[AI]", + CheckpointID: "d1b2c3d4e5f6", MetadataMissing: true, + MetadataMissingReason: "checkpoint metadata was not found locally. Run: git fetch origin entire/checkpoints/v1:entire/checkpoints/v1.", + Content: "func missing() {}", + }, + } + return &fileAttributionResult{ + File: "auth.py", + Lines: lines, + Summary: attributionSummary{ + TotalLines: 4, AILines: 2, HumanLines: 1, MixedLines: 1, + AIPercentage: 50, HumanPercentage: 25, MixedPercentage: 25, + }, + } +} + +func updateWhyTUI(t *testing.T, m whyTUIModel, msg tea.Msg) whyTUIModel { + t.Helper() + next, _ := m.Update(msg) + tm, ok := next.(whyTUIModel) + if !ok { + t.Fatalf("Update returned %T, want whyTUIModel", next) + } + return tm +} + +// Color off keeps assertions on raw text; a tall window keeps the detail pane +// fully visible so assertions aren't tripped by viewport scrolling. +func newSizedWhyTUI(t *testing.T, result *fileAttributionResult, startLine int) whyTUIModel { + t.Helper() + m := newWhyTUIModel(result, "acme/app", false, startLine) + return updateWhyTUI(t, m, tea.WindowSizeMsg{Width: 140, Height: 44}) +} + +func whyTUIViewText(t *testing.T, m whyTUIModel) string { + t.Helper() + return m.View().Content +} + +func keyPress(k string) tea.KeyPressMsg { + switch k { + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + default: + r := []rune(k)[0] + return tea.KeyPressMsg{Code: r, Text: k} + } +} + +func TestWhyTUIRendersFileAndSelectedLineDetail(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 0) + text := whyTUIViewText(t, m) + + for _, want := range []string{ + "why", "auth.py", "4 lines", "50% AI", + "package main", "func login() {}", // list content + "LINE 1", "[HU]", "Human: no agent checkpoint", // detail for initial cursor (line 1) + whyMarkerLegend, // footer legend + "quit", // help line + } { + if !strings.Contains(text, want) { + t.Fatalf("TUI view missing %q:\n%s", want, text) + } + } +} + +func TestWhyTUINavigationShowsPromptDetail(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 0) + m = updateWhyTUI(t, m, keyPress("down")) // to line 2 ([AI]) + text := whyTUIViewText(t, m) + + for _, want := range []string{ + "LINE 2", "[AI]", "fully agent-authored", + "Agent: Claude Code · claude-test", + "Session: session-", "Checkpoint: a1b2c3d4e5f6", + "PROMPT", "Fix the authentication bug", + "INTENT", "Fix auth bug", + "Full context: entire checkpoint explain a1b2c3d4e5f6", + } { + if !strings.Contains(text, want) { + t.Fatalf("after down, view missing %q:\n%s", want, text) + } + } +} + +func TestWhyTUIStartLinePositionsCursor(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 3) + text := whyTUIViewText(t, m) + + for _, want := range []string{ + "LINE 3", "[MX]", "combined agent work with human edits", + "SESSION PROMPT", "session-level prompt", + "CANDIDATE CHECKPOINTS (2)", "c1b2c3d4e5f6", "Codex", + } { + if !strings.Contains(text, want) { + t.Fatalf("start-line view missing %q:\n%s", want, text) + } + } +} + +func TestWhyTUIJumpToNextAgentLine(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 0) // cursor at line 1 [HU] + m = updateWhyTUI(t, m, keyPress("n")) // -> line 2 [AI] + if got := m.selectedLine().LineNumber; got != 2 { + t.Fatalf("n jumped to line %d, want 2", got) + } + m = updateWhyTUI(t, m, keyPress("n")) // -> line 3 [MX] + m = updateWhyTUI(t, m, keyPress("n")) // -> line 4 [AI] + if got := m.selectedLine().LineNumber; got != 4 { + t.Fatalf("n n jumped to line %d, want 4", got) + } + // No further agent lines: cursor stays, status message appears. + m = updateWhyTUI(t, m, keyPress("n")) + if got := m.selectedLine().LineNumber; got != 4 { + t.Fatalf("n at end moved to line %d, want 4", got) + } + if !strings.Contains(whyTUIViewText(t, m), "no more agent-attributed lines") { + t.Fatal("expected status message about no more agent lines") + } + // p goes back. + m = updateWhyTUI(t, m, keyPress("p")) + if got := m.selectedLine().LineNumber; got != 3 { + t.Fatalf("p jumped to line %d, want 3", got) + } +} + +func TestWhyTUIExpandTogglesFullPrompt(t *testing.T) { + t.Parallel() + long := strings.Repeat("very long prompt text ", 30) + result := whyTUIFixture() + result.Lines[1].Prompt = long + m := newSizedWhyTUI(t, result, 2) + + if !strings.Contains(whyTUIViewText(t, m), "enter to expand") { + t.Fatal("collapsed view should hint at expansion") + } + m = updateWhyTUI(t, m, keyPress("enter")) + if strings.Contains(whyTUIViewText(t, m), "enter to expand") { + t.Fatal("expanded view should not truncate the prompt") + } +} + +func TestWhyTUIMissingMetadataShowsReason(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 4) + text := whyTUIViewText(t, m) + if !strings.Contains(text, "checkpoint metadata was not found locally") { + t.Fatalf("missing-metadata reason absent:\n%s", text) + } + if strings.Contains(text, "Full context: entire checkpoint explain d1b2c3d4e5f6") { + t.Fatal("explain hint must be suppressed when metadata is missing") + } +} + +// g/G map to the shared keymap's Home/End bindings. +func TestWhyTUIHomeEndKeys(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 3) // start mid-file + m = updateWhyTUI(t, m, keyPress("g")) + if got := m.selectedLine().LineNumber; got != 1 { + t.Fatalf("g moved to line %d, want 1", got) + } + m = updateWhyTUI(t, m, keyPress("G")) + if got := m.selectedLine().LineNumber; got != 4 { + t.Fatalf("G moved to line %d, want 4", got) + } +} + +func TestWhyTUIQuitKeys(t *testing.T) { + t.Parallel() + m := newSizedWhyTUI(t, whyTUIFixture(), 0) + for _, k := range []string{"q"} { + _, cmd := m.Update(keyPress(k)) + if cmd == nil { + t.Fatalf("key %q should quit", k) + } + } +} + +func TestWhyTUIEmptyFileDoesNotPanic(t *testing.T) { + t.Parallel() + empty := &fileAttributionResult{File: "empty.py", Lines: nil} + m := newWhyTUIModel(empty, "", false, 0) + m = updateWhyTUI(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateWhyTUI(t, m, keyPress("down")) + m = updateWhyTUI(t, m, keyPress("n")) + text := whyTUIViewText(t, m) + if !strings.Contains(text, "empty.py") { + t.Fatalf("empty-file view missing filename:\n%s", text) + } +} + +func TestWhyTUITinyWindowDoesNotPanic(t *testing.T) { + t.Parallel() + m := newWhyTUIModel(whyTUIFixture(), "", false, 0) + m = updateWhyTUI(t, m, tea.WindowSizeMsg{Width: 8, Height: 3}) + _ = whyTUIViewText(t, m) + m = updateWhyTUI(t, m, keyPress("down")) + _ = whyTUIViewText(t, m) +} + +// The agent-safe fallback contract: --tui against a non-TTY writer must fall +// through to the deterministic plain-text output (never start the TUI, never +// block). A bytes.Buffer is the non-TTY case IsTerminalWriter reports false for. +func TestWhyTUIFlagFallsBackToPlainTextWhenNotTTY(t *testing.T) { + newAttributionRepo(t) + + var buf bytes.Buffer + err := runAttributionWhy(t.Context(), &buf, "auth.py", attributionWhyOptions{TUI: true}) + if err != nil { + t.Fatalf("runAttributionWhy --tui non-TTY: %v", err) + } + out := buf.String() + if !strings.Contains(out, "auth.py") || !strings.Contains(out, "lines") { + t.Fatalf("expected the plain file summary as fallback, got:\n%s", out) + } +} + +// The interactive viewer must reject an out-of-range start line the same way +// the plain path does, instead of silently opening at line 1. +func TestWhyTUIStartLineOutOfRangeErrors(t *testing.T) { + t.Parallel() + result := whyTUIFixture() // lines 1-4 + + if _, err := whyTUIStartLine(result, true, 99); err == nil { + t.Fatal("expected an error for a start line outside the file") + } else if !strings.Contains(err.Error(), "is outside") { + t.Fatalf("error %q missing the shared \"is outside\" wording", err) + } + + start, err := whyTUIStartLine(result, true, 3) + if err != nil { + t.Fatalf("in-range start line errored: %v", err) + } + if start != 3 { + t.Fatalf("in-range start line = %d, want 3", start) + } + + if start, err := whyTUIStartLine(result, false, 0); err != nil || start != 0 { + t.Fatalf("no explicit line: got (%d, %v), want (0, nil)", start, err) + } +} + +// --json must win over --tui so scripted callers always get JSON. +func TestWhyTUIFlagJSONWins(t *testing.T) { + newAttributionRepo(t) + + var buf bytes.Buffer + err := runAttributionWhy(t.Context(), &buf, "auth.py", attributionWhyOptions{TUI: true, JSON: true}) + if err != nil { + t.Fatalf("runAttributionWhy --tui --json: %v", err) + } + if !strings.HasPrefix(strings.TrimSpace(buf.String()), "{") { + t.Fatalf("expected JSON output, got:\n%s", buf.String()) + } +}