Skip to content

Commit 1860ef6

Browse files
yanmxaclaude
andcommitted
feat(theme): add light/dark theme selection and fix color rendering
- Add Theme field to Settings with SaveTheme helper - Add first-run theme selector TUI (themeselect package) - Add theme.Init() that calls lipgloss.SetHasDarkBackground() to ensure AdaptiveColor resolves correctly regardless of terminal auto-detection - Fix all hardcoded colors in tool/ui/styles.go, shared/styles.go, render/styles.go to reference theme.CurrentTheme.* - Remove Faint(true) usage that compounded contrast issues on light bg - Tune light palette for WCAG AA contrast on white backgrounds - Remove dead code: ThinkingContentStyle, ToolResultStyle (merged into ToolCallStyle), LineContentStyle, IconSearch, unused loader methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Meng Yan <myan@redhat.com>
1 parent 14fb4d7 commit 1860ef6

13 files changed

Lines changed: 511 additions & 640 deletions

File tree

internal/app/render/markdown.go

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,38 +17,54 @@ import (
1717

1818
// MDRenderer renders markdown content to styled terminal output using glamour.
1919
type MDRenderer struct {
20-
renderer *glamour.TermRenderer
21-
width int
20+
renderer *glamour.TermRenderer
21+
width int
22+
darkBg bool // tracks last known terminal background to detect theme changes
2223
}
2324

2425
// NewMDRenderer creates a new markdown renderer with the given width.
2526
func NewMDRenderer(width int) *MDRenderer {
2627
w := max(width-4, MinWrapWidth)
28+
dark := theme.IsDarkBackground()
29+
r := buildGlamourRenderer(w, dark)
30+
return &MDRenderer{renderer: r, width: w, darkBg: dark}
31+
}
2732

28-
// Pick base style based on terminal background
33+
// buildGlamourRenderer constructs a glamour TermRenderer for the given width and background.
34+
func buildGlamourRenderer(width int, dark bool) *glamour.TermRenderer {
2935
var style ansi.StyleConfig
30-
if lipgloss.HasDarkBackground() {
36+
if dark {
3137
style = styles.DarkStyleConfig
3238
} else {
3339
style = styles.LightStyleConfig
3440
}
35-
customizeStyle(&style, w)
41+
customizeStyle(&style, width)
3642

3743
r, err := glamour.NewTermRenderer(
3844
glamour.WithStyles(style),
39-
glamour.WithWordWrap(w),
45+
glamour.WithWordWrap(width),
4046
glamour.WithChromaFormatter("terminal256"),
4147
)
4248
if err != nil {
4349
r, _ = glamour.NewTermRenderer(glamour.WithAutoStyle())
4450
}
45-
return &MDRenderer{renderer: r, width: w}
51+
return r
52+
}
53+
54+
// rebuildIfNeeded recreates the glamour renderer when the terminal background changes.
55+
func (r *MDRenderer) rebuildIfNeeded() {
56+
dark := theme.IsDarkBackground()
57+
if dark != r.darkBg {
58+
r.renderer = buildGlamourRenderer(r.width, dark)
59+
r.darkBg = dark
60+
}
4661
}
4762

4863
// Render parses markdown source and returns styled terminal output.
4964
// Tables are extracted and rendered with lipgloss/table for full border control,
5065
// everything else (including code blocks) goes through glamour natively.
5166
func (r *MDRenderer) Render(content string) (string, error) {
67+
r.rebuildIfNeeded()
5268
// Normalize paragraph line breaks: LLMs often hard-wrap at ~80 columns,
5369
// producing softbreaks that glamour preserves as newlines. Joining them
5470
// lets glamour re-wrap at the actual terminal width.
@@ -155,7 +171,7 @@ func (r *MDRenderer) renderTable(content string) string {
155171
}
156172

157173
borderColor := lipgloss.NewStyle().Foreground(theme.CurrentTheme.Separator)
158-
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.CurrentTheme.Text)
174+
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.CurrentTheme.TextBright)
159175

160176
t := table.New().
161177
Headers(headers...).
@@ -174,7 +190,7 @@ func (r *MDRenderer) renderTable(content string) string {
174190
if row == table.HeaderRow {
175191
return headerStyle
176192
}
177-
return lipgloss.NewStyle().Foreground(theme.CurrentTheme.Text)
193+
return lipgloss.NewStyle().Foreground(theme.CurrentTheme.TextBright)
178194
})
179195

180196
return "\n" + t.String() + "\n"
@@ -273,11 +289,20 @@ func renderInlineMarkdown(text string) string {
273289
return result.String()
274290
}
275291

