feat(ui): import comprehensive UX system from beads

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>
This commit is contained in:
Ryan Snodgrass
2026-01-09 22:43:48 -08:00
parent 0f633be4b1
commit e1f2bb8b4b
45 changed files with 1400 additions and 75 deletions

13
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/go-rod/rod v0.116.2
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
@@ -16,22 +16,30 @@ require (
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/glamour v0.10.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
@@ -41,5 +49,8 @@ require (
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.39.0 // indirect
)

28
go.sum
View File

@@ -1,23 +1,33 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
@@ -29,6 +39,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
@@ -37,6 +49,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -45,16 +59,23 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -80,9 +101,16 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=

141
internal/cmd/help.go Normal file
View File

@@ -0,0 +1,141 @@
package cmd
import (
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/ui"
)
// colorizedHelpFunc wraps Cobra's default help with semantic coloring.
// Applies subtle accent color to group headers for visual hierarchy.
func colorizedHelpFunc(cmd *cobra.Command, args []string) {
// build full help output: Long description + Usage
var output strings.Builder
// include Long description first (like Cobra's default help)
if cmd.Long != "" {
output.WriteString(cmd.Long)
output.WriteString("\n\n")
} else if cmd.Short != "" {
output.WriteString(cmd.Short)
output.WriteString("\n\n")
}
// add the usage string which contains commands, flags, etc.
output.WriteString(cmd.UsageString())
// apply semantic coloring
result := colorizeHelpOutput(output.String())
fmt.Print(result)
}
// colorizeHelpOutput applies semantic colors to help text
// - Group headers get accent color for visual hierarchy
// - Section headers (Examples:, Flags:) get accent color
// - Command names get subtle styling for scanability
// - Flag names get bold styling, types get muted
// - Default values get muted styling
func colorizeHelpOutput(help string) string {
// match group header lines (e.g., "Working With Issues:")
// these are standalone lines ending with ":" and followed by commands
groupHeaderRE := regexp.MustCompile(`(?m)^([A-Z][A-Za-z &]+:)\s*$`)
result := groupHeaderRE.ReplaceAllStringFunc(help, func(match string) string {
// trim whitespace, colorize, then restore
trimmed := strings.TrimSpace(match)
return ui.RenderAccent(trimmed)
})
// match section headers in subcommand help (Examples:, Flags:, etc.)
sectionHeaderRE := regexp.MustCompile(`(?m)^(Examples|Flags|Usage|Global Flags|Aliases|Available Commands):`)
result = sectionHeaderRE.ReplaceAllStringFunc(result, func(match string) string {
return ui.RenderAccent(match)
})
// match command lines: " command Description text"
// commands are indented with 2 spaces, followed by spaces, then description
// pattern matches: indent + command-name (with hyphens) + spacing + description
cmdLineRE := regexp.MustCompile(`(?m)^( )([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(\s{2,})(.*)$`)
result = cmdLineRE.ReplaceAllStringFunc(result, func(match string) string {
parts := cmdLineRE.FindStringSubmatch(match)
if len(parts) != 5 {
return match
}
indent := parts[1]
cmdName := parts[2]
spacing := parts[3]
description := parts[4]
// colorize command references in description (e.g., 'comments add')
description = colorizeCommandRefs(description)
// highlight entry point hints (e.g., "(start here)")
description = highlightEntryPoints(description)
// subtle styling on command name for scanability
return indent + ui.RenderCommand(cmdName) + spacing + description
})
// match flag lines: " -f, --file string Description"
// pattern: indent + flags + spacing + optional type + description
flagLineRE := regexp.MustCompile(`(?m)^(\s+)(-\w,\s+--[\w-]+|--[\w-]+)(\s+)(string|int|duration|bool)?(\s*.*)$`)
result = flagLineRE.ReplaceAllStringFunc(result, func(match string) string {
parts := flagLineRE.FindStringSubmatch(match)
if len(parts) < 6 {
return match
}
indent := parts[1]
flags := parts[2]
spacing := parts[3]
typeStr := parts[4]
desc := parts[5]
// mute default values in description
desc = muteDefaults(desc)
if typeStr != "" {
return indent + ui.RenderCommand(flags) + spacing + ui.RenderMuted(typeStr) + desc
}
return indent + ui.RenderCommand(flags) + spacing + desc
})
return result
}
// muteDefaults applies muted styling to default value annotations
func muteDefaults(text string) string {
defaultRE := regexp.MustCompile(`(\(default[^)]*\))`)
return defaultRE.ReplaceAllStringFunc(text, func(match string) string {
return ui.RenderMuted(match)
})
}
// highlightEntryPoints applies accent styling to entry point hints like "(start here)"
func highlightEntryPoints(text string) string {
entryRE := regexp.MustCompile(`(\(start here\))`)
return entryRE.ReplaceAllStringFunc(text, func(match string) string {
return ui.RenderAccent(match)
})
}
// colorizeCommandRefs applies command styling to references in text
// Matches patterns like 'command name' or 'bd command'
func colorizeCommandRefs(text string) string {
// match 'command words' in single quotes (e.g., 'comments add')
cmdRefRE := regexp.MustCompile(`'([a-z][a-z0-9 -]+)'`)
return cmdRefRE.ReplaceAllStringFunc(text, func(match string) string {
// extract the command name without quotes
inner := match[1 : len(match)-1]
return "'" + ui.RenderCommand(inner) + "'"
})
}
func init() {
// Set custom help function for colorized output
rootCmd.SetHelpFunc(colorizedHelpFunc)
}

239
internal/cmd/thanks.go Normal file
View File

@@ -0,0 +1,239 @@
package cmd
import (
"cmp"
"fmt"
"slices"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/ui"
)
// Style definitions for thanks output using ui package colors
var (
thanksTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ui.ColorWarn)
thanksSubtitleStyle = lipgloss.NewStyle().
Foreground(ui.ColorMuted)
thanksSectionStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
thanksNameStyle = lipgloss.NewStyle().
Foreground(ui.ColorPass)
thanksDimStyle = lipgloss.NewStyle().
Foreground(ui.ColorMuted)
)
// thanksBoxStyle returns a bordered box style for the thanks header
func thanksBoxStyle(width int) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(ui.ColorMuted).
Padding(1, 4).
Width(width - 4).
Align(lipgloss.Center)
}
// gastownContributors maps HUMAN contributor names to their commit counts.
// Agent names (gastown/*, beads/*, lowercase single-word names) are excluded.
// Generated from: git shortlog -sn --all (then filtered for humans only)
var gastownContributors = map[string]int{
"Steve Yegge": 2056,
"Mike Lady": 19,
"Olivier Debeuf De Rijcker": 13,
"Danno Mayer": 11,
"Dan Shapiro": 7,
"Subhrajit Makur": 7,
"Julian Knutsen": 5,
"Darko Luketic": 4,
"Martin Emde": 4,
"Greg Hughes": 3,
"Avyukth": 2,
"Ben Kraus": 2,
"Joshua Vial": 2,
"Austin Wallace": 1,
"Cameron Palmer": 1,
"Chris Sloane": 1,
"Cong": 1,
"Dave Laird": 1,
"Dave Williams": 1,
"Jacob": 1,
"Johann Taberlet": 1,
"Joshua Samuel": 1,
"Madison Bullard": 1,
"PepijnSenders": 1,
"Raymond Weitekamp": 1,
"Sohail Mohammad": 1,
"Zachary Rosen": 1,
}
var thanksCmd = &cobra.Command{
Use: "thanks",
Short: "Thank the human contributors to Gas Town",
GroupID: GroupDiag,
Long: `Display acknowledgments to all the humans who have contributed
to the Gas Town project. This command celebrates the collaborative
effort behind the multi-agent workspace manager.`,
Run: func(cmd *cobra.Command, args []string) {
printThanksPage()
},
}
// getContributorsSorted returns contributor names sorted by commit count descending
func getContributorsSorted() []string {
names := make([]string, 0, len(gastownContributors))
for name := range gastownContributors {
names = append(names, name)
}
slices.SortFunc(names, func(a, b string) int {
// sort by commit count descending, then by name ascending for ties
countCmp := cmp.Compare(gastownContributors[b], gastownContributors[a])
if countCmp != 0 {
return countCmp
}
return cmp.Compare(a, b)
})
return names
}
// printThanksPage renders the complete thanks page
func printThanksPage() {
fmt.Println()
// get sorted contributors, split into featured (top 20) and rest
sorted := getContributorsSorted()
featuredCount := 20
if len(sorted) < featuredCount {
featuredCount = len(sorted)
}
featured := sorted[:featuredCount]
additional := sorted[featuredCount:]
// calculate content width based on 4 columns
cols := 4
contentWidth := calculateColumnsWidth(featured, cols)
if contentWidth < 60 {
contentWidth = 60
}
// build header content
title := thanksTitleStyle.Render("THANK YOU!")
subtitle := thanksSubtitleStyle.Render("To all the humans who contributed to Gas Town")
headerContent := title + "\n\n" + subtitle
// render header in bordered box
header := thanksBoxStyle(contentWidth).Render(headerContent)
fmt.Println(header)
fmt.Println()
// print featured contributors section
fmt.Println(thanksSectionStyle.Render(" Featured Contributors"))
fmt.Println()
printThanksColumns(featured, cols)
// print additional contributors if any
if len(additional) > 0 {
fmt.Println()
fmt.Println(thanksSectionStyle.Render(" Additional Contributors"))
fmt.Println()
printThanksWrappedList("", additional, contentWidth)
}
fmt.Println()
}
// calculateColumnsWidth determines the width needed for n columns of names
func calculateColumnsWidth(names []string, cols int) int {
maxWidth := 0
for _, name := range names {
if len(name) > maxWidth {
maxWidth = len(name)
}
}
// cap at 20 characters per column
if maxWidth > 20 {
maxWidth = 20
}
// add padding between columns
colWidth := maxWidth + 2
return colWidth * cols
}
// printThanksColumns prints names in n columns, reading left-to-right
func printThanksColumns(names []string, cols int) {
if len(names) == 0 {
return
}
// find max width for alignment
maxWidth := 0
for _, name := range names {
if len(name) > maxWidth {
maxWidth = len(name)
}
}
if maxWidth > 20 {
maxWidth = 20
}
colWidth := maxWidth + 2
// print in rows, reading left to right (matches bd thanks)
for i := 0; i < len(names); i += cols {
fmt.Print(" ")
for j := 0; j < cols && i+j < len(names); j++ {
name := names[i+j]
if len(name) > 20 {
name = name[:17] + "..."
}
// pad BEFORE styling to avoid ANSI code width issues
padded := fmt.Sprintf("%-*s", colWidth, name)
fmt.Print(thanksNameStyle.Render(padded))
}
fmt.Println()
}
}
// printThanksWrappedList prints a comma-separated list with word wrapping
func printThanksWrappedList(label string, names []string, maxWidth int) {
indent := " "
fmt.Print(indent)
lineLen := len(indent)
if label != "" {
fmt.Print(thanksSectionStyle.Render(label) + " ")
lineLen += len(label) + 1
}
for i, name := range names {
suffix := ", "
if i == len(names)-1 {
suffix = ""
}
entry := name + suffix
if lineLen+len(entry) > maxWidth && lineLen > len(indent) {
fmt.Println()
fmt.Print(indent)
lineLen = len(indent)
}
fmt.Print(thanksDimStyle.Render(entry))
lineLen += len(entry)
}
fmt.Println()
}
func init() {
rootCmd.AddCommand(thanksCmd)
}

