ascii-agents 0.3.0

Terminal pixel-art office for AI coding agents
Documentation
pub mod chitchat;
pub mod embedded_pack;
pub mod floor;
pub mod frame_cache;
pub mod hit_test;
pub mod layout;
pub mod pathfind;
pub mod pixel_painter;
pub mod pose;
pub mod renderer;
pub mod theme;
pub mod tui_renderer;
pub mod widgets;

use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};

use anyhow::Result;
use ascii_agents_core::Renderer;
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind};

use renderer::{setup_terminal, teardown_terminal};
use tui_renderer::TuiRenderer;

use crate::runtime::SceneRx;

pub async fn run_tui(
    mut scene_rx: SceneRx,
    pack_dir: Option<std::path::PathBuf>,
    max_desks: Arc<std::sync::atomic::AtomicUsize>,
    theme: &'static theme::Theme,
) -> Result<()> {
    let pack = embedded_pack::load_sprite_pack(pack_dir)?;
    let term = setup_terminal()?;
    let mut renderer = TuiRenderer::new(term, theme);
    let mut last_layout_sig: Option<(u16, u16)> = None;
    let mut paused = false;
    let mut frozen_now: Option<SystemTime> = None;
    let mut theme_picker: Option<usize> = None;
    let mut saved_theme_idx: usize = theme::ALL_THEMES
        .iter()
        .position(|t| std::ptr::eq(*t, theme))
        .unwrap_or(0);

    let tick = Duration::from_millis(33);
    let result: Result<()> = (async {
        loop {
            let now = if paused {
                *frozen_now.get_or_insert(SystemTime::now())
            } else {
                frozen_now = None;
                SystemTime::now()
            };
            let snapshot = scene_rx.borrow_and_update().clone();
            let snapshot = {
                let mut s = (*snapshot).clone();
                s.max_desks = max_desks.load(std::sync::atomic::Ordering::Relaxed);
                Arc::new(s)
            };
            renderer.evict_missing(&snapshot);
            let sig = (renderer.buf().width, renderer.buf().height);
            if last_layout_sig != Some(sig) {
                renderer.invalidate_routes();
                renderer.cancel_transition();
                last_layout_sig = Some(sig);
            }
            renderer.set_theme_picker(theme_picker);
            renderer.render(&snapshot, &pack, now)?;

            // Auto-compute max_desks as the minimum desk capacity across
            // all 5 floor variants. This ensures every floor variant can
            // fit its assigned agents — a Standard floor (8 desks) and an
            // OpenPlan floor (16 desks) both work with max_desks = 8.
            if let Some(layout) = renderer.cached_layout() {
                use ascii_agents_core::layout::{SceneLayout, MAX_VISIBLE_DESKS};
                let buf_w = layout.buf_w;
                let buf_h = layout.buf_h;
                let min_capacity = (0..5u64)
                    .filter_map(|seed| {
                        SceneLayout::compute_with_seed(buf_w, buf_h, MAX_VISIBLE_DESKS, seed)
                    })
                    .map(|l| l.home_desks.len())
                    .filter(|&n| n > 0)
                    .min()
                    .unwrap_or(MAX_VISIBLE_DESKS);
                if min_capacity > 0 {
                    max_desks.fetch_max(min_capacity, std::sync::atomic::Ordering::Relaxed);
                }
            }

            let start = Instant::now();
            let mut polled = event::poll(tick)?;
            let mut quit = false;
            while polled {
                match event::read()? {
                    Event::Key(k) => {
                        if let Some(idx) = theme_picker.as_mut() {
                            match k.code {
                                KeyCode::Up | KeyCode::Char('k') => {
                                    if *idx > 0 {
                                        *idx -= 1;
                                    }
                                    renderer.set_theme(theme::ALL_THEMES[*idx]);
                                }
                                KeyCode::Down | KeyCode::Char('j') => {
                                    if *idx + 1 < theme::ALL_THEMES.len() {
                                        *idx += 1;
                                    }
                                    renderer.set_theme(theme::ALL_THEMES[*idx]);
                                }
                                KeyCode::Enter => {
                                    saved_theme_idx = *idx;
                                    theme_picker = None;
                                }
                                KeyCode::Esc => {
                                    renderer.set_theme(theme::ALL_THEMES[saved_theme_idx]);
                                    theme_picker = None;
                                }
                                _ => {}
                            }
                        } else {
                            match (k.code, k.modifiers) {
                                (KeyCode::Char('q'), _)
                                | (KeyCode::Esc, _)
                                | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
                                    quit = true;
                                }
                                (KeyCode::Char('p'), _) => {
                                    paused = !paused;
                                }
                                (KeyCode::Char('t'), _) => {
                                    theme_picker = Some(saved_theme_idx);
                                }
                                (KeyCode::PageUp, _)
                                | (KeyCode::Up, _)
                                | (KeyCode::Char('k'), _) => {
                                    let n_floors = crate::tui::floor::num_floors(&snapshot);
                                    let cur = renderer.current_floor();
                                    if cur + 1 < n_floors && renderer.transition().is_none() {
                                        renderer.navigate_floor(cur + 1, now);
                                    }
                                }
                                (KeyCode::PageDown, _)
                                | (KeyCode::Down, _)
                                | (KeyCode::Char('j'), _) => {
                                    let cur = renderer.current_floor();
                                    if cur > 0 && renderer.transition().is_none() {
                                        renderer.navigate_floor(cur - 1, now);
                                    }
                                }
                                _ => {}
                            }
                        }
                    }
                    Event::Mouse(m) => match m.kind {
                        MouseEventKind::Moved | MouseEventKind::Drag(_) => {
                            renderer.set_mouse_pos(Some((m.column, m.row)));
                        }
                        MouseEventKind::Down(MouseButton::Left) => {
                            renderer.set_mouse_pos(Some((m.column, m.row)));
                            if m.row <= 1 && m.column >= 1 && m.column < 31 {
                                let _ = open::that("https://github.com/IvanWng97/ascii-agents");
                            } else if renderer.cached_layout().is_some_and(|layout| {
                                renderer::hit_test_coffee_machine(layout, m.column, m.row)
                            }) {
                                let _ = open::that("https://buymeacoffee.com/IvanWng97");
                            } else if let Some((cat_pos, anim)) = renderer.cached_cat_pos() {
                                if renderer.cat_pet().map_or(true, |p| !p.is_active(now))
                                    && renderer::hit_test_cat(cat_pos, anim, m.column, m.row)
                                {
                                    renderer.set_cat_pet(Some(renderer::CatPetState {
                                        petted_at: now,
                                        pet_pos: cat_pos,
                                    }));
                                } else {
                                    let pinned = renderer.pinned_agent();
                                    if pinned.is_some() {
                                        renderer.set_pinned_agent(None);
                                    } else {
                                        let snap = scene_rx.borrow().clone();
                                        let hit = renderer.cached_layout().and_then(|layout| {
                                            renderer::hit_test_from_tui(
                                                &snap, layout, m.column, m.row,
                                            )
                                        });
                                        renderer.set_pinned_agent(hit);
                                    }
                                }
                            } else {
                                let pinned = renderer.pinned_agent();
                                if pinned.is_some() {
                                    renderer.set_pinned_agent(None);
                                } else {
                                    let snap = scene_rx.borrow().clone();
                                    let hit = renderer.cached_layout().and_then(|layout| {
                                        renderer::hit_test_from_tui(&snap, layout, m.column, m.row)
                                    });
                                    renderer.set_pinned_agent(hit);
                                }
                            }
                        }
                        _ => {}
                    },
                    _ => {}
                }
                polled = event::poll(Duration::from_millis(0))?;
            }
            if quit {
                if theme_picker.is_some() {
                    renderer.set_theme(theme::ALL_THEMES[saved_theme_idx]);
                }
                break;
            }
            let elapsed = start.elapsed();
            if let Some(rem) = tick.checked_sub(elapsed) {
                tokio::time::sleep(rem).await;
            }
            tokio::task::yield_now().await;
        }
        Ok(())
    })
    .await;

    teardown_terminal(&mut renderer.terminal)?;
    result
}