From e1f2bb8b4be82cff401f41ca0b76c256be7b0937 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 9 Jan 2026 22:43:48 -0800 Subject: [PATCH] feat(ui): import comprehensive UX system from beads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 13 +- go.sum | 28 ++ internal/cmd/help.go | 141 +++++++ internal/cmd/thanks.go | 239 +++++++++++ internal/doctor/agent_beads_check.go | 1 + internal/doctor/bd_daemon_check.go | 1 + internal/doctor/beads_check.go | 3 + internal/doctor/boot_check.go | 1 + internal/doctor/branch_check.go | 3 + internal/doctor/claude_settings_check.go | 1 + internal/doctor/commands_check.go | 1 + internal/doctor/config_check.go | 5 + internal/doctor/crash_report_check.go | 1 + internal/doctor/crew_check.go | 2 + internal/doctor/daemon_check.go | 1 + internal/doctor/doctor.go | 23 + internal/doctor/doctor_test.go | 8 +- internal/doctor/env_check.go | 1 + internal/doctor/formula_check.go | 1 + internal/doctor/global_state_check.go | 1 + internal/doctor/hook_check.go | 3 + internal/doctor/identity_check.go | 20 +- internal/doctor/lifecycle_check.go | 1 + internal/doctor/orphan_check.go | 2 + internal/doctor/patrol_check.go | 5 + internal/doctor/precheckout_hook_check.go | 1 + internal/doctor/repo_fingerprint_check.go | 1 + internal/doctor/rig_beads_check.go | 1 + internal/doctor/rig_check.go | 10 + internal/doctor/routes_check.go | 1 + internal/doctor/sparse_checkout_check.go | 1 + internal/doctor/stale_binary_check.go | 1 + internal/doctor/theme_check.go | 1 + internal/doctor/tmux_check.go | 1 + internal/doctor/town_git_check.go | 1 + internal/doctor/town_root_branch_check.go | 1 + internal/doctor/types.go | 176 ++++++-- internal/doctor/wisp_check.go | 1 + internal/doctor/workspace_check.go | 5 + internal/style/style.go | 30 +- internal/tui/feed/styles.go | 17 +- internal/ui/markdown.go | 65 +++ internal/ui/pager.go | 106 +++++ internal/ui/styles.go | 486 ++++++++++++++++++++++ internal/ui/terminal.go | 63 +++ 45 files changed, 1400 insertions(+), 75 deletions(-) create mode 100644 internal/cmd/help.go create mode 100644 internal/cmd/thanks.go create mode 100644 internal/ui/markdown.go create mode 100644 internal/ui/pager.go create mode 100644 internal/ui/styles.go create mode 100644 internal/ui/terminal.go diff --git a/go.mod b/go.mod index 666f1e8b..39f35b43 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index bd213a25..a2f7aae9 100644 --- a/go.sum +++ b/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= diff --git a/internal/cmd/help.go b/internal/cmd/help.go new file mode 100644 index 00000000..0207f1a7 --- /dev/null +++ b/internal/cmd/help.go @@ -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) +} diff --git a/internal/cmd/thanks.go b/internal/cmd/thanks.go new file mode 100644 index 00000000..401ca348 --- /dev/null +++ b/internal/cmd/thanks.go @@ -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) +} diff --git a/internal/doctor/agent_beads_check.go b/internal/doctor/agent_beads_check.go index 17d58056..6651849a 100644 --- a/internal/doctor/agent_beads_check.go +++ b/internal/doctor/agent_beads_check.go @@ -28,6 +28,7 @@ func NewAgentBeadsCheck() *AgentBeadsCheck { BaseCheck: BaseCheck{ CheckName: "agent-beads-exist", CheckDescription: "Verify agent beads exist for all agents", + CheckCategory: CategoryRig, }, }, } diff --git a/internal/doctor/bd_daemon_check.go b/internal/doctor/bd_daemon_check.go index 1f07e7b7..42573e6c 100644 --- a/internal/doctor/bd_daemon_check.go +++ b/internal/doctor/bd_daemon_check.go @@ -20,6 +20,7 @@ func NewBdDaemonCheck() *BdDaemonCheck { BaseCheck: BaseCheck{ CheckName: "bd-daemon", CheckDescription: "Check if bd (beads) daemon is running", + CheckCategory: CategoryInfrastructure, }, }, } diff --git a/internal/doctor/beads_check.go b/internal/doctor/beads_check.go index 85aac9fa..dde13d31 100644 --- a/internal/doctor/beads_check.go +++ b/internal/doctor/beads_check.go @@ -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, }, }, } diff --git a/internal/doctor/boot_check.go b/internal/doctor/boot_check.go index 31202088..41271fec 100644 --- a/internal/doctor/boot_check.go +++ b/internal/doctor/boot_check.go @@ -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, }, } } diff --git a/internal/doctor/branch_check.go b/internal/doctor/branch_check.go index 69facb26..b36d4fcb 100644 --- a/internal/doctor/branch_check.go +++ b/internal/doctor/branch_check.go @@ -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, }, } } diff --git a/internal/doctor/claude_settings_check.go b/internal/doctor/claude_settings_check.go index b6267b70..d1319639 100644 --- a/internal/doctor/claude_settings_check.go +++ b/internal/doctor/claude_settings_check.go @@ -50,6 +50,7 @@ func NewClaudeSettingsCheck() *ClaudeSettingsCheck { BaseCheck: BaseCheck{ CheckName: "claude-settings", CheckDescription: "Verify Claude settings.json files match expected templates", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/commands_check.go b/internal/doctor/commands_check.go index 445c7ff9..de0faeee 100644 --- a/internal/doctor/commands_check.go +++ b/internal/doctor/commands_check.go @@ -22,6 +22,7 @@ func NewCommandsCheck() *CommandsCheck { BaseCheck: BaseCheck{ CheckName: "commands-provisioned", CheckDescription: "Check .claude/commands/ is provisioned at town level", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/config_check.go b/internal/doctor/config_check.go index b268a454..4914d628 100644 --- a/internal/doctor/config_check.go +++ b/internal/doctor/config_check.go @@ -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, }, }, } diff --git a/internal/doctor/crash_report_check.go b/internal/doctor/crash_report_check.go index 05e7cee6..ef1357db 100644 --- a/internal/doctor/crash_report_check.go +++ b/internal/doctor/crash_report_check.go @@ -31,6 +31,7 @@ func NewCrashReportCheck() *CrashReportCheck { BaseCheck: BaseCheck{ CheckName: "crash-reports", CheckDescription: "Check for recent macOS crash reports (tmux, Claude)", + CheckCategory: CategoryCleanup, }, } } diff --git a/internal/doctor/crew_check.go b/internal/doctor/crew_check.go index e6204853..7785f2c8 100644 --- a/internal/doctor/crew_check.go +++ b/internal/doctor/crew_check.go @@ -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, }, }, } diff --git a/internal/doctor/daemon_check.go b/internal/doctor/daemon_check.go index 08937f79..a77f6108 100644 --- a/internal/doctor/daemon_check.go +++ b/internal/doctor/daemon_check.go @@ -20,6 +20,7 @@ func NewDaemonCheck() *DaemonCheck { BaseCheck: BaseCheck{ CheckName: "daemon", CheckDescription: "Check if Gas Town daemon is running", + CheckCategory: CategoryInfrastructure, }, }, } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index ecc80a7e..13fb0a72 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -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. diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index dda09fa3..0f295c14 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -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") } } diff --git a/internal/doctor/env_check.go b/internal/doctor/env_check.go index c0751ea1..54c3297a 100644 --- a/internal/doctor/env_check.go +++ b/internal/doctor/env_check.go @@ -42,6 +42,7 @@ func NewEnvVarsCheck() *EnvVarsCheck { BaseCheck: BaseCheck{ CheckName: "env-vars", CheckDescription: "Verify tmux session environment variables match expected values", + CheckCategory: CategoryConfig, }, } } diff --git a/internal/doctor/formula_check.go b/internal/doctor/formula_check.go index 286daf8f..5535ab10 100644 --- a/internal/doctor/formula_check.go +++ b/internal/doctor/formula_check.go @@ -21,6 +21,7 @@ func NewFormulaCheck() *FormulaCheck { BaseCheck: BaseCheck{ CheckName: "formulas", CheckDescription: "Check embedded formulas are up-to-date", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/global_state_check.go b/internal/doctor/global_state_check.go index c31c63f0..23977caa 100644 --- a/internal/doctor/global_state_check.go +++ b/internal/doctor/global_state_check.go @@ -21,6 +21,7 @@ func NewGlobalStateCheck() *GlobalStateCheck { BaseCheck: BaseCheck{ CheckName: "global-state", CheckDescription: "Validates Gas Town global state and shell integration", + CheckCategory: CategoryCore, }, } } diff --git a/internal/doctor/hook_check.go b/internal/doctor/hook_check.go index 7ac9e91a..5861772a 100644 --- a/internal/doctor/hook_check.go +++ b/internal/doctor/hook_check.go @@ -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, }, } } diff --git a/internal/doctor/identity_check.go b/internal/doctor/identity_check.go index 4c61c8c7..23d98451 100644 --- a/internal/doctor/identity_check.go +++ b/internal/doctor/identity_check.go @@ -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 { diff --git a/internal/doctor/lifecycle_check.go b/internal/doctor/lifecycle_check.go index 912f544e..74b34526 100644 --- a/internal/doctor/lifecycle_check.go +++ b/internal/doctor/lifecycle_check.go @@ -27,6 +27,7 @@ func NewLifecycleHygieneCheck() *LifecycleHygieneCheck { BaseCheck: BaseCheck{ CheckName: "lifecycle-hygiene", CheckDescription: "Check for stale lifecycle messages", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/orphan_check.go b/internal/doctor/orphan_check.go index 03a2e426..e4f10aa8 100644 --- a/internal/doctor/orphan_check.go +++ b/internal/doctor/orphan_check.go @@ -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, }, } } diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go index 5efd2b8f..a39a025a 100644 --- a/internal/doctor/patrol_check.go +++ b/internal/doctor/patrol_check.go @@ -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, }, }, } diff --git a/internal/doctor/precheckout_hook_check.go b/internal/doctor/precheckout_hook_check.go index b6d119fd..21330e0a 100644 --- a/internal/doctor/precheckout_hook_check.go +++ b/internal/doctor/precheckout_hook_check.go @@ -21,6 +21,7 @@ func NewPreCheckoutHookCheck() *PreCheckoutHookCheck { BaseCheck: BaseCheck{ CheckName: "pre-checkout-hook", CheckDescription: "Verify pre-checkout hook prevents branch switches", + CheckCategory: CategoryHooks, }, }, } diff --git a/internal/doctor/repo_fingerprint_check.go b/internal/doctor/repo_fingerprint_check.go index d55b86a9..8e27d7b9 100644 --- a/internal/doctor/repo_fingerprint_check.go +++ b/internal/doctor/repo_fingerprint_check.go @@ -42,6 +42,7 @@ func NewRepoFingerprintCheck() *RepoFingerprintCheck { BaseCheck: BaseCheck{ CheckName: "repo-fingerprint", CheckDescription: "Verify beads database has valid repository fingerprint", + CheckCategory: CategoryInfrastructure, }, }, } diff --git a/internal/doctor/rig_beads_check.go b/internal/doctor/rig_beads_check.go index 1f138c20..e88a5f83 100644 --- a/internal/doctor/rig_beads_check.go +++ b/internal/doctor/rig_beads_check.go @@ -23,6 +23,7 @@ func NewRigBeadsCheck() *RigBeadsCheck { BaseCheck: BaseCheck{ CheckName: "rig-beads-exist", CheckDescription: "Verify rig identity beads exist for all rigs", + CheckCategory: CategoryRig, }, }, } diff --git a/internal/doctor/rig_check.go b/internal/doctor/rig_check.go index ed5fd0eb..b83c01db 100644 --- a/internal/doctor/rig_check.go +++ b/internal/doctor/rig_check.go @@ -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, }, }, } diff --git a/internal/doctor/routes_check.go b/internal/doctor/routes_check.go index 2e8bb619..3e948a6e 100644 --- a/internal/doctor/routes_check.go +++ b/internal/doctor/routes_check.go @@ -23,6 +23,7 @@ func NewRoutesCheck() *RoutesCheck { BaseCheck: BaseCheck{ CheckName: "routes-config", CheckDescription: "Check beads routing configuration", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/sparse_checkout_check.go b/internal/doctor/sparse_checkout_check.go index fcf85a3d..6ccf8699 100644 --- a/internal/doctor/sparse_checkout_check.go +++ b/internal/doctor/sparse_checkout_check.go @@ -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, }, }, } diff --git a/internal/doctor/stale_binary_check.go b/internal/doctor/stale_binary_check.go index da6077fa..60a64c75 100644 --- a/internal/doctor/stale_binary_check.go +++ b/internal/doctor/stale_binary_check.go @@ -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, }, }, } diff --git a/internal/doctor/theme_check.go b/internal/doctor/theme_check.go index 8d836056..37858dd2 100644 --- a/internal/doctor/theme_check.go +++ b/internal/doctor/theme_check.go @@ -20,6 +20,7 @@ func NewThemeCheck() *ThemeCheck { BaseCheck: BaseCheck{ CheckName: "themes", CheckDescription: "Check tmux session theme configuration", + CheckCategory: CategoryConfig, }, }, } diff --git a/internal/doctor/tmux_check.go b/internal/doctor/tmux_check.go index 173e554c..0e46c2db 100644 --- a/internal/doctor/tmux_check.go +++ b/internal/doctor/tmux_check.go @@ -23,6 +23,7 @@ func NewLinkedPaneCheck() *LinkedPaneCheck { BaseCheck: BaseCheck{ CheckName: "linked-panes", CheckDescription: "Detect tmux sessions sharing panes (causes crosstalk)", + CheckCategory: CategoryInfrastructure, }, }, } diff --git a/internal/doctor/town_git_check.go b/internal/doctor/town_git_check.go index a0543c63..54b6b72c 100644 --- a/internal/doctor/town_git_check.go +++ b/internal/doctor/town_git_check.go @@ -20,6 +20,7 @@ func NewTownGitCheck() *TownGitCheck { BaseCheck: BaseCheck{ CheckName: "town-git", CheckDescription: "Verify town root is under version control", + CheckCategory: CategoryCore, }, } } diff --git a/internal/doctor/town_root_branch_check.go b/internal/doctor/town_root_branch_check.go index a45d281e..27c81d39 100644 --- a/internal/doctor/town_root_branch_check.go +++ b/internal/doctor/town_root_branch_check.go @@ -21,6 +21,7 @@ func NewTownRootBranchCheck() *TownRootBranchCheck { BaseCheck: BaseCheck{ CheckName: "town-root-branch", CheckDescription: "Verify town root is on main branch", + CheckCategory: CategoryCore, }, }, } diff --git a/internal/doctor/types.go b/internal/doctor/types.go index 6115afd3..4a59622a 100644 --- a/internal/doctor/types.go +++ b/internal/doctor/types.go @@ -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) + } + } } diff --git a/internal/doctor/wisp_check.go b/internal/doctor/wisp_check.go index 7a1f44bc..925ee0a9 100644 --- a/internal/doctor/wisp_check.go +++ b/internal/doctor/wisp_check.go @@ -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, diff --git a/internal/doctor/workspace_check.go b/internal/doctor/workspace_check.go index b926e86c..76b3d7ec 100644 --- a/internal/doctor/workspace_check.go +++ b/internal/doctor/workspace_check.go @@ -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, }, } } diff --git a/internal/style/style.go b/internal/style/style.go index 6a676f03..e2fa2d50 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -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) } diff --git a/internal/tui/feed/styles.go b/internal/tui/feed/styles.go index 60b58401..866db20e 100644 --- a/internal/tui/feed/styles.go +++ b/internal/tui/feed/styles.go @@ -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 diff --git a/internal/ui/markdown.go b/internal/ui/markdown.go new file mode 100644 index 00000000..6caaa0da --- /dev/null +++ b/internal/ui/markdown.go @@ -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 +} diff --git a/internal/ui/pager.go b/internal/ui/pager.go new file mode 100644 index 00000000..2c138a5c --- /dev/null +++ b/internal/ui/pager.go @@ -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() +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 00000000..fbe00536 --- /dev/null +++ b/internal/ui/styles.go @@ -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) +} diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go new file mode 100644 index 00000000..afdf6e95 --- /dev/null +++ b/internal/ui/terminal.go @@ -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 +}