Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ CLI (roborev) -> HTTP API -> Daemon -> Worker Pool -> Agent adapters
- Runtime info: `~/.roborev/daemon.json`
- SQLite DB: `~/.roborev/reviews.db` using WAL mode
- Data dir override: `ROBOREV_DATA_DIR`
- Color mode: `ROBOREV_COLOR_MODE` env var (`auto`, `dark`, `light`, `none`); `NO_COLOR=1` also supported
- Global config: `~/.roborev/config.toml`
- Repo config: `.roborev.toml` at repo root
- Config precedence is generally: CLI flags -> repo config -> global config -> defaults
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ CLI (roborev) → HTTP API → Daemon (roborev daemon run) → Worker Pool → A
- **Storage**: SQLite at `~/.roborev/reviews.db` with WAL mode
- **Config**: Global at `~/.roborev/config.toml`, per-repo at `.roborev.toml`
- **Data dir**: Set `ROBOREV_DATA_DIR` env var to override `~/.roborev`
- **Color mode**: `ROBOREV_COLOR_MODE=auto|dark|light|none` controls TUI color theme; `NO_COLOR=1` strips all colors
- **Runtime info**: Daemon writes PID/addr/port to `~/.roborev/daemon.json`

## Package Map
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ Project-specific review instructions here.

