Skip to content

Commit 0526aad

Browse files
wesmclaude
andauthored
Add review metadata header to clipboard yank content (#106)
## Summary - When copying review content with `y`, prepend a header line with review ID, repo path, and truncated commit SHA for easy reference - Format: `Review #ID /repo/path abc1234` - Falls back to job ID when review ID is 0 (failed jobs) - Only truncates valid 40-char hex SHAs (case-insensitive), preserving branch names and ranges ## Test plan - [x] Existing yank tests updated and passing - [x] New `TestFormatClipboardContent` covers: nil review, empty output, ID-only, ID 0, full SHA truncation, long branch names, ranges, job ID fallback, short refs, uppercase SHAs - [x] New `TestTUIFetchReviewAndCopyJobInjection` covers job injection when API returns review without Job Closes #102 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d590a5d commit 0526aad

File tree

2 files changed

+231
-14
lines changed

2 files changed

+231
-14
lines changed

cmd/roborev/tui.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
neturl "net/url"
99
"os"
1010
"path/filepath"
11+
"regexp"
1112
"sort"
1213
"strings"
1314
"time"
@@ -53,6 +54,9 @@ var (
5354
Foreground(lipgloss.Color("241"))
5455
)
5556

57+
// fullSHAPattern matches a 40-character hex git SHA (not ranges or branch names)
58+
var fullSHAPattern = regexp.MustCompile(`(?i)^[0-9a-f]{40}$`)
59+
5660
type tuiView int
5761

5862
const (
@@ -542,15 +546,46 @@ func (m tuiModel) fetchReviewForPrompt(jobID int64) tea.Cmd {
542546
}
543547
}
544548

545-
func (m tuiModel) copyToClipboard(text string) tea.Cmd {
549+
// formatClipboardContent prepares review content for clipboard with a header line
550+
func formatClipboardContent(review *storage.Review) string {
551+
if review == nil || review.Output == "" {
552+
return ""
553+
}
554+
555+
// Build header: "Review #ID /repo/path abc1234"
556+
// Use job ID if review ID is 0 (e.g., failed jobs with no review record)
557+
var header string
558+
if review.Job != nil {
559+
gitRef := review.Job.GitRef
560+
// Truncate SHA to 7 chars if it's a full 40-char hex SHA (not a range or branch name)
561+
if fullSHAPattern.MatchString(gitRef) {
562+
gitRef = gitRef[:7]
563+
}
564+
id := review.ID
565+
if id == 0 {
566+
id = review.Job.ID
567+
}
568+
header = fmt.Sprintf("Review #%d %s %s\n\n", id, review.Job.RepoPath, gitRef)
569+
} else if review.ID != 0 {
570+
header = fmt.Sprintf("Review #%d\n\n", review.ID)
571+
}
572+
573+
return header + review.Output
574+
}
575+
576+
func (m tuiModel) copyToClipboard(review *storage.Review) tea.Cmd {
546577
view := m.currentView // Capture view at trigger time
578+
content := formatClipboardContent(review)
547579
return func() tea.Msg {
548-
err := clipboardWriter.WriteText(text)
580+
if content == "" {
581+
return tuiClipboardResultMsg{err: fmt.Errorf("no content to copy"), view: view}
582+
}
583+
err := clipboardWriter.WriteText(content)
549584
return tuiClipboardResultMsg{err: err, view: view}
550585
}
551586
}
552587

553-
func (m tuiModel) fetchReviewAndCopy(jobID int64) tea.Cmd {
588+
func (m tuiModel) fetchReviewAndCopy(jobID int64, job *storage.ReviewJob) tea.Cmd {
554589
view := m.currentView // Capture view at trigger time
555590
return func() tea.Msg {
556591
resp, err := m.client.Get(fmt.Sprintf("%s/api/review?job_id=%d", m.serverAddr, jobID))
@@ -575,7 +610,13 @@ func (m tuiModel) fetchReviewAndCopy(jobID int64) tea.Cmd {
575610
return tuiClipboardResultMsg{err: fmt.Errorf("review has no content"), view: view}
576611
}
577612

578-
err = clipboardWriter.WriteText(review.Output)
613+
// Attach job info if not already present (for header formatting)
614+
if review.Job == nil && job != nil {
615+
review.Job = job
616+
}
617+
618+
content := formatClipboardContent(&review)
619+
err = clipboardWriter.WriteText(content)
579620
return tuiClipboardResultMsg{err: err, view: view}
580621
}
581622
}
@@ -1510,13 +1551,13 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15101551
// Yank (copy) review content to clipboard
15111552
if m.currentView == tuiViewReview && m.currentReview != nil && m.currentReview.Output != "" {
15121553
// Copy from review view - we already have the content
1513-
return m, m.copyToClipboard(m.currentReview.Output)
1554+
return m, m.copyToClipboard(m.currentReview)
15141555
} else if m.currentView == tuiViewQueue && len(m.jobs) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.jobs) {
15151556
job := m.jobs[m.selectedIdx]
15161557
// Only allow copying from completed or failed jobs
15171558
if job.Status == storage.JobStatusDone || job.Status == storage.JobStatusFailed {
15181559
// Need to fetch review first, then copy
1519-
return m, m.fetchReviewAndCopy(job.ID)
1560+
return m, m.fetchReviewAndCopy(job.ID, &job)
15201561
} else {
15211562
// Queued, running, or canceled - show flash notification
15221563
var status string

cmd/roborev/tui_test.go

Lines changed: 184 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5089,8 +5089,9 @@ func TestTUIYankCopyFromReviewView(t *testing.T) {
50895089
t.Errorf("Expected no error, got %v", result.err)
50905090
}
50915091

5092-
if mock.lastText != "This is the review content to copy" {
5093-
t.Errorf("Expected clipboard to contain review content, got %q", mock.lastText)
5092+
expectedContent := "Review #1\n\nThis is the review content to copy"
5093+
if mock.lastText != expectedContent {
5094+
t.Errorf("Expected clipboard to contain review with header, got %q", mock.lastText)
50945095
}
50955096
}
50965097

@@ -5265,7 +5266,7 @@ func TestTUIFetchReviewAndCopySuccess(t *testing.T) {
52655266
m := newTuiModel(ts.URL)
52665267

52675268
// Execute fetchReviewAndCopy
5268-
cmd := m.fetchReviewAndCopy(123)
5269+
cmd := m.fetchReviewAndCopy(123, nil)
52695270
msg := cmd()
52705271

52715272
result, ok := msg.(tuiClipboardResultMsg)
@@ -5277,8 +5278,10 @@ func TestTUIFetchReviewAndCopySuccess(t *testing.T) {
52775278
t.Errorf("Expected no error, got %v", result.err)
52785279
}
52795280

5280-
if mock.lastText != "Review content for clipboard" {
5281-
t.Errorf("Expected clipboard to contain review content, got %q", mock.lastText)
5281+
// Clipboard should contain header + review content
5282+
expectedContent := "Review #1\n\nReview content for clipboard"
5283+
if mock.lastText != expectedContent {
5284+
t.Errorf("Expected clipboard to contain review with header, got %q", mock.lastText)
52825285
}
52835286
}
52845287

@@ -5291,7 +5294,7 @@ func TestTUIFetchReviewAndCopy404(t *testing.T) {
52915294

52925295
m := newTuiModel(ts.URL)
52935296

5294-
cmd := m.fetchReviewAndCopy(123)
5297+
cmd := m.fetchReviewAndCopy(123, nil)
52955298
msg := cmd()
52965299

52975300
result, ok := msg.(tuiClipboardResultMsg)
@@ -5323,7 +5326,7 @@ func TestTUIFetchReviewAndCopyEmptyOutput(t *testing.T) {
53235326

53245327
m := newTuiModel(ts.URL)
53255328

5326-
cmd := m.fetchReviewAndCopy(123)
5329+
cmd := m.fetchReviewAndCopy(123, nil)
53275330
msg := cmd()
53285331

53295332
result, ok := msg.(tuiClipboardResultMsg)
@@ -5401,7 +5404,7 @@ func TestTUIFetchReviewAndCopyClipboardFailure(t *testing.T) {
54015404
m := newTuiModel(ts.URL)
54025405

54035406
// Fetch succeeds but clipboard write fails
5404-
cmd := m.fetchReviewAndCopy(123)
5407+
cmd := m.fetchReviewAndCopy(123, nil)
54055408
msg := cmd()
54065409

54075410
result, ok := msg.(tuiClipboardResultMsg)
@@ -5418,6 +5421,179 @@ func TestTUIFetchReviewAndCopyClipboardFailure(t *testing.T) {
54185421
}
54195422
}
54205423

5424+
func TestTUIFetchReviewAndCopyJobInjection(t *testing.T) {
5425+
// Save original clipboard writer and restore after test
5426+
originalClipboard := clipboardWriter
5427+
mock := &mockClipboard{}
5428+
clipboardWriter = mock
5429+
defer func() { clipboardWriter = originalClipboard }()
5430+
5431+
// Create test server that returns a review WITHOUT Job populated
5432+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5433+
review := storage.Review{
5434+
ID: 42,
5435+
JobID: 123,
5436+
Agent: "test",
5437+
Output: "Review content",
5438+
// Job is intentionally nil
5439+
}
5440+
json.NewEncoder(w).Encode(review)
5441+
}))
5442+
defer ts.Close()
5443+
5444+
m := newTuiModel(ts.URL)
5445+
5446+
// Pass a job parameter - this should be injected when review.Job is nil
5447+
job := &storage.ReviewJob{
5448+
ID: 123,
5449+
RepoPath: "/path/to/repo",
5450+
GitRef: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", // 40 hex chars
5451+
}
5452+
5453+
cmd := m.fetchReviewAndCopy(123, job)
5454+
msg := cmd()
5455+
5456+
result, ok := msg.(tuiClipboardResultMsg)
5457+
if !ok {
5458+
t.Fatalf("Expected tuiClipboardResultMsg, got %T", msg)
5459+
}
5460+
5461+
if result.err != nil {
5462+
t.Errorf("Expected no error, got %v", result.err)
5463+
}
5464+
5465+
// Clipboard should contain header with injected job info (truncated SHA)
5466+
expectedContent := "Review #42 /path/to/repo a1b2c3d\n\nReview content"
5467+
if mock.lastText != expectedContent {
5468+
t.Errorf("Expected clipboard with injected job info, got %q", mock.lastText)
5469+
}
5470+
}
5471+
5472+
func TestFormatClipboardContent(t *testing.T) {
5473+
tests := []struct {
5474+
name string
5475+
review *storage.Review
5476+
expected string
5477+
}{
5478+
{
5479+
name: "nil review",
5480+
review: nil,
5481+
expected: "",
5482+
},
5483+
{
5484+
name: "empty output",
5485+
review: &storage.Review{
5486+
ID: 1,
5487+
Output: "",
5488+
},
5489+
expected: "",
5490+
},
5491+
{
5492+
name: "review with ID only (no job)",
5493+
review: &storage.Review{
5494+
ID: 42,
5495+
Output: "Content here",
5496+
},
5497+
expected: "Review #42\n\nContent here",
5498+
},
5499+
{
5500+
name: "review with ID 0 and no job (no header)",
5501+
review: &storage.Review{
5502+
ID: 0,
5503+
Output: "Content here",
5504+
},
5505+
expected: "Content here",
5506+
},
5507+
{
5508+
name: "review with job - full SHA truncated",
5509+
review: &storage.Review{
5510+
ID: 99,
5511+
Output: "Review content",
5512+
Job: &storage.ReviewJob{
5513+
ID: 99,
5514+
RepoPath: "/Users/test/myrepo",
5515+
GitRef: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", // exactly 40 hex chars
5516+
},
5517+
},
5518+
expected: "Review #99 /Users/test/myrepo a1b2c3d\n\nReview content",
5519+
},
5520+
{
5521+
name: "long branch name not truncated",
5522+
review: &storage.Review{
5523+
ID: 101,
5524+
Output: "Review content",
5525+
Job: &storage.ReviewJob{
5526+
ID: 101,
5527+
RepoPath: "/repo",
5528+
GitRef: "feature/very-long-branch-name-that-exceeds-forty-characters",
5529+
},
5530+
},
5531+
expected: "Review #101 /repo feature/very-long-branch-name-that-exceeds-forty-characters\n\nReview content",
5532+
},
5533+
{
5534+
name: "review with job - range not truncated",
5535+
review: &storage.Review{
5536+
ID: 100,
5537+
Output: "Review content",
5538+
Job: &storage.ReviewJob{
5539+
ID: 100,
5540+
RepoPath: "/path/to/repo",
5541+
GitRef: "abc1234..def5678",
5542+
},
5543+
},
5544+
expected: "Review #100 /path/to/repo abc1234..def5678\n\nReview content",
5545+
},
5546+
{
5547+
name: "review ID 0 uses job ID instead",
5548+
review: &storage.Review{
5549+
ID: 0,
5550+
Output: "Failed job content",
5551+
Job: &storage.ReviewJob{
5552+
ID: 555,
5553+
RepoPath: "/repo/path",
5554+
GitRef: "abcdef1234567890abcdef1234567890abcdef12",
5555+
},
5556+
},
5557+
expected: "Review #555 /repo/path abcdef1\n\nFailed job content",
5558+
},
5559+
{
5560+
name: "short git ref not truncated",
5561+
review: &storage.Review{
5562+
ID: 10,
5563+
Output: "Content",
5564+
Job: &storage.ReviewJob{
5565+
ID: 10,
5566+
RepoPath: "/repo",
5567+
GitRef: "abc1234",
5568+
},
5569+
},
5570+
expected: "Review #10 /repo abc1234\n\nContent",
5571+
},
5572+
{
5573+
name: "uppercase SHA truncated",
5574+
review: &storage.Review{
5575+
ID: 102,
5576+
Output: "Content",
5577+
Job: &storage.ReviewJob{
5578+
ID: 102,
5579+
RepoPath: "/repo",
5580+
GitRef: "ABCDEF1234567890ABCDEF1234567890ABCDEF12", // uppercase 40 hex chars
5581+
},
5582+
},
5583+
expected: "Review #102 /repo ABCDEF1\n\nContent",
5584+
},
5585+
}
5586+
5587+
for _, tt := range tests {
5588+
t.Run(tt.name, func(t *testing.T) {
5589+
got := formatClipboardContent(tt.review)
5590+
if got != tt.expected {
5591+
t.Errorf("formatClipboardContent() = %q, want %q", got, tt.expected)
5592+
}
5593+
})
5594+
}
5595+
}
5596+
54215597
func TestTUIConfigReloadFlash(t *testing.T) {
54225598
m := newTuiModel("http://localhost:7373")
54235599

0 commit comments

Comments
 (0)