Make code suggestions work aswell

This commit is contained in:
2026-07-27 14:04:41 +02:00
parent b0ac2f3e9a
commit 6bfe248388
4 changed files with 521 additions and 26 deletions

View File

@@ -3,7 +3,8 @@
A read-only terminal UI for people receiving GitHub pull-request reviews. It A read-only terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs, review threads with highlighted diff hunks and comment authors, shows open PRs, review threads with highlighted diff hunks and comment authors,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads reviewer/assignee state, and the latest commit's check rollup. Resolved threads
start folded. The current PR is refreshed in the background. start folded. GitHub suggestion blocks are shown as syntax-highlighted
remove/add previews. The current PR is refreshed in the background.
## Install and run ## Install and run

225
suggestions.go Normal file
View File

@@ -0,0 +1,225 @@
package main
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
type parsedCommentBody struct {
Prose string
Suggestions []string
}
type codeRange struct {
Start int
End int
}
func parseCommentBody(body string) parsedCommentBody {
var (
result parsedCommentBody
prose []string
suggestion []string
openingLine string
inSuggestion bool
)
for _, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
if !inSuggestion && isSuggestionFence(trimmed) {
inSuggestion = true
openingLine = line
suggestion = nil
continue
}
if inSuggestion && trimmed == "```" {
result.Suggestions = append(result.Suggestions, strings.Join(suggestion, "\n"))
inSuggestion = false
openingLine = ""
continue
}
if inSuggestion {
suggestion = append(suggestion, line)
} else {
prose = append(prose, line)
}
}
if inSuggestion {
prose = append(prose, openingLine)
prose = append(prose, suggestion...)
}
result.Prose = strings.TrimSpace(strings.Join(prose, "\n"))
return result
}
func isSuggestionFence(line string) bool {
if !strings.HasPrefix(line, "```suggestion") {
return false
}
suffix := strings.TrimPrefix(line, "```suggestion")
return suffix == "" || strings.HasPrefix(suffix, ":")
}
func reviewedSourceLines(thread ReviewThread, comment ReviewComment) []string {
start, end := commentReviewAnchor(thread, comment)
parsed := parseDiff(comment.DiffHunk, start, end, thread.DiffSide)
lines := make([]string, 0, end-start+1)
for _, line := range parsed {
if !line.selected {
continue
}
lines = append(lines, sourceFromDiffLine(line.raw))
}
return lines
}
func sourceFromDiffLine(raw string) string {
if strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") || strings.HasPrefix(raw, " ") {
raw = raw[1:]
}
return strings.ReplaceAll(raw, "\t", " ")
}
func normalizeSuggestion(removed []string, replacement string) ([]string, []string) {
added := []string(nil)
if replacement != "" {
added = strings.Split(replacement, "\n")
for i := range added {
added[i] = strings.ReplaceAll(added[i], "\t", " ")
}
}
padding := commonSourceIndent(removed, added)
return trimSourceIndent(removed, padding), trimSourceIndent(added, padding)
}
func commonSourceIndent(groups ...[]string) int {
padding := -1
for _, lines := range groups {
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if padding == -1 || indent < padding {
padding = indent
}
}
}
return max(0, padding)
}
func trimSourceIndent(lines []string, padding int) []string {
result := make([]string, len(lines))
for i, line := range lines {
result[i] = trimIndent(line, padding)
}
return result
}
func suggestionChangedRanges(removed, added []string) ([]codeRange, []codeRange) {
removedRanges := make([]codeRange, len(removed))
addedRanges := make([]codeRange, len(added))
matches := matchingLinePairs(removed, added)
matches = append(matches, [2]int{len(removed), len(added)})
oldStart, newStart := 0, 0
for _, match := range matches {
oldEnd, newEnd := match[0], match[1]
blockSize := max(oldEnd-oldStart, newEnd-newStart)
for offset := 0; offset < blockSize; offset++ {
oldIndex, newIndex := oldStart+offset, newStart+offset
switch {
case oldIndex < oldEnd && newIndex < newEnd:
removedRanges[oldIndex], addedRanges[newIndex] = changedRange(
removed[oldIndex], added[newIndex],
)
case oldIndex < oldEnd:
removedRanges[oldIndex] = fullCodeRange(removed[oldIndex])
case newIndex < newEnd:
addedRanges[newIndex] = fullCodeRange(added[newIndex])
}
}
if oldEnd < len(removed) && newEnd < len(added) {
oldStart, newStart = oldEnd+1, newEnd+1
} else {
oldStart, newStart = oldEnd, newEnd
}
}
return removedRanges, addedRanges
}
func matchingLinePairs(removed, added []string) [][2]int {
dp := make([][]int, len(removed)+1)
for i := range dp {
dp[i] = make([]int, len(added)+1)
}
for i := len(removed) - 1; i >= 0; i-- {
for j := len(added) - 1; j >= 0; j-- {
if removed[i] == added[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j+1])
}
}
}
var matches [][2]int
for i, j := 0, 0; i < len(removed) && j < len(added); {
switch {
case removed[i] == added[j]:
matches = append(matches, [2]int{i, j})
i++
j++
case dp[i+1][j] >= dp[i][j+1]:
i++
default:
j++
}
}
return matches
}
func changedRange(oldLine, newLine string) (codeRange, codeRange) {
oldRunes, newRunes := []rune(oldLine), []rune(newLine)
prefix := 0
for prefix < len(oldRunes) && prefix < len(newRunes) && oldRunes[prefix] == newRunes[prefix] {
prefix++
}
oldSuffix, newSuffix := len(oldRunes), len(newRunes)
for oldSuffix > prefix && newSuffix > prefix &&
oldRunes[oldSuffix-1] == newRunes[newSuffix-1] {
oldSuffix--
newSuffix--
}
return codeRange{
Start: ansi.StringWidth(string(oldRunes[:prefix])),
End: ansi.StringWidth(string(oldRunes[:oldSuffix])),
}, codeRange{
Start: ansi.StringWidth(string(newRunes[:prefix])),
End: ansi.StringWidth(string(newRunes[:newSuffix])),
}
}
func fullCodeRange(line string) codeRange {
return codeRange{End: ansi.StringWidth(line)}
}
func commentReviewAnchor(thread ReviewThread, comment ReviewComment) (int, int) {
end := comment.OriginalLine
start := comment.OriginalStartLine
if end == 0 {
end = comment.Line
start = comment.StartLine
}
if end > 0 {
if start == 0 {
start = end
}
return start, end
}
start = thread.StartLine
if start == 0 {
start = thread.Line
}
return start, thread.Line
}