View File

@@ -28,6 +28,7 @@ func NewAgentBeadsCheck() *AgentBeadsCheck {
BaseCheck: BaseCheck{
CheckName: "agent-beads-exist",
CheckDescription: "Verify agent beads exist for all agents",
CheckCategory: CategoryRig,
},
},
}

View File

@@ -20,6 +20,7 @@ func NewBdDaemonCheck() *BdDaemonCheck {
BaseCheck: BaseCheck{
CheckName: "bd-daemon",
CheckDescription: "Check if bd (beads) daemon is running",
CheckCategory: CategoryInfrastructure,
},
},
}

View File

@@ -26,6 +26,7 @@ func NewBeadsDatabaseCheck() *BeadsDatabaseCheck {
BaseCheck: BaseCheck{
CheckName: "beads-database",
CheckDescription: "Verify beads database is properly initialized",
CheckCategory: CategoryConfig,
},
},
}
@@ -176,6 +177,7 @@ func NewPrefixConflictCheck() *PrefixConflictCheck {
BaseCheck: BaseCheck{
CheckName: "prefix-conflict",
CheckDescription: "Check for duplicate beads prefixes across rigs",
CheckCategory: CategoryConfig,
},
}
}
@@ -243,6 +245,7 @@ func NewPrefixMismatchCheck() *PrefixMismatchCheck {
BaseCheck: BaseCheck{
CheckName: "prefix-mismatch",
CheckDescription: "Check for prefix mismatches between rigs.json and routes.jsonl",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -21,6 +21,7 @@ func NewBootHealthCheck() *BootHealthCheck {
BaseCheck: BaseCheck{
CheckName: "boot-health",
CheckDescription: "Check Boot watchdog health (the vet checks on the dog)",
CheckCategory: CategoryInfrastructure,
},
}
}

View File

@@ -23,6 +23,7 @@ func NewBranchCheck() *BranchCheck {
BaseCheck: BaseCheck{
CheckName: "persistent-role-branches",
CheckDescription: "Detect persistent roles not on main branch",
CheckCategory: CategoryCleanup,
},
},
}
@@ -213,6 +214,7 @@ func NewBeadsSyncOrphanCheck() *BeadsSyncOrphanCheck {
BaseCheck: BaseCheck{
CheckName: "beads-sync-orphans",
CheckDescription: "Detect orphaned code on beads-sync branch",
CheckCategory: CategoryCleanup,
},
}
}
@@ -338,6 +340,7 @@ func NewCloneDivergenceCheck() *CloneDivergenceCheck {
BaseCheck: BaseCheck{
CheckName: "clone-divergence",
CheckDescription: "Detect emergency divergence between git clones",
CheckCategory: CategoryCleanup,
},
}
}

