mod app;
mod render;
mod theme;
mod view;
use std::io::{self, Stdout, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, ExecutableCommand};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use scema_agent::Agent;
use scema_world::Goal;
use crate::app::{App, Focus, Job, Mode, Status, Tab, Worker};
use crate::theme::{Depth, Role, Theme};
const TICK: Duration = Duration::from_millis(100);
#[derive(Parser)]
#[command(
name = "scema-tui",
version,
about = "Scematica Omni — the agent loop as a terminal console",
long_about = None
)]
struct Cli {
#[arg(default_value = ".")]
path: String,
#[arg(long, default_value = ".scema")]
root: PathBuf,
#[arg(long)]
dqstar: Option<String>,
#[arg(long)]
once: bool,
#[arg(long)]
goal: Option<String>,
#[arg(long = "ground")]
ground: Vec<String>,
#[arg(long)]
no_color: bool,
#[arg(long)]
palette: bool,
#[arg(long, value_name = "WIDTHxHEIGHT")]
snapshot: Option<String>,
}
fn parse_size(spec: &str) -> (u16, u16) {
let (w, h) = spec.split_once(['x', 'X']).unwrap_or(("120", "40"));
(
w.trim().parse().unwrap_or(120),
h.trim().parse().unwrap_or(40),
)
}
fn main() -> Result<()> {
let cli = Cli::parse();
let theme = Theme::new(Depth::detect(cli.no_color));
if cli.palette {
return print_palette(theme);
}
let agent = Agent::new(cli.root.clone(), cli.dqstar.clone());
if cli.once {
return run_once(&cli, &agent);
}
if let Some(spec) = cli.snapshot.clone() {
return run_snapshot(&cli, agent, theme, &spec);
}
run_tui(cli, agent, theme)
}
fn run_once(cli: &Cli, agent: &Agent) -> Result<()> {
let world = agent
.observe(&cli.path)
.with_context(|| format!("observing {}", cli.path))?;
let Some(statement) = &cli.goal else {
println!("{}\n", scema_policy::render::world_header(&world));
println!("{}", scema_policy::render::signals(&world));
return Ok(());
};
for id in &cli.ground {
if !world.signals.iter().any(|s| &s.id == id) {
eprintln!(
"scema-tui: --ground `{id}` names no signal in this world; it will be ignored."
);
}
}
let mut goal = Goal::new("goal", statement.trim());
for id in &cli.ground {
goal = goal.grounded(id.trim());
}
let mut dry = Agent::new(cli.root.clone(), cli.dqstar.clone());
dry.persist = false;
let cycle = dry.cycle_over(world, goal)?;
print!("{}", render::plain_matrix(&cycle));
println!(
"\nRECORD not written — `--once` is a counterfactual. Would seal as {}.",
cycle.record.id
);
Ok(())
}
fn print_palette(theme: Theme) -> Result<()> {
let mut out = io::stdout();
writeln!(out, "depth: {:?}\n", theme.depth)?;
for role in Role::ALL {
let style = theme.style(role);
writeln!(
out,
" {:<16} fg={:<22} modifiers={:?}",
format!("{role:?}"),
format!("{:?}", style.fg),
style.add_modifier
)?;
}
writeln!(
out,
"\nIf `Measured` and `Unmeasured` look the same on this terminal, use --no-color:\nthe text still distinguishes them (a number versus an em dash) and the colour is\nnot carrying the message."
)?;
Ok(())
}
fn run_snapshot(cli: &Cli, agent: Agent, theme: Theme, spec: &str) -> Result<()> {
use ratatui::backend::TestBackend;
let (w, h) = parse_size(spec);
let mut app = App::new(theme, cli.root.clone(), cli.path.clone(), &agent);
if let Ok(world) = agent.observe(&cli.path) {
app.world = Some(world);
}
for id in &cli.ground {
app.grounded.insert(id.clone());
}
if let Some(g) = &cli.goal {
app.goal = g.clone();
app.tab = Tab::Simulate;
let mut dry = Agent::new(cli.root.clone(), cli.dqstar.clone());
dry.persist = false;
if let Some(world) = app.world.clone() {
if let Ok(c) = dry.cycle_over(world, app.build_goal()) {
app.cycle = Some(c);
}
}
}
let mut terminal = Terminal::new(TestBackend::new(w, h))?;
terminal.draw(|f| render::draw(f, &app))?;
print!("{}", frame_to_text(terminal.backend().buffer()));
Ok(())
}
fn frame_to_text(buffer: &ratatui::buffer::Buffer) -> String {
let area = buffer.area();
let mut out = String::new();
for y in 0..area.height {
let mut line = String::new();
for x in 0..area.width {
line.push_str(buffer.get(x, y).symbol());
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
fn run_tui(cli: Cli, agent: Agent, theme: Theme) -> Result<()> {
install_panic_hook();
let mut terminal = enter()?;
let agent = Arc::new(agent);
let mut app = App::new(theme, cli.root.clone(), cli.path.clone(), &agent);
let worker = Worker::spawn(Arc::clone(&agent), cli.root.clone());
app.busy = Some("observing");
worker.send(Job::Observe { path: cli.path.clone() });
worker.send(Job::LoadRecords);
worker.send(Job::LoadMemory);
let result = event_loop(&mut terminal, &mut app, &worker);
leave(&mut terminal)?;
result
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
worker: &Worker,
) -> Result<()> {
let mut last = Instant::now();
loop {
terminal.draw(|f| render::draw(f, app))?;
while let Ok(done) = worker.rx.try_recv() {
app.absorb(done);
}
let timeout = TICK.saturating_sub(last.elapsed());
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
handle_key(app, worker, key);
}
Event::Resize(_, _) => {}
_ => {}
}
}
if last.elapsed() >= TICK {
app.tick = app.tick.wrapping_add(1);
last = Instant::now();
}
if app.should_quit {
return Ok(());
}
}
}
fn handle_key(app: &mut App, worker: &Worker, key: KeyEvent) {
match app.mode {
Mode::EditGoal => return edit_line(app, key, true),
Mode::EditConstraint => return edit_line(app, key, false),
Mode::ConfirmDecide => return confirm(app, worker, key),
Mode::Normal => {}
}
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
app.should_quit = true;
return;
}
if app.help && !matches!(key.code, KeyCode::Char('?')) {
app.help = false;
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('?') => app.help = !app.help,
KeyCode::Char('1') => app.tab = Tab::World,
KeyCode::Char('2') => app.tab = Tab::Simulate,
KeyCode::Char('3') => app.tab = Tab::Records,
KeyCode::Char('4') => app.tab = Tab::Memory,
KeyCode::Char('5') => app.tab = Tab::Policy,
KeyCode::Right => app.tab = app.tab.next(),
KeyCode::Left => app.tab = app.tab.prev(),
KeyCode::Tab => app.focus = app.focus.flip(),
KeyCode::Up | KeyCode::Char('k') => move_selection(app, -1),
KeyCode::Down | KeyCode::Char('j') => move_selection(app, 1),
KeyCode::PageUp => move_selection(app, -10),
KeyCode::PageDown => move_selection(app, 10),
KeyCode::Char('o') => {
app.busy = Some("observing");
app.status = Status::Note(format!("observing {}", app.path));
worker.send(Job::Observe { path: app.path.clone() });
}
KeyCode::Char(' ') if app.tab == Tab::World && app.focus == Focus::Right => {
app.toggle_ground()
}
KeyCode::Char('g') => {
app.mode = Mode::EditGoal;
app.tab = Tab::Simulate;
}
KeyCode::Char('m') => {
app.mode = Mode::EditConstraint;
app.constraint_draft.clear();
app.tab = Tab::Simulate;
}
KeyCode::Char('s') | KeyCode::Enter if app.tab != Tab::Records => run_cycle(app, worker, false),
KeyCode::Enter if app.tab == Tab::Records => open_record(app, worker),
KeyCode::Char('D') => {
if let Some(reason) = app.cycle_blocked() {
app.status = Status::Warn(reason.to_string());
} else {
app.mode = Mode::ConfirmDecide;
}
}
KeyCode::Char('r') => {
app.busy = Some("reading .scema");
worker.send(Job::LoadRecords);
worker.send(Job::LoadMemory);
}
KeyCode::Char('v') => open_record(app, worker),
_ => {}
}
}
fn move_selection(app: &mut App, delta: isize) {
match (app.tab, app.focus) {
(Tab::World, Focus::Left) => {
let n = app.world.as_ref().map(|w| w.objects.len()).unwrap_or(0);
App::step(&mut app.object_sel, n, delta);
}
(Tab::World, Focus::Right) => {
let n = app.world.as_ref().map(|w| w.signals.len()).unwrap_or(0);
App::step(&mut app.signal_sel, n, delta);
}
(Tab::Simulate, _) => {
let n = app.cycle.as_ref().map(|c| c.decision.ranked.len()).unwrap_or(0);
App::step(&mut app.matrix_sel, n, delta);
}
(Tab::Records, _) => {
let n = app.records.len();
App::step(&mut app.record_sel, n, delta);
}
_ => {}
}
}
fn run_cycle(app: &mut App, worker: &Worker, persist: bool) {
if let Some(reason) = app.cycle_blocked() {
app.status = Status::Warn(reason.to_string());
return;
}
let Some(world) = app.world.clone() else { return };
let goal = app.build_goal();
app.busy = Some(if persist { "deciding" } else { "simulating" });
app.status = Status::Note(if persist {
"running the loop — this will seal a record".into()
} else {
"running the loop — nothing will be written".into()
});
worker.send(Job::Cycle { world: Box::new(world), goal: Box::new(goal), persist });
}
fn open_record(app: &mut App, worker: &Worker) {
let Some(row) = app.records.get(app.record_sel) else {
app.status = Status::Warn("no record selected".into());
return;
};
app.busy = Some("verifying");
worker.send(Job::OpenRecord(row.id.clone()));
}
fn edit_line(app: &mut App, key: KeyEvent, goal: bool) {
let buffer = if goal { &mut app.goal } else { &mut app.constraint_draft };
match key.code {
KeyCode::Esc => {
if !goal {
app.constraint_draft.clear();
}
app.mode = Mode::Normal;
}
KeyCode::Enter => {
if !goal {
let draft = app.constraint_draft.trim().to_string();
if draft.is_empty() {
app.status =
Status::Warn("empty constraint ignored — it would forbid every branch".into());
} else {
app.must_not.push(draft);
}
app.constraint_draft.clear();
}
app.mode = Mode::Normal;
}
KeyCode::Backspace => {
buffer.pop();
}
KeyCode::Char(c) => buffer.push(c),
_ => {}
}
}
fn confirm(app: &mut App, worker: &Worker, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
app.mode = Mode::Normal;
run_cycle(app, worker, true);
worker.send(Job::LoadRecords);
}
_ => {
app.mode = Mode::Normal;
app.status = Status::Note("not sealed".into());
}
}
}
fn enter() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, crossterm::cursor::Hide)?;
let terminal = Terminal::new(CrosstermBackend::new(out))?;
Ok(terminal)
}
fn leave(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
crossterm::cursor::Show
)?;
terminal.show_cursor()?;
Ok(())
}
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = io::stdout().execute(LeaveAlternateScreen);
let _ = io::stdout().execute(crossterm::cursor::Show);
previous(info);
}));
}