Skip to content

Commit 665ae7d

Browse files
committed
feat: introduce adaptive interactive terminal UI and abstract UI manager
- Add a terminal UI architecture with environment-adaptive, interactive Bubble Tea TUI and print fallback - Implement a new Manager interface abstracting all UI responsibilities and automatic UI selection - Introduce message-passing architecture and progress updates for OAuth flows via channels - Provide wrapper functions for OAuth flows that emit progress for UI consumption - Create reusable TUI components: step indicator, timer, progress bar, info box - Implement detailed browser and device flow models and views for TUI - Add and update test coverage for TUI manager behavior and UI components - Refactor main program flow to use UI Manager abstraction instead of direct fmt.Printf calls - Update authentication and token handling to report states via Manager methods - Add Bubble Tea, Lipgloss, and other TUI-related dependencies to go.mod - Increase Go version in go.mod to 1.24.2 - Improve documentation to explain interactive terminal UI and automatic environment detection Signed-off-by: appleboy <appleboy.tw@gmail.com>
1 parent 0e12efe commit 665ae7d

23 files changed

Lines changed: 2900 additions & 61 deletions

CLAUDE.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,117 @@ On each run (`main.go:run()`):
122122
- OAuth errors parsed from JSON response: `error` and `error_description` fields
123123
- HTTP retry client (`github.com/appleboy/go-httpretry`) wraps all OAuth requests
124124

125+
### Terminal UI Architecture
126+
127+
**TUI Package (`tui/`)**
128+
129+
The CLI features an interactive Terminal User Interface (TUI) built with Bubble Tea, providing visual feedback during OAuth flows.
130+
131+
**Manager Interface Pattern:**
132+
133+
```go
134+
type Manager interface {
135+
ShowHeader(clientMode, serverURL, clientID string)
136+
ShowFlowSelection(method string)
137+
RunBrowserFlow(ctx context.Context, perform BrowserFlowFunc) (*TokenStorage, bool, error)
138+
RunDeviceFlow(ctx context.Context, perform DeviceFlowFunc) (*TokenStorage, error)
139+
ShowTokenInfo(storage *TokenStorage)
140+
// ... other display methods
141+
}
142+
```
143+
144+
**Two Implementations:**
145+
146+
1. **SimplePrintManager** (`tui/simple_manager.go`):
147+
- Uses `fmt.Printf` for simple, parseable output
148+
- Preserves original CLI behavior (backward compatible)
149+
- Used in CI environments, piped output, small terminals
150+
- No dependencies beyond standard library
151+
152+
2. **BubbleTeaManager** (`tui/bubbletea_manager.go`):
153+
- Rich interactive TUI with Bubble Tea
154+
- Animated spinners, progress bars, timers
155+
- Visual step indicators and status updates
156+
- Gracefully falls back to SimplePrintManager on error
157+
158+
**Automatic UI Selection:**
159+
160+
The CLI auto-detects the environment (`tui/manager.go:shouldUseSimpleUI()`):
161+
162+
- **Simple Mode**: CI environments (GITHUB_ACTIONS, GITLAB_CI, etc.), non-TTY output, TERM=dumb, small terminals (<60x20)
163+
- **Interactive Mode**: Normal terminals with sufficient size and TTY support
164+
- **Fully Automatic**: No flags needed, environment detection handles everything
165+
166+
**Message Passing Architecture:**
167+
168+
OAuth flow functions send progress updates through channels:
169+
170+
```go
171+
type FlowUpdate struct {
172+
Type FlowUpdateType // StepStart, StepProgress, StepComplete, etc.
173+
Step int // Current step (1-indexed)
174+
TotalSteps int // Total steps in flow
175+
Message string // Human-readable status
176+
Progress float64 // 0.0 to 1.0 for progress bars
177+
Data map[string]any // Additional contextual data
178+
}
179+
```
180+
181+
**Flow Wrapper Pattern:**
182+
183+
Original OAuth functions remain unchanged. Wrapper functions add progress reporting:
184+
185+
- `performBrowserFlowWithUpdates()` wraps `performBrowserFlow()`
186+
- `performDeviceFlowWithUpdates()` wraps `performDeviceFlow()`
187+
- Wrappers send FlowUpdate messages through channels
188+
- TUI models subscribe to these channels and update UI
189+
190+
**Bubble Tea Models:**
191+
192+
- **BrowserModel** (`tui/browser_model.go`, `tui/browser_view.go`):
193+
- Displays step progress, countdown timer, progress bar
194+
- Shows authorization URL in step 1
195+
- Animated spinner during callback wait
196+
- Handles timeout and error states
197+
198+
- **DeviceModel** (`tui/device_model.go`, `tui/device_view.go`):
199+
- Shows device code in bordered box
200+
- Displays verification URL and user code
201+
- Live polling status with count and interval
202+
- Backoff warnings when server requests slower polling
203+
- Elapsed time counter
204+
205+
**Reusable Components (`tui/components/`):**
206+
207+
- `StepIndicator`: Multi-step progress (●/○ symbols)
208+
- `Timer`: Countdown or elapsed time display
209+
- `ProgressBar`: Visual progress bar with percentage
210+
- `InfoBox`: Bordered information display
211+
212+
**Styling System (`tui/styles.go`):**
213+
214+
- Consistent color palette (primary, secondary, success, error, warning, info)
215+
- Predefined styles for all UI elements
216+
- Helper functions: `RenderBox()`, `RenderError()`, `RenderSuccess()`, etc.
217+
- Uses Lipgloss for terminal styling
218+
219+
**Integration Flow:**
220+
221+
1. `main.go:main()` calls `tui.SelectManager()` based on environment
222+
2. Manager passed to `run()` and `authenticate()`
223+
3. OAuth flow functions use wrapper versions that send updates
224+
4. Manager runs Bubble Tea program consuming update channel
225+
5. On completion, Manager returns OAuth tokens to caller
226+
227+
**Design Benefits:**
228+
229+
- **No Breaking Changes**: Original OAuth logic untouched
230+
- **Clean Separation**: UI and business logic decoupled
231+
- **Easy Testing**: Can test OAuth without UI
232+
- **Flexible**: Easy to add new UI modes
233+
- **Environment Adaptive**: Auto-selects appropriate mode
234+
- **User Control**: Flags override detection
235+
125236
## Key Implementation Details
126237