View File

@@ -50,6 +50,7 @@ func NewClaudeSettingsCheck() *ClaudeSettingsCheck {
BaseCheck: BaseCheck{
CheckName: "claude-settings",
CheckDescription: "Verify Claude settings.json files match expected templates",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -22,6 +22,7 @@ func NewCommandsCheck() *CommandsCheck {
BaseCheck: BaseCheck{
CheckName: "commands-provisioned",
CheckDescription: "Check .claude/commands/ is provisioned at town level",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -24,6 +24,7 @@ func NewSettingsCheck() *SettingsCheck {
BaseCheck: BaseCheck{
CheckName: "rig-settings",
CheckDescription: "Check that rigs have settings/ directory",
CheckCategory: CategoryConfig,
},
},
}
@@ -105,6 +106,7 @@ func NewRuntimeGitignoreCheck() *RuntimeGitignoreCheck {
BaseCheck: BaseCheck{
CheckName: "runtime-gitignore",
CheckDescription: "Check that .runtime/ directories are gitignored",
CheckCategory: CategoryConfig,
},
}
}
@@ -194,6 +196,7 @@ func NewLegacyGastownCheck() *LegacyGastownCheck {
BaseCheck: BaseCheck{
CheckName: "legacy-gastown",
CheckDescription: "Check for old .gastown/ directories that should be migrated",
CheckCategory: CategoryConfig,
},
},
}
@@ -281,6 +284,7 @@ func NewSessionHookCheck() *SessionHookCheck {
BaseCheck: BaseCheck{
CheckName: "session-hooks",
CheckDescription: "Check that settings.json hooks use session-start.sh or --hook flag",
CheckCategory: CategoryConfig,
},
}
}
@@ -549,6 +553,7 @@ func NewCustomTypesCheck() *CustomTypesCheck {
BaseCheck: BaseCheck{
CheckName: "beads-custom-types",
CheckDescription: "Check that Gas Town custom types are registered with beads",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -31,6 +31,7 @@ func NewCrashReportCheck() *CrashReportCheck {
BaseCheck: BaseCheck{
CheckName: "crash-reports",
CheckDescription: "Check for recent macOS crash reports (tmux, Claude)",
CheckCategory: CategoryCleanup,
},
}
}

View File

@@ -32,6 +32,7 @@ func NewCrewStateCheck() *CrewStateCheck {
BaseCheck: BaseCheck{
CheckName: "crew-state",
CheckDescription: "Validate crew worker state.json files",
CheckCategory: CategoryCleanup,
},
},
}
@@ -239,6 +240,7 @@ func NewCrewWorktreeCheck() *CrewWorktreeCheck {
BaseCheck: BaseCheck{
CheckName: "crew-worktrees",
CheckDescription: "Detect stale cross-rig worktrees in crew directories",
CheckCategory: CategoryCleanup,
},
},
}

View File

@@ -20,6 +20,7 @@ func NewDaemonCheck() *DaemonCheck {
BaseCheck: BaseCheck{
CheckName: "daemon",
CheckDescription: "Check if Gas Town daemon is running",
CheckCategory: CategoryInfrastructure,
},
},
}

View File