178
suggestions_test.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestParseCommentBodyExtractsSuggestionFences(t *testing.T) {
body := "Use the clearer name:\n\n```suggestion:-0+0\n better_name = value\n```\n\nThis also matches the API."
got := parseCommentBody(body)
if len(got.Suggestions) != 1 || got.Suggestions[0] != " better_name = value" {
t.Fatalf("suggestions = %#v", got.Suggestions)
}
if strings.Contains(got.Prose, "```") || !strings.Contains(got.Prose, "Use the clearer name") ||
!strings.Contains(got.Prose, "matches the API") {
t.Fatalf("prose = %q", got.Prose)
}
}
func TestReviewedSourceLinesUseHistoricalSelection(t *testing.T) {
comment := ReviewComment{
DiffHunk: "@@ -38,5 +38,5 @@\n context\n old_name = value\n return old_name\n context\n context",
OriginalStartLine: 39,
OriginalLine: 40,
}
got := reviewedSourceLines(ReviewThread{DiffSide: "RIGHT"}, comment)
want := []string{"old_name = value", "return old_name"}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("reviewed source = %#v, want %#v", got, want)
}
}
func TestNormalizeSuggestionPreservesRelativeIndent(t *testing.T) {
removed, added := normalizeSuggestion(
[]string{" if old:", " run_old()"},
" if new:\n run_new()",
)
if strings.Join(removed, "\n") != "if old:\n run_old()" {
t.Fatalf("removed = %#v", removed)
}
if strings.Join(added, "\n") != "if new:\n run_new()" {
t.Fatalf("added = %#v", added)
}
}
func TestDetailRendersSuggestionAsRemovalAndAddition(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.width = 100
m.details = PRDetails{Threads: []ReviewThread{{
Path: "example.py",
Line: 10,
DiffSide: "RIGHT",
Comments: []ReviewComment{{
Author: "reviewer",
Body: "Use this instead:\n```suggestion\nnew_value = compute()\nreturn new_value\n```",
DiffHunk: "@@ -10,2 +10,2 @@\nold_value = compute()\nreturn old_value",
OriginalLine: 11,
OriginalStartLine: 10,
}},
}}}
var (
removed, added int
rendered strings.Builder
)
for _, line := range m.detailLines(60) {
rendered.WriteString(ansi.Strip(line.rail + line.fixed + line.text))
rendered.WriteByte('\n')
switch line.suggestionChange {
case '-':
removed++
case '+':
added++
}
}
if removed != 2 || added != 2 {
t.Fatalf("suggestion rows: removed=%d added=%d\n%s", removed, added, rendered.String())
}
if strings.Contains(rendered.String(), "```suggestion") ||
!strings.Contains(rendered.String(), "│ @reviewer") ||
!strings.Contains(rendered.String(), "│ suggested change") {
t.Fatalf("suggestion was not rendered structurally:\n%s", rendered.String())
}
}
func TestSuggestionBackgroundIsDirectionalWithoutTextUnderline(t *testing.T) {
removed := suggestionHighlight(" - ", "old", 12, '-')
added := suggestionHighlight(" + ", "new", 12, '+')
if !strings.Contains(removed, "\x1b[48;5;52m") ||
!strings.Contains(added, "\x1b[48;5;22m") {
t.Fatalf("suggestion decorations missing: removed=%q added=%q", removed, added)
}
if strings.Contains(removed, "\x1b[4m") || strings.Contains(added, "\x1b[4m") ||
strings.Contains(removed, "\x1b[4;") || strings.Contains(added, "\x1b[4;") ||
strings.Contains(removed, "\x1b[58;") || strings.Contains(added, "\x1b[58;") {
t.Fatal("suggestion rendering still enables terminal underlining")
}
if ansi.StringWidth(removed) != 12 || ansi.StringWidth(added) != 12 {
t.Fatal("suggestion backgrounds do not fill the row")
}
if !strings.Contains(removed, "\x1b[0m\x1b[48;5;52m ") ||
!strings.Contains(added, "\x1b[0m\x1b[48;5;22m ") {
t.Fatal("padded row remainder is still underlined")
}
}
func TestMultipleSuggestionsStayInsideCommentBlock(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.width = 100
m.details = PRDetails{Threads: []ReviewThread{{
Path: "main.go", Line: 1, DiffSide: "RIGHT",
Comments: []ReviewComment{{
Author: "alice", OriginalLine: 1,
DiffHunk: "@@ -1 +1 @@\nold",
Body: "```suggestion\nfirst\n```\n```suggestion\nsecond\n```",
}},
}}}
var rendered strings.Builder
for _, line := range m.detailLines(60) {
rendered.WriteString(ansi.Strip(line.rail + line.fixed + line.text))
rendered.WriteByte('\n')
}
if !strings.Contains(rendered.String(), "│ @alice") ||
!strings.Contains(rendered.String(), "│ suggested change 1/2") ||
!strings.Contains(rendered.String(), "│ suggested change 2/2") {
t.Fatalf("suggestions escaped the comment block:\n%s", rendered.String())
}
}
func TestSuggestionGutterHasNoLeadingPadding(t *testing.T) {
lines := wrapSuggestionLine("main.go", "replacement()", '+', fullCodeRange("replacement()"), 30)
if len(lines) == 0 || ansi.Strip(lines[0].fixed) != "+ " {
t.Fatalf("suggestion gutter = %q, want compact marker", ansi.Strip(lines[0].fixed))
}
}
func TestSuggestionChangedRangesAlignInsertedLines(t *testing.T) {
removed := []string{"def setup():", " if ready:", " run()"}
added := []string{"def setup():", " super().setup()", " if ready:", " run()"}
removedRanges, addedRanges := suggestionChangedRanges(removed, added)
for i, changed := range removedRanges {
if changed.End != changed.Start {
t.Fatalf("unchanged removed line %d marked as changed: %#v", i, changed)
}
}
for _, index := range []int{0, 2, 3} {
if changed := addedRanges[index]; changed.End != changed.Start {
t.Fatalf("unchanged added line %d marked as changed: %#v", index, changed)
}
}
if addedRanges[1] != fullCodeRange(added[1]) {
t.Fatalf("inserted line range = %#v", addedRanges[1])
}
}
func TestSuggestionChangedRangeIsCharacterPrecise(t *testing.T) {
removed, added := changedRange("value = old_name()", "value = new_name()")
if got := "value = old_name()"[removed.Start:removed.End]; got != "old" {
t.Fatalf("removed range selected %q", got)
}
if got := "value = new_name()"[added.Start:added.End]; got != "new" {
t.Fatalf("added range selected %q", got)
}
}
func TestChangedSnippetGetsDarkerBackground(t *testing.T) {
code := highlightedSource("go", "value := oldName()")
decorated := highlightCodeRange(code, codeRange{Start: 9, End: 16}, '-')
if !strings.Contains(decorated, "\x1b[48;2;55;0;0m") {
t.Fatalf("darker intra-line background missing: %q", decorated)
}
if ansi.Strip(decorated) != "value := oldName()" {
t.Fatalf("highlight altered source: %q", ansi.Strip(decorated))
}
}