292+
// adaptiveColorHex resolves an AdaptiveColor to its hex string based on the
293+
// current terminal background. Used for glamour StyleConfig which requires *string.
294+
func adaptiveColorHex(c lipgloss.AdaptiveColor) string {
295+
if theme.IsDarkBackground() {
296+
return c.Dark
297+
}
298+
return c.Light
299+
}
300+
276301
// customizeStyle adjusts glamour's default style for a clean, unified look.
277302
// Uses only 3 accent colors: blue (keywords/headings), green (strings/functions), muted (comments).
278303
func customizeStyle(s *ansi.StyleConfig, width int) {
279-
blue := string(theme.CurrentTheme.Primary)
280-
muted := string(theme.CurrentTheme.Muted)
304+
blue := adaptiveColorHex(theme.CurrentTheme.Primary)
305+
muted := adaptiveColorHex(theme.CurrentTheme.Muted)
281306

282307
// Headings: blue, bold, no prefix markers
283308
s.H1.Prefix = ""
@@ -384,35 +409,48 @@ func normalizeLineBreaks(content string) string {
384409
// isMarkdownStructural returns true for lines that start markdown block structures
385410
// (headers, list items, blockquotes, table rows, thematic breaks).
386411
func isMarkdownStructural(line string) bool {
412+
// Headers
387413
if strings.HasPrefix(line, "#") {
388414
return true
389415
}
416+
// Unordered lists
390417
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ") {
391418
return true
392419
}
420+
// Blockquotes
393421
if strings.HasPrefix(line, "> ") || line == ">" {
394422
return true
395423
}
424+
// Table rows
396425
if strings.HasPrefix(line, "|") {
397426
return true
398427
}
399-
// Thematic break: ---, ***, ___
400-
cleaned := strings.ReplaceAll(line, " ", "")
401-
if len(cleaned) >= 3 &&
402-
(strings.Count(cleaned, "-") == len(cleaned) ||
403-
strings.Count(cleaned, "*") == len(cleaned) ||
404-
strings.Count(cleaned, "_") == len(cleaned)) {
428+
// Thematic breaks (---, ***, ___)
429+
if isThematicBreak(line) {
405430
return true
406431
}
407-
// Ordered list: digit(s) + . + space
432+
// Ordered lists (digit(s) + . + space)
433+
return isOrderedListItem(line)
434+
}
435+
436+
// isThematicBreak checks if line is a markdown thematic break (---, ***, ___).
437+
func isThematicBreak(line string) bool {
438+
cleaned := strings.ReplaceAll(line, " ", "")
439+
if len(cleaned) < 3 {
440+
return false
441+
}
442+
return strings.Count(cleaned, "-") == len(cleaned) ||
443+
strings.Count(cleaned, "*") == len(cleaned) ||
444+
strings.Count(cleaned, "_") == len(cleaned)
445+
}
446+
447+
// isOrderedListItem checks if line starts with a numbered list marker (e.g., "1. ").
448+
func isOrderedListItem(line string) bool {
408449
for i, c := range line {
409450
if c >= '0' && c <= '9' {
410451
continue
411452
}
412-
if c == '.' && i > 0 && i+1 < len(line) && line[i+1] == ' ' {
413-
return true
414-
}
415-
break
453+
return c == '.' && i > 0 && i+1 < len(line) && line[i+1] == ' '
416454
}
417455
return false
418456
}

internal/app/render/message.go

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func RenderModeStatus(params OperationModeParams) string {
8282
// RenderOperationModeIndicator returns the mode status indicator for auto-accept or plan mode.
8383
func RenderOperationModeIndicator(mode int) string {
8484
var icon, label string
85-
var color lipgloss.Color
85+
var color lipgloss.TerminalColor
8686

8787
switch appmode.OperationMode(mode) {
8888
case appmode.AutoAccept:
@@ -139,17 +139,17 @@ func toolResultIcon(isError bool) string {
139139
}
140140

141141
// TokenUsageColorAndHint returns the color and hint text for token usage percentage.
142-
func TokenUsageColorAndHint(percent float64) (lipgloss.Color, string) {
143-
switch {
144-
case percent >= AutoCompactThreshold:
142+
func TokenUsageColorAndHint(percent float64) (lipgloss.TerminalColor, string) {
143+
if percent >= AutoCompactThreshold {
145144
return theme.CurrentTheme.Error, " ⚠ auto-compact"
146-
case percent >= 85:
145+
}
146+
if percent >= 85 {
147147
return theme.CurrentTheme.Warning, fmt.Sprintf(" (compact at %d%%)", AutoCompactThreshold)
148-
case percent >= 70:
148+
}
149+
if percent >= 70 {
149150
return theme.CurrentTheme.Accent, ""
150-
default:
151-
return theme.CurrentTheme.Muted, ""
152151
}
152+
return theme.CurrentTheme.Muted, ""
153153
}
154154

155155
// RenderUserMessage renders a user message with prompt and optional images.
@@ -243,11 +243,11 @@ func RenderAssistantMessage(params AssistantParams) string {
243243
var lines []string
244244
for _, line := range strings.Split(wrapped, "\n") {
245245
if strings.TrimSpace(line) != "" {
246-
lines = append(lines, ThinkingContentStyle.Render(line))
246+
lines = append(lines, ThinkingStyle.Render(line))
247247
}
248248
}
249249

250-
thinkingIcon := ThinkingContentStyle.Render("✦ ")
250+
thinkingIcon := ThinkingStyle.Render("✦ ")
251251
thinkingContent := strings.Join(lines, "\n"+aiIndent)
252252
sb.WriteString(thinkingIcon + thinkingContent + "\n\n")
253253
}
@@ -882,7 +882,6 @@ func FormatAgentLabel(input string) string {
882882
return agentType
883883
}
884884

885-
// ExtractToolArgs extracts the most relevant argument from a tool call input JSON.
886885
// extractTaskGetDisplay returns owner name for a TaskGet call if available,
887886
// falling back to the raw task ID.
888887
func extractTaskGetDisplay(input string, ownerMap map[string]string) string {
@@ -897,6 +896,7 @@ func extractTaskGetDisplay(input string, ownerMap map[string]string) string {
897896
return id
898897
}
899898

899+
// ExtractToolArgs extracts the most relevant argument from a tool call input JSON.
900900
func ExtractToolArgs(input string) string {
901901
var params map[string]any
902902
if err := json.Unmarshal([]byte(input), &params); err != nil {
@@ -942,46 +942,61 @@ func ExtractToolArgs(input string) string {
942942
func FormatToolResultSize(toolName, content string) string {
943943
switch toolName {
944944
case "WebFetch":
945-
size := len(content)
946-
if size >= 1024*1024 {
947-
return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
948-
}
949-
if size >= 1024 {
950-
return fmt.Sprintf("%.1f KB", float64(size)/1024)
951-
}
952-
return fmt.Sprintf("%d bytes", size)
953-
945+
return formatByteSize(len(content))
954946
case "Write", "Edit":
955-
start := strings.Index(content, "(")
956-
if start == -1 {
957-
return "completed"
958-
}
959-
end := strings.Index(content[start:], ")")
960-
if end == -1 {
961-
return "completed"
962-
}
963-
return content[start+1 : start+end]
947+
return extractParenContent(content, "completed")
948+
default:
949+
return formatLineCount(content)
950+
}
951+
}
964952

953+
// formatByteSize formats a byte count as human-readable size.
954+
func formatByteSize(size int) string {
955+
const (
956+
KB = 1024
957+
MB = KB * 1024
958+
)
959+
switch {
960+
case size >= MB:
961+
return fmt.Sprintf("%.1f MB", float64(size)/MB)
962+
case size >= KB:
963+
return fmt.Sprintf("%.1f KB", float64(size)/KB)
965964
default:
966-
trimmed := strings.TrimSuffix(content, "\n")
967-
if trimmed == "" {
968-
return "0 lines"
969-
}
970-
lineCount := strings.Count(trimmed, "\n") + 1
971-
return fmt.Sprintf("%d lines", lineCount)
965+
return fmt.Sprintf("%d bytes", size)
966+
}
967+
}
968+
969+
// extractParenContent extracts content between first ( and ), or returns fallback.
970+
func extractParenContent(s, fallback string) string {
971+
start := strings.Index(s, "(")
972+
if start == -1 {
973+
return fallback
974+
}
975+
end := strings.Index(s[start:], ")")
976+
if end == -1 {
977+
return fallback
972978
}
979+
return s[start+1 : start+end]
980+
}
981+
982+
// formatLineCount returns a line count string for the given content.
983+
func formatLineCount(content string) string {
984+
trimmed := strings.TrimSuffix(content, "\n")
985+
if trimmed == "" {
986+
return "0 lines"
987+
}
988+
lineCount := strings.Count(trimmed, "\n") + 1
989+
return fmt.Sprintf("%d lines", lineCount)
973990
}
974991

975992
// FormatTokenCount formats a token count for display.
976993
func FormatTokenCount(count int) string {
977-
if count >= 1000000 {
994+
switch {
995+
case count >= 1000000:
978996
return fmt.Sprintf("%.1fM", float64(count)/1000000)
979-
}
980-
if count >= 10000 {
981-
return fmt.Sprintf("%.1fk", float64(count)/1000)
982-
}
983-
if count >= 1000 {
997+
case count >= 1000:
984998
return fmt.Sprintf("%.1fk", float64(count)/1000)
999+
default:
1000+
return fmt.Sprintf("%d", count)
9851001
}
986-
return fmt.Sprintf("%d", count)
9871002
}

0 commit comments

Comments
 (0)