From 7a63396bfc3dcf618c7e1101bba996b46abce593 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 21:49:27 +0800 Subject: [PATCH 1/6] refactor(cli): fix bugs, remove dead code, and simplify logic - Use errors.Is for ErrRefreshTokenExpired instead of == to handle wrapped errors - Fix ShowUserInfo being a no-op in BubbleTea TUI mode - Remove unused pollForTokenWithProgress function and update tests to use pollForTokenWithUpdates - Remove unused tui/components package (ErrorView, HelpView, InfoBox, ProgressBar, StepIndicator, Timer) - Remove dead tea.Model interface methods from UnifiedFlowModel - Eliminate redundant token expiry re-check in run() by setting flow inline - Remove redundant getConfig call by storing TokenStoreMode in AppConfig - Remove unnecessary 500ms time.Sleep in non-interactive error display Co-Authored-By: Claude Opus 4.6 (1M context) --- auth.go | 3 +- config.go | 26 ++--- device_flow.go | 67 ------------- go.mod | 3 - go.sum | 10 -- main.go | 13 +-- polling_test.go | 31 +++++- tui/bubbletea_manager.go | 8 ++ tui/components/components_test.go | 151 ------------------------------ tui/components/error_view.go | 145 ---------------------------- tui/components/help_view.go | 92 ------------------ tui/components/info_box.go | 69 -------------- tui/components/progress_bar.go | 67 ------------- tui/components/step_indicator.go | 77 --------------- tui/components/timer.go | 62 ------------ tui/simple_manager.go | 3 - tui/unified_flow_view.go | 98 +------------------ 17 files changed, 55 insertions(+), 870 deletions(-) delete mode 100644 tui/components/components_test.go delete mode 100644 tui/components/error_view.go delete mode 100644 tui/components/help_view.go delete mode 100644 tui/components/info_box.go delete mode 100644 tui/components/progress_bar.go delete mode 100644 tui/components/step_indicator.go delete mode 100644 tui/components/timer.go diff --git a/auth.go b/auth.go index f4695e1..3c1017c 100644 --- a/auth.go +++ b/auth.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -143,7 +144,7 @@ func makeAPICallWithAutoRefresh( newStorage, err := refreshAccessToken(ctx, cfg, storage.RefreshToken) if err != nil { - if err == ErrRefreshTokenExpired { + if errors.Is(err, ErrRefreshTokenExpired) { return ErrRefreshTokenExpired } return fmt.Errorf("refresh failed: %w", err) diff --git a/config.go b/config.go index 8855196..d017ed4 100644 --- a/config.go +++ b/config.go @@ -47,15 +47,16 @@ const ( // AppConfig holds all resolved configuration for the CLI application. type AppConfig struct { - ServerURL string - ClientID string - ClientSecret string - RedirectURI string - CallbackPort int - Scope string - ForceDevice bool - RetryClient *retry.Client - Store credstore.Store[credstore.Token] + ServerURL string + ClientID string + ClientSecret string + RedirectURI string + CallbackPort int + Scope string + ForceDevice bool + TokenStoreMode string // "auto", "file", or "keyring" + RetryClient *retry.Client + Store credstore.Store[credstore.Token] } // IsPublicClient returns true when no client secret is configured — @@ -93,8 +94,8 @@ func loadStoreConfig() *AppConfig { cfg := &AppConfig{} cfg.ClientID = getConfig(flagClientID, "CLIENT_ID", "") + cfg.TokenStoreMode = getConfig(flagTokenStore, "TOKEN_STORE", "auto") tokenFile := getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json") - tokenStoreMode := getConfig(flagTokenStore, "TOKEN_STORE", "auto") if cfg.ClientID == "" { fmt.Fprintln(os.Stderr, "Error: CLIENT_ID not set. Please provide it via:") @@ -106,7 +107,7 @@ func loadStoreConfig() *AppConfig { } var storeErr error - cfg.Store, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService) + cfg.Store, storeErr = newTokenStore(cfg.TokenStoreMode, tokenFile, defaultKeyringService) if storeErr != nil { fmt.Fprintln(os.Stderr, storeErr) os.Exit(1) @@ -179,8 +180,7 @@ func loadConfig() *AppConfig { panic(fmt.Sprintf("failed to create retry client: %v", err)) } - tokenStoreMode := getConfig(flagTokenStore, "TOKEN_STORE", "auto") - if tokenStoreMode == "auto" { + if cfg.TokenStoreMode == "auto" { if ss, ok := cfg.Store.(*credstore.SecureStore[credstore.Token]); ok && !ss.UseKeyring() { fmt.Fprintln( os.Stderr, diff --git a/device_flow.go b/device_flow.go index 35ecd6d..a6d63e4 100644 --- a/device_flow.go +++ b/device_flow.go @@ -140,73 +140,6 @@ func handleDevicePollError( } } -// pollForTokenWithProgress polls for a token while showing progress dots. -// Implements exponential backoff for slow_down errors per RFC 8628. -func pollForTokenWithProgress( - ctx context.Context, - cfg *AppConfig, - config *oauth2.Config, - deviceAuth *oauth2.DeviceAuthResponse, -) (*oauth2.Token, error) { - interval := deviceAuth.Interval - if interval == 0 { - interval = defaultPollInterval - } - - uiUpdateInterval := 2 * time.Second - pollInterval := time.Duration(interval) * time.Second - backoffMultiplier := 1.0 - - ticker := time.NewTicker(uiUpdateInterval) - defer ticker.Stop() - - pollTicker := time.NewTicker(pollInterval) - defer pollTicker.Stop() - - dotCount := 0 - lastUpdate := time.Now() - - for { - select { - case <-ctx.Done(): - fmt.Println() - return nil, ctx.Err() - - case <-pollTicker.C: - token, err := exchangeDeviceCode( - ctx, - cfg, - config.Endpoint.TokenURL, - config.ClientID, - deviceAuth.DeviceCode, - ) - if err != nil { - result := handleDevicePollError(err, &pollInterval, &backoffMultiplier, pollTicker) - switch result.action { - case pollContinue, pollBackoff: - continue - default: - fmt.Println() - return nil, result.err - } - } - - fmt.Println() - return token, nil - - case <-ticker.C: - if time.Since(lastUpdate) >= uiUpdateInterval { - fmt.Print(".") - dotCount++ - lastUpdate = time.Now() - if dotCount%50 == 0 { - fmt.Println() - } - } - } - } -} - // exchangeDeviceCode exchanges a device code for an access token. func exchangeDeviceCode( ctx context.Context, diff --git a/go.mod b/go.mod index 70d50c8..7fd9892 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,6 @@ module github.com/go-authgate/cli go 1.25.0 require ( - charm.land/bubbles/v2 v2.0.0 - charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 github.com/appleboy/go-httpretry v0.11.0 github.com/go-authgate/sdk-go v0.2.0 @@ -19,7 +17,6 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 91aef2e..cc8ea94 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,15 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/appleboy/go-httpretry v0.11.0 h1:LI2kFDBI9ghxIip9dJz3uRMEVEwSSOC1bjS177QCi+w= github.com/appleboy/go-httpretry v0.11.0/go.mod h1:96v1IO6wg1+S10iFbOM3O8rn2vkFw8+uH4mDPhGoz+E= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo= github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 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= diff --git a/main.go b/main.go index 51a2d84..442cbba 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int { ui.ShowHeader(clientMode, cfg.ServerURL, cfg.ClientID) var storage *credstore.Token + var flow string // Try to reuse or refresh existing tokens. existing, err := cfg.Store.Load(cfg.ClientID) @@ -86,6 +87,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int { if time.Now().Before(existing.ExpiresAt) { ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenStillValid}) storage = &existing + flow = "cached" } else { ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenExpired}) newStorage, err := refreshAccessToken(ctx, cfg, existing.RefreshToken) @@ -93,6 +95,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int { ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshFailed, Err: err}) } else { storage = newStorage + flow = "refreshed" ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshSuccess}) } } @@ -101,14 +104,6 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int { } // No valid tokens — select and run the appropriate flow. - var flow string - if storage != nil && err == nil { - if time.Now().Before(existing.ExpiresAt) { - flow = "cached" - } else { - flow = "refreshed" - } - } if storage == nil { storage, flow, err = authenticate(ctx, ui, cfg) if err != nil { @@ -140,7 +135,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int { // Demonstrate auto-refresh on 401. ui.ShowStatus(tui.StatusUpdate{Event: tui.EventAutoRefreshDemo}) if err := makeAPICallWithAutoRefresh(ctx, cfg, storage, ui); err != nil { - if err == ErrRefreshTokenExpired { + if errors.Is(err, ErrRefreshTokenExpired) { ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshTokenExpired}) storage, _, err = authenticate(ctx, ui, cfg) if err != nil { diff --git a/polling_test.go b/polling_test.go index a99648e..4cdc75c 100644 --- a/polling_test.go +++ b/polling_test.go @@ -10,11 +10,20 @@ import ( "testing" "time" + "github.com/go-authgate/cli/tui" "golang.org/x/oauth2" ) const testAccessToken = "test-access-token" +// drainUpdates consumes FlowUpdate messages in the background so the producer never blocks. +func drainUpdates(ch <-chan tui.FlowUpdate) { + go func() { + for range ch { + } + }() +} + func TestPollForToken_AuthorizationPending(t *testing.T) { attempts := atomic.Int32{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -58,7 +67,10 @@ func TestPollForToken_AuthorizationPending(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - token, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth) + updates := make(chan tui.FlowUpdate, 100) + drainUpdates(updates) + + token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err != nil { t.Fatalf("expected success, got error: %v", err) } @@ -128,7 +140,10 @@ func TestPollForToken_SlowDown(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - token, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth) + updates := make(chan tui.FlowUpdate, 100) + drainUpdates(updates) + + token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err != nil { t.Fatalf("expected success, got error: %v", err) } @@ -140,7 +155,7 @@ func TestPollForToken_SlowDown(t *testing.T) { } } -// pollForTokenErrorTest is a shared helper for tests that expect pollForTokenWithProgress +// pollForTokenErrorTest is a shared helper for tests that expect pollForTokenWithUpdates // to return a specific error when the server responds with a terminal OAuth error code. func pollForTokenErrorTest(t *testing.T, errCode, errDesc, expectedMsg string) { t.Helper() @@ -170,7 +185,10 @@ func pollForTokenErrorTest(t *testing.T, errCode, errDesc, expectedMsg string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth) + updates := make(chan tui.FlowUpdate, 100) + drainUpdates(updates) + + _, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err == nil { t.Fatal("expected error, got nil") } @@ -222,7 +240,10 @@ func TestPollForToken_ContextTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth) + updates := make(chan tui.FlowUpdate, 100) + drainUpdates(updates) + + _, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err == nil { t.Fatal("expected context timeout error, got nil") } diff --git a/tui/bubbletea_manager.go b/tui/bubbletea_manager.go index 199c1a1..efaaf64 100644 --- a/tui/bubbletea_manager.go +++ b/tui/bubbletea_manager.go @@ -393,7 +393,15 @@ func (m *BubbleTeaManager) ShowStatus(update StatusUpdate) { func (m *BubbleTeaManager) ShowUserInfo(success bool, info string) { if m.renderer == nil { m.simple.ShowUserInfo(success, info) + return } + m.addStep("OIDC UserInfo") + if success { + m.updateStep("OIDC UserInfo", StepCompleted, "Profile retrieved") + } else { + m.updateStep("OIDC UserInfo", StepFailed, info) + } + m.refreshDisplay() } // getTerminalSize returns the width and height of the terminal. diff --git a/tui/components/components_test.go b/tui/components/components_test.go deleted file mode 100644 index fe43704..0000000 --- a/tui/components/components_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package components - -import ( - "strings" - "testing" - "time" -) - -func TestStepIndicator(t *testing.T) { - indicator := NewStepIndicator(3, []string{"Step One", "Step Two", "Step Three"}) - - if indicator.CurrentStep != 0 { - t.Errorf("Expected initial step to be 0, got %d", indicator.CurrentStep) - } - - if indicator.TotalSteps != 3 { - t.Errorf("Expected 3 total steps, got %d", indicator.TotalSteps) - } - - // Test setting current step - indicator.SetCurrentStep(2) - if indicator.CurrentStep != 2 { - t.Errorf("Expected current step to be 2, got %d", indicator.CurrentStep) - } - - // Test view renders - view := indicator.View() - if view == "" { - t.Error("View should not be empty") - } -} - -func TestTimer(t *testing.T) { - t.Run("Elapsed Timer", func(t *testing.T) { - timer := NewElapsedTimer() - - if timer.isCountdown { - t.Error("Elapsed timer should not be countdown") - } - - // Update with elapsed time - timer.Update(5 * time.Second) - - view := timer.View() - if !strings.Contains(view, "Elapsed") { - t.Error("Elapsed timer view should contain 'Elapsed'") - } - }) - - t.Run("Countdown Timer", func(t *testing.T) { - timer := NewCountdownTimer(2 * time.Minute) - - if !timer.isCountdown { - t.Error("Countdown timer should be countdown") - } - - if timer.totalDuration != 2*time.Minute { - t.Errorf("Expected total duration 2m, got %v", timer.totalDuration) - } - - // Update with elapsed time - timer.Update(30 * time.Second) - - view := timer.View() - if !strings.Contains(view, "remaining") { - t.Error("Countdown timer view should contain 'remaining'") - } - }) -} - -func TestProgressBar(t *testing.T) { - bar := NewProgressBar(40) - - // Test view renders with initial progress - view := bar.View() - if view == "" { - t.Error("View should not be empty") - } - - // Test setting progress - verify it doesn't panic - bar.SetProgress(0.5) - view = bar.View() - if view == "" { - t.Error("View should not be empty after setting progress") - } - - // Test clamping to 0-1 range - verify it doesn't panic - bar.SetProgress(-0.1) - view = bar.View() - if view == "" { - t.Error("View should not be empty after setting negative progress") - } - - bar.SetProgress(1.5) - view = bar.View() - if view == "" { - t.Error("View should not be empty after setting progress > 1.0") - } - - // Test width change - bar.SetWidth(60) - view = bar.View() - if view == "" { - t.Error("View should not be empty after changing width") - } -} - -func TestInfoBox(t *testing.T) { - box := NewInfoBox("Test Title", 60) - - if box.Title != "Test Title" { - t.Errorf("Expected title 'Test Title', got '%s'", box.Title) - } - - if box.Width != 60 { - t.Errorf("Expected width 60, got %d", box.Width) - } - - if len(box.Content) != 0 { - t.Errorf("Expected empty content, got %d lines", len(box.Content)) - } - - // Test adding lines - box.AddLine("Line 1") - box.AddLine("Line 2") - - if len(box.Content) != 2 { - t.Errorf("Expected 2 lines, got %d", len(box.Content)) - } - - // Test setting content - box.SetContent([]string{"New 1", "New 2", "New 3"}) - if len(box.Content) != 3 { - t.Errorf("Expected 3 lines, got %d", len(box.Content)) - } - - // Test clear - box.Clear() - if len(box.Content) != 0 { - t.Errorf("Expected empty content after clear, got %d lines", len(box.Content)) - } - - // Test view renders - box.AddLine("Test content") - view := box.View() - if view == "" { - t.Error("View should not be empty") - } -} - -// TestFormatPercentage removed - formatPercentage is now internal to bubbles/progress diff --git a/tui/components/error_view.go b/tui/components/error_view.go deleted file mode 100644 index 3d93286..0000000 --- a/tui/components/error_view.go +++ /dev/null @@ -1,145 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" -) - -// ErrorView displays rich error information with recommendations -type ErrorView struct { - Title string - Message string - Details string - Recommendations []string - Retryable bool -} - -// NewErrorView creates a new error view -func NewErrorView(title, message string) *ErrorView { - return &ErrorView{ - Title: title, - Message: message, - } -} - -// WithDetails adds details to the error view -func (e *ErrorView) WithDetails(details string) *ErrorView { - e.Details = details - return e -} - -// WithRecommendations adds recommendations to the error view -func (e *ErrorView) WithRecommendations(recs ...string) *ErrorView { - e.Recommendations = recs - return e -} - -// WithRetryable marks the error as retryable -func (e *ErrorView) WithRetryable(retryable bool) *ErrorView { - e.Retryable = retryable - return e -} - -// View renders the error view -func (e *ErrorView) View() string { - var b strings.Builder - - // Title bar with error icon - more prominent - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#D32F2F")). - Padding(0, 2). - Width(68). - Align(lipgloss.Center) - - b.WriteString(titleStyle.Render("✗ " + strings.ToUpper(e.Title) + " ✗")) - b.WriteString("\n\n") - - // Error message - larger and more prominent - messageStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5252")). - Bold(true). - MarginLeft(2). - MarginRight(2) - - // Add emoji for visual impact - icon := "❌" - b.WriteString(messageStyle.Render(icon + " " + e.Message)) - b.WriteString("\n") - - // Details (if provided) - if e.Details != "" { - b.WriteString("\n") - - // Details section with subtle box - detailsBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#666666")). - Padding(0, 1). - MarginLeft(2). - MarginRight(2). - Width(64) - - detailsStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#999999")). - Italic(true) - - detailsContent := detailsStyle.Render("ℹ️ " + e.Details) - b.WriteString(detailsBoxStyle.Render(detailsContent)) - b.WriteString("\n") - } - - // Recommendations - if len(e.Recommendations) > 0 { - b.WriteString("\n") - - // Recommendations header with icon - recTitleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#64B5F6")). - Background(lipgloss.Color("#1E3A5F")). - Padding(0, 1). - MarginLeft(2) - - b.WriteString(recTitleStyle.Render("💡 SUGGESTED ACTIONS")) - b.WriteString("\n\n") - - recStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#E0E0E0")). - MarginLeft(2) - - for i, rec := range e.Recommendations { - bullet := fmt.Sprintf(" %d. ", i+1) - bulletStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#64B5F6")). - Bold(true) - - b.WriteString(bulletStyle.Render(bullet)) - b.WriteString(recStyle.Render(rec)) - b.WriteString("\n") - } - } - - // Retry hint - if e.Retryable { - b.WriteString("\n") - retryStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFA726")). - Italic(true) - - b.WriteString(retryStyle.Render("↻ You can try authenticating again")) - b.WriteString("\n") - } - - // Wrap in box - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#D32F2F")). - Padding(1, 2). - Width(70) - - return boxStyle.Render(b.String()) -} diff --git a/tui/components/help_view.go b/tui/components/help_view.go deleted file mode 100644 index 93da4a9..0000000 --- a/tui/components/help_view.go +++ /dev/null @@ -1,92 +0,0 @@ -package components - -import ( - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" -) - -// KeyMap defines the key bindings for the TUI -type KeyMap struct { - Cancel key.Binding - Help key.Binding - Quit key.Binding - Up key.Binding - Down key.Binding -} - -// ShortHelp returns a short help text for the key map -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Cancel, k.Help} -} - -// FullHelp returns the full help text for the key map -func (k KeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Cancel, k.Help}, - {k.Up, k.Down, k.Quit}, - } -} - -// DefaultKeyMap returns the default key bindings -func DefaultKeyMap() KeyMap { - return KeyMap{ - Cancel: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "cancel authentication"), - ), - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "quit"), - ), - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "scroll up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "scroll down"), - ), - } -} - -// HelpView wraps the bubbles help component -type HelpView struct { - help help.Model - keys KeyMap - width int - hidden bool -} - -// NewHelpView creates a new help view -func NewHelpView() *HelpView { - return &HelpView{ - help: help.New(), - keys: DefaultKeyMap(), - width: 80, - hidden: true, - } -} - -// SetWidth sets the width of the help view -func (h *HelpView) SetWidth(width int) { - h.width = width - h.help.SetWidth(width) -} - -// Toggle toggles the help visibility -func (h *HelpView) Toggle() { - h.hidden = !h.hidden - h.help.ShowAll = !h.hidden -} - -// View renders the help view -func (h *HelpView) View() string { - if h.hidden { - return h.help.ShortHelpView(h.keys.ShortHelp()) - } - return h.help.View(h.keys) -} diff --git a/tui/components/info_box.go b/tui/components/info_box.go deleted file mode 100644 index 8eac6a1..0000000 --- a/tui/components/info_box.go +++ /dev/null @@ -1,69 +0,0 @@ -package components - -import ( - "strings" - - "charm.land/lipgloss/v2" -) - -// InfoBox displays information in a bordered box -type InfoBox struct { - Title string - Content []string - Width int -} - -// NewInfoBox creates a new info box -func NewInfoBox(title string, width int) *InfoBox { - return &InfoBox{ - Title: title, - Content: []string{}, - Width: width, - } -} - -// SetContent sets the content lines -func (i *InfoBox) SetContent(lines []string) { - i.Content = lines -} - -// AddLine adds a line to the content -func (i *InfoBox) AddLine(line string) { - i.Content = append(i.Content, line) -} - -// Clear clears all content -func (i *InfoBox) Clear() { - i.Content = []string{} -} - -// View renders the info box -func (i *InfoBox) View() string { - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color("#7D56F4")). - Padding(1, 2). - Width(i.Width) - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7D56F4")) - - contentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) - - var content strings.Builder - if i.Title != "" { - content.WriteString(titleStyle.Render(i.Title)) - content.WriteString("\n\n") - } - - for idx, line := range i.Content { - content.WriteString(contentStyle.Render(line)) - if idx < len(i.Content)-1 { - content.WriteString("\n") - } - } - - return borderStyle.Render(content.String()) -} diff --git a/tui/components/progress_bar.go b/tui/components/progress_bar.go deleted file mode 100644 index 8524c02..0000000 --- a/tui/components/progress_bar.go +++ /dev/null @@ -1,67 +0,0 @@ -package components - -import ( - "image/color" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// ProgressBar displays a visual progress bar using Bubbles v2 progress component -type ProgressBar struct { - model progress.Model -} - -// NewProgressBar creates a new progress bar with gradient fill -func NewProgressBar(width int) *ProgressBar { - p := progress.New( - progress.WithDefaultBlend(), // Purple haze to neon pink gradient - progress.WithWidth(width), - progress.WithoutPercentage(), // We'll show percentage separately - ) - return &ProgressBar{ - model: p, - } -} - -// SetProgress updates the progress (0.0 to 1.0) -func (p *ProgressBar) SetProgress(progress float64) { - if progress < 0 { - progress = 0 - } - if progress > 1 { - progress = 1 - } - p.model.SetPercent(progress) -} - -// Update handles progress bar messages (for animations) -func (p *ProgressBar) Update(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - p.model, cmd = p.model.Update(msg) - return cmd -} - -// View renders the progress bar -func (p *ProgressBar) View() string { - bar := p.model.View() - - // Add percentage display - percentage := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")). - Render(p.model.ViewAs(p.model.Percent())) - - return bar + " " + percentage -} - -// SetWidth updates the progress bar width -func (p *ProgressBar) SetWidth(width int) { - p.model.SetWidth(width) -} - -// SetColors sets custom colors for the progress bar -func (p *ProgressBar) SetColors(fullColor, emptyColor color.Color) { - p.model.FullColor = fullColor - p.model.EmptyColor = emptyColor -} diff --git a/tui/components/step_indicator.go b/tui/components/step_indicator.go deleted file mode 100644 index c3d5aa6..0000000 --- a/tui/components/step_indicator.go +++ /dev/null @@ -1,77 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" -) - -// StepIndicator displays progress through a multi-step process -type StepIndicator struct { - CurrentStep int - TotalSteps int - StepNames []string -} - -// NewStepIndicator creates a new step indicator -func NewStepIndicator(totalSteps int, stepNames []string) *StepIndicator { - return &StepIndicator{ - CurrentStep: 0, - TotalSteps: totalSteps, - StepNames: stepNames, - } -} - -// SetCurrentStep updates the current step -func (s *StepIndicator) SetCurrentStep(step int) { - s.CurrentStep = step -} - -// View renders the step indicator -func (s *StepIndicator) View() string { - var parts []string - - completedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00C853")). - Bold(true) - - currentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true) - - pendingStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")) - - for i := 1; i <= s.TotalSteps; i++ { - var symbol string - var style lipgloss.Style - var label string - - if i < len(s.StepNames)+1 { - label = s.StepNames[i-1] - } else { - label = fmt.Sprintf("Step %d", i) - } - - switch { - case i < s.CurrentStep: - // Completed step - symbol = "●" - style = completedStyle - case i == s.CurrentStep: - // Current step - symbol = "●" - style = currentStyle - default: - // Pending step - symbol = "○" - style = pendingStyle - } - - stepText := fmt.Sprintf("%s Step %d/%d: %s", symbol, i, s.TotalSteps, label) - parts = append(parts, style.Render(stepText)) - } - - return strings.Join(parts, "\n") -} diff --git a/tui/components/timer.go b/tui/components/timer.go deleted file mode 100644 index dc22684..0000000 --- a/tui/components/timer.go +++ /dev/null @@ -1,62 +0,0 @@ -package components - -import ( - "fmt" - "time" - - "charm.land/lipgloss/v2" -) - -// Timer displays elapsed time or countdown -type Timer struct { - startTime time.Time - elapsed time.Duration - isCountdown bool - totalDuration time.Duration -} - -// NewElapsedTimer creates a new elapsed time timer -func NewElapsedTimer() *Timer { - return &Timer{ - startTime: time.Now(), - isCountdown: false, - } -} - -// NewCountdownTimer creates a new countdown timer -func NewCountdownTimer(duration time.Duration) *Timer { - return &Timer{ - startTime: time.Now(), - totalDuration: duration, - isCountdown: true, - } -} - -// Update updates the timer with the current elapsed time -func (t *Timer) Update(elapsed time.Duration) { - t.elapsed = elapsed -} - -// View renders the timer -func (t *Timer) View() string { - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#5A8FE8")). - Bold(true) - - if t.isCountdown { - remaining := max(t.totalDuration-t.elapsed, 0) - return style.Render(fmt.Sprintf("Time remaining: %s / %s", - formatDuration(remaining), - formatDuration(t.totalDuration))) - } - - return style.Render("Elapsed: " + formatDuration(t.elapsed)) -} - -// formatDuration formats a duration in MM:SS format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - minutes := int(d.Minutes()) - seconds := int(d.Seconds()) % 60 - return fmt.Sprintf("%d:%02d", minutes, seconds) -} diff --git a/tui/simple_manager.go b/tui/simple_manager.go index 9dc241f..7cd0d30 100644 --- a/tui/simple_manager.go +++ b/tui/simple_manager.go @@ -331,8 +331,5 @@ func (m *SimpleManager) displayError(errMsg string) { fmt.Print("Press Enter to continue...") _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') fmt.Println() - } else { - // In non-interactive mode (tests, CI), just pause briefly - time.Sleep(500 * time.Millisecond) } } diff --git a/tui/unified_flow_view.go b/tui/unified_flow_view.go index 2e098aa..0221598 100644 --- a/tui/unified_flow_view.go +++ b/tui/unified_flow_view.go @@ -1,11 +1,6 @@ package tui -import ( - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) +// No external imports needed — rendering is handled by FlowRenderer via shared_render.go. // FlowStepStatus represents the status of a flow step type FlowStepStatus int @@ -42,8 +37,7 @@ type UnifiedFlowModel struct { tokenStorage *TokenStorage showToken bool - // Control - done bool + // Layout width int height int } @@ -89,91 +83,3 @@ func (m *UnifiedFlowModel) SetTokenInfo(storage *TokenStorage) { m.tokenStorage = storage m.showToken = true } - -// SetDone marks the flow as complete -func (m *UnifiedFlowModel) SetDone() { - m.done = true -} - -// Init initializes the model -func (m *UnifiedFlowModel) Init() tea.Cmd { - return nil -} - -// Update handles messages -func (m *UnifiedFlowModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - key := msg.Key() - // Allow quit with 'q' or Ctrl+C when done - if m.done && (key.Code == 'q' || (key.Code == 'c' && key.Mod == tea.ModCtrl)) { - return m, tea.Quit - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - } - - return m, nil -} - -// View renders the unified flow view -func (m *UnifiedFlowModel) View() tea.View { - var b strings.Builder - - // Header box - b.WriteString(m.renderHeader()) - b.WriteString("\n\n") - - // Warnings - if len(m.warnings) > 0 { - b.WriteString(m.renderWarnings()) - b.WriteString("\n\n") - } - - // Steps - if len(m.steps) > 0 { - b.WriteString(m.renderSteps()) - b.WriteString("\n\n") - } - - // Token info - if m.showToken && m.tokenStorage != nil { - b.WriteString(m.renderTokenInfo()) - b.WriteString("\n") - } - - // Help text when done - if m.done { - helpStyle := lipgloss.NewStyle(). - Foreground(colorSubtle). - Italic(true) - b.WriteString("\n") - b.WriteString(helpStyle.Render("Press q to quit")) - b.WriteString("\n") - } - - return tea.NewView(b.String()) -} - -// renderHeader renders the header box -func (m *UnifiedFlowModel) renderHeader() string { - return renderHeaderBox(m.flowType, m.clientMode, m.serverURL, m.width) -} - -// renderWarnings renders warning messages -func (m *UnifiedFlowModel) renderWarnings() string { - return renderWarningList(m.warnings) -} - -// renderSteps renders the step checklist -func (m *UnifiedFlowModel) renderSteps() string { - return renderStepList(m.steps, "●") -} - -// renderTokenInfo renders the token information box -func (m *UnifiedFlowModel) renderTokenInfo() string { - return renderTokenInfoBox(m.tokenStorage, m.width) -} From 0309d8b41e18c6b058f2b131e6c2d802b1055e4c Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 22:03:43 +0800 Subject: [PATCH 2/6] fix(test): prevent goroutine leak in drainUpdates helper Close the updates channel via t.Cleanup so the background goroutine exits when the test finishes instead of leaking until process exit. Co-Authored-By: Claude Opus 4.6 (1M context) --- polling_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/polling_test.go b/polling_test.go index 4cdc75c..ce644d0 100644 --- a/polling_test.go +++ b/polling_test.go @@ -16,8 +16,12 @@ import ( const testAccessToken = "test-access-token" -// drainUpdates consumes FlowUpdate messages in the background so the producer never blocks. -func drainUpdates(ch <-chan tui.FlowUpdate) { +// drainUpdates consumes FlowUpdate messages in the background so the producer +// never blocks. It registers a t.Cleanup that closes the channel, which +// terminates the goroutine when the test finishes. +func drainUpdates(t *testing.T, ch chan tui.FlowUpdate) { + t.Helper() + t.Cleanup(func() { close(ch) }) go func() { for range ch { } @@ -68,7 +72,7 @@ func TestPollForToken_AuthorizationPending(t *testing.T) { defer cancel() updates := make(chan tui.FlowUpdate, 100) - drainUpdates(updates) + drainUpdates(t, updates) token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err != nil { @@ -141,7 +145,7 @@ func TestPollForToken_SlowDown(t *testing.T) { defer cancel() updates := make(chan tui.FlowUpdate, 100) - drainUpdates(updates) + drainUpdates(t, updates) token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err != nil { @@ -186,7 +190,7 @@ func pollForTokenErrorTest(t *testing.T, errCode, errDesc, expectedMsg string) { defer cancel() updates := make(chan tui.FlowUpdate, 100) - drainUpdates(updates) + drainUpdates(t, updates) _, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err == nil { @@ -241,7 +245,7 @@ func TestPollForToken_ContextTimeout(t *testing.T) { defer cancel() updates := make(chan tui.FlowUpdate, 100) - drainUpdates(updates) + drainUpdates(t, updates) _, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates) if err == nil { From 99f2973afcaf818c4bca1d37bf8766cfb6785302 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 22:10:59 +0800 Subject: [PATCH 3/6] fix(tui): handle terminal resize and prevent duplicate steps - Re-check terminal size in FlowRenderer.UpdateDisplay() so layout adapts to live resizes after removing the Bubble Tea WindowSizeMsg handler - Make addStep idempotent to prevent duplicate UI steps on repeated calls Co-Authored-By: Claude Opus 4.6 (1M context) --- tui/bubbletea_manager.go | 6 +++++- tui/flow_renderer.go | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tui/bubbletea_manager.go b/tui/bubbletea_manager.go index efaaf64..a1576c3 100644 --- a/tui/bubbletea_manager.go +++ b/tui/bubbletea_manager.go @@ -55,9 +55,13 @@ func (m *BubbleTeaManager) ShowFlowSelection(method string) { m.refreshDisplay() } -// Helper methods for managing steps +// Helper methods for managing steps. +// addStep is idempotent — calling it with the same name twice is a no-op. func (m *BubbleTeaManager) addStep(name string) { if m.renderer != nil { + if _, exists := m.stepMap[name]; exists { + return + } idx := m.renderer.AddStep(name) m.stepMap[name] = idx } diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go index 03c809f..eef746a 100644 --- a/tui/flow_renderer.go +++ b/tui/flow_renderer.go @@ -108,6 +108,13 @@ func (r *FlowRenderer) RenderHeader() { // UpdateDisplay updates the display with current state func (r *FlowRenderer) UpdateDisplay() { + // Re-check terminal size so layout adapts to live resizes. + if w, h := getTerminalSize(); w != r.model.width || h != r.model.height { + r.model.width = w + r.model.height = h + r.contentDirty = true + } + // If only spinner changed (not content), do a minimal update if !r.contentDirty && r.hasInProgressStep { r.updateSpinnerOnly() From 333cf9be021d078c70d0d816fe66e9da536490a1 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 22:19:41 +0800 Subject: [PATCH 4/6] perf(tui): throttle terminal resize check and use done channel in test helper - Move getTerminalSize() call after spinner-only fast path so it only runs on content-dirty redraws, avoiding ~10 ioctl syscalls/sec - Replace channel-close with done channel in drainUpdates to avoid closing a producer-owned channel Co-Authored-By: Claude Opus 4.6 (1M context) --- polling_test.go | 16 +++++++++++----- tui/flow_renderer.go | 13 ++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/polling_test.go b/polling_test.go index ce644d0..4f3fa50 100644 --- a/polling_test.go +++ b/polling_test.go @@ -17,13 +17,19 @@ import ( const testAccessToken = "test-access-token" // drainUpdates consumes FlowUpdate messages in the background so the producer -// never blocks. It registers a t.Cleanup that closes the channel, which -// terminates the goroutine when the test finishes. -func drainUpdates(t *testing.T, ch chan tui.FlowUpdate) { +// never blocks. It signals the goroutine to stop via a done channel on +// cleanup, without closing the producer-owned updates channel. +func drainUpdates(t *testing.T, ch <-chan tui.FlowUpdate) { t.Helper() - t.Cleanup(func() { close(ch) }) + done := make(chan struct{}) + t.Cleanup(func() { close(done) }) go func() { - for range ch { + for { + select { + case <-ch: + case <-done: + return + } } }() } diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go index eef746a..0628fde 100644 --- a/tui/flow_renderer.go +++ b/tui/flow_renderer.go @@ -108,13 +108,6 @@ func (r *FlowRenderer) RenderHeader() { // UpdateDisplay updates the display with current state func (r *FlowRenderer) UpdateDisplay() { - // Re-check terminal size so layout adapts to live resizes. - if w, h := getTerminalSize(); w != r.model.width || h != r.model.height { - r.model.width = w - r.model.height = h - r.contentDirty = true - } - // If only spinner changed (not content), do a minimal update if !r.contentDirty && r.hasInProgressStep { r.updateSpinnerOnly() @@ -126,6 +119,12 @@ func (r *FlowRenderer) UpdateDisplay() { return } + // Re-check terminal size on content redraws so layout adapts to resizes. + if w, h := getTerminalSize(); w != r.model.width || h != r.model.height { + r.model.width = w + r.model.height = h + } + // Build current content var b strings.Builder From 6d97fd55b9ca51d0649c14e3fc824ce19ebabcd8 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 22:32:51 +0800 Subject: [PATCH 5/6] fix(tui): re-render header on terminal resize and check size consistently Extract checkResize() helper that detects size changes and promotes to a full redraw. On resize, re-render the header so its width stays in sync with the rest of the content. The check now runs on every UpdateDisplay call, including spinner-only updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- tui/flow_renderer.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go index 0628fde..e39bc33 100644 --- a/tui/flow_renderer.go +++ b/tui/flow_renderer.go @@ -106,8 +106,24 @@ func (r *FlowRenderer) RenderHeader() { fmt.Print(b.String()) } +// checkResize detects terminal size changes and marks content dirty so the +// next full redraw uses the new dimensions. Returns true when the size changed. +func (r *FlowRenderer) checkResize() bool { + w, h := getTerminalSize() + if w == r.model.width && h == r.model.height { + return false + } + r.model.width = w + r.model.height = h + r.contentDirty = true + return true +} + // UpdateDisplay updates the display with current state func (r *FlowRenderer) UpdateDisplay() { + // Detect terminal resize — promotes to a full redraw when size changed. + resized := r.checkResize() + // If only spinner changed (not content), do a minimal update if !r.contentDirty && r.hasInProgressStep { r.updateSpinnerOnly() @@ -119,10 +135,9 @@ func (r *FlowRenderer) UpdateDisplay() { return } - // Re-check terminal size on content redraws so layout adapts to resizes. - if w, h := getTerminalSize(); w != r.model.width || h != r.model.height { - r.model.width = w - r.model.height = h + // Re-render header when the terminal was resized so widths stay in sync. + if resized { + r.RenderHeader() } // Build current content From b684ca1e1746955faf741c957d2111a292b3b590 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 23 Mar 2026 22:40:19 +0800 Subject: [PATCH 6/6] perf(tui): throttle resize syscall and compute header lines dynamically - Throttle getTerminalSize() to at most once per 500ms to avoid unnecessary ioctl syscalls on the 100ms spinner tick - Track actual header line count from RenderHeader output instead of using a hardcoded formula, so cursor placement stays correct after terminal resizes that change line wrapping Co-Authored-By: Claude Opus 4.6 (1M context) --- tui/flow_renderer.go | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go index e39bc33..6e3cdab 100644 --- a/tui/flow_renderer.go +++ b/tui/flow_renderer.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" "charm.land/lipgloss/v2" ) @@ -12,14 +13,16 @@ type FlowRenderer struct { model *UnifiedFlowModel spinnerFrame int spinnerChars []rune - lastContent string // Track last rendered content to avoid unnecessary redraws - contentDirty bool // Flag to indicate if content needs redraw - inProgressStepIdx int // Index of the in-progress step for spinner updates - hasInProgressStep bool // Whether there's a step in progress - deviceUserCode string // Device code to display - deviceVerificationURI string // Device verification URL - deviceVerificationURIComplete string // Complete URL with user code - showDeviceCode bool // Whether to show device code info + lastContent string // Track last rendered content to avoid unnecessary redraws + contentDirty bool // Flag to indicate if content needs redraw + inProgressStepIdx int // Index of the in-progress step for spinner updates + hasInProgressStep bool // Whether there's a step in progress + deviceUserCode string // Device code to display + deviceVerificationURI string // Device verification URL + deviceVerificationURIComplete string // Complete URL with user code + showDeviceCode bool // Whether to show device code info + lastResizeCheck time.Time // Throttles getTerminalSize syscalls + headerLines int // Number of lines occupied by the header } // NewFlowRenderer creates a new flow renderer @@ -103,12 +106,24 @@ func (r *FlowRenderer) RenderHeader() { b.WriteString("\n") } - fmt.Print(b.String()) + header := b.String() + r.headerLines = strings.Count(header, "\n") + 1 + fmt.Print(header) } +// resizeCheckInterval limits how often we call getTerminalSize (a syscall). +const resizeCheckInterval = 500 * time.Millisecond + // checkResize detects terminal size changes and marks content dirty so the -// next full redraw uses the new dimensions. Returns true when the size changed. +// next full redraw uses the new dimensions. The syscall is throttled to at +// most once per resizeCheckInterval. Returns true when the size changed. func (r *FlowRenderer) checkResize() bool { + now := time.Now() + if now.Sub(r.lastResizeCheck) < resizeCheckInterval { + return false + } + r.lastResizeCheck = now + w, h := getTerminalSize() if w == r.model.width && h == r.model.height { return false @@ -165,7 +180,7 @@ func (r *FlowRenderer) UpdateDisplay() { currentContent := b.String() // Move cursor to after header - headerLines := 5 + len(r.model.warnings) + headerLines := r.headerLines // Move to position after header fmt.Printf("\033[%d;0H", headerLines) @@ -186,7 +201,7 @@ func (r *FlowRenderer) updateSpinnerOnly() { } // Calculate the line number for the in-progress step - headerLines := 5 + len(r.model.warnings) + headerLines := r.headerLines stepLine := headerLines + r.inProgressStepIdx // Render the spinner character