Skip to content

Commit 6a22fd8

Browse files
echobtfactorydroid
andauthored
feat(tui): add async loading panels for /billing and /account commands (#269)
- Add non-blocking UI for /billing and /account commands - Show loading indicator immediately while fetching data in background - Display account/billing info in card-style panels similar to /settings - Add AccountFlowState and BillingFlowState to manage async data loading - Add AccountAction and BillingAction interactive action handlers - Prevent TUI blocking during HTTP requests The panels display: - Account: auth method, expiration, account ID, status - Billing: plan name, status, billing period, usage stats, quota Both panels include action buttons (login, logout, refresh, manage billing) and proper error handling for auth failures. Co-authored-by: Droid Agent <droid@factory.ai>
1 parent 200e404 commit 6a22fd8

File tree

6 files changed

+772
-93
lines changed

6 files changed

+772
-93
lines changed

cortex-tui/src/app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,8 @@ pub struct AppState {
553553
pub spinner: Spinner,
554554
pub brain_frame: u64,
555555
pub login_flow: Option<crate::interactive::builders::LoginFlowState>,
556+
pub account_flow: Option<crate::interactive::builders::AccountFlowState>,
557+
pub billing_flow: Option<crate::interactive::builders::BillingFlowState>,
556558
pub pending_approval: Option<ApprovalState>,
557559
pub session_history: Vec<SessionSummary>,
558560
pub terminal_size: (u16, u16),
@@ -684,6 +686,8 @@ impl AppState {
684686
spinner: Spinner::dots(),
685687
brain_frame: 0,
686688
login_flow: None,
689+
account_flow: None,
690+
billing_flow: None,
687691
pending_approval: None,
688692
session_history: Vec::new(),
689693
terminal_size: (80, 24),
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//! Builder for account information display with loading state.
2+
//!
3+
//! Displays account info in a card similar to /settings panel:
4+
//! - Loading state while fetching
5+
//! - Account details when ready
6+
7+
use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState};
8+
9+
/// Account loading status
10+
#[derive(Debug, Clone, Default)]
11+
pub enum AccountStatus {
12+
#[default]
13+
Loading,
14+
Ready,
15+
Error(String),
16+
NotLoggedIn,
17+
}
18+
19+
/// Account flow state stored in AppState
20+
#[derive(Debug, Clone, Default)]
21+
pub struct AccountFlowState {
22+
pub status: AccountStatus,
23+
// Auth info
24+
pub auth_method: Option<String>,
25+
pub expires_at: Option<String>,
26+
pub account_id: Option<String>,
27+
}
28+
29+
impl AccountFlowState {
30+
/// Create a new account flow in loading state
31+
pub fn loading() -> Self {
32+
Self {
33+
status: AccountStatus::Loading,
34+
auth_method: None,
35+
expires_at: None,
36+
account_id: None,
37+
}
38+
}
39+
40+
/// Set account data from auth info
41+
pub fn set_account_data(
42+
&mut self,
43+
auth_method: String,
44+
expires_at: Option<String>,
45+
account_id: Option<String>,
46+
) {
47+
self.auth_method = Some(auth_method);
48+
self.expires_at = expires_at;
49+
self.account_id = account_id;
50+
self.status = AccountStatus::Ready;
51+
}
52+
53+
/// Set error state
54+
pub fn set_error(&mut self, error: String) {
55+
self.status = AccountStatus::Error(error);
56+
}
57+
58+
/// Set not logged in state
59+
pub fn set_not_logged_in(&mut self) {
60+
self.status = AccountStatus::NotLoggedIn;
61+
}
62+
}
63+
64+
/// Build interactive state for account information display.
65+
pub fn build_account_selector(flow: &AccountFlowState) -> InteractiveState {
66+
let mut items = Vec::new();
67+
68+
match &flow.status {
69+
AccountStatus::Loading => {
70+
items.push(
71+
InteractiveItem::new("__loading__", "Loading account information...")
72+
.as_separator(),
73+
);
74+
items.push(InteractiveItem::new("__spacer__", "").as_separator());
75+
items.push(
76+
InteractiveItem::new("cancel", "Close")
77+
.with_description("Close panel")
78+
.with_icon(' '),
79+
);
80+
}
81+
AccountStatus::NotLoggedIn => {
82+
items.push(InteractiveItem::new("__info__", "Not logged in").as_separator());
83+
items.push(InteractiveItem::new("__spacer__", "").as_separator());
84+
items.push(
85+
InteractiveItem::new("login", "Login")
86+
.with_description("Authenticate with Cortex")
87+
.with_icon('>'),
88+
);
89+
items.push(
90+
InteractiveItem::new("cancel", "Close")
91+
.with_description("Close panel")
92+
.with_icon(' '),
93+
);
94+
}
95+
AccountStatus::Error(err) => {
96+
items.push(InteractiveItem::new("__error__", format!("Error: {}", err)).as_separator());
97+
items.push(InteractiveItem::new("__spacer__", "").as_separator());
98+
items.push(
99+
InteractiveItem::new("retry", "Retry")
100+
.with_description("Try again")
101+
.with_icon('>'),
102+
);
103+
items.push(
104+
InteractiveItem::new("cancel", "Close")
105+
.with_description("Close panel")
106+
.with_icon(' '),
107+
);
108+
}
109+
AccountStatus::Ready => {
110+
// Account Information section
111+
items.push(InteractiveItem::new("__cat_info__", "Account").as_separator());
112+
113+
// Auth Method
114+
if let Some(ref method) = flow.auth_method {
115+
items.push(
116+
InteractiveItem::new("auth_method", "Auth Method")
117+
.with_description(method.clone())
118+
.with_icon(' '),
119+
);
120+
}
121+
122+
// Account ID
123+
if let Some(ref id) = flow.account_id {
124+
items.push(
125+
InteractiveItem::new("account_id", "Account ID")
126+
.with_description(id.clone())
127+
.with_icon(' '),
128+
);
129+
}
130+
131+
// Expiration
132+
if let Some(ref expires) = flow.expires_at {
133+
items.push(
134+
InteractiveItem::new("expires", "Expires")
135+
.with_description(expires.clone())
136+
.with_icon(' '),
137+
);
138+
}
139+
140+
// Status
141+
items.push(
142+
InteractiveItem::new("status", "Status")
143+
.with_description("Active".to_string())
144+
.with_icon('>'),
145+
);
146+
147+
items.push(InteractiveItem::new("__spacer__", "").as_separator());
148+
149+
// Actions section
150+
items.push(InteractiveItem::new("__cat_actions__", "Actions").as_separator());
151+
items.push(
152+
InteractiveItem::new("logout", "Logout")
153+
.with_description("Sign out of account")
154+
.with_icon(' '),
155+
);
156+
items.push(
157+
InteractiveItem::new("cancel", "Close")
158+
.with_description("Close panel")
159+
.with_icon(' '),
160+
);
161+
}
162+
}
163+
164+
InteractiveState::new("Account", items, InteractiveAction::AccountAction)
165+
.with_hints(vec![
166+
("Enter".into(), "select".into()),
167+
("Esc".into(), "close".into()),
168+
])
169+
.with_max_visible(15)
170+
}
171+
172+
#[cfg(test)]
173+
mod tests {
174+
use super::*;
175+
176+
#[test]
177+
fn test_build_account_selector_loading() {
178+
let flow = AccountFlowState::loading();
179+
let state = build_account_selector(&flow);
180+
assert_eq!(state.title, "Account");
181+
assert!(!state.items.is_empty());
182+
}
183+
184+
#[test]
185+
fn test_build_account_selector_ready() {
186+
let mut flow = AccountFlowState::loading();
187+
flow.set_account_data(
188+
"OAuth".to_string(),
189+
Some("2025-12-31".to_string()),
190+
Some("user123".to_string()),
191+
);
192+
let state = build_account_selector(&flow);
193+
assert!(state.items.iter().any(|i| i.id == "auth_method"));
194+
assert!(state.items.iter().any(|i| i.id == "account_id"));
195+
}
196+
197+
#[test]
198+
fn test_build_account_selector_not_logged_in() {
199+
let mut flow = AccountFlowState::loading();
200+
flow.set_not_logged_in();
201+
let state = build_account_selector(&flow);
202+
assert!(state.items.iter().any(|i| i.id == "login"));
203+
}
204+
205+
#[test]
206+
fn test_build_account_selector_error() {
207+
let mut flow = AccountFlowState::loading();
208+
flow.set_error("Connection failed".to_string());
209+
let state = build_account_selector(&flow);
210+
assert!(state.items.iter().any(|i| i.id == "retry"));
211+
}
212+
}

0 commit comments

Comments
 (0)