use std::io;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use ai_usagebar::config::Config;
use ai_usagebar::tui::app::{
ANTHROPIC_REFRESH_STAGGER, App, REFRESH_INTERVAL, TabId, TabState, refresh_one,
refresh_stagger, tabs_with_desktop,
};
use ai_usagebar::tui::view::draw;
use ai_usagebar::vendor::HTTP_CLIENT_TIMEOUT;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::Rect;
use reqwest::Client;
use tokio::sync::mpsc;
#[tokio::main(flavor = "current_thread")]
async fn main() {
if let Err(e) = run().await {
eprintln!("ai-usagebar-tui: {e}");
std::process::exit(1);
}
}
async fn run() -> io::Result<()> {
let mut config = Config::load().map_err(|e| {
io::Error::other(format!(
"{} could not be loaded: {e}\n\
Fix the file (or move it aside) and try again.",
ai_usagebar::config::config_path_hint()
))
})?;
let tabs = tabs_with_desktop(&config);
if tabs.is_empty() {
eprintln!(
"No vendors are enabled in {}. Exiting.",
ai_usagebar::config::config_path_hint()
);
return Ok(());
}
let client = Client::builder()
.timeout(HTTP_CLIENT_TIMEOUT)
.redirect(ai_usagebar::vendor::same_origin_redirect_policy())
.build()
.map_err(io::Error::other)?;
let mut app = App::new_with_primary(tabs, config.ui.primary);
app.context_enabled = config.context.enabled;
app.overview_vendors = config.ui.overview_vendors.clone();
app.vendor_box = config.ui.vendor_box();
let _guard = TerminalGuard::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
event_loop(&mut terminal, &mut app, &client, &mut config).await
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
let _ = disable_raw_mode();
return Err(e);
}
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let mut stdout = io::stdout();
let _ = execute!(
stdout,
LeaveAlternateScreen,
DisableMouseCapture,
ratatui::crossterm::cursor::Show
);
let _ = disable_raw_mode();
}
}
const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfigStamp {
path: PathBuf,
modified: SystemTime,
len: u64,
}
fn config_stamp() -> Option<ConfigStamp> {
let path = ai_usagebar::config::resolved_path()?;
let metadata = std::fs::metadata(&path).ok()?;
Some(ConfigStamp {
path,
modified: metadata.modified().ok()?,
len: metadata.len(),
})
}
fn reload_config(
app: &mut App,
config: &mut Config,
client: &Client,
tx: &mpsc::UnboundedSender<(u64, TabId, TabState)>,
reselect_primary: bool,
) -> bool {
let Ok(reloaded) = Config::load() else {
return false;
};
*config = reloaded;
app.context_enabled = config.context.enabled;
app.overview_vendors = config.ui.overview_vendors.clone();
app.vendor_box = config.ui.vendor_box();
app.set_tabs(tabs_with_desktop(config));
if reselect_primary {
app.select_primary(config.ui.primary);
}
spawn_all(app, client, config, tx);
true
}
async fn event_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
client: &Client,
config: &mut Config,
) -> io::Result<()>
where
io::Error: From<B::Error>,
{
let (tx, mut rx) = mpsc::unbounded_channel::<(u64, TabId, TabState)>();
let (context_tx, mut context_rx) = mpsc::unbounded_channel::<(
u64,
std::result::Result<ai_usagebar::context::ContextScan, String>,
)>();
spawn_all(app, client, config, &tx);
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<InputEvent>();
std::thread::spawn(move || {
loop {
match event::read() {
Ok(Event::Key(k)) => {
if input_tx.send(InputEvent::Key(k)).is_err() {
return; }
}
Ok(Event::Resize(cols, rows)) => {
if input_tx.send(InputEvent::Resize { cols, rows }).is_err() {
return;
}
}
Ok(_) => {}
Err(_) => return,
}
}
});
let mut tick = tokio::time::interval(REFRESH_INTERVAL);
tick.tick().await;
let mut config_poll = tokio::time::interval(CONFIG_POLL_INTERVAL);
config_poll.tick().await; let mut last_config_stamp = config_stamp();
loop {
terminal.draw(|f| draw(f, app))?;
tokio::select! {
biased;
Some((generation, tab, state)) = rx.recv() => {
app.apply_refresh(generation, &tab, state);
}
Some((generation, result)) = context_rx.recv() => {
if let Some(context) = app.context.as_mut() {
context.apply_scan(generation, result);
}
}
_ = tick.tick() => {
spawn_all(app, client, config, &tx);
}
_ = config_poll.tick() => {
let now = config_stamp();
if now != last_config_stamp
&& reload_config(app, config, client, &tx, false)
{
last_config_stamp = now;
}
}
maybe_input = input_rx.recv() => {
let Some(input) = maybe_input else {
return Ok(()); };
let k = match input {
InputEvent::Resize { cols, rows } => {
let _ = terminal.resize(Rect::new(0, 0, cols, rows));
continue;
}
InputEvent::Key(k) => k,
};
{
if k.kind != KeyEventKind::Press {
continue;
}
if app.context.is_some() {
use ai_usagebar::tui::context::{Action as CAction, handle_key as chandle};
let action = {
let context = app.context.as_mut().expect("checked above");
chandle(context, k.code, k.modifiers)
};
match action {
CAction::Continue => {}
CAction::Close => app.context = None,
CAction::Refresh => {
spawn_context_scan(app, config, &context_tx);
}
CAction::Quit => return Ok(()),
}
continue;
}
if let Some(s) = app.settings.as_mut() {
use ai_usagebar::tui::settings::{Action as SAction, handle_key as shandle};
match shandle(s, k.code, k.modifiers) {
SAction::Continue => {}
SAction::Close => app.settings = None,
SAction::SavedAndClose => {
app.settings = None;
if reload_config(app, config, client, &tx, true) {
last_config_stamp = config_stamp();
}
}
SAction::Quit => return Ok(()),
}
continue;
}
if matches!(k.code, KeyCode::Char('s')) {
let cfg = ai_usagebar::config::Config::load()
.unwrap_or_else(|_| config.clone());
app.settings = Some(
ai_usagebar::tui::settings::SettingsState::from_config(&cfg),
);
continue;
}
if matches!(k.code, KeyCode::Char('c'))
&& !k.modifiers.intersects(
KeyModifiers::CONTROL
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
)
&& app.context_enabled
{
app.context = Some(ai_usagebar::tui::context::ContextState::new(
config.context.layout,
));
spawn_context_scan(app, config, &context_tx);
continue;
}
if handle_key(app, k.code, k.modifiers) {
return Ok(());
}
if matches!(k.code, KeyCode::Char('r')) {
if app.overview {
spawn_all(app, client, config, &tx);
} else if let Some(tab) = app.active_tab_id().cloned() {
spawn_one(app, tab, client, config, &tx, Duration::ZERO);
}
}
if matches!(k.code, KeyCode::Char('R')) {
spawn_all(app, client, config, &tx);
}
}
}
}
if app.quit {
return Ok(());
}
}
}
enum InputEvent {
Key(event::KeyEvent),
Resize { cols: u16, rows: u16 },
}
fn spawn_context_scan(
app: &mut App,
config: &Config,
tx: &mpsc::UnboundedSender<(
u64,
std::result::Result<ai_usagebar::context::ContextScan, String>,
)>,
) {
let Some(context) = app.context.as_mut() else {
return;
};
app.context_generation = app.context_generation.wrapping_add(1);
let generation = app.context_generation;
context.begin_refresh(generation);
let context_config = config.context.clone();
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = (|| {
let path = match context_config.projects_path.as_deref() {
Some(path) => path.to_path_buf(),
None => ai_usagebar::context::default_projects_path()?,
};
ai_usagebar::context::scan_dir(&path, &context_config)
})()
.map_err(|error| error.to_string());
let _ = tx.send((generation, result));
});
}
fn spawn_all(
app: &mut App,
client: &Client,
config: &Config,
tx: &mpsc::UnboundedSender<(u64, TabId, TabState)>,
) {
let tabs = app.tabs_meta.clone();
let delays = refresh_stagger(&tabs, ANTHROPIC_REFRESH_STAGGER);
for (tab, delay) in tabs.into_iter().zip(delays) {
spawn_one(app, tab, client, config, tx, delay);
}
}
fn spawn_one(
app: &mut App,
tab: TabId,
client: &Client,
config: &Config,
tx: &mpsc::UnboundedSender<(u64, TabId, TabState)>,
delay: Duration,
) {
if !app.begin_refresh(&tab) {
return;
}
let tx = tx.clone();
let client = client.clone();
let cfg = config.clone();
let generation = app.tab_generation;
tokio::spawn(async move {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let state = refresh_one(&client, &cfg, &tab).await;
let _ = tx.send((generation, tab, state));
});
}
fn handle_key(app: &mut App, code: KeyCode, mods: KeyModifiers) -> bool {
match code {
KeyCode::Char('q') | KeyCode::Esc => {
app.quit = true;
true
}
KeyCode::Char('c') if mods.contains(KeyModifiers::CONTROL) => {
app.quit = true;
true
}
KeyCode::Tab | KeyCode::Char('l') | KeyCode::Right => {
app.next_tab();
false
}
KeyCode::BackTab | KeyCode::Char('h') | KeyCode::Left => {
app.prev_tab();
false
}
_ => false,
}
}