From 8f0ff3d1c0fdeb793c870e8c058e9176c3bba3fd Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 18:07:23 +0300 Subject: [PATCH 01/11] fix(deps): pin chzyer/readline to the version promptui supports readline v1.5.x changed prompt teardown (stdin wrapping, ioloop/Close semantics) in ways promptui v0.9.0 was never adapted to: every interactive picker leaked readers parked on stdin/channel sends, which could wedge the process after the prompt returned - terminal stopped echoing, Ctrl+C was swallowed, the command never exited (seen on 'openframe cluster status' with interactive selection). Pin readline to the commit promptui declares and exclude the broken v1.5.0/v1.5.1 so 'go get -u' cannot silently re-upgrade onto them. --- go.mod | 14 +++++++++++++- go.sum | 10 +++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 0b94e6fd..28200f62 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,14 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/chzyer/readline v1.5.1 // indirect + // readline is pinned to the commit promptui v0.9.0 declares. readline + // v1.5.x changes prompt teardown (stdin wrapping, ioloop/Close semantics) + // in ways promptui was never adapted to: after any interactive picker it + // leaks readers parked on stdin/channel sends, which can wedge the process + // after the prompt returns — the terminal stops echoing, Ctrl+C is + // swallowed, and the command never exits. The excludes below keep a future + // `go get -u` from silently re-upgrading onto the broken combination. + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.4 // indirect @@ -167,3 +174,8 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) + +exclude ( + github.com/chzyer/readline v1.5.0 + github.com/chzyer/readline v1.5.1 +) diff --git a/go.sum b/go.sum index cf2ed29b..393c9f3a 100644 --- a/go.sum +++ b/go.sum @@ -111,15 +111,12 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 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/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -528,7 +525,6 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From fa9180ff5a3b3ed3c8e56f4abc8177c0ac16c1ed Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 18:26:13 +0300 Subject: [PATCH 02/11] refactor(ui): migrate interactive prompts from promptui to huh promptui is unmaintained (last release Oct 2021, master == v0.9.0) and its readline dependency is the root of the interactive-prompt teardown bugs the previous commit pinned around. Replace it with charmbracelet/huh, which is actively maintained and built on bubbletea (already a dependency for the app-status TUI). - shared/ui: SelectFromList keeps its (int, string, error) contract; new SelectOption covers short fixed menus; new PromptInput replaces promptui.Prompt (defaults pre-fill the field, validation on submit). - Long lists scroll with a 'type / to filter' hint; filtering is fuzzy. - Prompt aborts (Ctrl+C/Esc) surface as ui.ErrPromptInterrupted; the error handler matches it structurally (errors.Is) so %w-wrapped aborts still print the friendly 'cancelled by user' notice instead of an error panel. - Wizard steps, cluster/chart mode selects and the typed-name delete confirmation now go through the shared prompt helpers; promptui and chzyer/readline (with its version excludes) drop out of go.mod entirely. Verified under a PTY harness: picker select/arrows//-filter, Ctrl+C abort, and the full create wizard in --dry-run all complete and exit cleanly. --- go.mod | 24 ++-- go.sum | 39 +++++-- internal/chart/ui/configuration/modes.go | 21 +--- internal/cluster/ui/prompts.go | 6 +- internal/cluster/ui/wizard.go | 21 +--- internal/cluster/ui/wizard_steps.go | 134 ++++------------------- internal/shared/errors/errors.go | 12 +- internal/shared/ui/prompts.go | 95 ++++++++++++---- internal/shared/ui/prompts_test.go | 27 +++-- 9 files changed, 171 insertions(+), 208 deletions(-) diff --git a/go.mod b/go.mod index 28200f62..2e28941a 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.26.0 require ( github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/elastic/go-sysinfo v1.15.5 github.com/go-git/go-git/v5 v5.19.2 github.com/hashicorp/terraform-exec v0.25.2 github.com/hashicorp/terraform-json v0.28.0 - github.com/manifoldco/promptui v0.9.0 github.com/pterm/pterm v0.12.83 github.com/sigstore/sigstore-go v1.3.0 github.com/spf13/cobra v1.10.2 @@ -24,6 +24,15 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect +) + require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.10 // indirect @@ -43,14 +52,6 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - // readline is pinned to the commit promptui v0.9.0 declares. readline - // v1.5.x changes prompt teardown (stdin wrapping, ioloop/Close semantics) - // in ways promptui was never adapted to: after any interactive picker it - // leaks readers parked on stdin/channel sends, which can wedge the process - // after the prompt returns — the terminal stops echoing, Ctrl+C is - // swallowed, and the command never exits. The excludes below keep a future - // `go get -u` from silently re-upgrading onto the broken combination. - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.4 // indirect @@ -174,8 +175,3 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) - -exclude ( - github.com/chzyer/readline v1.5.0 - github.com/chzyer/readline v1.5.1 -) diff --git a/go.sum b/go.sum index 393c9f3a..151674c4 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -59,6 +61,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= @@ -91,32 +95,46 @@ github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 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.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 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/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +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/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -130,6 +148,8 @@ github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6 github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= @@ -145,6 +165,8 @@ github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/go-sysinfo v1.15.5 h1:fCVUDmjHgljLUQCygherMnsRRJ9AkuAQIywTL7dEH28= github.com/elastic/go-sysinfo v1.15.5/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= @@ -333,8 +355,6 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -343,6 +363,8 @@ github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhV github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -517,7 +539,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/chart/ui/configuration/modes.go b/internal/chart/ui/configuration/modes.go index d3ac6833..0dbbd9d0 100644 --- a/internal/chart/ui/configuration/modes.go +++ b/internal/chart/ui/configuration/modes.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" - "github.com/manifoldco/promptui" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" ) @@ -14,21 +14,10 @@ func (w *ConfigurationWizard) showConfigurationModeSelection() (string, error) { pterm.Info.Printf("How would you like to configure your chart installation?\n") fmt.Println() - prompt := promptui.Select{ - Label: "Configuration Mode", - Items: []string{ - "Default configuration", - "Interactive configuration", - }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - idx, _, err := prompt.Run() + idx, _, err := sharedUI.SelectOption("Configuration Mode", []string{ + "Default configuration", + "Interactive configuration", + }) if err != nil { return "", err } diff --git a/internal/cluster/ui/prompts.go b/internal/cluster/ui/prompts.go index a3b4d721..9f999fcb 100644 --- a/internal/cluster/ui/prompts.go +++ b/internal/cluster/ui/prompts.go @@ -6,7 +6,6 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" - "github.com/manifoldco/promptui" "github.com/pterm/pterm" ) @@ -89,10 +88,7 @@ func CostHint(clusterType models.ClusterType) string { // deletes billed infrastructure irreversibly. func ConfirmTypedClusterName(name string) (bool, error) { pterm.Warning.Printf("Deleting a cloud cluster destroys all its cloud resources.\n") - prompt := promptui.Prompt{ - Label: fmt.Sprintf("Type the cluster name (%s) to confirm", name), - } - entered, err := prompt.Run() + entered, err := sharedUI.PromptInput(fmt.Sprintf("Type the cluster name (%s) to confirm", name), "", nil) if err != nil { return false, err } diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index 7265dbb9..e652face 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" - "github.com/manifoldco/promptui" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" ) @@ -189,21 +189,10 @@ func (h *ConfigurationHandler) showCreationModeSelection() (string, error) { pterm.Info.Printf("How would you like to create your cluster?\n") fmt.Println() - prompt := promptui.Select{ - Label: "Creation Mode", - Items: []string{ - "Default configuration", - "Interactive configuration", - }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - idx, _, err := prompt.Run() + idx, _, err := sharedUI.SelectOption("Creation Mode", []string{ + "Default configuration", + "Interactive configuration", + }) if err != nil { return "", err } diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index c632d29a..317c885a 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -9,7 +9,6 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" - "github.com/manifoldco/promptui" "github.com/pterm/pterm" ) @@ -23,45 +22,28 @@ func NewWizardSteps() *WizardSteps { // PromptClusterName prompts for cluster name with validation func (ws *WizardSteps) PromptClusterName(defaultName string) (string, error) { - prompt := promptui.Prompt{ - Label: "Cluster Name", - Default: defaultName, - Validate: func(input string) error { - // First check if empty - if err := sharedUI.ValidateNonEmpty("cluster name")(input); err != nil { - return err - } - // Then validate with domain rules - return models.ValidateClusterName(strings.TrimSpace(input)) - }, - } - - result, err := prompt.Run() + result, err := sharedUI.PromptInput("Cluster Name", defaultName, func(input string) error { + // First check if empty + if err := sharedUI.ValidateNonEmpty("cluster name")(input); err != nil { + return err + } + // Then validate with domain rules + return models.ValidateClusterName(strings.TrimSpace(input)) + }) if err != nil { return "", err } - return strings.TrimSpace(result), nil + return result, nil } // PromptClusterType prompts for cluster type selection. func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { - prompt := promptui.Select{ - Label: "Cluster Type", - Items: []string{ - "k3d (Recommended for local development)", - "gke (Google Kubernetes Engine — provisions cloud resources that cost money)", - "eks (AWS Elastic Kubernetes Service — provisions cloud resources that cost money)", - }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - idx, _, err := prompt.Run() + idx, _, err := sharedUI.SelectOption("Cluster Type", []string{ + "k3d (Recommended for local development)", + "gke (Google Kubernetes Engine — provisions cloud resources that cost money)", + "eks (AWS Elastic Kubernetes Service — provisions cloud resources that cost money)", + }) if err != nil { return "", err } @@ -108,15 +90,7 @@ func (ws *WizardSteps) PromptProject() (string, error) { } } } - prompt := promptui.Prompt{ - Label: "GCP Project", - Validate: sharedUI.ValidateNonEmpty("project"), - } - result, err := prompt.Run() - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil + return sharedUI.PromptInput("GCP Project", "", sharedUI.ValidateNonEmpty("project")) } // listSelectableProfiles fetches the user's named AWS profiles for the wizard @@ -159,14 +133,7 @@ func (ws *WizardSteps) PromptProfile() (string, error) { } } } - prompt := promptui.Prompt{ - Label: "AWS Profile (empty for default credentials)", - } - result, err := prompt.Run() - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil + return sharedUI.PromptInput("AWS Profile (empty for default credentials)", "", nil) } // listSelectableAWSRegions fetches the AWS regions enabled for the account for @@ -196,16 +163,7 @@ func (ws *WizardSteps) PromptAWSRegion(label, defaultRegion, profile string) (st } } } - prompt := promptui.Prompt{ - Label: label, - Default: defaultRegion, - Validate: sharedUI.ValidateNonEmpty("region"), - } - result, err := prompt.Run() - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil + return sharedUI.PromptInput(label, defaultRegion, sharedUI.ValidateNonEmpty("region")) } // listSelectableRegions fetches a GCP project's Compute regions for the wizard @@ -240,41 +198,18 @@ func (ws *WizardSteps) PromptRegion(label, defaultRegion, project string) (strin } } } - prompt := promptui.Prompt{ - Label: label, - Default: defaultRegion, - Validate: sharedUI.ValidateNonEmpty("region"), - } - result, err := prompt.Run() - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil + return sharedUI.PromptInput(label, defaultRegion, sharedUI.ValidateNonEmpty("region")) } // PromptMachineType prompts for the node instance type of a cloud cluster. func (ws *WizardSteps) PromptMachineType(defaultType string) (string, error) { - prompt := promptui.Prompt{ - Label: "Node Instance Type", - Default: defaultType, - Validate: sharedUI.ValidateNonEmpty("instance type"), - } - result, err := prompt.Run() - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil + return sharedUI.PromptInput("Node Instance Type", defaultType, sharedUI.ValidateNonEmpty("instance type")) } // PromptNodeCount prompts for number of worker nodes func (ws *WizardSteps) PromptNodeCount(defaultCount int) (int, error) { - prompt := promptui.Prompt{ - Label: "Number of Worker Nodes", - Default: strconv.Itoa(defaultCount), - Validate: sharedUI.ValidateIntRange(1, 10, "node count"), - } - - result, err := prompt.Run() + result, err := sharedUI.PromptInput("Number of Worker Nodes", strconv.Itoa(defaultCount), + sharedUI.ValidateIntRange(1, 10, "node count")) if err != nil { return 0, err } @@ -291,18 +226,7 @@ func (ws *WizardSteps) PromptK8sVersion() (string, error) { "v1.30.9-k3s1", } - prompt := promptui.Select{ - Label: "Kubernetes Version", - Items: versions, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - _, result, err := prompt.Run() + _, result, err := sharedUI.SelectOption("Kubernetes Version", versions) if err != nil { return "", err } @@ -348,18 +272,8 @@ func (ws *WizardSteps) ConfirmConfiguration(config models.ClusterConfig) (bool, } } - prompt := promptui.Select{ - Label: "Create cluster with this configuration?", - Items: []string{"Yes, create the cluster", "No, go back and modify"}, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - idx, _, err := prompt.Run() + idx, _, err := sharedUI.SelectOption("Create cluster with this configuration?", + []string{"Yes, create the cluster", "No, go back and modify"}) if err != nil { return false, err } diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index ac271d7f..aed75937 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -234,15 +234,17 @@ func panelRow(key, value string) { // cancelled: "). Crucially it does NOT match context.DeadlineExceeded, // so a real timeout is not mislabeled as a user cancellation — and it won't // false-match an unrelated error that merely mentions "context canceled" in its -// text. The remaining string checks cover promptui's Ctrl-C at an interactive -// prompt ("^C") and the exact "interrupted" some prompt sites return. "interrupted" -// is matched exactly (not as a substring) so an unrelated "connection was -// interrupted" network error is not mislabeled as a user cancellation. +// text. ui.ErrPromptInterrupted matches structurally too, so a %w-wrapped +// aborted prompt ("cluster selection failed: interrupted") still counts. The +// remaining string checks cover the exact "interrupted" some prompt sites +// return and legacy "^C" strings. "interrupted" is matched exactly (not as a +// substring) so an unrelated "connection was interrupted" network error is not +// mislabeled as a user cancellation. func isInterruption(err error) bool { if err == nil { return false } - if stderrors.Is(err, context.Canceled) { + if stderrors.Is(err, context.Canceled) || stderrors.Is(err, ui.ErrPromptInterrupted) { return true } msg := strings.ToLower(strings.TrimSpace(err.Error())) diff --git a/internal/shared/ui/prompts.go b/internal/shared/ui/prompts.go index b399e9e7..8023f74d 100644 --- a/internal/shared/ui/prompts.go +++ b/internal/shared/ui/prompts.go @@ -1,12 +1,13 @@ package ui import ( + stderrors "errors" "fmt" "os" "strconv" "strings" - "github.com/manifoldco/promptui" + "github.com/charmbracelet/huh" "github.com/pterm/pterm" "golang.org/x/term" ) @@ -25,6 +26,26 @@ func IsNonInteractive() bool { return !term.IsTerminal(int(os.Stdin.Fd())) } +// ErrPromptInterrupted is returned when the user aborts an interactive prompt +// with Ctrl+C (huh's quit binding; Esc only manages the list filter). Its text +// is exactly "interrupted": the shared error handler matches it structurally +// via errors.Is and by that string (see errors.isInterruption) and prints a +// friendly "cancelled" notice instead of a failure panel. +var ErrPromptInterrupted = stderrors.New("interrupted") + +// normalizePromptError maps huh's abort sentinel onto ErrPromptInterrupted so +// every prompt in the CLI reports user cancellation the same way. +func normalizePromptError(err error) error { + if stderrors.Is(err, huh.ErrUserAborted) { + return ErrPromptInterrupted + } + return err +} + +// selectPageSize caps how many rows a long select shows at once; longer lists +// scroll (and are two filter keystrokes away from any entry). +const selectPageSize = 10 + // confirm shows pterm's styled interactive y/N confirmation with the given // default. It is the single implementation behind the exported confirm helpers. func confirm(message string, defaultYes bool) (bool, error) { @@ -57,30 +78,60 @@ func ConfirmDeletion(resourceType, resourceName string) (bool, error) { return confirm(fmt.Sprintf("Are you sure you want to delete %s '%s'?", resourceType, pterm.Cyan(resourceName)), false) } -// selectTemplates is the shared styling for the interactive list selectors. -var selectTemplates = &promptui.SelectTemplates{ - Label: "{{ . }}?", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . | white }}", - Selected: "✓ {{ . | green }}", +// SelectFromList prompts the user to select from a list of options. Pressing +// "/" filters the list (fuzzy, case-insensitive), so a 30-cluster list is a +// few keystrokes away from the right entry; arrow keys navigate as before. +// The label is rendered with a trailing "?", matching the CLI's historical +// picker wording. +func SelectFromList(label string, items []string) (int, string, error) { + return runSelect(label+"?", items) } -// SelectFromList prompts the user to select from a list of options. Typing -// filters the list (case-insensitive substring over the whole row), so a -// 30-cluster list is two keystrokes away from the right entry; arrow keys -// still navigate as before. -func SelectFromList(label string, items []string) (int, string, error) { - prompt := promptui.Select{ - Label: label, - Items: items, - Templates: selectTemplates, - Size: 10, - Searcher: func(input string, index int) bool { - return strings.Contains(strings.ToLower(items[index]), strings.ToLower(input)) - }, - StartInSearchMode: true, +// SelectOption prompts the user to pick from a short fixed list (wizard steps, +// yes/no style choices). Same interaction as SelectFromList; the separate name +// keeps call sites explicit about intent. +func SelectOption(label string, items []string) (int, string, error) { + return runSelect(label, items) +} + +func runSelect(label string, items []string) (int, string, error) { + options := make([]huh.Option[int], len(items)) + for i, item := range items { + options[i] = huh.NewOption(item, i) + } + var idx int + sel := huh.NewSelect[int](). + Title(label). + Options(options...). + Value(&idx) + if len(items) > selectPageSize { + // Long lists scroll; tell the user the filter exists. + sel = sel. + Description("type / to filter"). + Height(selectPageSize + 3) // rows + title/description chrome + } + if err := sel.Run(); err != nil { + return 0, "", normalizePromptError(err) + } + return idx, items[idx], nil +} + +// PromptInput shows a single-line text prompt. defaultVal pre-fills the field +// (editable in place, so Enter accepts it as-is); validate, when non-nil, runs +// on each submission attempt and blocks until it passes. The result is +// whitespace-trimmed. +func PromptInput(label, defaultVal string, validate func(string) error) (string, error) { + value := defaultVal + in := huh.NewInput(). + Title(label). + Value(&value) + if validate != nil { + in = in.Validate(validate) + } + if err := in.Run(); err != nil { + return "", normalizePromptError(err) } - return prompt.Run() + return strings.TrimSpace(value), nil } // ValidateNonEmpty validates that input is not empty after trimming diff --git a/internal/shared/ui/prompts_test.go b/internal/shared/ui/prompts_test.go index 0d64769c..d965578b 100644 --- a/internal/shared/ui/prompts_test.go +++ b/internal/shared/ui/prompts_test.go @@ -6,17 +6,8 @@ import ( "github.com/stretchr/testify/assert" ) -// Note: These tests are limited because promptui interacts with stdin/stdout -// In a real test environment, you would mock the promptui package or use integration tests - -// TestSelectTemplates asserts on the shared selectTemplates the selectors use, -// so a styling change is a deliberate, reviewed edit. -func TestSelectTemplates(t *testing.T) { - assert.Equal(t, "{{ . }}?", selectTemplates.Label) - assert.Equal(t, "→ {{ . | cyan }}", selectTemplates.Active) // active row: arrow - assert.Equal(t, " {{ . | white }}", selectTemplates.Inactive) - assert.Equal(t, "✓ {{ . | green }}", selectTemplates.Selected) // chosen row: check -} +// Note: These tests are limited because the prompts interact with a live +// terminal; interactive behavior is covered by PTY-driven integration testing. // Test that the package exports the expected functions func TestPackageExports(t *testing.T) { @@ -27,6 +18,20 @@ func TestPackageExports(t *testing.T) { assert.NotNil(t, SelectFromList) }) + t.Run("SelectOption function exists", func(t *testing.T) { + assert.NotNil(t, SelectOption) + }) + + t.Run("PromptInput function exists", func(t *testing.T) { + assert.NotNil(t, PromptInput) + }) +} + +// TestErrPromptInterrupted pins the exact error text: the shared error handler +// (errors.isInterruption) matches the literal "interrupted" to print a friendly +// cancellation notice, so a wording change here silently breaks Ctrl+C UX. +func TestErrPromptInterrupted(t *testing.T) { + assert.Equal(t, "interrupted", ErrPromptInterrupted.Error()) } func TestValidateNonEmpty(t *testing.T) { From fa32c95d3082294bdd4231d7dadfc938c3662cb6 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 18:52:09 +0300 Subject: [PATCH 03/11] chore: apply go get -u ./... --- go.mod | 6 +++--- go.sum | 28 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 2e28941a..8a25fc72 100644 --- a/go.mod +++ b/go.mod @@ -27,8 +27,8 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect ) @@ -99,7 +99,7 @@ require ( github.com/google/go-containerregistry v0.21.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.6.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect diff --git a/go.sum b/go.sum index 151674c4..a2d832d4 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 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.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -127,8 +127,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 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/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -179,8 +179,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -291,8 +291,8 @@ github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -487,16 +487,16 @@ github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4 github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= From def533f565c3eea3d5baab5500e7f713088215e5 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 18:58:44 +0300 Subject: [PATCH 04/11] docs: update dependency table for the huh migration promptui is gone from the module graph; interactive prompts now run on charmbracelet/huh over bubbletea (which also powers the app status TUI). --- docs/reference/architecture/overview.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/architecture/overview.md b/docs/reference/architecture/overview.md index 4b3df421..c9d7343d 100644 --- a/docs/reference/architecture/overview.md +++ b/docs/reference/architecture/overview.md @@ -339,7 +339,8 @@ The project uses these key Go library dependencies: |---|---| | **github.com/spf13/cobra** | CLI framework for all commands, flags, help generation, and completion | | **github.com/pterm/pterm** | Rich terminal UI: spinners, tables, boxes, interactive prompts, color output | -| **github.com/manifoldco/promptui** | Interactive selection menus and text input prompts in wizards | +| **github.com/charmbracelet/huh** | Interactive selection menus (with `/` filtering) and text input prompts in wizards | +| **github.com/charmbracelet/bubbletea** | TUI runtime: powers huh prompts and the interactive `app status` view | | **k8s.io/client-go** | Native Kubernetes API access: kubeconfig loading, rest.Config, typed clients | | **k8s.io/apimachinery** | Kubernetes API types, GVR definitions for ArgoCD Application CRDs | | **k8s.io/apiextensions-apiserver** | CRD client for checking/managing ArgoCD CRD installation | From dd01589c9927fb9d7871305de0947de1e333fd8e Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 19:19:53 +0300 Subject: [PATCH 05/11] fix(ui): fail prompts fast in non-interactive sessions; correct select height Interactive prompts now refuse to start when the session is non-interactive (CI env or non-TTY stdin), matching the RequireConfirmation contract. On Unix CI this was already the effective behavior - bubbletea cannot open /dev/tty there - but Windows runners DO have a console, so a prompt reached in tests blocked on ReadConsole until the 10-minute job timeout (TestWizardSteps_ConfirmConfiguration on windows-amd64). Also size long-list selects correctly: huh's Height includes the title and description rows, so selectPageSize+2 - not +3 - shows exactly ten option rows (CodeRabbit flagged the mismatch; its literal suggestion of +0 would have shown eight). --- internal/shared/ui/prompts.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/shared/ui/prompts.go b/internal/shared/ui/prompts.go index 8023f74d..7fe1c62b 100644 --- a/internal/shared/ui/prompts.go +++ b/internal/shared/ui/prompts.go @@ -94,7 +94,22 @@ func SelectOption(label string, items []string) (int, string, error) { return runSelect(label, items) } +// requireInteractive fails fast when no one can answer a prompt (CI, piped +// stdin). Unix CI already fails fast — bubbletea cannot open /dev/tty there — +// but Windows runners DO have a console, where a prompt blocks reading it until +// the job times out (a wizard test hung a Windows runner for its full 10 +// minutes exactly this way). Same contract as RequireConfirmation. +func requireInteractive(label string) error { + if IsNonInteractive() { + return fmt.Errorf("prompt %q requires an interactive terminal", label) + } + return nil +} + func runSelect(label string, items []string) (int, string, error) { + if err := requireInteractive(label); err != nil { + return 0, "", err + } options := make([]huh.Option[int], len(items)) for i, item := range items { options[i] = huh.NewOption(item, i) @@ -105,10 +120,13 @@ func runSelect(label string, items []string) (int, string, error) { Options(options...). Value(&idx) if len(items) > selectPageSize { - // Long lists scroll; tell the user the filter exists. + // Long lists scroll; tell the user the filter exists. huh's Height + // includes the title and description rows (viewport = height - chrome), + // so +2 shows exactly selectPageSize option rows; while filtering the + // title row becomes the filter input — still one row, so this holds. sel = sel. Description("type / to filter"). - Height(selectPageSize + 3) // rows + title/description chrome + Height(selectPageSize + 2) } if err := sel.Run(); err != nil { return 0, "", normalizePromptError(err) @@ -121,6 +139,9 @@ func runSelect(label string, items []string) (int, string, error) { // on each submission attempt and blocks until it passes. The result is // whitespace-trimmed. func PromptInput(label, defaultVal string, validate func(string) error) (string, error) { + if err := requireInteractive(label); err != nil { + return "", err + } value := defaultVal in := huh.NewInput(). Title(label). From 5527e2324d847062246f50b0370fc9f813ef2317 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 19:39:07 +0300 Subject: [PATCH 06/11] feat(ui): restyle status prefixes per output mode; warnings as CI annotations One central hook (ApplyStatusPrefixTheme, wired into ApplyGlobalOutputFlags) re-themes the package-level pterm printers every call site prints through: - Interactive terminals drop the block badges for quiet glyph prefixes from the shared GlyphSet (dim bullet for info, triangle/cross/check for warning/error/success), inheriting its ASCII fallback. - Non-interactive output (CI, pipes, --plain) keeps word tags for grep-ability but lowercase, column-aligned and foreground-colored: info/warning/error/success/debug, no background blocks. - Inside GitHub Actions, warnings are teed into ::warning:: annotations so they surface on the PR; errors already got richer annotations from the shared error handler, which now also stops prepending a manual failure glyph the themed prefix carries. --silent still wins (theme runs after the flag handlers), NO_COLOR degrades to plain glyph/tag text via the existing color contract. --- internal/shared/errors/errors.go | 4 +- internal/shared/ui/ghactions.go | 9 ++ internal/shared/ui/silent.go | 2 + internal/shared/ui/status_theme.go | 109 ++++++++++++++++++++++++ internal/shared/ui/status_theme_test.go | 70 +++++++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 internal/shared/ui/status_theme.go create mode 100644 internal/shared/ui/status_theme_test.go diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index aed75937..169b4b4d 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -142,7 +142,9 @@ func (eh *ErrorHandler) handleGenericError(err error) { // In GitHub Actions the failure also becomes a job/PR annotation, so the // cause is visible without opening the 40-minute log. ui.ErrorAnnotation(headline, firstLine(cause)) - pterm.Error.Printf("%s %s\n", ui.Glyphs().Fail, headline) + // No manual failure glyph: the themed pterm.Error prefix carries it + // (✖ interactively, the "error" tag otherwise). + pterm.Error.Printf("%s\n", headline) if cause != "" { panelRow("cause", cause) } diff --git a/internal/shared/ui/ghactions.go b/internal/shared/ui/ghactions.go index bd043dd3..6e4ddcfc 100644 --- a/internal/shared/ui/ghactions.go +++ b/internal/shared/ui/ghactions.go @@ -38,6 +38,15 @@ func ErrorAnnotation(title, message string) { fmt.Printf("::error title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message)) } +// WarningAnnotation surfaces a warning as a ::warning:: annotation (shown in +// the job summary and on the PR when applicable). +func WarningAnnotation(title, message string) { + if !InGitHubActions() { + return + } + fmt.Printf("::warning title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message)) +} + // AppendStepSummary appends a markdown fragment to the job's Step Summary. // Best-effort: a missing or unwritable summary file is silently skipped. func AppendStepSummary(markdown string) { diff --git a/internal/shared/ui/silent.go b/internal/shared/ui/silent.go index 6550771c..946adbe6 100644 --- a/internal/shared/ui/silent.go +++ b/internal/shared/ui/silent.go @@ -29,6 +29,8 @@ func ApplyGlobalOutputFlags(cmd *cobra.Command) { // actions with cluster events, which needs a clock on every line. pterm.Debug = *pterm.Debug.WithWriter(NewTimestampWriter(os.Stdout)) } + // Last: the theme reads IsPlain/IsSilent, which the flags above just set. + ApplyStatusPrefixTheme() } // silent records whether --silent suppressed non-error output. Read by the logo diff --git a/internal/shared/ui/status_theme.go b/internal/shared/ui/status_theme.go new file mode 100644 index 00000000..e1e07ace --- /dev/null +++ b/internal/shared/ui/status_theme.go @@ -0,0 +1,109 @@ +package ui + +import ( + "io" + "os" + "regexp" + "strings" + + "github.com/pterm/pterm" +) + +// ApplyStatusPrefixTheme restyles the package-level pterm status printers that +// the whole CLI prints through (pterm.Info/Warning/Error/Success/Debug), so a +// single call here re-themes every call site. +// +// Interactive terminals get quiet glyph prefixes from the shared GlyphSet +// (which already degrades to ASCII under OPENFRAME_ASCII/TERM=dumb): a dim +// bullet for Info, ▲/✖/✔ for Warning/Error/Success — no background badges. +// Non-interactive output (CI, pipes, --plain) keeps word tags for +// grep-ability, but lowercase, column-aligned, and foreground-colored instead +// of the block badges; the words themselves stay info/warning/error/success. +// +// Inside GitHub Actions, Warning and Error additionally emit +// ::warning::/::error:: workflow commands so they surface as job/PR +// annotations instead of sinking into the log. +// +// It mutates pterm's package-level printers (like SetSilent), so it runs once, +// early, from ApplyGlobalOutputFlags — after the --plain/--silent flags are +// applied, because both the mode choice and the silent writers must win. +// pterm's With* helpers copy the printer struct, so composing with SetSilent's +// io.Discard writers and the --verbose timestamp writer is order-safe either +// way; running last just keeps the reasoning simple. +func ApplyStatusPrefixTheme() { + interactive := IsTerminal() && !IsPlain() + + type look struct { + printer *pterm.PrefixPrinter + glyph string // interactive prefix + word string // non-interactive prefix, column-aligned + style *pterm.Style + } + g := Glyphs() + looks := []look{ + {&pterm.Info, g.Bullet, "info ", pterm.NewStyle(pterm.FgGray)}, + {&pterm.Warning, g.Warn, "warning", pterm.NewStyle(pterm.FgLightYellow)}, + {&pterm.Error, g.Fail, "error ", pterm.NewStyle(pterm.FgLightRed)}, + {&pterm.Success, g.OK, "success", pterm.NewStyle(pterm.FgLightGreen)}, + {&pterm.Debug, g.Bullet, "debug ", pterm.NewStyle(pterm.FgGray)}, + } + for _, l := range looks { + text := l.word + if interactive { + text = l.glyph + } + *l.printer = *l.printer.WithPrefix(pterm.Prefix{Text: text, Style: l.style}) + } + // Info's non-interactive tag stays cyan (its message color family), not the + // gray the interactive bullet uses — in a colorless-context log the tag is + // the only severity signal. + if !interactive { + pterm.Info = *pterm.Info.WithPrefix(pterm.Prefix{Text: "info ", Style: pterm.NewStyle(pterm.FgLightCyan)}) + } + + // Surface warnings as Actions annotations. Errors are NOT teed here: the + // shared error handler already emits a richer ::error:: annotation + // (headline as title, cause as message) for every command failure, and a + // second writer-level annotation would duplicate it. The tee respects + // --silent, which discarded Warning's writer above — silent means errors + // only, annotations included. + if InGitHubActions() && !IsSilent() { + pterm.Warning = *pterm.Warning.WithWriter(newAnnotationWriter(pterm.Warning.Writer, "warning")) + } +} + +// annotationWriter tees a status printer's output into a GitHub Actions +// ::warning:: or ::error:: workflow command, one annotation per printed line, +// with ANSI styling and the printer's own prefix column stripped. +type annotationWriter struct { + inner io.Writer + level string +} + +func newAnnotationWriter(inner io.Writer, level string) io.Writer { + if inner == nil { + inner = os.Stdout + } + return &annotationWriter{inner: inner, level: level} +} + +var ansiSeq = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func (a *annotationWriter) Write(p []byte) (int, error) { + n, err := a.inner.Write(p) + msg := strings.TrimSpace(ansiSeq.ReplaceAllString(string(p), "")) + // Drop the prefix column ("warning"/"error "/▲/✖) — the annotation level + // already carries the severity. + for _, prefix := range []string{"warning", "error", Glyphs().Warn, Glyphs().Fail} { + msg = strings.TrimSpace(strings.TrimPrefix(msg, prefix)) + } + if msg != "" { + // Reuse the escaped emitters so runner parsing rules live in one place. + if a.level == "warning" { + WarningAnnotation("openframe", msg) + } else { + ErrorAnnotation("openframe", msg) + } + } + return n, err +} diff --git a/internal/shared/ui/status_theme_test.go b/internal/shared/ui/status_theme_test.go new file mode 100644 index 00000000..550dfe14 --- /dev/null +++ b/internal/shared/ui/status_theme_test.go @@ -0,0 +1,70 @@ +package ui + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" +) + +// restorePrinters snapshots the package-level pterm printers the theme +// mutates and restores them on cleanup, so theme tests don't leak styling +// into other tests in the package. +func restorePrinters(t *testing.T) { + t.Helper() + info, warn, errP, succ, debug := pterm.Info, pterm.Warning, pterm.Error, pterm.Success, pterm.Debug + t.Cleanup(func() { + pterm.Info, pterm.Warning, pterm.Error, pterm.Success, pterm.Debug = info, warn, errP, succ, debug + }) +} + +// Test env has no TTY on stdout, so the theme must pick the non-interactive +// look: lowercase, column-aligned word tags — the exact words are part of the +// log contract (grep -i WARNING must still hit). +func TestApplyStatusPrefixTheme_NonInteractive(t *testing.T) { + restorePrinters(t) + + ApplyStatusPrefixTheme() + + assert.Equal(t, "info ", pterm.Info.Prefix.Text) + assert.Equal(t, "warning", pterm.Warning.Prefix.Text) + assert.Equal(t, "error ", pterm.Error.Prefix.Text) + assert.Equal(t, "success", pterm.Success.Prefix.Text) + assert.Equal(t, "debug ", pterm.Debug.Prefix.Text) + + // All tags occupy one column so messages line up. + for _, text := range []string{ + pterm.Info.Prefix.Text, pterm.Warning.Prefix.Text, pterm.Error.Prefix.Text, + pterm.Success.Prefix.Text, pterm.Debug.Prefix.Text, + } { + assert.Len(t, text, 7) + } +} + +func TestAnnotationWriter_EmitsWorkflowCommand(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + // WarningAnnotation prints to os.Stdout; capture it. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + aw := newAnnotationWriter(io.Discard, "warning") + _, err := aw.Write([]byte("\x1b[93mwarning\x1b[0m disk almost full\n")) + assert.NoError(t, err) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + + got := sb.String() + assert.Contains(t, got, "::warning title=openframe::disk almost full") + // ANSI styling and the prefix column must not leak into the annotation. + assert.NotContains(t, got, "\x1b[") + assert.NotContains(t, got, "::warning title=openframe::warning") +} From f1a4b9f5aaef54dbf468b66e24b89b47abf035e2 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 22:33:43 +0300 Subject: [PATCH 07/11] fix(ui): dedupe CI warning annotations; align wait-loop status messages The Actions runner echoes every ::warning:: workflow command inline in the log, so the ArgoCD wait's recurring stuck-app summary produced a doubled, unstyled 'Warning:' line next to each themed one, every cycle - and GitHub keeps only 10 annotations per step, so repeats crowded out new warnings. The annotation tee now emits each distinct message once per process. Also drop the manual two-space indent from the wait loop's pending/stuck/ hint messages: it predates the aligned prefix tags, which now hold the column on their own - the indent just made rows ragged. Deliberately nested verbose detail (per-app drill-down) keeps its deeper indentation. --- internal/chart/providers/argocd/wait.go | 8 ++--- internal/shared/ui/status_theme.go | 39 ++++++++++++++++++------- internal/shared/ui/status_theme_test.go | 28 ++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index 695d683d..3cd467d2 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -623,7 +623,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn if config.Verbose { describeUnknownApps(unknownApps) } else if dash == nil { - pterm.Info.Println(" Re-run with --verbose for per-application detail.") + pterm.Info.Println("Re-run with --verbose for per-application detail.") } } @@ -634,7 +634,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn lastStuckSummary = time.Now() for _, app := range apps { if app.Health != ArgoCDHealthHealthy && app.Health != ArgoCDHealthMissing { - line := fmt.Sprintf(" Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) + line := fmt.Sprintf("Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) if app.Condition != "" { line += " condition=" + app.Condition } @@ -659,10 +659,10 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn pterm.Info.Printf("[%s] apps %d/%d ready (%+d since last check) · elapsed %s\n", time.Now().Format("15:04:05"), currentlyReady, totalApps, delta, elapsed.Round(time.Second)) if p := pendingSummary(apps, 6); p != "" { - pterm.Info.Printf(" pending: %s\n", p) + pterm.Info.Printf("pending: %s\n", p) } if config.Verbose && len(healthyApps) > 0 && len(healthyApps) <= 5 { - pterm.Debug.Printf(" Recently completed: %v\n", healthyApps) + pterm.Debug.Printf("Recently completed: %v\n", healthyApps) } } diff --git a/internal/shared/ui/status_theme.go b/internal/shared/ui/status_theme.go index e1e07ace..9ee40fe7 100644 --- a/internal/shared/ui/status_theme.go +++ b/internal/shared/ui/status_theme.go @@ -5,6 +5,7 @@ import ( "os" "regexp" "strings" + "sync" "github.com/pterm/pterm" ) @@ -73,18 +74,26 @@ func ApplyStatusPrefixTheme() { } // annotationWriter tees a status printer's output into a GitHub Actions -// ::warning:: or ::error:: workflow command, one annotation per printed line, -// with ANSI styling and the printer's own prefix column stripped. +// ::warning:: or ::error:: workflow command, with ANSI styling and the +// printer's own prefix column stripped. +// +// Each distinct message is annotated ONCE per process: the runner echoes every +// workflow command inline in the log ("Warning: …"), so re-annotating a +// repeating message (the ArgoCD wait re-prints its stuck-app summary every few +// minutes) would double a growing share of the log — and GitHub keeps only 10 +// annotations per step, so repeats also crowd out genuinely new warnings. type annotationWriter struct { inner io.Writer level string + mu sync.Mutex + seen map[string]struct{} } func newAnnotationWriter(inner io.Writer, level string) io.Writer { if inner == nil { inner = os.Stdout } - return &annotationWriter{inner: inner, level: level} + return &annotationWriter{inner: inner, level: level, seen: make(map[string]struct{})} } var ansiSeq = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -97,13 +106,23 @@ func (a *annotationWriter) Write(p []byte) (int, error) { for _, prefix := range []string{"warning", "error", Glyphs().Warn, Glyphs().Fail} { msg = strings.TrimSpace(strings.TrimPrefix(msg, prefix)) } - if msg != "" { - // Reuse the escaped emitters so runner parsing rules live in one place. - if a.level == "warning" { - WarningAnnotation("openframe", msg) - } else { - ErrorAnnotation("openframe", msg) - } + if msg == "" { + return n, err + } + a.mu.Lock() + _, dup := a.seen[msg] + if !dup { + a.seen[msg] = struct{}{} + } + a.mu.Unlock() + if dup { + return n, err + } + // Reuse the escaped emitters so runner parsing rules live in one place. + if a.level == "warning" { + WarningAnnotation("openframe", msg) + } else { + ErrorAnnotation("openframe", msg) } return n, err } diff --git a/internal/shared/ui/status_theme_test.go b/internal/shared/ui/status_theme_test.go index 550dfe14..b4c48a67 100644 --- a/internal/shared/ui/status_theme_test.go +++ b/internal/shared/ui/status_theme_test.go @@ -68,3 +68,31 @@ func TestAnnotationWriter_EmitsWorkflowCommand(t *testing.T) { assert.NotContains(t, got, "\x1b[") assert.NotContains(t, got, "::warning title=openframe::warning") } + +// A message printed repeatedly (the ArgoCD wait re-prints its stuck-app +// summary every few minutes) must annotate only once: the runner echoes every +// workflow command inline in the log, and GitHub keeps just 10 annotations per +// step, so repeats both double the log and crowd out new warnings. +func TestAnnotationWriter_DeduplicatesRepeats(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + aw := newAnnotationWriter(io.Discard, "warning") + for range 3 { + _, _ = aw.Write([]byte("warning Stuck app tenant: health=Degraded\n")) + } + _, _ = aw.Write([]byte("warning Stuck app mysql: health=Progressing\n")) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + + got := sb.String() + assert.Equal(t, 1, strings.Count(got, "Stuck app tenant")) + assert.Equal(t, 1, strings.Count(got, "Stuck app mysql")) +} From 4b717cc5af86e3052339583772b929c5ed227ab9 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 22:56:52 +0300 Subject: [PATCH 08/11] fix(ui): stop repeating status tags on list items and sub-lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verbose ArgoCD wait intro printed its explanation as debug lines with a manual ' - ' indent, and several list sites (orphan GKE disks / EBS volumes, verbose cluster list, out-of-sync apps, unreachable-cluster cause) stamped a status tag on every item. With the aligned prefix tags the manual indents read as ragged columns, and a tag per list item is noise. Intro lines lose the dash indent (each timestamped debug line stands on its own); list items under a tagged header now go through DefaultBasicText — the repo's existing header+items pattern (see ShowCleanupSummary) — and the unreachable-cluster cause row mirrors the shared error panel's dim-key style. Deliberately nested verbose drill-downs keep their indentation. --- cmd/app/status.go | 4 +++- cmd/app/upgrade.go | 3 ++- internal/chart/providers/argocd/wait.go | 6 +++--- internal/chart/services/cluster.go | 3 ++- internal/cluster/providers/eks/teardown.go | 4 +++- internal/cluster/providers/gke/teardown.go | 7 +++++-- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/cmd/app/status.go b/cmd/app/status.go index 5b5580b7..e6a42053 100644 --- a/cmd/app/status.go +++ b/cmd/app/status.go @@ -207,7 +207,9 @@ func renderStatus(rep appstatus.Report) { case !rep.Health.Reachable && rep.Total == 0: pterm.Error.Println("Cluster is not reachable. Is it running and is your kube-context correct?") if rep.HealthErr != nil { - pterm.Error.Printf(" cause: %v\n", rep.HealthErr) + // Same shape as the shared error panel's cause row — dim key, + // indented under the headline, no repeated error tag. + pterm.DefaultBasicText.Printf(" %s %v\n", pterm.FgGray.Sprintf("%-7s", "cause"), rep.HealthErr) } return case !rep.Health.Reachable: diff --git a/cmd/app/upgrade.go b/cmd/app/upgrade.go index 13155bec..294fdbe5 100644 --- a/cmd/app/upgrade.go +++ b/cmd/app/upgrade.go @@ -177,7 +177,8 @@ func previewOutOfSync(ctx context.Context, manager *argocd.Manager, verbose, pru for _, a := range apps { if a.Sync != argocd.ArgoCDSyncSynced { outOfSync++ - pterm.Info.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync) + // List item, not a standalone status: no repeated info tag. + pterm.DefaultBasicText.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync) } } if outOfSync == 0 { diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index 3cd467d2..b151642b 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -83,9 +83,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Show initial verbose info if enabled if config.Verbose { pterm.Info.Println("Starting ArgoCD application synchronization...") - pterm.Debug.Println(" - Waiting for applications to be created by app-of-apps") - pterm.Debug.Println(" - Each application must reach Healthy + Synced status") - pterm.Debug.Println(" - Progress updates every 10 seconds in verbose mode") + pterm.Debug.Println("Waiting for applications to be created by app-of-apps") + pterm.Debug.Println("Each application must reach Healthy + Synced status") + pterm.Debug.Println("Progress updates every 10 seconds in verbose mode") } // Display: the live dashboard (interactive terminal, non-verbose) shows diff --git a/internal/chart/services/cluster.go b/internal/chart/services/cluster.go index c4d49268..f9df3b90 100644 --- a/internal/chart/services/cluster.go +++ b/internal/chart/services/cluster.go @@ -47,7 +47,8 @@ func (c *ClusterSelector) SelectCluster(args []string, nonInteractive, verbose b if verbose { pterm.Info.Printf("Found %d clusters\n", len(clusters)) for _, cluster := range clusters { - pterm.Info.Printf(" - %s (%s)\n", cluster.Name, cluster.Status) + // Items under the Info header: no repeated info tag per line. + pterm.DefaultBasicText.Printf(" - %s (%s)\n", cluster.Name, cluster.Status) } } diff --git a/internal/cluster/providers/eks/teardown.go b/internal/cluster/providers/eks/teardown.go index 1e98104a..c9cbdb6f 100644 --- a/internal/cluster/providers/eks/teardown.go +++ b/internal/cluster/providers/eks/teardown.go @@ -271,7 +271,9 @@ func (p *Provider) sweepOrphanedVolumes(ctx context.Context, rec tfengine.Record func printOrphanList(volumes []string, name string) { pterm.Warning.Printf("%d EBS volume(s) tagged for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(volumes), name) for _, id := range volumes { - pterm.Warning.Printf(" - %s\n", id) + // Items under the Warning header go through DefaultBasicText so the + // warning tag isn't repeated per line (same pattern as cleanup lists). + pterm.DefaultBasicText.Printf(" - %s\n", id) } } diff --git a/internal/cluster/providers/gke/teardown.go b/internal/cluster/providers/gke/teardown.go index c1f791bf..b3cd8411 100644 --- a/internal/cluster/providers/gke/teardown.go +++ b/internal/cluster/providers/gke/teardown.go @@ -262,10 +262,13 @@ func printOrphanList(disks []disk, name string) { for _, d := range disks { // The location tells the operator WHICH cluster's disks these are — // GKE cluster names repeat across locations. + // List items go through DefaultBasicText (the repo's header+items + // pattern): the Warning header above carries the severity, and + // repeating the warning tag on every item just breaks the column. if loc := d.location(); loc != "" { - pterm.Warning.Printf(" - %s (%s)\n", d.name, loc) + pterm.DefaultBasicText.Printf(" - %s (%s)\n", d.name, loc) } else { - pterm.Warning.Printf(" - %s\n", d.name) + pterm.DefaultBasicText.Printf(" - %s\n", d.name) } } } From 3e8120faaeb795e98aeb2225164424d8cc8e51d4 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 23:01:55 +0300 Subject: [PATCH 09/11] fix(ui): route in-wait warnings through the status printers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitNote took a pre-styled string and printed it via DefaultBasicText, so the ArgoCD wait's warnings (repo-server not recovering, apps stuck in Unknown, stall-sync failures) looked like warnings but bypassed pterm.Warning's writer — where the CI ::warning:: annotation tee lives — and never surfaced as PR annotations. waitNote now takes the status printer plus the raw message: the non-dashboard path prints through that printer (annotation tee included); the dashboard path still pins the styled line under the live area and never annotates, but it only runs on interactive terminals — CI always takes the printer path. --- internal/chart/providers/argocd/wait.go | 38 ++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index b151642b..497a5bcb 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -114,12 +114,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // waitNote routes one-off in-wait announcements: pinned under the live // dashboard when it is active (a plain print would be visually swallowed // by the area redraw within 2s), ordinary silence-aware prints otherwise. - waitNote := func(styled string) { + // It takes the status printer plus the RAW message — not a pre-styled + // string — so the non-dashboard path prints through the printer's own + // writer: that is where the CI ::warning:: annotation tee lives, and a + // pre-styled DefaultBasicText print silently bypassed it. The dashboard + // path never annotates, but it only runs on interactive terminals — CI is + // always the printer path. + waitNote := func(p *pterm.PrefixPrinter, format string, args ...any) { if dash != nil { - dash.Note(styled) + dash.Note(p.Sprintf(format, args...)) return } - pterm.DefaultBasicText.Println(styled) + p.Printfln(format, args...) } // Function to stop spinner safely @@ -424,7 +430,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Reset consecutive failures on successful query if consecutiveFailures > 0 { - waitNote(pterm.Success.Sprint("Application queries restored")) + waitNote(&pterm.Success, "Application queries restored") consecutiveFailures = 0 } @@ -513,18 +519,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn if config.SyncStragglersOnStall { if !stragglerSyncTriggered { stragglerSyncTriggered = true - stallNote(pterm.Warning.Sprintf("No progress for %s; triggering sync of %d OutOfSync application(s): %v", - stallAfter.Round(time.Second), len(stragglers), stragglers)) + stallNote(&pterm.Warning, "No progress for %s; triggering sync of %d OutOfSync application(s): %v", + stallAfter.Round(time.Second), len(stragglers), stragglers) patched, failedCount, syncErr := m.syncApplicationsByName(localCtx, stragglers, false) if failedCount > 0 { - stallNote(pterm.Warning.Sprintf("Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr)) + stallNote(&pterm.Warning, "Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr) } } } else if !stallHintShown { stallHintShown = true - stallNote(pterm.Warning.Sprintf("No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v", - stallAfter.Round(time.Second), len(stragglers), stragglers)) - stallNote(pterm.Info.Sprint("They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.")) + stallNote(&pterm.Warning, "No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v", + stallAfter.Round(time.Second), len(stragglers), stragglers) + stallNote(&pterm.Info, "They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.") } } @@ -598,15 +604,15 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // out to its timeout. triggerRepoServerRecovery already // hard-refreshed app.Name; cover the rest. if refreshed := m.hardRefreshApplications(localCtx, appNames(unknownApps)); refreshed > 0 { - waitNote(pterm.Info.Sprintf("Hard-refreshed %d application(s) stuck in Unknown.", refreshed)) + waitNote(&pterm.Info, "Hard-refreshed %d application(s) stuck in Unknown.", refreshed) } } else { - waitNote(pterm.Warning.Sprint("Could not restart the ArgoCD repo-server; continuing to wait.")) + waitNote(&pterm.Warning, "Could not restart the ArgoCD repo-server; continuing to wait.") } } else if repoServerRecoveryAttempts == maxRepoServerRecoveryAttempts { repoServerRecoveryAttempts++ // prevent repeated attempts - waitNote(pterm.Warning.Sprintf("ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.", - maxRepoServerRecoveryAttempts)) + waitNote(&pterm.Warning, "ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.", + maxRepoServerRecoveryAttempts) } break // Only recover one app at a time } @@ -618,8 +624,8 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // (throttled); the per-application dump stays behind --verbose. if len(unknownApps) > 0 && elapsed > 5*time.Minute && time.Since(lastUnknownWarn) >= 5*time.Minute { lastUnknownWarn = time.Now() - waitNote(pterm.Warning.Sprintf("%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.", - len(unknownApps), elapsed.Round(time.Second))) + waitNote(&pterm.Warning, "%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.", + len(unknownApps), elapsed.Round(time.Second)) if config.Verbose { describeUnknownApps(unknownApps) } else if dash == nil { From f84b00b6ed4d953a27d4f759eed32b999f63f21f Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 23:06:44 +0300 Subject: [PATCH 10/11] fix(ui): strip exactly one severity marker from CI annotations Two defects in the annotation tee's prefix stripping: - Under NO_COLOR pterm's RawOutput mode renders the tag as 'warning: msg'; the strip list only knew the bare word, so a leading colon leaked into the annotation text. - Prefixes were stripped sequentially without a boundary, so after removing 'warning' the loop would also eat a message-initial 'error' ('warning errors found' -> 's found'). No current call site hits this, but it was latent. Strip exactly one marker (break on first match) and drop RawOutput's trailing colon after the matched prefix. --- internal/shared/ui/status_theme.go | 15 +++++++++--- internal/shared/ui/status_theme_test.go | 31 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/internal/shared/ui/status_theme.go b/internal/shared/ui/status_theme.go index 9ee40fe7..e30ce9fe 100644 --- a/internal/shared/ui/status_theme.go +++ b/internal/shared/ui/status_theme.go @@ -101,10 +101,19 @@ var ansiSeq = regexp.MustCompile(`\x1b\[[0-9;]*m`) func (a *annotationWriter) Write(p []byte) (int, error) { n, err := a.inner.Write(p) msg := strings.TrimSpace(ansiSeq.ReplaceAllString(string(p), "")) - // Drop the prefix column ("warning"/"error "/▲/✖) — the annotation level - // already carries the severity. + // Drop the printer's own severity marker — the annotation level already + // carries it. Exactly ONE marker is stripped (the printed line always + // starts with the printer's tag, and stripping sequentially would eat + // message text: "warning errors found" must not become "s found"). + // Under NO_COLOR pterm's RawOutput mode renders the tag as ": ", + // so a trailing colon after the matched prefix is dropped too. for _, prefix := range []string{"warning", "error", Glyphs().Warn, Glyphs().Fail} { - msg = strings.TrimSpace(strings.TrimPrefix(msg, prefix)) + rest, ok := strings.CutPrefix(msg, prefix) + if !ok { + continue + } + msg = strings.TrimSpace(strings.TrimPrefix(rest, ":")) + break } if msg == "" { return n, err diff --git a/internal/shared/ui/status_theme_test.go b/internal/shared/ui/status_theme_test.go index b4c48a67..c2eddd94 100644 --- a/internal/shared/ui/status_theme_test.go +++ b/internal/shared/ui/status_theme_test.go @@ -96,3 +96,34 @@ func TestAnnotationWriter_DeduplicatesRepeats(t *testing.T) { assert.Equal(t, 1, strings.Count(got, "Stuck app tenant")) assert.Equal(t, 1, strings.Count(got, "Stuck app mysql")) } + +// Exactly one severity marker is stripped, in both forms pterm prints it: +// the padded/styled tag ("warning msg") and RawOutput's "warning: msg" +// (NO_COLOR). Stripping must not cascade into message text. +func TestAnnotationWriter_PrefixStripping(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + cases := []struct { + line string + want string + }{ + // RawOutput (NO_COLOR) form: no leading colon may leak through. + {"warning: disk almost full\n", "::warning title=openframe::disk almost full\n"}, + // A message that itself starts with "error..." must survive intact. + {"warning errors found in 3 charts\n", "::warning title=openframe::errors found in 3 charts\n"}, + } + for _, tc := range cases { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + aw := newAnnotationWriter(io.Discard, "warning") + _, _ = aw.Write([]byte(tc.line)) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + assert.Equal(t, tc.want, sb.String(), "line %q", tc.line) + } +} From 3ae0cedd502d9a5de98b7b2a5d05301c9bf721c7 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 5 Aug 2026 23:23:19 +0300 Subject: [PATCH 11/11] feat(ui): timestamp all status printers under --verbose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --verbose exists to correlate CLI actions with cluster events, but only Debug lines carried the wall clock: info/warning rows in the same stream were clock-less and their message column sat 13 characters to the left of every debug row. All five status printers now share one timestamp writer (single line-start state), so a verbose run reads as one aligned timeline. The ArgoCD wait heartbeat keeps its own embedded [clock] ONLY when the writer is not stamping lines (plain CI logs, where it is the sole time reference) — under --verbose it drops it instead of printing two clocks on one row (ui.TimestampsActive). CI annotations are unaffected: the tee sees rendered text before the clock is prepended. --- internal/chart/providers/argocd/wait.go | 12 ++++++++-- internal/shared/ui/silent.go | 29 ++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index 497a5bcb..6542fafa 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -12,6 +12,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" "github.com/flamingo-stack/openframe-cli/internal/platform" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + sharedui "github.com/flamingo-stack/openframe-cli/internal/shared/ui" uispinner "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" "github.com/pterm/pterm" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -662,8 +663,15 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn lastProgressPrint = time.Now() delta := currentlyReady - heartbeatLastReady heartbeatLastReady = currentlyReady - pterm.Info.Printf("[%s] apps %d/%d ready (%+d since last check) · elapsed %s\n", - time.Now().Format("15:04:05"), currentlyReady, totalApps, delta, elapsed.Round(time.Second)) + beat := fmt.Sprintf("apps %d/%d ready (%+d since last check) · elapsed %s", + currentlyReady, totalApps, delta, elapsed.Round(time.Second)) + // The embedded clock serves plain (non-verbose) CI logs; under + // --verbose every status line is already timestamped by the + // writer, and a second clock on the same row is noise. + if !sharedui.TimestampsActive() { + beat = fmt.Sprintf("[%s] ", time.Now().Format("15:04:05")) + beat + } + pterm.Info.Println(beat) if p := pendingSummary(apps, 6); p != "" { pterm.Info.Printf("pending: %s\n", p) } diff --git a/internal/shared/ui/silent.go b/internal/shared/ui/silent.go index 946adbe6..0395f20c 100644 --- a/internal/shared/ui/silent.go +++ b/internal/shared/ui/silent.go @@ -25,11 +25,24 @@ func ApplyGlobalOutputFlags(cmd *cobra.Command) { } if v, _ := cmd.Flags().GetBool("verbose"); v && !silentFlag { pterm.EnableDebugMessages() - // Timestamped debug lines: --verbose exists to correlate the CLI's - // actions with cluster events, which needs a clock on every line. - pterm.Debug = *pterm.Debug.WithWriter(NewTimestampWriter(os.Stdout)) + // Timestamped status lines: --verbose exists to correlate the CLI's + // actions with cluster events, which needs a clock on every line. ALL + // status printers get the clock, not just Debug — info/warning lines + // are events on the same timeline, and a timestamp on only some rows + // leaves the message columns ragged. One shared writer keeps the + // line-start state consistent across printers. + timestamped = true + ts := NewTimestampWriter(os.Stdout) + for _, p := range []*pterm.PrefixPrinter{ + &pterm.Debug, &pterm.Info, &pterm.Warning, &pterm.Error, &pterm.Success, + } { + *p = *p.WithWriter(ts) + } } // Last: the theme reads IsPlain/IsSilent, which the flags above just set. + // Under --verbose it also composes with the timestamp writers above: the + // CI annotation tee wraps Warning's writer, so it sees the rendered text + // BEFORE the clock is prepended — annotations stay timestamp-free. ApplyStatusPrefixTheme() } @@ -37,6 +50,16 @@ func ApplyGlobalOutputFlags(cmd *cobra.Command) { // renderer so it can honor the flag. var silent bool +// timestamped records whether the status printers carry a per-line wall clock +// (--verbose). Long-running loops that embed their OWN clock in messages (the +// ArgoCD wait heartbeat does, for plain CI logs) consult this to avoid +// printing two clocks on one line. +var timestamped bool + +// TimestampsActive reports whether status-printer lines are already +// timestamped by the --verbose writer. +func TimestampsActive() bool { return timestamped } + // SetSilent honors the --silent flag's contract ("suppress all output except // errors"): it routes every non-error pterm printer to io.Discard and marks the // UI silent so the ASCII logo is skipped. Error and Fatal printers are left