Import beads' UX design system into gastown: - Add internal/ui/ package with Ayu theme colors and semantic styling - styles.go: AdaptiveColor definitions for light/dark mode - terminal.go: TTY detection, NO_COLOR/CLICOLOR support - markdown.go: Glamour rendering with agent mode bypass - pager.go: Smart paging with GT_PAGER support - Add colorized help output (internal/cmd/help.go) - Group headers in accent color - Command names styled for scannability - Flag types and defaults muted - Add gt thanks command (internal/cmd/thanks.go) - Contributor display with same logic as bd thanks - Styled with Ayu theme colors - Update gt doctor to match bd doctor UX - Category grouping (Core, Infrastructure, Rig, Patrol, etc.) - Semantic icons (✓ ⚠ ✖) with Ayu colors - Tree connectors for detail lines - Summary line with pass/warn/fail counts - Warnings section at end with numbered issues - Migrate existing styles to use ui package - internal/style/style.go uses ui.ColorPass etc. - internal/tui/feed/styles.go uses ui package colors Co-Authored-By: SageOx <ox@sageox.ai>
487 lines
14 KiB
Go
487 lines
14 KiB
Go
// Package ui provides terminal styling for gastown CLI output.
|
||
// Uses the Ayu color theme with adaptive light/dark mode support.
|
||
// Design philosophy: semantic colors that communicate meaning at a glance,
|
||
// minimal visual noise, and consistent rendering across all commands.
|
||
package ui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/muesli/termenv"
|
||
)
|
||
|
||
func init() {
|
||
if !ShouldUseColor() {
|
||
// disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
|
||
lipgloss.SetColorProfile(termenv.Ascii)
|
||
} else {
|
||
// use TrueColor for distinct priority/status colors in modern terminals
|
||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||
}
|
||
}
|
||
|
||
// Ayu theme color palette
|
||
// Dark: https://terminalcolors.com/themes/ayu/dark/
|
||
// Light: https://terminalcolors.com/themes/ayu/light/
|
||
// Source: https://github.com/ayu-theme/ayu-colors
|
||
var (
|
||
// Core semantic colors (Ayu theme - adaptive light/dark)
|
||
ColorPass = lipgloss.AdaptiveColor{
|
||
Light: "#86b300", // ayu light bright green
|
||
Dark: "#c2d94c", // ayu dark bright green
|
||
}
|
||
ColorWarn = lipgloss.AdaptiveColor{
|
||
Light: "#f2ae49", // ayu light bright yellow
|
||
Dark: "#ffb454", // ayu dark bright yellow
|
||
}
|
||
ColorFail = lipgloss.AdaptiveColor{
|
||
Light: "#f07171", // ayu light bright red
|
||
Dark: "#f07178", // ayu dark bright red
|
||
}
|
||
ColorMuted = lipgloss.AdaptiveColor{
|
||
Light: "#828c99", // ayu light muted
|
||
Dark: "#6c7680", // ayu dark muted
|
||
}
|
||
ColorAccent = lipgloss.AdaptiveColor{
|
||
Light: "#399ee6", // ayu light bright blue
|
||
Dark: "#59c2ff", // ayu dark bright blue
|
||
}
|
||
|
||
// === Workflow Status Colors ===
|
||
// Only actionable states get color - open/closed match standard text
|
||
ColorStatusOpen = lipgloss.AdaptiveColor{
|
||
Light: "", // standard text color
|
||
Dark: "",
|
||
}
|
||
ColorStatusInProgress = lipgloss.AdaptiveColor{
|
||
Light: "#f2ae49", // yellow - active work, very visible
|
||
Dark: "#ffb454",
|
||
}
|
||
ColorStatusClosed = lipgloss.AdaptiveColor{
|
||
Light: "#9099a1", // slightly dimmed - visually shows "done"
|
||
Dark: "#8090a0",
|
||
}
|
||
ColorStatusBlocked = lipgloss.AdaptiveColor{
|
||
Light: "#f07171", // red - needs attention
|
||
Dark: "#f26d78",
|
||
}
|
||
ColorStatusPinned = lipgloss.AdaptiveColor{
|
||
Light: "#d2a6ff", // purple - special/elevated
|
||
Dark: "#d2a6ff",
|
||
}
|
||
ColorStatusHooked = lipgloss.AdaptiveColor{
|
||
Light: "#59c2ff", // cyan - actively worked by agent
|
||
Dark: "#59c2ff",
|
||
}
|
||
|
||
// === Priority Colors ===
|
||
// P0/P1/P2 get color - they need attention
|
||
// P3/P4 are neutral (low/backlog don't need visual urgency)
|
||
ColorPriorityP0 = lipgloss.AdaptiveColor{
|
||
Light: "#f07171", // bright red - critical, demands attention
|
||
Dark: "#f07178",
|
||
}
|
||
ColorPriorityP1 = lipgloss.AdaptiveColor{
|
||
Light: "#ff8f40", // orange - high priority, needs attention soon
|
||
Dark: "#ff8f40",
|
||
}
|
||
ColorPriorityP2 = lipgloss.AdaptiveColor{
|
||
Light: "#e6b450", // muted gold - medium priority, visible but calm
|
||
Dark: "#e6b450",
|
||
}
|
||
ColorPriorityP3 = lipgloss.AdaptiveColor{
|
||
Light: "", // neutral - low priority
|
||
Dark: "",
|
||
}
|
||
ColorPriorityP4 = lipgloss.AdaptiveColor{
|
||
Light: "", // neutral - backlog
|
||
Dark: "",
|
||
}
|
||
|
||
// === Issue Type Colors ===
|
||
// Bugs and epics get color - they need attention
|
||
// All other types use standard text
|
||
ColorTypeBug = lipgloss.AdaptiveColor{
|
||
Light: "#f07171", // bright red - bugs are problems
|
||
Dark: "#f26d78",
|
||
}
|
||
ColorTypeFeature = lipgloss.AdaptiveColor{
|
||
Light: "", // standard text color
|
||
Dark: "",
|
||
}
|
||
ColorTypeTask = lipgloss.AdaptiveColor{
|
||
Light: "", // standard text color
|
||
Dark: "",
|
||
}
|
||
ColorTypeEpic = lipgloss.AdaptiveColor{
|
||
Light: "#d2a6ff", // purple - larger scope work
|
||
Dark: "#d2a6ff",
|
||
}
|
||
ColorTypeChore = lipgloss.AdaptiveColor{
|
||
Light: "", // standard text color
|
||
Dark: "",
|
||
}
|
||
|
||
// === Issue ID Color ===
|
||
// IDs use standard text color - subtle, not attention-grabbing
|
||
ColorID = lipgloss.AdaptiveColor{
|
||
Light: "", // standard text color
|
||
Dark: "",
|
||
}
|
||
)
|
||
|
||
// Core styles - consistent across all commands
|
||
var (
|
||
PassStyle = lipgloss.NewStyle().Foreground(ColorPass)
|
||
WarnStyle = lipgloss.NewStyle().Foreground(ColorWarn)
|
||
FailStyle = lipgloss.NewStyle().Foreground(ColorFail)
|
||
MutedStyle = lipgloss.NewStyle().Foreground(ColorMuted)
|
||
AccentStyle = lipgloss.NewStyle().Foreground(ColorAccent)
|
||
)
|
||
|
||
// Issue ID style
|
||
var IDStyle = lipgloss.NewStyle().Foreground(ColorID)
|
||
|
||
// Status styles for workflow states
|
||
var (
|
||
StatusOpenStyle = lipgloss.NewStyle().Foreground(ColorStatusOpen)
|
||
StatusInProgressStyle = lipgloss.NewStyle().Foreground(ColorStatusInProgress)
|
||
StatusClosedStyle = lipgloss.NewStyle().Foreground(ColorStatusClosed)
|
||
StatusBlockedStyle = lipgloss.NewStyle().Foreground(ColorStatusBlocked)
|
||
StatusPinnedStyle = lipgloss.NewStyle().Foreground(ColorStatusPinned)
|
||
StatusHookedStyle = lipgloss.NewStyle().Foreground(ColorStatusHooked)
|
||
)
|
||
|
||
// Priority styles - P0 is bold for extra emphasis
|
||
var (
|
||
PriorityP0Style = lipgloss.NewStyle().Foreground(ColorPriorityP0).Bold(true)
|
||
PriorityP1Style = lipgloss.NewStyle().Foreground(ColorPriorityP1)
|
||
PriorityP2Style = lipgloss.NewStyle().Foreground(ColorPriorityP2)
|
||
PriorityP3Style = lipgloss.NewStyle().Foreground(ColorPriorityP3)
|
||
PriorityP4Style = lipgloss.NewStyle().Foreground(ColorPriorityP4)
|
||
)
|
||
|
||
// Type styles for issue categories
|
||
var (
|
||
TypeBugStyle = lipgloss.NewStyle().Foreground(ColorTypeBug)
|
||
TypeFeatureStyle = lipgloss.NewStyle().Foreground(ColorTypeFeature)
|
||
TypeTaskStyle = lipgloss.NewStyle().Foreground(ColorTypeTask)
|
||
TypeEpicStyle = lipgloss.NewStyle().Foreground(ColorTypeEpic)
|
||
TypeChoreStyle = lipgloss.NewStyle().Foreground(ColorTypeChore)
|
||
)
|
||
|
||
// CategoryStyle for section headers - bold with accent color
|
||
var CategoryStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent)
|
||
|
||
// BoldStyle for emphasis
|
||
var BoldStyle = lipgloss.NewStyle().Bold(true)
|
||
|
||
// CommandStyle for command names - subtle contrast, not attention-grabbing
|
||
var CommandStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
|
||
Light: "#5c6166", // slightly darker than standard
|
||
Dark: "#bfbdb6", // slightly brighter than standard
|
||
})
|
||
|
||
// Status icons - consistent semantic indicators
|
||
// Design: small Unicode symbols, NOT emoji-style icons for visual consistency
|
||
const (
|
||
IconPass = "✓"
|
||
IconWarn = "⚠"
|
||
IconFail = "✖"
|
||
IconSkip = "-"
|
||
IconInfo = "ℹ"
|
||
)
|
||
|
||
// Issue status icons - used consistently across all commands
|
||
// Design principle: icons > text labels for scannability
|
||
const (
|
||
StatusIconOpen = "○" // available to work (hollow circle)
|
||
StatusIconInProgress = "◐" // active work (half-filled)
|
||
StatusIconBlocked = "●" // needs attention (filled circle)
|
||
StatusIconClosed = "✓" // completed (checkmark)
|
||
StatusIconDeferred = "❄" // scheduled for later (snowflake)
|
||
StatusIconPinned = "📌" // elevated priority
|
||
)
|
||
|
||
// Priority icon - small filled circle, colored by priority level
|
||
const PriorityIcon = "●"
|
||
|
||
// Tree characters for hierarchical display
|
||
const (
|
||
TreeChild = "⎿ " // child indicator
|
||
TreeLast = "└─ " // last child / detail line
|
||
TreeIndent = " " // 2-space indent per level
|
||
)
|
||
|
||
// Separators - 42 characters wide
|
||
const (
|
||
SeparatorLight = "──────────────────────────────────────────"
|
||
SeparatorHeavy = "══════════════════════════════════════════"
|
||
)
|
||
|
||
// === Core Render Functions ===
|
||
|
||
// RenderPass renders text with pass (green) styling
|
||
func RenderPass(s string) string {
|
||
return PassStyle.Render(s)
|
||
}
|
||
|
||
// RenderWarn renders text with warning (yellow) styling
|
||
func RenderWarn(s string) string {
|
||
return WarnStyle.Render(s)
|
||
}
|
||
|
||
// RenderFail renders text with fail (red) styling
|
||
func RenderFail(s string) string {
|
||
return FailStyle.Render(s)
|
||
}
|
||
|
||
// RenderMuted renders text with muted (gray) styling
|
||
func RenderMuted(s string) string {
|
||
return MutedStyle.Render(s)
|
||
}
|
||
|
||
// RenderAccent renders text with accent (blue) styling
|
||
func RenderAccent(s string) string {
|
||
return AccentStyle.Render(s)
|
||
}
|
||
|
||
// RenderCategory renders a category header in uppercase with accent color
|
||
func RenderCategory(s string) string {
|
||
return CategoryStyle.Render(strings.ToUpper(s))
|
||
}
|
||
|
||
// RenderSeparator renders the light separator line in muted color
|
||
func RenderSeparator() string {
|
||
return MutedStyle.Render(SeparatorLight)
|
||
}
|
||
|
||
// RenderBold renders text in bold
|
||
func RenderBold(s string) string {
|
||
return BoldStyle.Render(s)
|
||
}
|
||
|
||
// RenderCommand renders a command name with subtle styling
|
||
func RenderCommand(s string) string {
|
||
return CommandStyle.Render(s)
|
||
}
|
||
|
||
// === Icon Render Functions ===
|
||
|
||
// RenderPassIcon renders the pass icon with styling
|
||
func RenderPassIcon() string {
|
||
return PassStyle.Render(IconPass)
|
||
}
|
||
|
||
// RenderWarnIcon renders the warning icon with styling
|
||
func RenderWarnIcon() string {
|
||
return WarnStyle.Render(IconWarn)
|
||
}
|
||
|
||
// RenderFailIcon renders the fail icon with styling
|
||
func RenderFailIcon() string {
|
||
return FailStyle.Render(IconFail)
|
||
}
|
||
|
||
// RenderSkipIcon renders the skip icon with styling
|
||
func RenderSkipIcon() string {
|
||
return MutedStyle.Render(IconSkip)
|
||
}
|
||
|
||
// RenderInfoIcon renders the info icon with styling
|
||
func RenderInfoIcon() string {
|
||
return AccentStyle.Render(IconInfo)
|
||
}
|
||
|
||
// === Issue Component Renderers ===
|
||
|
||
// RenderID renders an issue ID with semantic styling
|
||
func RenderID(id string) string {
|
||
return IDStyle.Render(id)
|
||
}
|
||
|
||
// RenderStatus renders a status with semantic styling
|
||
// in_progress/blocked/pinned get color; open/closed use standard text
|
||
func RenderStatus(status string) string {
|
||
switch status {
|
||
case "in_progress":
|
||
return StatusInProgressStyle.Render(status)
|
||
case "blocked":
|
||
return StatusBlockedStyle.Render(status)
|
||
case "pinned":
|
||
return StatusPinnedStyle.Render(status)
|
||
case "hooked":
|
||
return StatusHookedStyle.Render(status)
|
||
case "closed":
|
||
return StatusClosedStyle.Render(status)
|
||
default: // open and others
|
||
return StatusOpenStyle.Render(status)
|
||
}
|
||
}
|
||
|
||
// RenderStatusIcon returns the appropriate icon for a status with semantic coloring
|
||
// This is the canonical source for status icon rendering - use this everywhere
|
||
func RenderStatusIcon(status string) string {
|
||
switch status {
|
||
case "open":
|
||
return StatusIconOpen // no color - available but not urgent
|
||
case "in_progress":
|
||
return StatusInProgressStyle.Render(StatusIconInProgress)
|
||
case "blocked":
|
||
return StatusBlockedStyle.Render(StatusIconBlocked)
|
||
case "closed":
|
||
return StatusClosedStyle.Render(StatusIconClosed)
|
||
case "deferred":
|
||
return MutedStyle.Render(StatusIconDeferred)
|
||
case "pinned":
|
||
return StatusPinnedStyle.Render(StatusIconPinned)
|
||
default:
|
||
return "?" // unknown status
|
||
}
|
||
}
|
||
|
||
// GetStatusIcon returns just the icon character without styling
|
||
// Useful when you need to apply custom styling or for non-TTY output
|
||
func GetStatusIcon(status string) string {
|
||
switch status {
|
||
case "open":
|
||
return StatusIconOpen
|
||
case "in_progress":
|
||
return StatusIconInProgress
|
||
case "blocked":
|
||
return StatusIconBlocked
|
||
case "closed":
|
||
return StatusIconClosed
|
||
case "deferred":
|
||
return StatusIconDeferred
|
||
case "pinned":
|
||
return StatusIconPinned
|
||
default:
|
||
return "?"
|
||
}
|
||
}
|
||
|
||
// GetStatusStyle returns the lipgloss style for a given status
|
||
// Use this when you need to apply the semantic color to custom text
|
||
func GetStatusStyle(status string) lipgloss.Style {
|
||
switch status {
|
||
case "in_progress":
|
||
return StatusInProgressStyle
|
||
case "blocked":
|
||
return StatusBlockedStyle
|
||
case "closed":
|
||
return StatusClosedStyle
|
||
case "deferred":
|
||
return MutedStyle
|
||
case "pinned":
|
||
return StatusPinnedStyle
|
||
case "hooked":
|
||
return StatusHookedStyle
|
||
default: // open and others - no special styling
|
||
return lipgloss.NewStyle()
|
||
}
|
||
}
|
||
|
||
// RenderPriority renders a priority level with semantic styling
|
||
// Format: "● P0" (icon + label)
|
||
// P0/P1/P2 get color; P3/P4 use standard text
|
||
func RenderPriority(priority int) string {
|
||
label := fmt.Sprintf("%s P%d", PriorityIcon, priority)
|
||
switch priority {
|
||
case 0:
|
||
return PriorityP0Style.Render(label)
|
||
case 1:
|
||
return PriorityP1Style.Render(label)
|
||
case 2:
|
||
return PriorityP2Style.Render(label)
|
||
case 3:
|
||
return PriorityP3Style.Render(label)
|
||
case 4:
|
||
return PriorityP4Style.Render(label)
|
||
default:
|
||
return label
|
||
}
|
||
}
|
||
|
||
// RenderPriorityCompact renders just the priority label without icon
|
||
// Format: "P0"
|
||
// Use when space is constrained or icon would be redundant
|
||
func RenderPriorityCompact(priority int) string {
|
||
label := fmt.Sprintf("P%d", priority)
|
||
switch priority {
|
||
case 0:
|
||
return PriorityP0Style.Render(label)
|
||
case 1:
|
||
return PriorityP1Style.Render(label)
|
||
case 2:
|
||
return PriorityP2Style.Render(label)
|
||
case 3:
|
||
return PriorityP3Style.Render(label)
|
||
case 4:
|
||
return PriorityP4Style.Render(label)
|
||
default:
|
||
return label
|
||
}
|
||
}
|
||
|
||
// RenderType renders an issue type with semantic styling
|
||
// bugs and epics get color; all other types use standard text
|
||
func RenderType(issueType string) string {
|
||
switch issueType {
|
||
case "bug":
|
||
return TypeBugStyle.Render(issueType)
|
||
case "feature":
|
||
return TypeFeatureStyle.Render(issueType)
|
||
case "task":
|
||
return TypeTaskStyle.Render(issueType)
|
||
case "epic":
|
||
return TypeEpicStyle.Render(issueType)
|
||
case "chore":
|
||
return TypeChoreStyle.Render(issueType)
|
||
default:
|
||
return issueType
|
||
}
|
||
}
|
||
|
||
// RenderIssueCompact renders a compact one-line issue summary
|
||
// Format: ID [Priority] [Type] Status - Title
|
||
// When status is "closed", the entire line is dimmed to show it's done
|
||
func RenderIssueCompact(id string, priority int, issueType, status, title string) string {
|
||
line := fmt.Sprintf("%s [P%d] [%s] %s - %s",
|
||
id, priority, issueType, status, title)
|
||
if status == "closed" {
|
||
// entire line is dimmed - visually shows "done"
|
||
return StatusClosedStyle.Render(line)
|
||
}
|
||
return fmt.Sprintf("%s [%s] [%s] %s - %s",
|
||
RenderID(id),
|
||
RenderPriority(priority),
|
||
RenderType(issueType),
|
||
RenderStatus(status),
|
||
title,
|
||
)
|
||
}
|
||
|
||
// RenderPriorityForStatus renders priority with color only if not closed
|
||
func RenderPriorityForStatus(priority int, status string) string {
|
||
if status == "closed" {
|
||
return fmt.Sprintf("P%d", priority)
|
||
}
|
||
return RenderPriority(priority)
|
||
}
|
||
|
||
// RenderTypeForStatus renders type with color only if not closed
|
||
func RenderTypeForStatus(issueType, status string) string {
|
||
if status == "closed" {
|
||
return issueType
|
||
}
|
||
return RenderType(issueType)
|
||
}
|
||
|
||
// RenderClosedLine renders an entire line in the closed/dimmed style
|
||
func RenderClosedLine(line string) string {
|
||
return StatusClosedStyle.Render(line)
|
||
}
|