@@ -27,6 +27,11 @@ func (d *Doctor) Checks() []Check {
return d.checks
}
// categoryGetter interface for checks that provide a category
type categoryGetter interface {
Category() string
}
// Run executes all registered checks and returns a report.
func (d *Doctor) Run(ctx *CheckContext) *Report {
report := NewReport()
@@ -37,6 +42,10 @@ func (d *Doctor) Run(ctx *CheckContext) *Report {
if result.Name == "" {
result.Name = check.Name()
}
// Set category from check if available
if cg, ok := check.(categoryGetter); ok && result.Category == "" {
result.Category = cg.Category()
}
report.Add(result)
}
@@ -53,6 +62,10 @@ func (d *Doctor) Fix(ctx *CheckContext) *Report {
if result.Name == "" {
result.Name = check.Name()
}
// Set category from check if available
if cg, ok := check.(categoryGetter); ok && result.Category == "" {
result.Category = cg.Category()
}
// Attempt fix if check failed and is fixable
if result.Status != StatusOK && check.CanFix() {
@@ -63,6 +76,10 @@ func (d *Doctor) Fix(ctx *CheckContext) *Report {
if result.Name == "" {
result.Name = check.Name()
}
// Set category again after re-run
if cg, ok := check.(categoryGetter); ok && result.Category == "" {
result.Category = cg.Category()
}
// Update message to indicate fix was applied
if result.Status == StatusOK {
result.Message = result.Message + " (fixed)"
@@ -84,6 +101,12 @@ func (d *Doctor) Fix(ctx *CheckContext) *Report {
type BaseCheck struct {
CheckName string
CheckDescription string
CheckCategory string // Category for grouping (e.g., CategoryCore)
}
// Category returns the check's category for grouping in output.
func (b *BaseCheck) Category() string {
return b.CheckCategory
}
// Name returns the check name.

View File

@@ -219,8 +219,12 @@ func TestReport_Print(t *testing.T) {
if !bytes.Contains(buf.Bytes(), []byte("TestCheck")) {
t.Error("Output should contain check name")
}
if !bytes.Contains(buf.Bytes(), []byte("2 checks")) {
t.Error("Output should contain summary")
// New summary format: "✓ N passed ⚠ N warnings ✖ N failed"
if !bytes.Contains(buf.Bytes(), []byte("1 passed")) {
t.Error("Output should contain summary with passed count")
}
if !bytes.Contains(buf.Bytes(), []byte("1 warnings")) {
t.Error("Output should contain summary with warnings count")
}
}

View File

@@ -42,6 +42,7 @@ func NewEnvVarsCheck() *EnvVarsCheck {
BaseCheck: BaseCheck{
CheckName: "env-vars",
CheckDescription: "Verify tmux session environment variables match expected values",
CheckCategory: CategoryConfig,
},
}
}

View File

@@ -21,6 +21,7 @@ func NewFormulaCheck() *FormulaCheck {
BaseCheck: BaseCheck{
CheckName: "formulas",
CheckDescription: "Check embedded formulas are up-to-date",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -21,6 +21,7 @@ func NewGlobalStateCheck() *GlobalStateCheck {
BaseCheck: BaseCheck{
CheckName: "global-state",
CheckDescription: "Validates Gas Town global state and shell integration",
CheckCategory: CategoryCore,
},
}
}

View File

@@ -32,6 +32,7 @@ func NewHookAttachmentValidCheck() *HookAttachmentValidCheck {
BaseCheck: BaseCheck{
CheckName: "hook-attachment-valid",
CheckDescription: "Verify attached molecules exist and are not closed",
CheckCategory: CategoryHooks,
},
},
}
@@ -207,6 +208,7 @@ func NewHookSingletonCheck() *HookSingletonCheck {
BaseCheck: BaseCheck{
CheckName: "hook-singleton",
CheckDescription: "Ensure each agent has at most one handoff bead",
CheckCategory: CategoryHooks,
},
},
}
@@ -346,6 +348,7 @@ func NewOrphanedAttachmentsCheck() *OrphanedAttachmentsCheck {
BaseCheck: BaseCheck{
CheckName: "orphaned-attachments",
CheckDescription: "Detect handoff beads for non-existent agents",
CheckCategory: CategoryHooks,
},
}
}

View File

@@ -9,19 +9,19 @@ import (
)
// IdentityCollisionCheck checks for agent identity collisions and stale locks.
type IdentityCollisionCheck struct{}
type IdentityCollisionCheck struct {
BaseCheck
}
// NewIdentityCollisionCheck creates a new identity collision check.
func NewIdentityCollisionCheck() *IdentityCollisionCheck {
return &IdentityCollisionCheck{}
return &IdentityCollisionCheck{
BaseCheck: BaseCheck{
CheckName: "identity-collision",
CheckDescription: "Check for agent identity collisions and stale locks",
CheckCategory: CategoryInfrastructure,
},
}
func (c *IdentityCollisionCheck) Name() string {
return "identity-collision"
}
func (c *IdentityCollisionCheck) Description() string {
return "Check for agent identity collisions and stale locks"
}
func (c *IdentityCollisionCheck) CanFix() bool {

View File

@@ -27,6 +27,7 @@ func NewLifecycleHygieneCheck() *LifecycleHygieneCheck {
BaseCheck: BaseCheck{
CheckName: "lifecycle-hygiene",
CheckDescription: "Check for stale lifecycle messages",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -27,6 +27,7 @@ func NewOrphanSessionCheck() *OrphanSessionCheck {
BaseCheck: BaseCheck{
CheckName: "orphan-sessions",
CheckDescription: "Detect orphaned tmux sessions",
CheckCategory: CategoryCleanup,
},
},
}
@@ -244,6 +245,7 @@ func NewOrphanProcessCheck() *OrphanProcessCheck {
BaseCheck: BaseCheck{
CheckName: "orphan-processes",
CheckDescription: "Detect runtime processes outside tmux",
CheckCategory: CategoryCleanup,
},
}
}

View File

@@ -28,6 +28,7 @@ func NewPatrolMoleculesExistCheck() *PatrolMoleculesExistCheck {
BaseCheck: BaseCheck{
CheckName: "patrol-molecules-exist",
CheckDescription: "Check if patrol molecules exist for each rig",
CheckCategory: CategoryPatrol,
},
},
}
@@ -155,6 +156,7 @@ func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck {
BaseCheck: BaseCheck{
CheckName: "patrol-hooks-wired",
CheckDescription: "Check if hooks trigger patrol execution",
CheckCategory: CategoryPatrol,
},
},
}
@@ -225,6 +227,7 @@ func NewPatrolNotStuckCheck() *PatrolNotStuckCheck {
BaseCheck: BaseCheck{
CheckName: "patrol-not-stuck",
CheckDescription: "Check for stuck patrol wisps (>1h in_progress)",
CheckCategory: CategoryPatrol,
},
stuckThreshold: 1 * time.Hour,
}
@@ -329,6 +332,7 @@ func NewPatrolPluginsAccessibleCheck() *PatrolPluginsAccessibleCheck {
BaseCheck: BaseCheck{
CheckName: "patrol-plugins-accessible",
CheckDescription: "Check if plugin directories exist and are readable",
CheckCategory: CategoryPatrol,
},
},
}
@@ -398,6 +402,7 @@ func NewPatrolRolesHavePromptsCheck() *PatrolRolesHavePromptsCheck {
BaseCheck: BaseCheck{
CheckName: "patrol-roles-have-prompts",
CheckDescription: "Check if internal/templates/roles/*.md.tmpl exist for each patrol role",
CheckCategory: CategoryPatrol,
},
},
}

View File

@@ -21,6 +21,7 @@ func NewPreCheckoutHookCheck() *PreCheckoutHookCheck {
BaseCheck: BaseCheck{
CheckName: "pre-checkout-hook",
CheckDescription: "Verify pre-checkout hook prevents branch switches",
CheckCategory: CategoryHooks,
},
},
}

View File

@@ -42,6 +42,7 @@ func NewRepoFingerprintCheck() *RepoFingerprintCheck {
BaseCheck: BaseCheck{
CheckName: "repo-fingerprint",
CheckDescription: "Verify beads database has valid repository fingerprint",
CheckCategory: CategoryInfrastructure,
},
},
}

