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:
13
go.mod
13
go.mod
@@ -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
28
go.sum
@@ -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
141
internal/cmd/help.go
Normal 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
239
internal/cmd/thanks.go
Normal 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)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func NewAgentBeadsCheck() *AgentBeadsCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "agent-beads-exist",
|
||||
CheckDescription: "Verify agent beads exist for all agents",
|
||||
CheckCategory: CategoryRig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewBdDaemonCheck() *BdDaemonCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "bd-daemon",
|
||||
CheckDescription: "Check if bd (beads) daemon is running",
|
||||
CheckCategory: CategoryInfrastructure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func NewClaudeSettingsCheck() *ClaudeSettingsCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "claude-settings",
|
||||
CheckDescription: "Verify Claude settings.json files match expected templates",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewCommandsCheck() *CommandsCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "commands-provisioned",
|
||||
CheckDescription: "Check .claude/commands/ is provisioned at town level",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func NewCrashReportCheck() *CrashReportCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "crash-reports",
|
||||
CheckDescription: "Check for recent macOS crash reports (tmux, Claude)",
|
||||
CheckCategory: CategoryCleanup,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewDaemonCheck() *DaemonCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "daemon",
|
||||
CheckDescription: "Check if Gas Town daemon is running",
|
||||
CheckCategory: CategoryInfrastructure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func NewEnvVarsCheck() *EnvVarsCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "env-vars",
|
||||
CheckDescription: "Verify tmux session environment variables match expected values",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewFormulaCheck() *FormulaCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "formulas",
|
||||
CheckDescription: "Check embedded formulas are up-to-date",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewGlobalStateCheck() *GlobalStateCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "global-state",
|
||||
CheckDescription: "Validates Gas Town global state and shell integration",
|
||||
CheckCategory: CategoryCore,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
func (c *IdentityCollisionCheck) Name() string {
|
||||
return "identity-collision"
|
||||
}
|
||||
|
||||
func (c *IdentityCollisionCheck) Description() string {
|
||||
return "Check for agent identity collisions and stale locks"
|
||||
return &IdentityCollisionCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "identity-collision",
|
||||
CheckDescription: "Check for agent identity collisions and stale locks",
|
||||
CheckCategory: CategoryInfrastructure,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IdentityCollisionCheck) CanFix() bool {
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewLifecycleHygieneCheck() *LifecycleHygieneCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "lifecycle-hygiene",
|
||||
CheckDescription: "Check for stale lifecycle messages",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewPreCheckoutHookCheck() *PreCheckoutHookCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "pre-checkout-hook",
|
||||
CheckDescription: "Verify pre-checkout hook prevents branch switches",
|
||||
CheckCategory: CategoryHooks,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func NewRepoFingerprintCheck() *RepoFingerprintCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "repo-fingerprint",
|
||||
CheckDescription: "Verify beads database has valid repository fingerprint",
|
||||
CheckCategory: CategoryInfrastructure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func NewRigBeadsCheck() *RigBeadsCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "rig-beads-exist",
|
||||
CheckDescription: "Verify rig identity beads exist for all rigs",
|
||||
CheckCategory: CategoryRig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func NewRoutesCheck() *RoutesCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "routes-config",
|
||||
CheckDescription: "Check beads routing configuration",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewThemeCheck() *ThemeCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "themes",
|
||||
CheckDescription: "Check tmux session theme configuration",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func NewLinkedPaneCheck() *LinkedPaneCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "linked-panes",
|
||||
CheckDescription: "Detect tmux sessions sharing panes (causes crosstalk)",
|
||||
CheckCategory: CategoryInfrastructure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewTownGitCheck() *TownGitCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "town-git",
|
||||
CheckDescription: "Verify town root is under version control",
|
||||
CheckCategory: CategoryCore,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewTownRootBranchCheck() *TownRootBranchCheck {
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "town-root-branch",
|
||||
CheckDescription: "Verify town root is on main branch",
|
||||
CheckCategory: CategoryCore,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,11 +77,12 @@ func (ctx *CheckContext) RigPath() string {
|
||||
|
||||
// CheckResult represents the outcome of a health check.
|
||||
type CheckResult struct {
|
||||
Name string // Check name
|
||||
Status CheckStatus // Result status
|
||||
Message string // Primary result message
|
||||
Details []string // Additional information
|
||||
FixHint string // Suggestion if not auto-fixable
|
||||
Name string // Check name
|
||||
Status CheckStatus // Result status
|
||||
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
|
||||
// Print header with version placeholder (caller should set via PrintWithVersion)
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
// Group checks by category
|
||||
checksByCategory := make(map[string][]*CheckResult)
|
||||
for _, check := range r.Checks {
|
||||
r.printCheck(w, check, verbose)
|
||||
cat := check.Category
|
||||
if cat == "" {
|
||||
cat = "Other"
|
||||
}
|
||||
checksByCategory[cat] = append(checksByCategory[cat], check)
|
||||
}
|
||||
|
||||
// Print summary (output errors non-actionable)
|
||||
_, _ = fmt.Fprintln(w)
|
||||
// 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 (output errors non-actionable).
|
||||
// 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),
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, strings.Join(parts, ", "))
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
_, _ = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
65
internal/ui/markdown.go
Normal 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
106
internal/ui/pager.go
Normal 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
486
internal/ui/styles.go
Normal 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
63
internal/ui/terminal.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user