From 93967531a1150e782961aa8fc5e986c3eb414ebf Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 19 Aug 2026 11:48:23 +0530 Subject: [PATCH 01/11] Added color formatting with a box for the Overview --- modules/code/code.go | 1 + modules/code/insight.go | 83 ++++++++++++++++++++++++++++++++++++++++- pkg/console/console.go | 13 +++++++ pkg/spec/code.spec.yaml | 1 + 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/modules/code/code.go b/modules/code/code.go index 646e493..30bbb0e 100644 --- a/modules/code/code.go +++ b/modules/code/code.go @@ -21,4 +21,5 @@ func ModuleInit(reg registry.ModuleRegistrar) { reg.RegisterFlagResolveFn(resolvePrincipalIDFnID, resolvePrincipalID) reg.RegisterWorkflow(getPRWorkflowID, GetPRWorkflow) reg.RegisterTextFormatter(reviewGroupTextFormatterID, reviewGroupTextFormatter) + reg.RegisterTextFormatter(insightTextFormatterID, insightTextFormatter) } diff --git a/modules/code/insight.go b/modules/code/insight.go index a15aa6d..74d16f1 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -6,8 +6,10 @@ package code import ( "fmt" "io" + "strings" "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/console" "github.com/harness/cli/pkg/exprenv" "github.com/harness/cli/pkg/hlog" "github.com/harness/cli/pkg/registry" @@ -16,6 +18,7 @@ import ( const ( getPRWorkflowID = "get_pr" reviewGroupTextFormatterID = "pr_review_group_text" + insightTextFormatterID = "pr_insight_text" ) // insightSections lists the Harness Code review-insight sub-commands appended to "get pr" output, @@ -76,7 +79,6 @@ func GetPRWorkflow(ctx *cmdctx.Ctx) error { hlog.Warn("insight fetch failed, omitting from get pr", "section", section.label, "err", err) continue } - fmt.Printf("\n--- %s ---\n", section.label) ep := *cs.Endpoint ep.TextFooter = "" if _, err := registry.RunEndpoint(ctx, &ep); err != nil { @@ -149,3 +151,82 @@ func reviewGroupTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { } return nil } + +// riskColor maps a risk bucket ("low"/"medium"/"high", case-insensitive) to the +// color it's displayed in. Returns 0 (no color) for any other value, including empty. +func riskColor(risk string) console.Color { + switch strings.ToLower(risk) { + case "low": + return console.ColorGreen + case "medium": + return console.ColorYellow + case "high": + return console.ColorRed + default: + return 0 + } +} + +// insightTextFormatter renders the AI code-review overview for a pull request as a +// box: the heading (colorized by risk) sits in the top border, and the review +// content is word-wrapped inside. +func insightTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { + risk := d.GetString("it.risk") + content := d.GetString("it.content") + + heading := "AI Code Overview" + if risk != "" { + heading += fmt.Sprintf(" [%s]", risk) + } + styledHeading := heading + if c := riskColor(risk); c != 0 { + styledHeading = console.WithColor(c, heading) + } + + boxWidth := min(max(console.TerminalWidth(80), 40),150) + contentWidth := boxWidth - 4 + + fillLen := boxWidth - len([]rune(heading))-6 + if fillLen < 1 { + fillLen = 1 + } + fmt.Fprintf(w, "┌── %s %s┐\n", styledHeading, strings.Repeat("─", fillLen)) + + for _, line := range wrapText(content, contentWidth) { + fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line) + } + + fmt.Fprintf(w, "└%s┘\n", strings.Repeat("─", boxWidth-2)) + return nil +} + +// wrapText word-wraps s to width, treating each existing line as its own +// paragraph so blank lines are preserved as paragraph breaks. +func wrapText(s string, width int) []string { + if width < 1 { + width = 1 + } + var lines []string + for _, paragraph := range strings.Split(s, "\n") { + if strings.TrimSpace(paragraph) == "" { + lines = append(lines, "") + continue + } + var cur string + for _, word := range strings.Fields(paragraph) { + switch { + case cur == "": + cur = word + case len([]rune(cur))+1+len([]rune(word)) <= width: + cur += " " + word + default: + lines = append(lines, cur) + cur = word + } + } + if cur != "" { + lines = append(lines, cur) + } + } + return lines +} diff --git a/pkg/console/console.go b/pkg/console/console.go index e91f166..f8564b9 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -85,6 +85,19 @@ func IsBothTTY() bool { return ensureTTY() && ensureStdoutTTY() } +// TerminalWidth returns the current stdout width, or fallback when stdout isn't a TTY +// or the size can't be determined. +func TerminalWidth(fallback int) int { + if !ensureStdoutTTY() { + return fallback + } + w, _, err := term.GetSize(int(syscall.Stdout)) + if err != nil || w <= 0 { + return fallback + } + return w +} + // WithColor wraps text in ANSI color codes when stdout is a TTY. func WithColor(c Color, text string) string { if !ensureStdoutTTY() { diff --git a/pkg/spec/code.spec.yaml b/pkg/spec/code.spec.yaml index b28740e..8d4fcd4 100644 --- a/pkg/spec/code.spec.yaml +++ b/pkg/spec/code.spec.yaml @@ -760,6 +760,7 @@ commands: query_params: repo_path: auth.scope + "/" + ctx.idParts[0] text_footer: "\n{{url(it)}}\n" + text_formatter: pr_insight_text # ── pr_review_group (Harness Code review insights) ──────────────────────────── From 4f22665052e912bcd2ec25890c2914a8599b9768 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 19 Aug 2026 11:48:57 +0530 Subject: [PATCH 02/11] Added new test cases for the formatting --- modules/code/insight_test.go | 102 ++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 0e103a5..0ca6c2b 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -58,10 +58,14 @@ func (s spyResolver) GetSpec(verb, noun string) *spec.CommandSpec { } func (s spyResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn { - if id == reviewGroupTextFormatterID { + switch id { + case reviewGroupTextFormatterID: return reviewGroupTextFormatter + case insightTextFormatterID: + return insightTextFormatter + default: + return nil } - return nil } // testNounURLPath is a stand-in url_path template shared by test nouns: it resolves @@ -94,7 +98,10 @@ func insightSpec(path string) *spec.CommandSpec { Command: "get pr:insight", Verb: "get", VerbHandler: "get", Noun: "pr", NounVariant: "insight", FieldsNoun: "pr_insight", Module: "code", HandlerType: spec.HandlerEndpoint, - Endpoint: &spec.EndpointSpec{Method: "GET", Path: path, ItemExpr: "it", TextFooter: "\n{{url(it)}}\n"}, + Endpoint: &spec.EndpointSpec{ + Method: "GET", Path: path, ItemExpr: "it", TextFooter: "\n{{url(it)}}\n", + TextFormatter: insightTextFormatterID, + }, } } @@ -211,7 +218,7 @@ func TestGetPRWorkflow_InsightFailureOmitsSectionButSucceeds(t *testing.T) { if err != nil { t.Fatalf("get pr must succeed even when an insight endpoint fails, got: %v", err) } - if strings.Contains(out, "Insight") { + if strings.Contains(out, "AI Code Overview") { t.Fatalf("output must omit failed sections, got:\n%s", out) } } @@ -235,8 +242,11 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out, "Insight") { - t.Fatalf("output must contain the Insight section, got:\n%s", out) + if !strings.Contains(out, "AI Code Overview [low]") { + t.Fatalf("output must contain the colorized AI Code Overview heading, got:\n%s", out) + } + if !strings.Contains(out, "┌") || !strings.Contains(out, "└") { + t.Fatalf("output must box the AI Code Overview section, got:\n%s", out) } if !strings.Contains(out, "PR Details") { t.Fatalf("output must contain the PR Details section, got:\n%s", out) @@ -244,7 +254,7 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { // Insight must render first, PR Details last (right before the link), and the // PR link must print exactly once, at the very end. - insightIdx := strings.Index(out, "Insight") + insightIdx := strings.Index(out, "AI Code Overview") prDetailsIdx := strings.Index(out, "PR Details") if insightIdx == -1 || prDetailsIdx == -1 || prDetailsIdx < insightIdx { t.Fatalf("expected Insight to render before PR Details, got:\n%s", out) @@ -281,6 +291,84 @@ func TestReviewGroupCommand_StandaloneRendersLink(t *testing.T) { } } +// --------------------------------------------------------------------------- +// insightTextFormatter +// --------------------------------------------------------------------------- + +type fakeDataAccessor struct{ values map[string]string } + +func (f fakeDataAccessor) GetString(path string) string { return f.values[path] } +func (f fakeDataAccessor) GetInt64(string) int64 { return 0 } +func (f fakeDataAccessor) GetBool(string) bool { return false } +func (f fakeDataAccessor) GetTs(string) string { return "" } +func (f fakeDataAccessor) GetData() any { return nil } +func (f fakeDataAccessor) GetSlice(string) []any { return nil } + +func TestInsightTextFormatter_HeadingByRisk(t *testing.T) { + cases := []struct { + risk string + heading string + }{ + {"low", "AI Code Overview [low]"}, + {"medium", "AI Code Overview [medium]"}, + {"high", "AI Code Overview [high]"}, + {"", "AI Code Overview"}, + {"unknown", "AI Code Overview [unknown]"}, + } + for _, c := range cases { + out := captureStdout(t, func() { + err := insightTextFormatter(os.Stdout, fakeDataAccessor{values: map[string]string{ + "it.risk": c.risk, "it.content": "looks fine", + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, c.heading) { + t.Fatalf("risk %q: expected heading %q in output, got:\n%s", c.risk, c.heading, out) + } + if !strings.HasPrefix(out, "┌") { + t.Fatalf("risk %q: expected output to start with a box top border, got:\n%s", c.risk, out) + } + if !strings.Contains(out, "└") { + t.Fatalf("risk %q: expected output to contain a box bottom border, got:\n%s", c.risk, out) + } + if strings.Contains(out, "http") { + t.Fatalf("risk %q: formatter must not print a trailing link, got:\n%s", c.risk, out) + } + } +} + +func TestInsightTextFormatter_WrapsContentWithinBox(t *testing.T) { + longWord := strings.Repeat("word ", 30) + out := captureStdout(t, func() { + err := insightTextFormatter(os.Stdout, fakeDataAccessor{values: map[string]string{ + "it.risk": "medium", "it.content": longWord, + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least a top border, content, and bottom border, got:\n%s", out) + } + boxWidth := len([]rune(lines[0])) + contentLines := 0 + for _, line := range lines[1 : len(lines)-1] { + if len([]rune(line)) != boxWidth { + t.Fatalf("content line %q has length %d, want %d (box width)", line, len([]rune(line)), boxWidth) + } + if !strings.HasPrefix(line, "│ ") || !strings.HasSuffix(line, " │") { + t.Fatalf("content line %q must be bordered with │, got:\n%s", line, out) + } + contentLines++ + } + if contentLines < 2 { + t.Fatalf("expected long content to wrap across multiple lines, got %d line(s):\n%s", contentLines, out) + } +} + // --------------------------------------------------------------------------- // ModuleInit // --------------------------------------------------------------------------- From 4759b824da40415126965b83bc1fbc04bd811364 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 19 Aug 2026 12:47:41 +0530 Subject: [PATCH 03/11] Added bullet and risk colors to the groups --- modules/code/insight.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index 74d16f1..6ef1035 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -112,7 +112,7 @@ func reviewGroupTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { if len(groups) == 0 { fmt.Fprintln(w, "No review groups.") } - for i, raw := range groups { + for _, raw := range groups { g, ok := raw.(map[string]any) if !ok { continue @@ -123,11 +123,16 @@ func reviewGroupTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { if tags, ok := g["tags"].(map[string]any); ok { risk, _ = tags["risk"].(string) } - fmt.Fprintf(w, "\nGroup %d: %s", i+1, title) + bullet := "●" + riskTag := "" if risk != "" { - fmt.Fprintf(w, " [risk: %s]", risk) + riskTag = fmt.Sprintf(" [%s]", risk) } - fmt.Fprintln(w) + if c := riskColor(risk); c != 0 { + bullet = console.WithColor(c, bullet) + riskTag = console.WithColor(c, riskTag) + } + fmt.Fprintf(w, "\n%s %s%s\n", bullet, title, riskTag) if desc != "" { fmt.Fprintln(w, desc) } From 18dbdaa62fef2d9283db59907737b5f4a09ae158 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 19 Aug 2026 12:48:11 +0530 Subject: [PATCH 04/11] Added new test cases for the bullet case --- modules/code/insight_test.go | 47 ++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 0ca6c2b..7e4f07a 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -295,14 +295,17 @@ func TestReviewGroupCommand_StandaloneRendersLink(t *testing.T) { // insightTextFormatter // --------------------------------------------------------------------------- -type fakeDataAccessor struct{ values map[string]string } +type fakeDataAccessor struct { + values map[string]string + slices map[string][]any +} func (f fakeDataAccessor) GetString(path string) string { return f.values[path] } func (f fakeDataAccessor) GetInt64(string) int64 { return 0 } func (f fakeDataAccessor) GetBool(string) bool { return false } func (f fakeDataAccessor) GetTs(string) string { return "" } func (f fakeDataAccessor) GetData() any { return nil } -func (f fakeDataAccessor) GetSlice(string) []any { return nil } +func (f fakeDataAccessor) GetSlice(path string) []any { return f.slices[path] } func TestInsightTextFormatter_HeadingByRisk(t *testing.T) { cases := []struct { @@ -369,6 +372,46 @@ func TestInsightTextFormatter_WrapsContentWithinBox(t *testing.T) { } } +// --------------------------------------------------------------------------- +// reviewGroupTextFormatter +// --------------------------------------------------------------------------- + +func TestReviewGroupTextFormatter_ColorizesBulletAndRiskTag(t *testing.T) { + groups := []any{ + map[string]any{ + "title": "Auth middleware changes", + "description": "Touches token validation.", + "tags": map[string]any{"risk": "high"}, + "files": []any{ + map[string]any{"path": "auth/middleware.go"}, + }, + }, + } + out := captureStdout(t, func() { + err := reviewGroupTextFormatter(os.Stdout, fakeDataAccessor{ + slices: map[string][]any{"it.groups": groups}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + if strings.Contains(out, "Group 1") || strings.Contains(out, "Group ") { + t.Fatalf("output must not contain a numbered \"Group\" label, got:\n%s", out) + } + if !strings.Contains(out, "●") { + t.Fatalf("output must contain the risk bullet, got:\n%s", out) + } + if !strings.Contains(out, "Auth middleware changes") { + t.Fatalf("output must contain the group title, got:\n%s", out) + } + if !strings.Contains(out, "[high]") { + t.Fatalf("output must contain the risk tag, got:\n%s", out) + } + if !strings.Contains(out, "auth/middleware.go") { + t.Fatalf("output must still list the file path, got:\n%s", out) + } +} + // --------------------------------------------------------------------------- // ModuleInit // --------------------------------------------------------------------------- From d57a974b712145fd12814b199c1376f30d7470b7 Mon Sep 17 00:00:00 2001 From: Naman Nirwan Date: Wed, 19 Aug 2026 15:06:48 +0530 Subject: [PATCH 05/11] Fix spacing in boxWidth calculation for go formatting. --- modules/code/insight.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index 6ef1035..816bf72 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -188,7 +188,7 @@ func insightTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { styledHeading = console.WithColor(c, heading) } - boxWidth := min(max(console.TerminalWidth(80), 40),150) + boxWidth := min(max(console.TerminalWidth(80), 40), 150) contentWidth := boxWidth - 4 fillLen := boxWidth - len([]rune(heading))-6 From a598ada8fbf48e0b625ba497ac471c2b94919693 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 19 Aug 2026 15:45:10 +0530 Subject: [PATCH 06/11] Changed the formatting heading text --- modules/code/insight.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index 816bf72..127c2c6 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -181,7 +181,7 @@ func insightTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { heading := "AI Code Overview" if risk != "" { - heading += fmt.Sprintf(" [%s]", risk) + heading += fmt.Sprintf(" [%s risk]", risk) } styledHeading := heading if c := riskColor(risk); c != 0 { @@ -191,7 +191,7 @@ func insightTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { boxWidth := min(max(console.TerminalWidth(80), 40), 150) contentWidth := boxWidth - 4 - fillLen := boxWidth - len([]rune(heading))-6 + fillLen := boxWidth - len([]rune(heading)) - 6 if fillLen < 1 { fillLen = 1 } From 077b6cae9a7bfd82138375cead459428d7ea1481 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 19 Aug 2026 13:55:04 -0700 Subject: [PATCH 07/11] update textbox to be more plain to preserve formatting of content. no hard line breaks. --- modules/code/insight.go | 73 ++++++++-------------------------- modules/code/insight_test.go | 76 ++---------------------------------- pkg/console/console.go | 23 ++++++----- pkg/console/textbox.go | 32 +++++++++++++++ 4 files changed, 64 insertions(+), 140 deletions(-) create mode 100644 pkg/console/textbox.go diff --git a/modules/code/insight.go b/modules/code/insight.go index 127c2c6..57d7e30 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -173,65 +173,26 @@ func riskColor(risk string) console.Color { } // insightTextFormatter renders the AI code-review overview for a pull request as a -// box: the heading (colorized by risk) sits in the top border, and the review -// content is word-wrapped inside. +// colorized (by risk) header/footer section marker around the review content. +// The content is printed verbatim: wrapping is left to the terminal so words and +// URLs are never split, and the output stays copy-paste clean. func insightTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { - risk := d.GetString("it.risk") - content := d.GetString("it.content") - - heading := "AI Code Overview" - if risk != "" { - heading += fmt.Sprintf(" [%s risk]", risk) - } - styledHeading := heading - if c := riskColor(risk); c != 0 { - styledHeading = console.WithColor(c, heading) - } - - boxWidth := min(max(console.TerminalWidth(80), 40), 150) - contentWidth := boxWidth - 4 - - fillLen := boxWidth - len([]rune(heading)) - 6 - if fillLen < 1 { - fillLen = 1 - } - fmt.Fprintf(w, "┌── %s %s┐\n", styledHeading, strings.Repeat("─", fillLen)) - - for _, line := range wrapText(content, contentWidth) { - fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line) - } - - fmt.Fprintf(w, "└%s┘\n", strings.Repeat("─", boxWidth-2)) + renderInsight(w, d.GetString("it.risk"), d.GetString("it.content")) return nil } -// wrapText word-wraps s to width, treating each existing line as its own -// paragraph so blank lines are preserved as paragraph breaks. -func wrapText(s string, width int) []string { - if width < 1 { - width = 1 - } - var lines []string - for _, paragraph := range strings.Split(s, "\n") { - if strings.TrimSpace(paragraph) == "" { - lines = append(lines, "") - continue - } - var cur string - for _, word := range strings.Fields(paragraph) { - switch { - case cur == "": - cur = word - case len([]rune(cur))+1+len([]rune(word)) <= width: - cur += " " + word - default: - lines = append(lines, cur) - cur = word - } - } - if cur != "" { - lines = append(lines, cur) - } +// renderInsight is the shared rendering step behind insightTextFormatter and +// DebugRenderInsightHandler, so the debug command exercises the exact same +// styling without needing a fake cmdctx.DataAccessor. +func renderInsight(w io.Writer, risk, content string) { + heading := "AI Code Review" + if risk != "" { + heading += fmt.Sprintf(" [%s risk]", risk) } - return lines + console.RenderTextBox(w, console.TextBox{ + Icon: "✨", + Header: heading, + HeaderColor: riskColor(risk), + Text: content, + }) } diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 7e4f07a..39e002e 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -218,7 +218,7 @@ func TestGetPRWorkflow_InsightFailureOmitsSectionButSucceeds(t *testing.T) { if err != nil { t.Fatalf("get pr must succeed even when an insight endpoint fails, got: %v", err) } - if strings.Contains(out, "AI Code Overview") { + if strings.Contains(out, "AI Code Review") { t.Fatalf("output must omit failed sections, got:\n%s", out) } } @@ -242,11 +242,8 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out, "AI Code Overview [low]") { - t.Fatalf("output must contain the colorized AI Code Overview heading, got:\n%s", out) - } - if !strings.Contains(out, "┌") || !strings.Contains(out, "└") { - t.Fatalf("output must box the AI Code Overview section, got:\n%s", out) + if !strings.Contains(out, "AI Code Review") { + t.Fatalf("output must contain the AI Code Review heading, got:\n%s", out) } if !strings.Contains(out, "PR Details") { t.Fatalf("output must contain the PR Details section, got:\n%s", out) @@ -254,7 +251,7 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { // Insight must render first, PR Details last (right before the link), and the // PR link must print exactly once, at the very end. - insightIdx := strings.Index(out, "AI Code Overview") + insightIdx := strings.Index(out, "AI Code Review") prDetailsIdx := strings.Index(out, "PR Details") if insightIdx == -1 || prDetailsIdx == -1 || prDetailsIdx < insightIdx { t.Fatalf("expected Insight to render before PR Details, got:\n%s", out) @@ -307,71 +304,6 @@ func (f fakeDataAccessor) GetTs(string) string { return "" } func (f fakeDataAccessor) GetData() any { return nil } func (f fakeDataAccessor) GetSlice(path string) []any { return f.slices[path] } -func TestInsightTextFormatter_HeadingByRisk(t *testing.T) { - cases := []struct { - risk string - heading string - }{ - {"low", "AI Code Overview [low]"}, - {"medium", "AI Code Overview [medium]"}, - {"high", "AI Code Overview [high]"}, - {"", "AI Code Overview"}, - {"unknown", "AI Code Overview [unknown]"}, - } - for _, c := range cases { - out := captureStdout(t, func() { - err := insightTextFormatter(os.Stdout, fakeDataAccessor{values: map[string]string{ - "it.risk": c.risk, "it.content": "looks fine", - }}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - if !strings.Contains(out, c.heading) { - t.Fatalf("risk %q: expected heading %q in output, got:\n%s", c.risk, c.heading, out) - } - if !strings.HasPrefix(out, "┌") { - t.Fatalf("risk %q: expected output to start with a box top border, got:\n%s", c.risk, out) - } - if !strings.Contains(out, "└") { - t.Fatalf("risk %q: expected output to contain a box bottom border, got:\n%s", c.risk, out) - } - if strings.Contains(out, "http") { - t.Fatalf("risk %q: formatter must not print a trailing link, got:\n%s", c.risk, out) - } - } -} - -func TestInsightTextFormatter_WrapsContentWithinBox(t *testing.T) { - longWord := strings.Repeat("word ", 30) - out := captureStdout(t, func() { - err := insightTextFormatter(os.Stdout, fakeDataAccessor{values: map[string]string{ - "it.risk": "medium", "it.content": longWord, - }}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) < 3 { - t.Fatalf("expected at least a top border, content, and bottom border, got:\n%s", out) - } - boxWidth := len([]rune(lines[0])) - contentLines := 0 - for _, line := range lines[1 : len(lines)-1] { - if len([]rune(line)) != boxWidth { - t.Fatalf("content line %q has length %d, want %d (box width)", line, len([]rune(line)), boxWidth) - } - if !strings.HasPrefix(line, "│ ") || !strings.HasSuffix(line, " │") { - t.Fatalf("content line %q must be bordered with │, got:\n%s", line, out) - } - contentLines++ - } - if contentLines < 2 { - t.Fatalf("expected long content to wrap across multiple lines, got %d line(s):\n%s", contentLines, out) - } -} - // --------------------------------------------------------------------------- // reviewGroupTextFormatter // --------------------------------------------------------------------------- diff --git a/pkg/console/console.go b/pkg/console/console.go index f8564b9..9c3f991 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -85,25 +85,24 @@ func IsBothTTY() bool { return ensureTTY() && ensureStdoutTTY() } -// TerminalWidth returns the current stdout width, or fallback when stdout isn't a TTY -// or the size can't be determined. -func TerminalWidth(fallback int) int { +// WithColor wraps text in ANSI color codes when stdout is a TTY. +func WithColor(c Color, text string) string { if !ensureStdoutTTY() { - return fallback - } - w, _, err := term.GetSize(int(syscall.Stdout)) - if err != nil || w <= 0 { - return fallback + return text } - return w + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", int(c), text) } -// WithColor wraps text in ANSI color codes when stdout is a TTY. -func WithColor(c Color, text string) string { +// WithBoldColor wraps text in a bold ANSI code, additionally tinted with c when +// c != 0, when stdout is a TTY. +func WithBoldColor(c Color, text string) string { if !ensureStdoutTTY() { return text } - return fmt.Sprintf("\x1b[%dm%s\x1b[0m", int(c), text) + if c != 0 { + return fmt.Sprintf("\x1b[1;%dm%s\x1b[0m", int(c), text) + } + return fmt.Sprintf("\x1b[1m%s\x1b[0m", text) } // ReadSecret prints prompt to stderr and reads a masked line from the terminal. diff --git a/pkg/console/textbox.go b/pkg/console/textbox.go new file mode 100644 index 0000000..cdd01fe --- /dev/null +++ b/pkg/console/textbox.go @@ -0,0 +1,32 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package console + +import ( + "fmt" + "io" +) + +// TextBox is a lightweight section marker: a colorized header line (optionally +// preceded by Icon, suppressed when stdout isn't a TTY), then Text verbatim, +// then a dimmed fixed-width closing rule. +type TextBox struct { + Icon string + Header string + HeaderColor Color + Text string +} + +// RenderTextBox writes box to w. Text is written verbatim — callers must not +// wrap it themselves; wrapping is left to the terminal so words and URLs are +// never split, and the output stays copy-paste clean. +func RenderTextBox(w io.Writer, box TextBox) { + header := box.Header + ":" + if box.Icon != "" && ensureStdoutTTY() { + header = box.Icon + " " + header + } + fmt.Fprintln(w, WithBoldColor(box.HeaderColor, header)) + fmt.Fprintln(w, box.Text) + fmt.Fprintln(w, WithColor(ColorBrightBlack, "──────────")) +} From ab81ce2881badcc5bce1460458c11313670ce143 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 19 Aug 2026 14:33:43 -0700 Subject: [PATCH 08/11] begin undoing the complicated pr rendering logic --- modules/code/insight.go | 87 --------------------------- modules/code/insight_test.go | 12 ++-- modules/code/pr.go | 112 +++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 93 deletions(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index 57d7e30..d0e664f 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -10,100 +10,13 @@ import ( "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/console" - "github.com/harness/cli/pkg/exprenv" - "github.com/harness/cli/pkg/hlog" - "github.com/harness/cli/pkg/registry" ) const ( - getPRWorkflowID = "get_pr" reviewGroupTextFormatterID = "pr_review_group_text" insightTextFormatterID = "pr_insight_text" ) -// insightSections lists the Harness Code review-insight sub-commands appended to "get pr" output, -// each best-effort: a failure only omits that section, it never fails the command. -var insightSections = []struct { - verb, noun, label string -}{ - {"get", "pr:insight", "Insight"}, -} - -// isMachineFormat mirrors exprenv.isMachineFormat (unexported): these formats are -// meant for structured consumption, so insight sections (extra, ad hoc text) are skipped. -func isMachineFormat(format string) bool { - switch format { - case "json", "jsonl", "csv", "tsv", "markdown", "ui": - return true - } - return false -} - -// GetPRWorkflow implements "get pr". It fetches and renders the base pull request -// exactly as the old handler_type: endpoint command did (hard fail on error, unchanged -// output), then best-effort appends Harness Code review-insight sections below it. Any insight -// endpoint failure is logged as a warning and the section is omitted — it never fails -// the command. -func GetPRWorkflow(ctx *cmdctx.Ctx) error { - baseSpec := ctx.Resolver.GetSpec("get", "pr") - if baseSpec == nil || baseSpec.Endpoint == nil { - return fmt.Errorf("get pr command spec not found") - } - - if isMachineFormat(ctx.FormatFlags.Format) || cmdctx.GetBool(ctx.FlagValues, "list-fields") { - _, err := registry.RunEndpoint(ctx, baseSpec.Endpoint) - return err - } - - // Fetch (hard-fail on error, same as before) but don't render yet — the base - // PR block now prints last, under "PR Details", after the Insight section. - pr, err := registry.CallEndpoint(ctx, baseSpec.Endpoint) - if err != nil { - return err - } - - origNoun, origFieldsNoun := ctx.Noun, ctx.FieldsNoun - defer func() { ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun }() - - for _, section := range insightSections { - cs := ctx.Resolver.GetSpec(section.verb, section.noun) - if cs == nil || cs.Endpoint == nil { - hlog.Warn("insight section spec not found, omitting from get pr", "verb", section.verb, "noun", section.noun) - continue - } - ctx.Noun, ctx.FieldsNoun = cs.Noun, cs.FieldsNoun - // Probe with a fetch-only call first so a failure never prints a section - // header with nothing under it; RunEndpoint's own render then re-fetches - // (cheap: these are all idempotent GETs). - if _, err := registry.CallEndpoint(ctx, cs.Endpoint); err != nil { - hlog.Warn("insight fetch failed, omitting from get pr", "section", section.label, "err", err) - continue - } - ep := *cs.Endpoint - ep.TextFooter = "" - if _, err := registry.RunEndpoint(ctx, &ep); err != nil { - hlog.Warn("insight fetch failed, omitting from get pr", "section", section.label, "err", err) - } - } - - ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun - - fmt.Print("\n--- PR Details ---\n") - baseEP := *baseSpec.Endpoint - baseEP.TextFooter = "" - if _, err := registry.RunEndpoint(ctx, &baseEP); err != nil { - return err - } - - if footer := baseSpec.Endpoint.TextFooter; footer != "" { - env := exprenv.WithIt(exprenv.Make(ctx), pr) - if text, err := exprenv.ResolvePath(env, footer); err == nil { - fmt.Print(text) - } - } - return nil -} - // reviewGroupTextFormatter renders the risk-bucketed review groups for a pull // request as a readable report: one block per group with its title, risk, // description, and the full list of changed file paths. diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 39e002e..7984022 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -249,16 +249,16 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { t.Fatalf("output must contain the PR Details section, got:\n%s", out) } - // Insight must render first, PR Details last (right before the link), and the + // PR Details must render first, Insight last (right before the link), and the // PR link must print exactly once, at the very end. - insightIdx := strings.Index(out, "AI Code Review") prDetailsIdx := strings.Index(out, "PR Details") - if insightIdx == -1 || prDetailsIdx == -1 || prDetailsIdx < insightIdx { - t.Fatalf("expected Insight to render before PR Details, got:\n%s", out) + insightIdx := strings.Index(out, "AI Code Review") + if insightIdx == -1 || prDetailsIdx == -1 || insightIdx < prDetailsIdx { + t.Fatalf("expected PR Details to render before Insight, got:\n%s", out) } lastLinkIdx := strings.LastIndex(out, "/pulls/42") - if lastLinkIdx == -1 || lastLinkIdx < prDetailsIdx { - t.Fatalf("expected the PR link to appear after the PR Details section, got:\n%s", out) + if lastLinkIdx == -1 || lastLinkIdx < insightIdx { + t.Fatalf("expected the PR link to appear after the Insight section, got:\n%s", out) } if linkCount := strings.Count(out, "/pulls/42"); linkCount != 1 { t.Fatalf("expected exactly one PR link (sections must not duplicate it), got %d in:\n%s", linkCount, out) diff --git a/modules/code/pr.go b/modules/code/pr.go index b752b92..ece6a59 100644 --- a/modules/code/pr.go +++ b/modules/code/pr.go @@ -5,11 +5,123 @@ package code import ( "fmt" + "os" "strings" "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/exprenv" + "github.com/harness/cli/pkg/extractutil" + "github.com/harness/cli/pkg/format" + "github.com/harness/cli/pkg/hlog" + "github.com/harness/cli/pkg/registry" + "github.com/harness/cli/pkg/spec" ) +const getPRWorkflowID = "get_pr" + +// isMachineFormat mirrors exprenv.isMachineFormat (unexported): these formats are +// meant for structured consumption, so the insight section (extra, ad hoc text) is skipped. +func isMachineFormat(format string) bool { + switch format { + case "json", "jsonl", "csv", "tsv", "markdown", "ui": + return true + } + return false +} + +// GetPRWorkflow implements "get pr". It fetches the base pull request (hard fail on +// error) and the "pr:insight" section (best-effort — a failure only omits it, it never +// fails the command), then hands everything fetched to renderPR. +func GetPRWorkflow(ctx *cmdctx.Ctx) error { + baseSpec := ctx.Resolver.GetSpec("get", "pr") + if baseSpec == nil || baseSpec.Endpoint == nil { + return fmt.Errorf("get pr command spec not found") + } + + if isMachineFormat(ctx.FormatFlags.Format) || cmdctx.GetBool(ctx.FlagValues, "list-fields") { + _, err := registry.RunEndpoint(ctx, baseSpec.Endpoint) + return err + } + + pr, err := registry.CallEndpoint(ctx, baseSpec.Endpoint) + if err != nil { + return err + } + + origNoun, origFieldsNoun := ctx.Noun, ctx.FieldsNoun + defer func() { ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun }() + + insightSpec := ctx.Resolver.GetSpec("get", "pr:insight") + var insightData any + if insightSpec == nil || insightSpec.Endpoint == nil { + hlog.Warn("insight section spec not found, omitting from get pr") + insightSpec = nil + } else { + ctx.Noun, ctx.FieldsNoun = insightSpec.Noun, insightSpec.FieldsNoun + insightData, err = registry.CallEndpoint(ctx, insightSpec.Endpoint) + if err != nil { + hlog.Warn("insight fetch failed, omitting from get pr", "err", err) + insightSpec = nil + } + } + + ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun + + return renderPR(ctx, baseSpec, pr, insightSpec, insightData) +} + +// renderPR prints "get pr" output in three parts so the AI Code Review (Insight) +// section can sit between them: labeled fields, then the insight section (best-effort +// — a failure here still only omits it), then the description text block and footer. +func renderPR(ctx *cmdctx.Ctx, baseSpec *spec.CommandSpec, pr any, insightSpec *spec.CommandSpec, insightData any) error { + nounForFields := ctx.Noun + if ctx.FieldsNoun != "" { + nounForFields = ctx.FieldsNoun + } + var labeledFields, textFields []spec.FieldDef + if nd := ctx.Resolver.GetNoun(nounForFields); nd != nil { + for _, f := range nd.Fields { + if f.FieldType == "multiline_text" || f.FieldType == "yaml" { + textFields = append(textFields, f) + } else { + labeledFields = append(labeledFields, f) + } + } + } + + exprEnv := exprenv.Make(ctx) + interpolate := func(tmpl string, item any) string { + s, _ := exprenv.ResolvePath(exprenv.WithIt(exprEnv, item), tmpl) + return s + } + data := extractutil.MakeDataAccessor(exprEnv, pr) + + fmt.Print("\n--- PR Details ---\n") + if err := format.BuildTextFieldFormatter(labeledFields, "", "", interpolate)(os.Stdout, data); err != nil { + return err + } + + origNoun, origFieldsNoun := ctx.Noun, ctx.FieldsNoun + defer func() { ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun }() + + if insightSpec != nil { + ctx.Noun, ctx.FieldsNoun = insightSpec.Noun, insightSpec.FieldsNoun + textFmt := ctx.Resolver.ResolveTextFormatter(insightSpec.Endpoint.TextFormatter) + if textFmt == nil { + hlog.Warn("insight section has no text formatter, omitting from get pr") + } else { + sectionData := extractutil.MakeDataAccessor(exprenv.Make(ctx), insightData) + if err := textFmt(os.Stdout, sectionData); err != nil { + hlog.Warn("insight render failed, omitting from get pr", "err", err) + } + } + } + + ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun + + return format.BuildTextFieldFormatter(textFields, "", baseSpec.Endpoint.TextFooter, interpolate)(os.Stdout, data) +} + // createPRBodyFn builds the pull request create body. // Required: --set title= source_branch=<branch> target_branch=<branch> // Optional: --set description=<text> OR -f <file> for multi-line description. From e8ce44b01a0780cc2b514602d80a593f24e06b95 Mon Sep 17 00:00:00 2001 From: Mike Sawka <mike.sawka@harness.io> Date: Wed, 19 Aug 2026 14:50:36 -0700 Subject: [PATCH 09/11] more simplification --- modules/code/insight_test.go | 12 ++++++------ modules/code/pr.go | 35 +++++++++++------------------------ 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 7984022..2e9e74c 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -245,16 +245,16 @@ func TestGetPRWorkflow_InsightSuccessRendersSection(t *testing.T) { if !strings.Contains(out, "AI Code Review") { t.Fatalf("output must contain the AI Code Review heading, got:\n%s", out) } - if !strings.Contains(out, "PR Details") { - t.Fatalf("output must contain the PR Details section, got:\n%s", out) + if !strings.Contains(out, "Number:") { + t.Fatalf("output must contain the PR's labeled fields, got:\n%s", out) } - // PR Details must render first, Insight last (right before the link), and the + // Labeled fields must render first, Insight last (right before the link), and the // PR link must print exactly once, at the very end. - prDetailsIdx := strings.Index(out, "PR Details") + numberIdx := strings.Index(out, "Number:") insightIdx := strings.Index(out, "AI Code Review") - if insightIdx == -1 || prDetailsIdx == -1 || insightIdx < prDetailsIdx { - t.Fatalf("expected PR Details to render before Insight, got:\n%s", out) + if insightIdx == -1 || numberIdx == -1 || insightIdx < numberIdx { + t.Fatalf("expected labeled fields to render before Insight, got:\n%s", out) } lastLinkIdx := strings.LastIndex(out, "/pulls/42") if lastLinkIdx == -1 || lastLinkIdx < insightIdx { diff --git a/modules/code/pr.go b/modules/code/pr.go index ece6a59..4c6b862 100644 --- a/modules/code/pr.go +++ b/modules/code/pr.go @@ -5,7 +5,6 @@ package code import ( "fmt" - "os" "strings" "github.com/harness/cli/pkg/cmdctx" @@ -48,16 +47,12 @@ func GetPRWorkflow(ctx *cmdctx.Ctx) error { return err } - origNoun, origFieldsNoun := ctx.Noun, ctx.FieldsNoun - defer func() { ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun }() - insightSpec := ctx.Resolver.GetSpec("get", "pr:insight") var insightData any if insightSpec == nil || insightSpec.Endpoint == nil { hlog.Warn("insight section spec not found, omitting from get pr") insightSpec = nil } else { - ctx.Noun, ctx.FieldsNoun = insightSpec.Noun, insightSpec.FieldsNoun insightData, err = registry.CallEndpoint(ctx, insightSpec.Endpoint) if err != nil { hlog.Warn("insight fetch failed, omitting from get pr", "err", err) @@ -65,8 +60,6 @@ func GetPRWorkflow(ctx *cmdctx.Ctx) error { } } - ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun - return renderPR(ctx, baseSpec, pr, insightSpec, insightData) } @@ -74,6 +67,12 @@ func GetPRWorkflow(ctx *cmdctx.Ctx) error { // section can sit between them: labeled fields, then the insight section (best-effort // — a failure here still only omits it), then the description text block and footer. func renderPR(ctx *cmdctx.Ctx, baseSpec *spec.CommandSpec, pr any, insightSpec *spec.CommandSpec, insightData any) error { + w, closeW, err := format.OpenWriter(ctx.FormatFlags.OutFile) + if err != nil { + return err + } + defer closeW() + nounForFields := ctx.Noun if ctx.FieldsNoun != "" { nounForFields = ctx.FieldsNoun @@ -96,30 +95,18 @@ func renderPR(ctx *cmdctx.Ctx, baseSpec *spec.CommandSpec, pr any, insightSpec * } data := extractutil.MakeDataAccessor(exprEnv, pr) - fmt.Print("\n--- PR Details ---\n") - if err := format.BuildTextFieldFormatter(labeledFields, "", "", interpolate)(os.Stdout, data); err != nil { + if err := format.BuildTextFieldFormatter(labeledFields, "", "", interpolate)(w, data); err != nil { return err } - origNoun, origFieldsNoun := ctx.Noun, ctx.FieldsNoun - defer func() { ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun }() - if insightSpec != nil { - ctx.Noun, ctx.FieldsNoun = insightSpec.Noun, insightSpec.FieldsNoun - textFmt := ctx.Resolver.ResolveTextFormatter(insightSpec.Endpoint.TextFormatter) - if textFmt == nil { - hlog.Warn("insight section has no text formatter, omitting from get pr") - } else { - sectionData := extractutil.MakeDataAccessor(exprenv.Make(ctx), insightData) - if err := textFmt(os.Stdout, sectionData); err != nil { - hlog.Warn("insight render failed, omitting from get pr", "err", err) - } + sectionData := extractutil.MakeDataAccessor(exprEnv, insightData) + if err := insightTextFormatter(w, sectionData); err != nil { + hlog.Warn("insight render failed, omitting from get pr", "err", err) } } - ctx.Noun, ctx.FieldsNoun = origNoun, origFieldsNoun - - return format.BuildTextFieldFormatter(textFields, "", baseSpec.Endpoint.TextFooter, interpolate)(os.Stdout, data) + return format.BuildTextFieldFormatter(textFields, "", baseSpec.Endpoint.TextFooter, interpolate)(w, data) } // createPRBodyFn builds the pull request create body. From c82b47b23615b571f5cc7a299236e571a3372189 Mon Sep 17 00:00:00 2001 From: Mike Sawka <mike.sawka@harness.io> Date: Wed, 19 Aug 2026 15:09:10 -0700 Subject: [PATCH 10/11] trim and then remove integration with BuildTextFormatter --- modules/code/insight.go | 2 +- modules/code/pr.go | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index d0e664f..c0ec93d 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -106,6 +106,6 @@ func renderInsight(w io.Writer, risk, content string) { Icon: "✨", Header: heading, HeaderColor: riskColor(risk), - Text: content, + Text: strings.TrimSpace(content), }) } diff --git a/modules/code/pr.go b/modules/code/pr.go index 4c6b862..0475513 100644 --- a/modules/code/pr.go +++ b/modules/code/pr.go @@ -77,14 +77,13 @@ func renderPR(ctx *cmdctx.Ctx, baseSpec *spec.CommandSpec, pr any, insightSpec * if ctx.FieldsNoun != "" { nounForFields = ctx.FieldsNoun } - var labeledFields, textFields []spec.FieldDef + var labeledFields []spec.FieldDef if nd := ctx.Resolver.GetNoun(nounForFields); nd != nil { for _, f := range nd.Fields { if f.FieldType == "multiline_text" || f.FieldType == "yaml" { - textFields = append(textFields, f) - } else { - labeledFields = append(labeledFields, f) + continue } + labeledFields = append(labeledFields, f) } } @@ -106,7 +105,11 @@ func renderPR(ctx *cmdctx.Ctx, baseSpec *spec.CommandSpec, pr any, insightSpec * } } - return format.BuildTextFieldFormatter(textFields, "", baseSpec.Endpoint.TextFooter, interpolate)(w, data) + if desc := strings.TrimSpace(data.GetString("it.description")); desc != "" { + fmt.Fprintf(w, "\n%s\n", desc) + } + _, err = fmt.Fprint(w, interpolate(baseSpec.Endpoint.TextFooter, pr)) + return err } // createPRBodyFn builds the pull request create body. From 6e2f0dad53052c96d8694a6539f4fc8707448810 Mon Sep 17 00:00:00 2001 From: Mike Sawka <mike.sawka@harness.io> Date: Wed, 19 Aug 2026 15:47:02 -0700 Subject: [PATCH 11/11] update groups to highlight entire line --- modules/code/insight.go | 7 +++---- pkg/console/console.go | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/modules/code/insight.go b/modules/code/insight.go index c0ec93d..b7bb337 100644 --- a/modules/code/insight.go +++ b/modules/code/insight.go @@ -36,16 +36,15 @@ func reviewGroupTextFormatter(w io.Writer, d cmdctx.DataAccessor) error { if tags, ok := g["tags"].(map[string]any); ok { risk, _ = tags["risk"].(string) } - bullet := "●" riskTag := "" if risk != "" { riskTag = fmt.Sprintf(" [%s]", risk) } + line := fmt.Sprintf("● %s%s", title, riskTag) if c := riskColor(risk); c != 0 { - bullet = console.WithColor(c, bullet) - riskTag = console.WithColor(c, riskTag) + line = console.WithColor(c, line) } - fmt.Fprintf(w, "\n%s %s%s\n", bullet, title, riskTag) + fmt.Fprintf(w, "\n%s\n", line) if desc != "" { fmt.Fprintln(w, desc) } diff --git a/pkg/console/console.go b/pkg/console/console.go index 9c3f991..7608ab5 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -105,6 +105,14 @@ func WithBoldColor(c Color, text string) string { return fmt.Sprintf("\x1b[1m%s\x1b[0m", text) } +// WithBold wraps text in a bold ANSI code (no color) when stdout is a TTY. +func WithBold(text string) string { + if !ensureStdoutTTY() { + return text + } + return fmt.Sprintf("\x1b[1m%s\x1b[0m", text) +} + // ReadSecret prints prompt to stderr and reads a masked line from the terminal. func ReadSecret(prompt string) (string, error) { if !ensureTTY() {