View File

@@ -23,6 +23,7 @@ func NewRigBeadsCheck() *RigBeadsCheck {
BaseCheck: BaseCheck{
CheckName: "rig-beads-exist",
CheckDescription: "Verify rig identity beads exist for all rigs",
CheckCategory: CategoryRig,
},
},
}

View File

@@ -25,6 +25,7 @@ func NewRigIsGitRepoCheck() *RigIsGitRepoCheck {
BaseCheck: BaseCheck{
CheckName: "rig-is-git-repo",
CheckDescription: "Verify rig has a valid mayor/rig git clone",
CheckCategory: CategoryRig,
},
}
}
@@ -99,6 +100,7 @@ func NewGitExcludeConfiguredCheck() *GitExcludeConfiguredCheck {
BaseCheck: BaseCheck{
CheckName: "git-exclude-configured",
CheckDescription: "Check .git/info/exclude has Gas Town directories",
CheckCategory: CategoryRig,
},
},
}
@@ -249,6 +251,7 @@ func NewHooksPathConfiguredCheck() *HooksPathConfiguredCheck {
BaseCheck: BaseCheck{
CheckName: "hooks-path-configured",
CheckDescription: "Check core.hooksPath is set for all clones",
CheckCategory: CategoryRig,
},
},
}
@@ -371,6 +374,7 @@ func NewWitnessExistsCheck() *WitnessExistsCheck {
BaseCheck: BaseCheck{
CheckName: "witness-exists",
CheckDescription: "Verify witness/ directory structure exists",
CheckCategory: CategoryRig,
},
},
}
@@ -477,6 +481,7 @@ func NewRefineryExistsCheck() *RefineryExistsCheck {
BaseCheck: BaseCheck{
CheckName: "refinery-exists",
CheckDescription: "Verify refinery/ directory structure exists",
CheckCategory: CategoryRig,
},
},
}
@@ -582,6 +587,7 @@ func NewMayorCloneExistsCheck() *MayorCloneExistsCheck {
BaseCheck: BaseCheck{
CheckName: "mayor-clone-exists",
CheckDescription: "Verify mayor/rig/ git clone exists",
CheckCategory: CategoryRig,
},
},
}
@@ -664,6 +670,7 @@ func NewPolecatClonesValidCheck() *PolecatClonesValidCheck {
BaseCheck: BaseCheck{
CheckName: "polecat-clones-valid",
CheckDescription: "Verify polecat directories are valid git clones",
CheckCategory: CategoryRig,
},
}
}
@@ -798,6 +805,7 @@ func NewBeadsConfigValidCheck() *BeadsConfigValidCheck {
BaseCheck: BaseCheck{
CheckName: "beads-config-valid",
CheckDescription: "Verify beads configuration if .beads/ exists",
CheckCategory: CategoryRig,
},
},
}
@@ -893,6 +901,7 @@ func NewBeadsRedirectCheck() *BeadsRedirectCheck {
BaseCheck: BaseCheck{
CheckName: "beads-redirect",
CheckDescription: "Verify rig-level beads redirect for tracked beads",
CheckCategory: CategoryRig,
},
},
}
@@ -1105,6 +1114,7 @@ func NewBareRepoRefspecCheck() *BareRepoRefspecCheck {
BaseCheck: BaseCheck{
CheckName: "bare-repo-refspec",
CheckDescription: "Verify bare repo has correct refspec for worktrees",
CheckCategory: CategoryRig,
},
},
}

View File

@@ -23,6 +23,7 @@ func NewRoutesCheck() *RoutesCheck {
BaseCheck: BaseCheck{
CheckName: "routes-config",
CheckDescription: "Check beads routing configuration",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -26,6 +26,7 @@ func NewSparseCheckoutCheck() *SparseCheckoutCheck {
BaseCheck: BaseCheck{
CheckName: "sparse-checkout",
CheckDescription: "Verify sparse checkout excludes Claude context files (.claude/, CLAUDE.md, etc.)",
CheckCategory: CategoryRig,
},
},
}

View File

@@ -18,6 +18,7 @@ func NewStaleBinaryCheck() *StaleBinaryCheck {
BaseCheck: BaseCheck{
CheckName: "stale-binary",
CheckDescription: "Check if gt binary is up to date with repo",
CheckCategory: CategoryInfrastructure,
},
},
}

View File

@@ -20,6 +20,7 @@ func NewThemeCheck() *ThemeCheck {
BaseCheck: BaseCheck{
CheckName: "themes",
CheckDescription: "Check tmux session theme configuration",
CheckCategory: CategoryConfig,
},
},
}

View File