See [configuration guide](https://roborev.io/configuration/) for all options.

### Environment Variables

| Variable | Description |
|----------|-------------|
| `ROBOREV_DATA_DIR` | Override default data directory (`~/.roborev`) |
| `ROBOREV_COLOR_MODE` | TUI color theme: `auto` (default), `dark`, `light`, `none` |
| `NO_COLOR` | Set to any value to disable all color output ([no-color.org](https://no-color.org)) |

## Supported Agents

| Agent | Install |
Expand Down
42 changes: 21 additions & 21 deletions cmd/roborev/tui/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
gansi "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
xansi "github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
"github.com/roborev-dev/roborev/internal/storage"
"github.com/roborev-dev/roborev/internal/streamfmt"
)

// Filter type constants used in filterStack and popFilter/pushFilter.
Expand Down Expand Up @@ -299,6 +299,7 @@ func wrapLine(line string, width int) []string {
// which blocks for seconds inside bubbletea's raw-mode input loop.
type markdownCache struct {
glamourStyle gansi.StyleConfig // custom style derived from dark/light, detected once at init
colorProfile termenv.Profile // color profile for glamour rendering (Ascii when NO_COLOR)
tabWidth int // tab expansion width (default 2)

reviewLines []string
Expand All @@ -320,26 +321,17 @@ type markdownCache struct {

// newMarkdownCache creates a markdownCache, detecting terminal background
// color now (before bubbletea enters raw mode and takes over stdin).
// Builds a custom style with zero margins to avoid extra padding.
// Delegates style and color profile resolution to the streamfmt package,
// which respects ROBOREV_COLOR_MODE env var and NO_COLOR convention.
func newMarkdownCache(tabWidth int) *markdownCache {
style := styles.LightStyleConfig
if termenv.HasDarkBackground() {
style = styles.DarkStyleConfig
}
// Remove document and code block margins that add extra indentation.
zeroMargin := uint(0)
style.Document.Margin = &zeroMargin
style.CodeBlock.Margin = &zeroMargin
// Remove inline code prefix/suffix spaces (rendered as visible
// colored blocks around `backtick` content).
style.Code.Prefix = ""
style.Code.Suffix = ""
style := streamfmt.GlamourStyle()
profile := streamfmt.ResolveColorProfile()
if tabWidth <= 0 {
tabWidth = 2
} else if tabWidth > 16 {
tabWidth = 16
}
return &markdownCache{glamourStyle: style, tabWidth: tabWidth}
return &markdownCache{glamourStyle: style, colorProfile: profile, tabWidth: tabWidth}
}

// truncateLongLines normalizes tabs and truncates lines inside fenced code
Expand Down Expand Up @@ -481,22 +473,29 @@ var trailingPadRe = regexp.MustCompile(`(\s|\x1b\[[0-9;]*m)+$`)

// stripTrailingPadding removes trailing whitespace and ANSI SGR codes from a
// glamour output line, then appends a reset to ensure clean color state.
func stripTrailingPadding(line string) string {
return trailingPadRe.ReplaceAllString(line, "") + "\x1b[0m"
// When noColor is true, the reset sequence is omitted.
func stripTrailingPadding(line string, noColor bool) string {
line = trailingPadRe.ReplaceAllString(line, "")
if noColor {
return line
}
return line + "\x1b[0m"
}

// renderMarkdownLines renders markdown text using glamour and splits into lines.
// wrapWidth controls glamour's word-wrap column (capped for readability).
// maxWidth controls line truncation (actual terminal width).
// colorProfile controls glamour's color output (use termenv.Ascii to suppress colors).
// Falls back to wrapText if glamour rendering fails.
func renderMarkdownLines(text string, wrapWidth, maxWidth int, glamourStyle gansi.StyleConfig, tabWidth int) []string {
func renderMarkdownLines(text string, wrapWidth, maxWidth int, glamourStyle gansi.StyleConfig, tabWidth int, colorProfile termenv.Profile) []string {
// Truncate long lines before glamour so they don't get word-wrapped.
// Use maxWidth (terminal width) so content fills the available space.
text = truncateLongLines(text, maxWidth, tabWidth)
r, err := glamour.NewTermRenderer(
glamour.WithStyles(glamourStyle),
glamour.WithWordWrap(wrapWidth),
glamour.WithPreservedNewLines(),
glamour.WithColorProfile(colorProfile),
)
if err != nil {
return sanitizeLines(wrapText(text, wrapWidth))
Expand All @@ -505,9 +504,10 @@ func renderMarkdownLines(text string, wrapWidth, maxWidth int, glamourStyle gans
if err != nil {
return sanitizeLines(wrapText(text, wrapWidth))
}
noColor := colorProfile == termenv.Ascii
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
for i, line := range lines {
line = stripTrailingPadding(line)
line = stripTrailingPadding(line, noColor)
line = sanitizeEscapes(line)
// Truncate output lines that still exceed maxWidth (glamour can add
// indentation for block quotes, lists, etc. beyond the wrap width).
Expand All @@ -526,7 +526,7 @@ func (c *markdownCache) getReviewLines(text string, wrapWidth, maxWidth int, rev
if c.reviewID == reviewID && c.reviewWidth == maxWidth && c.reviewText == text {
return c.reviewLines
}
c.reviewLines = renderMarkdownLines(text, wrapWidth, maxWidth, c.glamourStyle, c.tabWidth)
c.reviewLines = renderMarkdownLines(text, wrapWidth, maxWidth, c.glamourStyle, c.tabWidth, c.colorProfile)
c.reviewID = reviewID
c.reviewWidth = maxWidth
c.reviewText = text
Expand All @@ -540,7 +540,7 @@ func (c *markdownCache) getPromptLines(text string, wrapWidth, maxWidth int, rev
if c.promptID == reviewID && c.promptWidth == maxWidth && c.promptText == text {
return c.promptLines
}
c.promptLines = renderMarkdownLines(text, wrapWidth, maxWidth, c.glamourStyle, c.tabWidth)
c.promptLines = renderMarkdownLines(text, wrapWidth, maxWidth, c.glamourStyle, c.tabWidth, c.colorProfile)
c.promptID = reviewID
c.promptWidth = maxWidth
c.promptText = text
Expand Down
25 changes: 21 additions & 4 deletions cmd/roborev/tui/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour/styles"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
"github.com/roborev-dev/roborev/internal/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -23,7 +24,7 @@ func stripTestANSI(s string) string {

func TestRenderMarkdownLinesPreservesNewlines(t *testing.T) {
// Verify that single newlines in plain text are preserved (not collapsed into one paragraph)
lines := renderMarkdownLines("Line 1\nLine 2\nLine 3", 80, 80, styles.DarkStyleConfig, 2)
lines := renderMarkdownLines("Line 1\nLine 2\nLine 3", 80, 80, styles.DarkStyleConfig, 2, termenv.TrueColor)

found := 0
for _, line := range lines {
Expand All @@ -36,7 +37,7 @@ func TestRenderMarkdownLinesPreservesNewlines(t *testing.T) {
}

func TestRenderMarkdownLinesFallsBackOnEmpty(t *testing.T) {
lines := renderMarkdownLines("", 80, 80, styles.DarkStyleConfig, 2)
lines := renderMarkdownLines("", 80, 80, styles.DarkStyleConfig, 2, termenv.TrueColor)
// Should not panic and should produce some output (even if empty)
assert.NotNil(t, lines)
}
Expand Down Expand Up @@ -304,7 +305,7 @@ func TestRenderMarkdownLinesPreservesLongProse(t *testing.T) {
// Long prose lines should be word-wrapped by glamour, not truncated.
// All words must appear in the rendered output.
longProse := "This is a very long prose line with important content that should be word-wrapped by glamour rather than truncated so that no information is lost from the rendered output"
lines := renderMarkdownLines(longProse, 60, 80, styles.DarkStyleConfig, 2)
lines := renderMarkdownLines(longProse, 60, 80, styles.DarkStyleConfig, 2, termenv.TrueColor)

var combined strings.Builder
for _, line := range lines {
Expand Down Expand Up @@ -423,7 +424,7 @@ func TestRenderMarkdownLinesNoOverflow(t *testing.T) {
longLine := strings.Repeat("x", 200)
text := "Review:\n\n```\n" + longLine + "\n```\n"
width := 76
lines := renderMarkdownLines(text, width, width, styles.DarkStyleConfig, 2)
lines := renderMarkdownLines(text, width, width, styles.DarkStyleConfig, 2, termenv.TrueColor)

for i, line := range lines {
stripped := stripTestANSI(line)
Expand All @@ -433,6 +434,22 @@ func TestRenderMarkdownLinesNoOverflow(t *testing.T) {
}
}

func TestRenderMarkdownLinesNoColor(t *testing.T) {
// When colorProfile is Ascii, output should contain no ANSI color sequences.
// Bold/reset sequences (\x1b[;1m, \x1b[0m) are still emitted by glamour
// for text formatting — these are not colors and are acceptable under NO_COLOR.
text := "# Heading\n\nSome **bold** text and `code`."
lines := renderMarkdownLines(text, 80, 80, styles.DarkStyleConfig, 2, termenv.Ascii)

combined := strings.Join(lines, "\n")
// Match ANSI SGR sequences that set foreground/background colors:
// \x1b[3Xm (fg), \x1b[4Xm (bg), \x1b[9Xm (bright fg), \x1b[10Xm (bright bg),
// \x1b[38;...m (extended fg), \x1b[48;...m (extended bg).
colorSGR := regexp.MustCompile(`\x1b\[(3[0-7]|4[0-7]|9[0-7]|10[0-7]|38;|48;)[0-9;]*m`)
matches := colorSGR.FindAllString(combined, -1)
assert.Empty(t, matches, "expected no ANSI color sequences with Ascii profile, got: %v", matches)
}

func TestReflowHelpRows(t *testing.T) {

tests := []struct {
Expand Down
18 changes: 18 additions & 0 deletions cmd/roborev/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
"github.com/roborev-dev/roborev/internal/config"
"github.com/roborev-dev/roborev/internal/daemon"
"github.com/roborev-dev/roborev/internal/git"
Expand Down Expand Up @@ -527,6 +528,23 @@ func newModel(ep daemon.DaemonEndpoint, opts ...option) model {
filterStack = append(filterStack, filterTypeBranch)
}

// Apply ROBOREV_COLOR_MODE to the lipgloss default renderer so that
// AdaptiveColor styles on the queue screen respect the env var.
// NO_COLOR takes precedence per the convention.
// The glamour/markdown layer is handled separately via streamfmt.
if termenv.EnvNoColor() {
lipgloss.SetColorProfile(termenv.Ascii)
} else {
switch strings.ToLower(os.Getenv("ROBOREV_COLOR_MODE")) {
case "dark":
lipgloss.SetHasDarkBackground(true)
case "light":
lipgloss.SetHasDarkBackground(false)
case "none":
lipgloss.SetColorProfile(termenv.Ascii)
}
}

return model{
endpoint: ep,
daemonVersion: daemonVersion,
Expand Down
21 changes: 15 additions & 6 deletions internal/streamfmt/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
gansi "github.com/charmbracelet/glamour/ansi"
xansi "github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)

// ansiEscapePattern matches ANSI escape sequences (colors, cursor
Expand Down Expand Up @@ -88,9 +89,13 @@ var trailingPadRe = regexp.MustCompile(`(\s|\x1b\[[0-9;]*m)+$`)

// StripTrailingPadding removes trailing whitespace and ANSI SGR codes
// from a glamour output line, then appends a reset to ensure clean
// color state.
func StripTrailingPadding(line string) string {
return trailingPadRe.ReplaceAllString(line, "") + "\x1b[0m"
// color state. When noColor is true, the reset sequence is omitted.
func StripTrailingPadding(line string, noColor bool) string {
line = trailingPadRe.ReplaceAllString(line, "")
if noColor {
return line
}
return line + "\x1b[0m"
}

// WrapText wraps text to the specified width, preserving existing
Expand Down Expand Up @@ -233,17 +238,20 @@ func ParseFence(line string) (byte, int, bool) {

// RenderMarkdownLines renders markdown text using glamour and splits
// into lines. wrapWidth controls glamour's word-wrap column.
// maxWidth controls line truncation (actual terminal width). Falls
// back to WrapText if glamour rendering fails.
// maxWidth controls line truncation (actual terminal width).
// colorProfile controls glamour's color output (use termenv.Ascii to suppress colors).
// Falls back to WrapText if glamour rendering fails.
func RenderMarkdownLines(
text string, wrapWidth, maxWidth int,
glamourStyle gansi.StyleConfig, tabWidth int,
colorProfile termenv.Profile,
) []string {
text = TruncateLongLines(text, maxWidth, tabWidth)
r, err := glamour.NewTermRenderer(
glamour.WithStyles(glamourStyle),
glamour.WithWordWrap(wrapWidth),
glamour.WithPreservedNewLines(),
glamour.WithColorProfile(colorProfile),
)
if err != nil {
return SanitizeLines(WrapText(text, wrapWidth))
Expand All @@ -252,9 +260,10 @@ func RenderMarkdownLines(
if err != nil {
return SanitizeLines(WrapText(text, wrapWidth))
}
noColor := colorProfile == termenv.Ascii
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
for i, line := range lines {
line = StripTrailingPadding(line)
line = StripTrailingPadding(line, noColor)
line = SanitizeEscapes(line)
if xansi.StringWidth(line) > maxWidth {
line = xansi.Truncate(line, maxWidth, "")
Expand Down
Loading
Loading