127238
### Public vs. Confidential Clients

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This mirrors the authentication strategy used by **GitHub CLI**, **Azure CLI**,
1616
- [Why This CLI?](#why-this-cli)
1717
- [Quick Start](#quick-start)
1818
- [How It Works](#how-it-works)
19+
- [Interactive Terminal UI](#interactive-terminal-ui)
1920
- [Configuration](#configuration)
2021
- [Authentication Flows](#authentication-flows)
2122
- [Token Storage](#token-storage)
@@ -154,6 +155,90 @@ On each run the CLI follows this order:
154155

155156
---
156157

158+
## Interactive Terminal UI
159+
160+
Authgate CLI features a rich **interactive Terminal User Interface (TUI)** built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), providing visual feedback during OAuth authentication flows.
161+
162+
### Features
163+
164+
The TUI provides:
165+
166+
- **Visual Progress Indicators**: Step-by-step progress with animated spinners
167+
- **Real-time Timers**: Countdown for browser flow, elapsed time for device flow
168+
- **Progress Bars**: Visual representation of callback timeout
169+
- **Polling Status**: Live updates showing device flow polling count and intervals
170+
- **Backoff Warnings**: Clear notifications when server requests slower polling
171+
- **Clean Layout**: Bordered boxes, color-coded messages, and structured information
172+
173+
### Browser Flow (Authorization Code + PKCE)
174+
175+
```
176+
╭─────────────────────────────────────────────────╮
177+
│ Authorization Code Flow with PKCE │
178+
├─────────────────────────────────────────────────┤
179+
│ │
180+
│ ● Step 1/3: Opening browser ✓ │
181+
│ ● Step 2/3: Waiting for callback ◐ │
182+
│ ○ Step 3/3: Exchanging tokens │
183+
│ │
184+
│ Time remaining: 1:23 / 2:00 │
185+
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░ 48% │
186+
│ │
187+
│ ◐ Please complete authorization in your browser │
188+
│ │
189+
│ Press Ctrl+C to cancel │
190+
╰─────────────────────────────────────────────────╯
191+
```
192+
193+
### Device Flow (Device Authorization Grant)
194+
195+
```
196+
╭─────────────────────────────────────────────────╮
197+
│ Device Authorization Grant Flow │
198+
├─────────────────────────────────────────────────┤
199+
│ │
200+
│ ╔═══════════════════════════════════════════╗ │
201+
│ ║ Device Authorization ║ │
202+
│ ║ ║ │
203+
│ ║ Visit: https://auth.example.com/device ║ │
204+
│ ║ ?user_code=ABCD-EFGH ║ │
205+
│ ║ ║ │
206+
│ ║ Or go to: https://auth.example.com ║ │
207+
│ ║ And enter: ABCD-EFGH ║ │
208+
│ ╚═══════════════════════════════════════════╝ │
209+
│ │
210+
│ ◐ Waiting for authorization... (poll #8, 5s) │
211+
│ │
212+
│ Elapsed: 0:43 │
213+
│ │
214+
│ Press Ctrl+C to cancel │
215+
╰─────────────────────────────────────────────────╯
216+
```
217+
218+
### UI Mode Selection
219+
220+
The CLI automatically chooses the appropriate UI mode:
221+
222+
**Interactive TUI Mode** (default):
223+
224+
- Normal terminal with sufficient size (≥60x20)
225+
- TTY detected
226+
- TERM environment variable set (not "dumb")
227+
228+
**Simple Printf Mode** (automatic fallback):
229+
230+
- CI environments (GitHub Actions, GitLab CI, CircleCI, etc.)
231+
- Output piped to file or another command
232+
- Terminal too small (< 60 columns or < 20 rows)
233+
- `TERM=dumb` or TERM unset
234+
- SSH session without display forwarding
235+
236+
### Note on UI Selection
237+
238+
The CLI automatically detects the environment and selects the appropriate UI mode. No configuration or flags are needed - it just works.
239+
240+
---
241+
157242
## Configuration
158243

159244
Configuration is resolved in priority order: **CLI flag → environment variable → default**.

browser_flow.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"net/url"
1111
"strings"
1212
"time"
13+
14+
"github.com/go-authgate/cli/tui"
1315
)
1416

1517
// performBrowserFlow runs the Authorization Code Flow with PKCE.
@@ -162,3 +164,131 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*TokenStorage
162164
ClientID: clientID,
163165
}, nil
164166
}
167+
168+
// performBrowserFlowWithUpdates runs the Authorization Code Flow with PKCE
169+
// and sends progress updates through the provided channel.
170+
//
171+
// Returns:
172+
// - (storage, true, nil) on success
173+
// - (nil, false, nil) when openBrowser() fails — caller should fall back to Device Code Flow
174+
// - (nil, false, err) on a hard error (CSRF mismatch, token exchange failure, etc.)
175+
func performBrowserFlowWithUpdates(ctx context.Context, updates chan<- tui.FlowUpdate) (*tui.TokenStorage, bool, error) {
176+
updates <- tui.FlowUpdate{
177+
Type: tui.StepStart,
178+
Step: 1,
179+
TotalSteps: 3,
180+
Message: "Generating PKCE parameters",
181+
}
182+
183+
state, err := generateState()
184+
if err != nil {
185+
return nil, false, fmt.Errorf("failed to generate state: %w", err)
186+
}
187+
188+
pkce, err := GeneratePKCE()
189+
if err != nil {
190+
return nil, false, fmt.Errorf("failed to generate PKCE: %w", err)
191+
}
192+
193+
authURL := buildAuthURL(state, pkce)
194+
updates <- tui.FlowUpdate{
195+
Type: tui.StepStart,
196+
Step: 1,
197+
TotalSteps: 3,
198+
Message: "Opening browser",
199+
Data: map[string]interface{}{
200+
"url": authURL,
201+
},
202+
}
203+
204+
if err := openBrowser(ctx, authURL); err != nil {
205+
// Browser failed to open — signal the caller to fall back immediately.
206+
updates <- tui.FlowUpdate{
207+
Type: tui.StepError,
208+
Message: fmt.Sprintf("Could not open browser: %v", err),
209+
}
210+
return nil, false, nil
211+
}
212+
213+
updates <- tui.FlowUpdate{Type: tui.BrowserOpened}
214+
updates <- tui.FlowUpdate{
215+
Type: tui.StepStart,
216+
Step: 2,
217+
TotalSteps: 3,
218+
Message: "Waiting for callback",
219+
Data: map[string]interface{}{
220+
"port": callbackPort,
221+
},
222+
}
223+
224+
// Start goroutine to send timer updates
225+
done := make(chan struct{})
226+
defer close(done)
227+
228+
go func() {
229+
ticker := time.NewTicker(100 * time.Millisecond)
230+
defer ticker.Stop()
231+
startTime := time.Now()
232+
233+
for {
234+
select {
235+
case <-done:
236+
return
237+
case <-ticker.C:
238+
elapsed := time.Since(startTime)
239+
progress := float64(elapsed) / float64(callbackTimeout)
240+
if progress > 1.0 {
241+
progress = 1.0
242+
}
243+
updates <- tui.FlowUpdate{
244+
Type: tui.TimerTick,
245+
Progress: progress,
246+
Data: map[string]interface{}{
247+
"elapsed": elapsed,
248+
"timeout": callbackTimeout,
249+
},
250+
}
251+
}
252+
}
253+
}()
254+
255+
storage, err := startCallbackServer(ctx, callbackPort, state,
256+
func(callbackCtx context.Context, code string) (*TokenStorage, error) {
257+
updates <- tui.FlowUpdate{
258+
Type: tui.StepStart,
259+
Step: 3,
260+
TotalSteps: 3,
261+
Message: "Exchanging tokens",
262+
}
263+
return exchangeCode(callbackCtx, code, pkce.Verifier)
264+
})
265+
266+
if err != nil {
267+
if errors.Is(err, ErrCallbackTimeout) {
268+
updates <- tui.FlowUpdate{
269+
Type: tui.StepError,
270+
Message: "Browser authorization timed out",
271+
}
272+
return nil, false, nil
273+
}
274+
return nil, false, fmt.Errorf("authentication failed: %w", err)
275+
}
276+
277+
updates <- tui.FlowUpdate{Type: tui.CallbackReceived}
278+
storage.Flow = "browser"
279+
280+
if err := saveTokens(storage); err != nil {
281+
updates <- tui.FlowUpdate{
282+
Type: tui.StepError,
283+
Message: fmt.Sprintf("Warning: Failed to save tokens: %v", err),
284+
}
285+
}
286+
287+
updates <- tui.FlowUpdate{
288+
Type: tui.StepComplete,
289+
Step: 3,
290+
TotalSteps: 3,
291+
}
292+
293+
return toTUITokenStorage(storage), true, nil
294+
}

0 commit comments

Comments
 (0)