@@ -23,6 +23,7 @@ func NewLinkedPaneCheck() *LinkedPaneCheck {
BaseCheck: BaseCheck{
CheckName: "linked-panes",
CheckDescription: "Detect tmux sessions sharing panes (causes crosstalk)",
CheckCategory: CategoryInfrastructure,
},
},
}

View File

@@ -20,6 +20,7 @@ func NewTownGitCheck() *TownGitCheck {
BaseCheck: BaseCheck{
CheckName: "town-git",
CheckDescription: "Verify town root is under version control",
CheckCategory: CategoryCore,
},
}
}

View File

@@ -21,6 +21,7 @@ func NewTownRootBranchCheck() *TownRootBranchCheck {
BaseCheck: BaseCheck{
CheckName: "town-root-branch",
CheckDescription: "Verify town root is on main branch",
CheckCategory: CategoryCore,
},
},
}

View File

@@ -4,12 +4,34 @@ package doctor
import (
"fmt"
"io"
"strings"
"slices"
"time"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/ui"
)
// Category constants for grouping checks
const (
CategoryCore = "Core"
CategoryInfrastructure = "Infrastructure"
CategoryRig = "Rig"
CategoryPatrol = "Patrol"
CategoryConfig = "Configuration"
CategoryCleanup = "Cleanup"
CategoryHooks = "Hooks"
)
// CategoryOrder defines the display order for categories
var CategoryOrder = []string{
CategoryCore,
CategoryInfrastructure,
CategoryRig,
CategoryPatrol,
CategoryConfig,
CategoryCleanup,
CategoryHooks,
}
// CheckStatus represents the result status of a health check.
type CheckStatus int
@@ -60,6 +82,7 @@ type CheckResult struct {
Message string // Primary result message
Details []string // Additional information
FixHint string // Suggestion if not auto-fixable
Category string // Category for grouping (e.g., CategoryCore)
}
// Check defines the interface for a health check.
@@ -135,59 +158,132 @@ func (r *Report) IsHealthy() bool {
}
// Print outputs the report to the given writer.
// Matches bd doctor UX: grouped by category, semantic icons, warnings section.
func (r *Report) Print(w io.Writer, verbose bool) {
// Print individual check results
for _, check := range r.Checks {
r.printCheck(w, check, verbose)
}
// Print summary (output errors non-actionable)
// Print header with version placeholder (caller should set via PrintWithVersion)
_, _ = fmt.Fprintln(w)
r.printSummary(w)
// Group checks by category
checksByCategory := make(map[string][]*CheckResult)
for _, check := range r.Checks {
cat := check.Category
if cat == "" {
cat = "Other"
}
checksByCategory[cat] = append(checksByCategory[cat], check)
}
// printCheck outputs a single check result (output errors non-actionable).
// Track warnings/errors for summary section
var warnings []*CheckResult
// Print checks by category in defined order
for _, category := range CategoryOrder {
checks, exists := checksByCategory[category]
if !exists || len(checks) == 0 {
continue
}
// Print category header
_, _ = fmt.Fprintln(w, ui.RenderCategory(category))
// Print each check in this category
for _, check := range checks {
r.printCheck(w, check, verbose)
if check.Status != StatusOK {
warnings = append(warnings, check)
}
}
_, _ = fmt.Fprintln(w)
}
// Print any checks without a category
if otherChecks, exists := checksByCategory["Other"]; exists && len(otherChecks) > 0 {
_, _ = fmt.Fprintln(w, ui.RenderCategory("Other"))
for _, check := range otherChecks {
r.printCheck(w, check, verbose)
if check.Status != StatusOK {
warnings = append(warnings, check)
}
}
_, _ = fmt.Fprintln(w)
}
// Print separator and summary
_, _ = fmt.Fprintln(w, ui.RenderSeparator())
r.printSummary(w)
// Print warnings/errors section with fixes
r.printWarningsSection(w, warnings)
}
// printCheck outputs a single check result with semantic styling.
func (r *Report) printCheck(w io.Writer, check *CheckResult, verbose bool) {
var prefix string
var statusIcon string
switch check.Status {
case StatusOK:
prefix = style.SuccessPrefix
statusIcon = ui.RenderPassIcon()
case StatusWarning:
prefix = style.WarningPrefix
statusIcon = ui.RenderWarnIcon()
case StatusError:
prefix = style.ErrorPrefix
statusIcon = ui.RenderFailIcon()
}
_, _ = fmt.Fprintf(w, "%s %s: %s\n", prefix, check.Name, check.Message)
// Print check line: icon + name + muted message
_, _ = fmt.Fprintf(w, " %s %s", statusIcon, check.Name)
if check.Message != "" {
_, _ = fmt.Fprintf(w, "%s", ui.RenderMuted(" "+check.Message))
}
_, _ = fmt.Fprintln(w)
// Print details in verbose mode or for non-OK results
// Print details in verbose mode or for non-OK results (with tree connector)
if len(check.Details) > 0 && (verbose || check.Status != StatusOK) {
for _, detail := range check.Details {
_, _ = fmt.Fprintf(w, " %s\n", detail)
_, _ = fmt.Fprintf(w, " %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(detail))
}
}
}
// Print fix hint for errors/warnings
if check.FixHint != "" && check.Status != StatusOK {
_, _ = fmt.Fprintf(w, " %s %s\n", style.ArrowPrefix, check.FixHint)
}
}
// printSummary outputs the summary line (output errors non-actionable).
// printSummary outputs the summary line with semantic icons.
func (r *Report) printSummary(w io.Writer) {
parts := []string{
fmt.Sprintf("%d checks", r.Summary.Total),
summary := fmt.Sprintf("%s %d passed %s %d warnings %s %d failed",
ui.RenderPassIcon(), r.Summary.OK,
ui.RenderWarnIcon(), r.Summary.Warnings,
ui.RenderFailIcon(), r.Summary.Errors,
)
_, _ = fmt.Fprintln(w, summary)
}
if r.Summary.OK > 0 {
parts = append(parts, style.Success.Render(fmt.Sprintf("%d passed", r.Summary.OK)))
}
if r.Summary.Warnings > 0 {
parts = append(parts, style.Warning.Render(fmt.Sprintf("%d warnings", r.Summary.Warnings)))
}
if r.Summary.Errors > 0 {
parts = append(parts, style.Error.Render(fmt.Sprintf("%d errors", r.Summary.Errors)))
// printWarningsSection outputs numbered warnings/errors sorted by severity.
func (r *Report) printWarningsSection(w io.Writer, warnings []*CheckResult) {
if len(warnings) == 0 {
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, ui.RenderPass(ui.IconPass+" All checks passed"))
return
}
_, _ = fmt.Fprintln(w, strings.Join(parts, ", "))
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, ui.RenderWarn(ui.IconWarn+" WARNINGS"))
// Sort by severity: errors first, then warnings
slices.SortStableFunc(warnings, func(a, b *CheckResult) int {
if a.Status == StatusError && b.Status != StatusError {
return -1
}
if a.Status != StatusError && b.Status == StatusError {
return 1
}
return 0
})
for i, check := range warnings {
line := fmt.Sprintf("%s: %s", check.Name, check.Message)
if check.Status == StatusError {
_, _ = fmt.Fprintf(w, " %s %s %s\n", ui.RenderFailIcon(), ui.RenderFail(fmt.Sprintf("%d.", i+1)), ui.RenderFail(line))
} else {
_, _ = fmt.Fprintf(w, " %s %s %s\n", ui.RenderWarnIcon(), ui.RenderWarn(fmt.Sprintf("%d.", i+1)), line)
}
if check.FixHint != "" {
_, _ = fmt.Fprintf(w, " %s%s\n", ui.MutedStyle.Render(ui.TreeLast), check.FixHint)
}
}
}

