Conversation
appleboy
commented
Feb 28, 2026
- Upgrade from Charmbracelet BubbleTea/Bubbles/Lipgloss v1 to v2 and refactor imports accordingly
- Introduce modular TUI configuration structure with support for themes, animations, and QR code toggling
- Implement a unified flow renderer for rich terminal display without a full Bubble Tea program
- Add a dedicated help view component for dynamic keybinding/help display in the TUI
- Replace ad-hoc progress bar with Bubbles v2 progress component and add animation support
- Replace static help text with toggleable, context-aware help panel
- Enhance error presentation with a new ErrorView component including rich suggestions
- Mask access and refresh tokens throughout all views and outputs for improved security
- Add QR code rendering utility for device flow URLs with Unicode and ASCII fallback
- Provide flexible flow step tracking and visualization in BubbleTeaManager
- Improve device code flow UX with clearer device info presentation and spinner animation
- Refactor token info display to show masked tokens, file location, and additional metadata
- Remove formatPercentage test since logic moved into Bubbles progress implementation
- Add platform detection and terminal size utilities for responsive layouts
- Introduce comprehensive tests for progress bar and info box view rendering
- Improve token expiration and refresh flow feedback with step visualization
- Add unified flow step and status struct for better state representation and display
- Add granular update methods for BubbleTeaManager to handle each flow scenario interactively
- Integrate new components and views for improved modularity and future extensibility
- Upgrade from Charmbracelet BubbleTea/Bubbles/Lipgloss v1 to v2 and refactor imports accordingly - Introduce modular TUI configuration structure with support for themes, animations, and QR code toggling - Implement a unified flow renderer for rich terminal display without a full Bubble Tea program - Add a dedicated help view component for dynamic keybinding/help display in the TUI - Replace ad-hoc progress bar with Bubbles v2 progress component and add animation support - Replace static help text with toggleable, context-aware help panel - Enhance error presentation with a new ErrorView component including rich suggestions - Mask access and refresh tokens throughout all views and outputs for improved security - Add QR code rendering utility for device flow URLs with Unicode and ASCII fallback - Provide flexible flow step tracking and visualization in BubbleTeaManager - Improve device code flow UX with clearer device info presentation and spinner animation - Refactor token info display to show masked tokens, file location, and additional metadata - Remove formatPercentage test since logic moved into Bubbles progress implementation - Add platform detection and terminal size utilities for responsive layouts - Introduce comprehensive tests for progress bar and info box view rendering - Improve token expiration and refresh flow feedback with step visualization - Add unified flow step and status struct for better state representation and display - Add granular update methods for BubbleTeaManager to handle each flow scenario interactively - Integrate new components and views for improved modularity and future extensibility Signed-off-by: appleboy <appleboy.tw@gmail.com>
There was a problem hiding this comment.
Pull request overview
Refactors the CLI’s terminal UI to the Charmbracelet v2 ecosystem and introduces a unified, modular rendering approach for OAuth flows with improved theming, help, progress, error display, and token masking.
Changes:
- Upgrades Bubble Tea/Bubbles/Lipgloss imports to v2 (
charm.land/*/v2) and refactors models/views accordingly. - Adds a unified flow renderer (
FlowRenderer+UnifiedFlowModel) to render rich flow output without running a full Bubble Tea program. - Introduces new modular TUI components (HelpView, ErrorView, QR code rendering, v2 progress bar) and masks tokens across views/outputs.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tui/unified_flow_view.go | New unified flow model + rendering helpers (header/warnings/steps/token info). |
| tui/token_view.go | New scrollable token details viewport with masked token display. |
| tui/styles.go | Switches to lipgloss v2 import; adds maskTokenPreview. |
| tui/simple_manager.go | Masks tokens in simple output; adds token-file notice; adds maskTokenSimple. |
| tui/flow_renderer.go | New non-BubbleTea renderer with step spinner animation and device/token boxes. |
| tui/device_view.go | Uses lipgloss v2; switches to HelpView and enhanced ErrorView; masks token preview. |
| tui/device_model.go | Migrates to Bubble Tea v2 message types; adds HelpView/config plumbing; View() now returns tea.View. |
| tui/config.go | New env-driven TUI config (animations, QR codes, theme preset, unicode). |
| tui/components/timer.go | Updates lipgloss import to v2. |
| tui/components/step_indicator.go | Updates lipgloss import to v2. |
| tui/components/qrcode.go | New Unicode + ASCII QR code rendering utility. |
| tui/components/progress_bar.go | Replaces custom bar with Bubbles v2 progress component + animation support. |
| tui/components/info_box.go | Updates lipgloss import to v2. |
| tui/components/help_view.go | New help/keybinding component wrapping Bubbles help/key. |
| tui/components/error_view.go | New rich error presentation with suggestions/retry hint. |
| tui/components/components_test.go | Updates tests to accommodate new progress bar implementation; removes percentage formatter test. |
| tui/bubbletea_manager.go | Refactors manager to use FlowRenderer with step tracking; adds terminal sizing helper. |
| tui/browser_view.go | Uses lipgloss v2; switches to HelpView and enhanced ErrorView; masks token preview. |
| tui/browser_model.go | Migrates to Bubble Tea v2 message types; integrates HelpView; forwards msgs to progress bar. |
| go.mod | Switches to charm.land/*/v2 deps; adds go-qrcode and related indirect deps. |
| go.sum | Updates dependency checksums for new v2 and QR code dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (p *ProgressBar) View() string { | ||
| filledStyle := lipgloss.NewStyle(). | ||
| Background(lipgloss.Color("#7D56F4")). | ||
| Foreground(lipgloss.Color("#FFFFFF")) | ||
|
|
||
| emptyStyle := lipgloss.NewStyle(). | ||
| Background(lipgloss.Color("#3E3E3E")). | ||
| Foreground(lipgloss.Color("#888888")) | ||
|
|
||
| filled := int(float64(p.Width) * p.Progress) | ||
| empty := p.Width - filled | ||
|
|
||
| bar := filledStyle.Render(strings.Repeat("▓", filled)) + | ||
| emptyStyle.Render(strings.Repeat("░", empty)) | ||
| bar := p.model.View() | ||
|
|
||
| // Add percentage display | ||
| percentage := lipgloss.NewStyle(). | ||
| Foreground(lipgloss.Color("#888888")). | ||
| Render(" " + formatPercentage(p.Progress)) | ||
| Render(p.model.ViewAs(p.model.Percent())) | ||
|
|
||
| return bar + percentage | ||
| return bar + " " + percentage | ||
| } | ||
|
|
||
| // ViewCompact renders a compact version of the progress bar | ||
| func (p *ProgressBar) ViewCompact() string { | ||
| return formatPercentage(p.Progress) | ||
| return p.model.ViewAs(p.model.Percent()) | ||
| } |
There was a problem hiding this comment.
The percentage display is rendered using p.model.ViewAs(p.model.Percent()), which (per the progress API naming) renders another progress bar, not a percentage string. This likely results in the bar being printed twice and ViewCompact() returning a bar instead of something like 50%. Consider formatting the percent explicitly from p.model.Percent() (or using the progress component’s percentage formatting option if available) and keep ViewAs only for rendering the bar at a given percent in tests/debugging.
| 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"), | ||
| ), | ||
| } |
There was a problem hiding this comment.
Quit is advertised as q/esc in the default key map, but the BrowserModel/DeviceModel Update handlers currently only quit on Ctrl+C (and TokenViewModel quits on q). This makes the on-screen help misleading. Either add q/esc handling to the relevant models or update the key map/help text to match the actual supported keys per view.
| // formatDuration formats duration in a human-readable way | ||
| func formatDuration(d time.Duration) string { | ||
| d = d.Round(time.Second) | ||
| h := int(d.Hours()) | ||
| m := int(d.Minutes()) % 60 | ||
| s := int(d.Seconds()) % 60 | ||
|
|
||
| if h > 0 { | ||
| return fmt.Sprintf("%dh%dm%ds", h, m, s) | ||
| } | ||
| if m > 0 { | ||
| return fmt.Sprintf("%dm%ds", m, s) | ||
| } | ||
| return fmt.Sprintf("%ds", s) | ||
| } |
There was a problem hiding this comment.
formatDuration doesn’t handle negative durations. When a token is already expired, time.Until(ExpiresAt) will be negative and this will render values like -3s, which is confusing compared to the existing formatDurationHuman logic that returns expired. Consider clamping at 0 or returning an "expired" label when d < 0.
| // Move to position after header | ||
| fmt.Printf("\033[%d;0H", headerLines) |
There was a problem hiding this comment.
ANSI cursor positioning sequences are 1-based. Using \033[%d;0H relies on terminals treating column 0 as 1, which isn’t guaranteed and can cause cursor placement glitches. Consider using column 1 (e.g., ;1H) consistently for portability.
| // Move to position after header | |
| fmt.Printf("\033[%d;0H", headerLines) | |
| // Move to position after header (column indices are 1-based) | |
| fmt.Printf("\033[%d;1H", headerLines) |
| // maskTokenPreview masks token for preview display (shows first 8 and last 4 chars) | ||
| func maskTokenPreview(token string) string { | ||
| if len(token) <= 16 { | ||
| if len(token) <= 8 { | ||
| return token[:min(len(token), 4)] + "..." | ||
| } | ||
| return token[:4] + "..." + token[len(token)-4:] | ||
| } | ||
| return token[:8] + "..." + token[len(token)-4:] | ||
| } |
There was a problem hiding this comment.
Token masking logic is now implemented in multiple places (maskTokenPreview, maskToken in token_view.go, and maskTokenSimple in simple_manager.go) with identical behavior. This duplication makes it easy for masking behavior to diverge over time. Consider consolidating on a single helper in the tui package and reusing it everywhere tokens are displayed.
| if len(token) <= 16 { | ||
| if len(token) <= 8 { | ||
| return token[:min(len(token), 4)] + "..." | ||
| } | ||
| return token[:4] + "..." + token[len(token)-4:] | ||
| } | ||
| return token[:8] + "..." + token[len(token)-4:] |
There was a problem hiding this comment.
ShowTokenInfo duplicates token file path resolution and token masking logic that already exists elsewhere in the tui package (e.g., getTokenFilePath and maskTokenPreview). Reusing the shared helpers would reduce drift and keep token masking/file-path behavior consistent across simple and interactive outputs.
| if len(token) <= 16 { | |
| if len(token) <= 8 { | |
| return token[:min(len(token), 4)] + "..." | |
| } | |
| return token[:4] + "..." + token[len(token)-4:] | |
| } | |
| return token[:8] + "..." + token[len(token)-4:] | |
| // Delegate to shared masking helper to keep behavior consistent | |
| return maskTokenPreview(token) |
| func TestProgressBar(t *testing.T) { | ||
| bar := NewProgressBar(40) | ||
|
|
||
| if bar.Progress != 0.0 { | ||
| t.Errorf("Expected initial progress to be 0.0, got %f", bar.Progress) | ||
| } | ||
|
|
||
| if bar.Width != 40 { | ||
| t.Errorf("Expected width to be 40, got %d", bar.Width) | ||
| // Test view renders with initial progress | ||
| view := bar.View() | ||
| if view == "" { | ||
| t.Error("View should not be empty") | ||
| } | ||
|
|
||
| // Test setting progress | ||
| // Test setting progress - verify it doesn't panic | ||
| bar.SetProgress(0.5) | ||
| if bar.Progress != 0.5 { | ||
| t.Errorf("Expected progress to be 0.5, got %f", bar.Progress) | ||
| view = bar.View() | ||
| if view == "" { | ||
| t.Error("View should not be empty after setting progress") | ||
| } | ||
|
|
||
| // Test clamping to 0-1 range | ||
| // Test clamping to 0-1 range - verify it doesn't panic | ||
| bar.SetProgress(-0.1) | ||
| if bar.Progress != 0.0 { | ||
| t.Errorf("Expected progress to be clamped to 0.0, got %f", bar.Progress) | ||
| view = bar.View() | ||
| if view == "" { | ||
| t.Error("View should not be empty after setting negative progress") | ||
| } | ||
|
|
||
| bar.SetProgress(1.5) | ||
| if bar.Progress != 1.0 { | ||
| t.Errorf("Expected progress to be clamped to 1.0, got %f", bar.Progress) | ||
| view = bar.View() | ||
| if view == "" { | ||
| t.Error("View should not be empty after setting progress > 1.0") | ||
| } | ||
|
|
||
| // Test view renders | ||
| view := bar.View() | ||
| // Test width change | ||
| bar.SetWidth(60) | ||
| view = bar.View() | ||
| if view == "" { | ||
| t.Error("View should not be empty") | ||
| t.Error("View should not be empty after changing width") | ||
| } |
There was a problem hiding this comment.
The updated ProgressBar tests only assert that View() is non-empty, which won’t catch regressions like incorrect percentage rendering or width handling. Since the progress bar implementation changed significantly, consider adding assertions that the rendered output contains the expected percentage (e.g., 0%, 50%, 100%) and reacts to SetWidth() as intended.