141
tui.go
View File

@@ -437,7 +437,15 @@ func (m App) threadDetail(width, height int) string {
rendered := make([]string, 0, len(visible)) rendered := make([]string, 0, len(visible))
innerWidth := max(1, width-2) innerWidth := max(1, width-2)
for _, line := range visible { for _, line := range visible {
renderedLine := ansi.Truncate(line.fixed+line.text, innerWidth, "…") railWidth := ansi.StringWidth(line.rail)
contentWidth := max(1, innerWidth-railWidth)
var renderedLine string
if line.suggestionChange != 0 {
renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange)
} else {
renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "…")
}
renderedLine = line.rail + renderedLine
if line.selected { if line.selected {
renderedLine = selectedBackground(renderedLine, innerWidth) renderedLine = selectedBackground(renderedLine, innerWidth)
} }
@@ -447,9 +455,11 @@ func (m App) threadDetail(width, height int) string {
} }
type detailLine struct { type detailLine struct {
text string text string
fixed string fixed string
selected bool rail string
selected bool
suggestionChange byte
} }
func (m App) detailLines(width int) []detailLine { func (m App) detailLines(width int) []detailLine {
@@ -481,9 +491,40 @@ func (m App) detailLines(width int) []detailLine {
} }
} }
for _, comment := range thread.Comments { for _, comment := range thread.Comments {
lines = append(lines, detailLine{}, detailLine{text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))}) content := parseCommentBody(comment.Body)
for _, commentLine := range strings.Split(wrap(comment.Body, max(10, width-4)), "\n") { rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ")
lines = append(lines, detailLine{text: commentLine}) lines = append(lines, detailLine{}, detailLine{
rail: rail,
text: authorStyle(comment.Author).Render("@"+comment.Author) + " " +
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
})
if content.Prose != "" {
for _, commentLine := range strings.Split(wrap(content.Prose, max(10, width-4)), "\n") {
lines = append(lines, detailLine{rail: rail, text: commentLine})
}
}
for suggestionIndex, suggestion := range content.Suggestions {
removed, added := normalizeSuggestion(reviewedSourceLines(thread, comment), suggestion)
removedRanges, addedRanges := suggestionChangedRanges(removed, added)
label := "suggested change"
if len(content.Suggestions) > 1 {
label = fmt.Sprintf("suggested change %d/%d", suggestionIndex+1, len(content.Suggestions))
}
lines = append(lines, detailLine{rail: rail}, detailLine{rail: rail, text: dimStyle.Render(label)})
for sourceIndex, source := range removed {
lines = append(lines, addCommentRail(
wrapSuggestionLine(
thread.Path, source, '-', removedRanges[sourceIndex], max(1, width-4),
), rail,
)...)
}
for sourceIndex, source := range added {
lines = append(lines, addCommentRail(
wrapSuggestionLine(
thread.Path, source, '+', addedRanges[sourceIndex], max(1, width-4),
), rail,
)...)
}
} }
} }
if thread.IsTruncated { if thread.IsTruncated {
@@ -520,6 +561,53 @@ func wrapDiffLine(line highlightedDiffLine, width int) []detailLine {
return result return result
} }
func wrapSuggestionLine(path, source string, change byte, changed codeRange, width int) []detailLine {
marker := badStyle.Render(string(change))
if change == '+' {
marker = okStyle.Render(string(change))
}
code := highlightedSource(lexerForPath(path), source)
code = highlightCodeRange(code, changed, change)
lines := wrapDiffLine(highlightedDiffLine{
gutter: marker + " ",
code: code,
}, width)
for i := range lines {
lines[i].suggestionChange = change
}
return lines
}
func highlightCodeRange(code string, changed codeRange, change byte) string {
if changed.End <= changed.Start {
return code
}
totalWidth := ansi.StringWidth(code)
start := clamp(changed.Start, 0, totalWidth)
end := clamp(changed.End, start, totalWidth)
if end <= start {
return code
}
background := "\x1b[48;2;55;0;0m"
if change == '+' {
background = "\x1b[48;2;0;55;0m"
}
const reset = "\x1b[0m"
before := ansi.Cut(code, 0, start)
middle := ansi.Cut(code, start, end)
after := ansi.Cut(code, end, totalWidth)
middle = strings.ReplaceAll(middle, reset, reset+background)
return before + background + middle + reset + after
}
func addCommentRail(lines []detailLine, rail string) []detailLine {
for i := range lines {
lines[i].rail = rail
}
return lines
}
func wrapCodeWithIndent(code string, width int) []string { func wrapCodeWithIndent(code string, width int) []string {
if width <= 0 || ansi.StringWidth(code) <= width { if width <= 0 || ansi.StringWidth(code) <= width {
return []string{code} return []string{code}
@@ -580,25 +668,9 @@ func isCodeBreakpoint(r rune) bool {
func reviewAnchor(thread ReviewThread) (int, int) { func reviewAnchor(thread ReviewThread) (int, int) {
if len(thread.Comments) > 0 { if len(thread.Comments) > 0 {
comment := thread.Comments[0] return commentReviewAnchor(thread, thread.Comments[0])
end := comment.OriginalLine
start := comment.OriginalStartLine
if end == 0 {
end = comment.Line
start = comment.StartLine
}
if end > 0 {
if start == 0 {
start = end
}
return start, end
}
} }
start := thread.StartLine return commentReviewAnchor(thread, ReviewComment{})
if start == 0 {
start = thread.Line
}
return start, thread.Line
} }
func shortOID(oid string) string { func shortOID(oid string) string {
@@ -618,6 +690,25 @@ func selectedBackground(line string, width int) string {
return background + line + reset return background + line + reset
} }
func suggestionHighlight(gutter, code string, width int, change byte) string {
background := "\x1b[48;5;52m"
if change == '+' {
background = "\x1b[48;5;22m"
}
const reset = "\x1b[0m"
gutter = ansi.Truncate(gutter, width, "")
gutter = strings.ReplaceAll(gutter, reset, reset+background)
codeWidth := max(0, width-ansi.StringWidth(gutter))
code = ansi.Truncate(code, codeWidth, "")
code = strings.ReplaceAll(code, reset, reset+background)
content := background + gutter + code + reset
padding := max(0, width-ansi.StringWidth(gutter)-ansi.StringWidth(code))
if padding > 0 {
content += background + strings.Repeat(" ", padding) + reset
}
return content
}
var authorPalette = []lipgloss.Color{ var authorPalette = []lipgloss.Color{
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#61AFEF", "#C678DD", "#56B6C2", "#E5C07B",
"#E06C75", "#98C379", "#D19A66", "#7FC8FF", "#E06C75", "#98C379", "#D19A66", "#7FC8FF",