View File

@@ -28,6 +28,7 @@ func NewWispGCCheck() *WispGCCheck {
BaseCheck: BaseCheck{
CheckName: "wisp-gc",
CheckDescription: "Detect and clean orphaned wisps (>1h old)",
CheckCategory: CategoryCleanup,
},
},
threshold: 1 * time.Hour,

View File

@@ -18,6 +18,7 @@ func NewTownConfigExistsCheck() *TownConfigExistsCheck {
BaseCheck: BaseCheck{
CheckName: "town-config-exists",
CheckDescription: "Check that mayor/town.json exists",
CheckCategory: CategoryCore,
},
}
}
@@ -53,6 +54,7 @@ func NewTownConfigValidCheck() *TownConfigValidCheck {
BaseCheck: BaseCheck{
CheckName: "town-config-valid",
CheckDescription: "Check that mayor/town.json is valid with required fields",
CheckCategory: CategoryCore,
},
}
}
@@ -130,6 +132,7 @@ func NewRigsRegistryExistsCheck() *RigsRegistryExistsCheck {
BaseCheck: BaseCheck{
CheckName: "rigs-registry-exists",
CheckDescription: "Check that mayor/rigs.json exists",
CheckCategory: CategoryCore,
},
},
}
@@ -188,6 +191,7 @@ func NewRigsRegistryValidCheck() *RigsRegistryValidCheck {
BaseCheck: BaseCheck{
CheckName: "rigs-registry-valid",
CheckDescription: "Check that registered rigs exist on disk",
CheckCategory: CategoryCore,
},
},
}
@@ -320,6 +324,7 @@ func NewMayorExistsCheck() *MayorExistsCheck {
BaseCheck: BaseCheck{
CheckName: "mayor-exists",
CheckDescription: "Check that mayor/ directory exists with required files",
CheckCategory: CategoryCore,
},
}
}

View File

@@ -1,48 +1,50 @@
// Package style provides consistent terminal styling using Lipgloss.
// Uses the Ayu theme colors from internal/ui for semantic consistency.
package style
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/steveyegge/gastown/internal/ui"
)
var (
// Success style for positive outcomes
// Success style for positive outcomes (green)
Success = lipgloss.NewStyle().
Foreground(lipgloss.Color("10")). // Green
Foreground(ui.ColorPass).
Bold(true)
// Warning style for cautionary messages
// Warning style for cautionary messages (yellow)
Warning = lipgloss.NewStyle().
Foreground(lipgloss.Color("11")). // Yellow
Foreground(ui.ColorWarn).
Bold(true)
// Error style for failures
// Error style for failures (red)
Error = lipgloss.NewStyle().
Foreground(lipgloss.Color("9")). // Red
Foreground(ui.ColorFail).
Bold(true)
// Info style for informational messages
// Info style for informational messages (blue)
Info = lipgloss.NewStyle().
Foreground(lipgloss.Color("12")) // Blue
Foreground(ui.ColorAccent)
// Dim style for secondary information
// Dim style for secondary information (gray)
Dim = lipgloss.NewStyle().
Foreground(lipgloss.Color("8")) // Gray
Foreground(ui.ColorMuted)
// Bold style for emphasis
Bold = lipgloss.NewStyle().
Bold(true)
// SuccessPrefix is the checkmark prefix for success messages
SuccessPrefix = Success.Render("✓")
SuccessPrefix = Success.Render(ui.IconPass)
// WarningPrefix is the warning prefix
WarningPrefix = Warning.Render("⚠")
WarningPrefix = Warning.Render(ui.IconWarn)
// ErrorPrefix is the error prefix
ErrorPrefix = Error.Render("✗")
ErrorPrefix = Error.Render(ui.IconFail)
// ArrowPrefix for action indicators
ArrowPrefix = Info.Render("→")
@@ -52,5 +54,5 @@ var (
// The format and args work like fmt.Printf.
func PrintWarning(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s %s\n", Warning.Render(" Warning:"), msg)
fmt.Printf("%s %s\n", Warning.Render(ui.IconWarn+" Warning:"), msg)
}

View File

@@ -4,17 +4,18 @@ package feed
import (
"github.com/charmbracelet/lipgloss"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/ui"
)
// Color palette
// Color palette using Ayu theme colors from ui package
var (
colorPrimary = lipgloss.Color("12") // Blue
colorSuccess = lipgloss.Color("10") // Green
colorWarning = lipgloss.Color("11") // Yellow
colorError = lipgloss.Color("9") // Red
colorDim = lipgloss.Color("8") // Gray
colorHighlight = lipgloss.Color("14") // Cyan
colorAccent = lipgloss.Color("13") // Magenta
colorPrimary = ui.ColorAccent // Blue
colorSuccess = ui.ColorPass // Green
colorWarning = ui.ColorWarn // Yellow
colorError = ui.ColorFail // Red
colorDim = ui.ColorMuted // Gray
colorHighlight = lipgloss.AdaptiveColor{Light: "#59c2ff", Dark: "#59c2ff"} // Cyan (Ayu)
colorAccent = lipgloss.AdaptiveColor{Light: "#d2a6ff", Dark: "#d2a6ff"} // Purple (Ayu)
)
// Styles for the feed TUI

65
internal/ui/markdown.go Normal file
View File

@@ -0,0 +1,65 @@
package ui
import (
"os"
"github.com/charmbracelet/glamour"
"golang.org/x/term"
)
// RenderMarkdown renders markdown text with glamour styling.
// Returns raw markdown on failure for graceful degradation.
func RenderMarkdown(markdown string) string {
// agent mode outputs plain text for machine parsing
if IsAgentMode() {
return markdown
}
// no styling when colors are disabled
if !ShouldUseColor() {
return markdown
}
wrapWidth := getTerminalWidth()
renderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
return markdown
}
rendered, err := renderer.Render(markdown)
if err != nil {
return markdown
}
return rendered
}
// getTerminalWidth returns the terminal width for word wrapping.
// Caps at 100 chars for readability (research suggests 50-75 optimal, 80-100 comfortable).
// Falls back to 80 if detection fails.
func getTerminalWidth() int {
const (
defaultWidth = 80
maxWidth = 100
)
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return defaultWidth
}
width, _, err := term.GetSize(fd)
if err != nil || width <= 0 {
return defaultWidth
}
if width > maxWidth {
return maxWidth
}
return width
}

106
internal/ui/pager.go Normal file
View File

@@ -0,0 +1,106 @@
package ui
import (
"fmt"
"os"
"os/exec"
"strings"
"golang.org/x/term"
)
// PagerOptions configures pager behavior for command output.
type PagerOptions struct {
// NoPager disables pager for this command (--no-pager flag)
NoPager bool
}
// shouldUsePager determines if output should be piped to a pager.
// Returns false if explicitly disabled, env var set, or stdout is not a TTY.
func shouldUsePager(opts PagerOptions) bool {
if opts.NoPager {
return false
}
if os.Getenv("GT_NO_PAGER") != "" {
return false
}
if !term.IsTerminal(int(os.Stdout.Fd())) {
return false
}
return true
}
// getPagerCommand returns the pager command to use.
// Checks GT_PAGER, then PAGER, defaults to "less".
func getPagerCommand() string {
if pager := os.Getenv("GT_PAGER"); pager != "" {
return pager
}
if pager := os.Getenv("PAGER"); pager != "" {
return pager
}
return "less"
}
// getTerminalHeight returns the terminal height in lines.
// Returns 0 if unable to determine (not a TTY).
func getTerminalHeight() int {
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return 0
}
_, height, err := term.GetSize(fd)
if err != nil {
return 0
}
return height
}
// contentHeight counts the number of lines in content.
// Returns 0 if content is empty.
func contentHeight(content string) int {
if content == "" {
return 0
}
return strings.Count(content, "\n") + 1
}
// ToPager pipes content to a pager if appropriate.
// Prints directly if pager is disabled, stdout is not a TTY, or content fits in terminal.
func ToPager(content string, opts PagerOptions) error {
if !shouldUsePager(opts) {
fmt.Print(content)
return nil
}
termHeight := getTerminalHeight()
lines := contentHeight(content)
// print directly if content fits in terminal (leave room for prompt)
if termHeight > 0 && lines <= termHeight-1 {
fmt.Print(content)
return nil
}
pagerCmd := getPagerCommand()
parts := strings.Fields(pagerCmd)
if len(parts) == 0 {
fmt.Print(content)
return nil
}
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Stdin = strings.NewReader(content)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// set LESS options if not already configured
// -R: allow ANSI color codes
// -F: quit if content fits on one screen
// -X: don't clear screen on exit
if os.Getenv("LESS") == "" {
cmd.Env = append(os.Environ(), "LESS=-RFX")
}
return cmd.Run()
}

486
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,486 @@
// 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)
}

63
internal/ui/terminal.go Normal file
View File

@@ -0,0 +1,63 @@
package ui
import (
"os"
"golang.org/x/term"
)
// IsTerminal returns true if stdout is connected to a terminal (TTY).
func IsTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// ShouldUseColor determines if ANSI color codes should be used.
// Respects NO_COLOR (https://no-color.org/), CLICOLOR, and CLICOLOR_FORCE conventions.
func ShouldUseColor() bool {
// NO_COLOR takes precedence - any value disables color
if _, exists := os.LookupEnv("NO_COLOR"); exists {
return false
}
// CLICOLOR=0 disables color
if os.Getenv("CLICOLOR") == "0" {
return false
}
// CLICOLOR_FORCE enables color even in non-TTY
if _, exists := os.LookupEnv("CLICOLOR_FORCE"); exists {
return true
}
// default: use color only if stdout is a TTY
return IsTerminal()
}
// ShouldUseEmoji determines if emoji decorations should be used.
// Disabled in non-TTY mode to keep output machine-readable.
func ShouldUseEmoji() bool {
// GT_NO_EMOJI disables emoji output
if _, exists := os.LookupEnv("GT_NO_EMOJI"); exists {
return false
}
// default: use emoji only if stdout is a TTY
return IsTerminal()
}
// IsAgentMode returns true if the CLI is running in agent-optimized mode.
// This is triggered by:
// - GT_AGENT_MODE=1 environment variable (explicit)
// - CLAUDE_CODE environment variable (auto-detect Claude Code)
//
// Agent mode provides ultra-compact output optimized for LLM context windows.
func IsAgentMode() bool {
if os.Getenv("GT_AGENT_MODE") == "1" {
return true
}
// auto-detect Claude Code environment
if os.Getenv("CLAUDE_CODE") != "" {
return true
}
return false
}