swarm / office/src/bin/office-tui.rs

raw file 30534 bytes
//! office-tui — terminal dashboard for a running (graphical or --headless)
//! office sim. Read-only cockpit polling the company's work/ folder.
//!
//!   header            day/clock + tasks-done + live-meeting + build-gate state
//!   left rail         who's working on what today (task id, status, %)
//!   tab: board        full assignment table + selected task details
//!   tab: desk chat    who said what to whom, face-to-face (work/log/desk.md)
//!   tab: meetings     ended meeting notes (topic + who-said-what)
//!   tab: mail         per-person inbox (incl. build-gate failure mail)
//!   tab: docs         work/project tree + file viewer (c in input mode → cmd)
//!   command input     `assign T3 Grace Kim` etc → work/commands.txt
//!
//! Run:  village/target/debug/office-tui companies/algodojo

use office::company::{slug, CompanyCfg};
use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::{Constraint, Layout, Rect},
    style::{Color, Modifier, Style, Stylize},
    text::{Line, Span},
    widgets::{Block, Paragraph, Wrap},
    DefaultTerminal, Frame,
};
use std::path::{Path, PathBuf};

const TABS: [&str; 7] = ["board", "desk chat", "meetings", "mail", "docs", "commits", "llm"];
const HELP: &str =
    "tab/1-6 page · ↑↓ select · PgUp/PgDn scroll detail · ←→ person (mail) · c command · q quit";

fn main() {
    let given = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "companies/algodojo".into());
    let company_dir = resolve_dir(&given);
    let cfg = CompanyCfg::load(&company_dir).unwrap_or_else(|e| {
        eprintln!("error: {e}");
        std::process::exit(1);
    });
    let mut term = ratatui::init();
    let app = &mut App {
        company_dir,
        cfg,
        tab: 0,
        sel: [0; 7],
        person_sel: 0,
        detail_scroll: [0; 7],
        doc_files: Vec::new(),
        meeting_files: Vec::new(),
        mail_files: Vec::new(),
        commit_lines: Vec::new(),
        llm_flights: Vec::new(),
        input: None,
        flash: String::new(),
    };
    let res = run(&mut term, app);
    ratatui::restore();
    if let Err(e) = res {
        eprintln!("tui error: {e}");
    }
}

fn resolve_dir(given: &str) -> PathBuf {
    let given = Path::new(given);
    if given.join("company.toml").exists() {
        return given.to_path_buf();
    }
    let mut dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    for _ in 0..6 {
        if dir.join(given).join("company.toml").exists() {
            return dir.join(given);
        }
        match dir.parent() {
            Some(p) => dir = p.to_path_buf(),
            None => break,
        }
    }
    given.to_path_buf()
}

fn wks(app: &App) -> PathBuf {
    app.company_dir.join("work")
}

// ────────────────────────────────── snapshot ────────────────────────────────

struct TaskRow {
    id: String,
    assignee: String,
    status: String,
    progress: u32,
}

struct Snapshot {
    day: u32,
    hhmm: String,
    meeting_live: Option<String>,
    tasks: Vec<TaskRow>,
    gate: Option<&'static str>,
}

fn snap(app: &App) -> Snapshot {
    let (day, minutes, tasks) = read_board(&wks(app));
    let hhmm = minutes_to_clock(minutes);
    let meeting_live = std::fs::read_to_string(wks(app).join("log/meeting_open.md"))
        .ok()
        .map(|t| {
            t.lines()
                .next()
                .unwrap_or("")
                .trim_start_matches('#')
                .trim()
                .to_string()
        })
        .filter(|t| !t.is_empty());
    let gate = std::fs::read_to_string(wks(app).join("project/verify_report.md"))
        .ok()
        .map(|t| if t.contains("FAIL ") { "red" } else { "green" });
    Snapshot { day, hhmm, meeting_live, tasks, gate }
}

fn read_board(root: &Path) -> (u32, f32, Vec<TaskRow>) {
    let text = std::fs::read_to_string(root.join("tasks.json")).unwrap_or_default();
    let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
    let day = v["day"].as_u64().unwrap_or(1) as u32;
    let sim_minutes = v["sim_minutes"].as_f64().unwrap_or(540.0) as f32;
    let tasks = v["tasks"]
        .as_array()
        .unwrap_or(&Vec::new())
        .iter()
        .map(|t| TaskRow {
            id: t["id"].as_str().unwrap_or("?").into(),
            assignee: t["assignee"]
                .as_str()
                .unwrap_or("unassigned")
                .to_string(),
            status: t["status"].as_str().unwrap_or("Backlog").into(),
            progress: t["progress"].as_u64().unwrap_or(0) as u32,
        })
        .collect();
    (day, sim_minutes, tasks)
}

fn minutes_to_clock(sim_minutes: f32) -> String {
    let m = sim_minutes.rem_euclid(1440.0);
    format!("{:02}:{:02}", (m / 60.0) as u32, (m % 60.0) as u32)
}

// ─────────────────────────────────── main loop ──────────────────────────────

struct App {
    company_dir: PathBuf,
    cfg: CompanyCfg,
    tab: usize,
    sel: [usize; 7],
    person_sel: usize,
    detail_scroll: [u16; 7],
    doc_files: Vec<String>,
    meeting_files: Vec<String>,
    mail_files: Vec<String>,
    commit_lines: Vec<String>,
    llm_flights: Vec<serde_json::Value>,
    input: Option<String>,
    flash: String,
}

fn run(term: &mut DefaultTerminal, app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
    loop {
        term.draw(|f| draw(f, app))?;
        if !event::poll(std::time::Duration::from_millis(1000))? {
            continue;
        }
        if let Event::Key(key) = event::read()? {
            if key.kind == KeyEventKind::Press {
                let typing = app.input.is_some();
                handle_key(app, key.code, typing);
            }
        }
    }
}

fn handle_key(app: &mut App, code: KeyCode, typing: bool) {
    match code {
        KeyCode::Char('c') if !typing => app.input = Some(String::new()),
        KeyCode::Esc => app.input = None,
        KeyCode::Enter if typing => submit_command(app),
        KeyCode::Backspace if typing => {
            if let Some(s) = app.input.as_mut() {
                s.pop();
            }
        }
        KeyCode::Char(ch) if typing => {
            if let Some(s) = app.input.as_mut() {
                if s.len() < 200 {
                    s.push(ch);
                }
            }
        }
        KeyCode::Tab if !typing => {
            app.tab = (app.tab + 1) % TABS.len();
            app.detail_scroll[app.tab] = 0;
        }
        KeyCode::Char(d) if !typing && ('1'..='7').contains(&d) => {
            app.tab = d as usize - '1' as usize; // tabs indexed 0..=5
            app.detail_scroll[app.tab] = 0;
        }
        KeyCode::Up if !typing => {
            app.sel[app.tab] = app.sel[app.tab].saturating_sub(1);
            app.detail_scroll[app.tab] = 0;
        }
        KeyCode::Down if !typing => {
            app.sel[app.tab] += 1;
            app.detail_scroll[app.tab] = 0;
        }
        KeyCode::PageDown if !typing => {
            app.detail_scroll[app.tab] = (app.detail_scroll[app.tab] + 10).min(500);
        }
        KeyCode::PageUp if !typing => {
            app.detail_scroll[app.tab] = app.detail_scroll[app.tab].saturating_sub(10);
        }
        KeyCode::Left if !typing && app.tab == 3 => {
            app.person_sel = app.person_sel.saturating_sub(1);
        }
        KeyCode::Right if !typing && app.tab == 3 => {
            app.person_sel += 1;
        }
        KeyCode::Char('q') if !typing => std::process::exit(0),
        _ => {}
    }
}

fn submit_command(app: &mut App) {
    let Some(line) = app.input.take() else { return };
    let line = line.trim().to_string();
    if line.is_empty() {
        return;
    }
    use std::io::Write;
    match std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(wks(app).join("commands.txt"))
    {
        Ok(mut f) => {
            let _ = writeln!(f, "{line}");
            app.flash = format!("→ sent: {line}");
        }
        Err(e) => app.flash = format!("send failed: {e}"),
    }
}

// ───────────────────────────────────── draw ─────────────────────────────────

fn draw(f: &mut Frame, app: &mut App) {
    let s = snap(app);
    app.doc_files = tree_list(&wks(app).join("project"));
    app.meeting_files = fs_list(&wks(app).join("meetings"));
    app.commit_lines = office::gitrepo::log(&wks(app).join("project"), 60)
        .unwrap_or_default();
    if let Ok(text) = std::fs::read_to_string(wks(app).join("log/llm.json")) {
        if let Ok(serde_json::Value::Array(v)) = serde_json::from_str(&text) {
            app.llm_flights = v;
        }
    }

    let outer = Layout::vertical([
        Constraint::Length(3),
        Constraint::Min(8),
        Constraint::Length(2),
    ])
    .split(f.area());
    let body = Layout::horizontal([Constraint::Length(36), Constraint::Min(30)]).split(outer[1]);

    draw_header(f, outer[0], app, &s);
    draw_people(f, body[0], app, &s);
    draw_pane(f, body[1], app, &s);
    draw_footer(f, outer[2], app);
}

fn title_block(title: &str, color: Color) -> Block<'static> {
    Block::bordered().title(Span::styled(
        format!(" {title} "),
        Style::default().fg(color).bold(),
    ))
}

fn body_lines(text: &str) -> Vec<Line<'static>> {
    text.lines()
        .map(|l| {
            if l.contains("FAIL") {
                Line::from(Span::styled(l.to_string(), Style::default().fg(Color::Red)))
            } else if l.contains("PASS") {
                Line::from(Span::styled(l.to_string(), Style::default().fg(Color::Green)))
            } else if l.starts_with('#') {
                Line::from(Span::styled(
                    l.to_string(),
                    Style::default().fg(Color::Cyan).bold(),
                ))
            } else {
                Line::from(l.to_string())
            }
        })
        .collect()
}

fn draw_header(f: &mut Frame, area: Rect, app: &App, s: &Snapshot) {
    let done = s.tasks.iter().filter(|t| t.status == "Done").count();
    let gate = match s.gate {
        Some("green") => Span::styled("GATE green ", Style::default().fg(Color::Green).bold()),
        Some("red") => Span::styled("GATE red ", Style::default().fg(Color::Red).bold()),
        _ => Span::styled("GATE — ", Style::default().fg(Color::DarkGray)),
    };
    let meeting = match &s.meeting_live {
        Some(topic) => Span::styled(
            format!("MEETING LIVE: {topic} "),
            Style::default().fg(Color::Yellow).bold(),
        ),
        None => Span::styled("no meeting ", Style::default().fg(Color::DarkGray)),
    };
    let header = Line::from(vec![
        Span::styled(
            app.cfg.company.name.clone(),
            Style::default().fg(Color::Cyan).bold(),
        ),
        Span::styled(
            format!("  DAY {} {}  ", s.day, s.hhmm),
            Style::default().fg(Color::White).bold(),
        ),
        Span::styled(
            format!("{}/{} done  ", done, s.tasks.len()),
            Style::default().fg(Color::Gray),
        ),
        meeting,
        gate,
    ]);
    let tabs = Line::from(
        TABS.iter()
            .enumerate()
            .map(|(i, t)| {
                let on = i == app.tab;
                Span::styled(
                    format!("({}) {}  ", i + 1, t),
                    Style::default()
                        .fg(if on { Color::Cyan } else { Color::DarkGray })
                        .add_modifier(if on { Modifier::BOLD } else { Modifier::empty() }),
                )
            })
            .collect::<Vec<_>>(),
    );
    f.render_widget(
        Paragraph::new(vec![header]).block(title_block(" office · live sim ", Color::Cyan)),
        area,
    );
    f.render_widget(
        tabs,
        Rect::new(area.x + 1, area.y + 2, area.width.saturating_sub(2), 1),
    );
}

fn rail_name(name: &str) -> String {
    let parts: Vec<&str> = name.split_whitespace().collect();
    match parts.as_slice() {
        [first, last] => format!("{first} {}", &last[..1]),
        _ => name.chars().take(14).collect(),
    }
}

fn status_icon(status: &str) -> &'static str {
    match status {
        "InReview" => "⇄",
        "Done" => "✔",
        "ChangesRequested" => "↩",
        _ => "→",
    }
}

fn status_color(status: &str) -> Color {
    match status {
        "Done" => Color::DarkGray,
        "InReview" => Color::Magenta,
        "ChangesRequested" => Color::Red,
        "InProgress" => Color::Green,
        _ => Color::Gray,
    }
}

fn draw_people(f: &mut Frame, area: Rect, app: &App, s: &Snapshot) {
    let mut lines = Vec::new();
    for p in &app.cfg.people {
        let mine: Vec<&TaskRow> = s.tasks.iter().filter(|t| t.assignee == p.name).collect();
        let star = if p.is_manager { "★" } else { " " };
        let name_style = Style::default()
            .fg(if p.is_manager { Color::Yellow } else { Color::Cyan })
            .add_modifier(Modifier::BOLD);
        lines.push(Line::from(vec![
            Span::styled(format!("{star}"), name_style),
            Span::styled(format!(" {:13}", rail_name(&p.name)), name_style),
            Span::styled(
                format!(" {}", p.role.chars().take(17).collect::<String>()),
                Style::default().fg(Color::Gray),
            ),
        ]));
        let sub = if mine.is_empty() {
            Line::from(Span::styled(
                "    · idle / between tasks",
                Style::default().fg(Color::DarkGray),
            ))
        } else {
            Line::from(
                mine.iter()
                    .map(|t| {
                        Span::styled(
                            format!(
                                " {}{} {:>3}%  ",
                                status_icon(&t.status),
                                t.id,
                                t.progress
                            ),
                            Style::default().fg(status_color(&t.status)),
                        )
                    })
                    .collect::<Vec<_>>(),
            )
        };
        lines.push(sub);
    }
    f.render_widget(
        Paragraph::new(lines).block(title_block(
            " people · on what today",
            Color::Cyan,
        )),
        area,
    );
}

fn draw_pane(f: &mut Frame, area: Rect, app: &mut App, s: &Snapshot) {
    let chunks = Layout::vertical([Constraint::Percentage(62), Constraint::Percentage(38)])
        .split(area);
    match app.tab {
        0 => draw_board(f, chunks[0], app, s),
        1 => draw_desk(f, chunks[0], app),
        2 => draw_meetings(f, chunks[0], app),
        3 => draw_mail(f, chunks[0], app),
        4 => draw_docs(f, chunks[0], app),
        5 => draw_commits(f, chunks[0], app),
        _ => draw_llm(f, chunks[0], app),
    }
    let (detail, title) = match app.tab {
        0 => (task_detail(app), " details · notes + description"),
        1 => (badge_detail(app), " who is in earshot · live"),
        2 => (meeting_detail(app), " meeting record"),
        3 => (mail_detail(app), " email text"),
        4 => (doc_detail(app), " document viewer"),
        5 => (commit_detail(app), " commit detail"),
        _ => (llm_detail(app), " gpu activity prompt+output"),
    };
    f.render_widget(
        Paragraph::new(body_lines(&detail))
            .wrap(Wrap { trim: false })
            .scroll((app.detail_scroll[app.tab], 0))
            .block(title_block(title, Color::Magenta)),
        chunks[1],
    );
}

fn select_index(sel: usize, len: usize) -> Option<usize> {
    if len == 0 {
        return None;
    }
    Some(sel.min(len - 1))
}

fn rendered(
    f: &mut Frame,
    area: Rect,
    title: &str,
    items: Vec<String>,
    sel: Option<usize>,
    color: Color,
) {
    let painted: Vec<Line> = items
        .into_iter()
        .enumerate()
        .map(|(i, item)| {
            let is_sel = Some(i) == sel;
            let mut spans = vec![if is_sel {
                Span::styled("► ", Style::default().fg(Color::Cyan).bold())
            } else {
                Span::raw("  ")
            }];
            spans.push(if is_sel {
                Span::styled(
                    item,
                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
                )
            } else {
                Span::styled(item, Style::default())
            });
            Line::from(spans)
        })
        .collect();
    f.render_widget(Paragraph::new(painted).block(title_block(title, color)), area);
}

fn draw_board(f: &mut Frame, area: Rect, app: &App, s: &Snapshot) {
    let items: Vec<String> = s
        .tasks
        .iter()
        .map(|t| {
            format!(
                "{} {:3} {:12} {:>3}%  {}",
                status_icon(&t.status),
                t.id,
                rail_name(&t.assignee),
                t.progress,
                t.id,
            )
        })
        .collect();
    rendered(
        f,
        area,
        " board · assignments + status + progress",
        items,
        select_index(app.sel[0], s.tasks.len()),
        Color::Blue,
    );
}

fn task_detail(app: &App) -> String {
    let idx = select_index(app.sel[0], board_len(app)).unwrap_or(0);
    board_detail(app, idx)
}

fn board_len(app: &App) -> usize {
    let text = std::fs::read_to_string(wks(app).join("tasks.json")).unwrap_or_default();
    serde_json::from_str::<serde_json::Value>(&text)
        .ok()
        .and_then(|v| v["tasks"].as_array().map(|a| a.len()))
        .unwrap_or(0)
}

fn board_detail(app: &App, idx: usize) -> String {
    let Ok(text) = std::fs::read_to_string(wks(app).join("tasks.json")) else {
        return "(no tasks.json yet)".into();
    };
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
        return "(bad tasks.json)".into();
    };
    let Some(tasks) = v["tasks"].as_array() else { return String::new() };
    let Some(t) = tasks.get(idx) else { return String::new() };
    let mut out = format!(
        "{} \"{}\" — {}\nassignee {} · {}% · status {}\n",
        t["id"].as_str().unwrap_or("?"),
        t["title"].as_str().unwrap_or(""),
        t["area"].as_str().unwrap_or(""),
        t["assignee"].as_str().unwrap_or("unassigned"),
        t["progress"].as_u64().unwrap_or(0),
        t["status"].as_str().unwrap_or("?"),
    );
    if let Some(d) = t["due_day"].as_u64() {
        out.push_str(&format!("due day {d}\n"));
    }
    if let Some(concern) = t["concern"].as_str() {
        out.push_str(&format!("⚠ {concern}\n"));
    }
    for note in t["notes"].as_array().unwrap_or(&Vec::new()) {
        if let Some(n) = note.as_str() {
            out.push_str(&format!("  · {n}\n"));
        }
    }
    if let Some(desc) = t["description"].as_str() {
        out.push_str(&format!("\n{desc}"));
    }
    out
}

fn draw_desk(f: &mut Frame, area: Rect, app: &App) {
    let text = std::fs::read_to_string(wks(app).join("log/desk.md")).unwrap_or_default();
    let lines: Vec<Line> = text
        .lines()
        .rev()
        .take(300)
        .filter(|l| !l.is_empty())
        .map(render_chat_line)
        .collect();
    f.render_widget(
        Paragraph::new(lines).block(title_block(
            " desk chat · face-to-face, latest first",
            Color::Cyan,
        )),
        area,
    );
}

fn render_chat_line(line: &str) -> Line<'static> {
    let (stamp, rest) = match line.strip_prefix('[').and_then(|a| a.find(']').map(|p| (a, p))) {
        Some((a, p)) => (a[..p].to_string(), a[p + 1..].trim()),
        None => (String::new(), line),
    };
    let (from, right) = rest.split_once(" → ").unwrap_or((rest, ""));
    let (to, msg) = right.split_once(": ").unwrap_or((right, ""));
    Line::from(vec![
        Span::styled(format!("{stamp:>14} "), Style::default().fg(Color::DarkGray)),
        Span::styled(format!("{from:13}"), Style::default().fg(Color::Cyan)),
        Span::styled("› ", Style::default().fg(Color::DarkGray)),
        Span::styled(format!("{:12}", to), Style::default().fg(Color::Magenta)),
        Span::styled(msg.to_string(), Style::default()),
    ])
}

/// desk-tab detail: latest utterance big, so users see one exchange clearly.
fn badge_detail(app: &App) -> String {
    let text = std::fs::read_to_string(wks(app).join("log/desk.md")).unwrap_or_default();
    let latest: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
    let n = latest.len();
    match latest.len() {
        0 => "(nobody has spoken yet — standup soon)".into(),
        _ => {
            let mut out = format!("({n} utterances logged)\n\n{}", latest[n - 1]);
            if n > 1 {
                out.push_str(&format!("\n{}\n{}", latest[n - 2], latest[n.min(6) - 1]));
            }
            out
        }
    }
}

fn draw_meetings(f: &mut Frame, area: Rect, app: &mut App) {
    rendered(
        f,
        area,
        " meetings · ended notes",
        app.meeting_files.clone(),
        select_index(app.sel[2], app.meeting_files.len()),
        Color::Magenta,
    );
}

fn meeting_detail(app: &App) -> String {
    match select_index(app.sel[2], app.meeting_files.len()) {
        Some(i) => {
            let p = wks(app).join("meetings").join(&app.meeting_files[i]);
            std::fs::read_to_string(&p).unwrap_or_else(|_| "(unreadable)".into())
        }
        None => "(no meetings have ended yet — a live one writes to log/meeting_open.md)".into(),
    }
}

fn draw_mail(f: &mut Frame, area: Rect, app: &mut App) {
    let n = app.cfg.people.len();
    app.person_sel = app.person_sel.min(n.saturating_sub(1));
    let person = &app.cfg.people[app.person_sel];
    let dir = wks(app).join("mail").join(slug(&person.name));
    app.mail_files = fs_list(&dir);
    let title = format!(
        " mail · {} [{} msgs]  (←→ change person)",
        person.name,
        app.mail_files.len()
    );
    let files = app.mail_files.clone();
    rendered(
        f,
        area,
        &title,
        files,
        select_index(
            app.sel[3],
            app.mail_files.len(),
        ),
        Color::Magenta,
    );
}

fn mail_detail(app: &App) -> String {
    let person = &app.cfg.people[app.person_sel.min(app.cfg.people.len().saturating_sub(1))];
    let dir = wks(app).join("mail").join(slug(&person.name));
    let files = fs_list(&dir);
    match select_index(app.sel[3], files.len()) {
        Some(i) => std::fs::read_to_string(dir.join(&files[i]))
            .unwrap_or_else(|_| "(unreadable)".into()),
        None => format!("no mail yet for {}", person.name),
    }
}

fn draw_docs(f: &mut Frame, area: Rect, app: &mut App) {
    rendered(
        f,
        area,
        " docs · work/project tree",
        app.doc_files.clone(),
        select_index(app.sel[4], app.doc_files.len()),
        Color::Blue,
    );
}

fn doc_detail(app: &App) -> String {
    match select_index(app.sel[4], app.doc_files.len()) {
        Some(i) => {
            let path = wks(app).join("project").join(&app.doc_files[i]);
            match std::fs::read(&path) {
                Ok(bytes) => match String::from_utf8(bytes) {
                    Ok(text) if text.len() <= 20000 => text,
                    Ok(text) => {
                        let head: String = text.chars().take(20000).collect();
                        format!("{head}\n…(truncated)")
                    }
                    Err(_) => "(binary file — open outside the TUI)".into(),
                },
                Err(_) => "(unreadable)".into(),
            }
        }
        None => "(project tree empty — agents haven't committed files yet)".into(),
    }
}

/// sorted, flat, recursive file list of dir (relative paths)
fn tree_list(dir: &Path) -> Vec<String> {
    let mut out = Vec::new();
    let mut stack = vec![PathBuf::from("")];
    while let Some(rel) = stack.pop() {
        if let Ok(entries) = std::fs::read_dir(dir.join(&rel)) {
            for e in entries.flatten() {
                let name = e.file_name().to_string_lossy().to_string();
                if name.starts_with('.') {
                    continue;
                }
                let child = if rel.as_os_str().is_empty() {
                    PathBuf::from(name)
                } else {
                    rel.join(&name)
                };
                if e.path().is_dir() {
                    stack.push(child);
                } else {
                    out.push(child.to_string_lossy().replace('\\', "/"));
                }
            }
        }
    }
    out.sort();
    out
}

fn fs_list(dir: &Path) -> Vec<String> {
    let mut out = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for e in entries.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if name.starts_with('.') {
                continue;
            }
            out.push(name.trim_end_matches(".read").to_string());
        }
    }
    out.sort();
    out
}

fn draw_footer(f: &mut Frame, area: Rect, app: &mut App) {
    let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
    let hint = match &app.input {
        Some(buf) => Line::from(vec![
            Span::styled(" c » ", Style::default().fg(Color::Yellow).bold()),
            Span::styled(
                buf.clone(),
                Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
            ),
            Span::styled("▏", Style::default().fg(Color::Yellow)),
            Span::styled("  Enter=send · Esc=cancel", Style::default().fg(Color::DarkGray)),
        ]),
        None => Line::from(Span::styled(
            format!(" {HELP}"),
            Style::default().fg(Color::DarkGray),
        )),
    };
    f.render_widget(hint, rows[0]);
    if !app.flash.is_empty() {
        f.render_widget(
            Line::from(Span::styled(
                app.flash.clone(),
                Style::default().fg(Color::Green),
            )),
            rows[1],
        );
    }
}

fn draw_commits(f: &mut Frame, area: Rect, app: &mut App) {
    let items: Vec<String> = app.commit_lines.clone();
    rendered(
        f,
        area,
        " commits · who did what, when (authored history)",
        items,
        select_index(app.sel[5], app.commit_lines.len()),
        Color::Yellow,
    );
}

fn commit_detail(app: &App) -> String {
    if app.commit_lines.is_empty() {
        return "(no commits yet)".into();
    }
    let idx = select_index(app.sel[5], app.commit_lines.len()).unwrap_or(0);
    let hash: String = app.commit_lines[idx]
        .split('›')
        .next()
        .unwrap_or_default()
        .trim()
        .split(' ')
        .next()
        .unwrap_or_default()
        .to_string();
    // show detail via the shared git helper
    office::gitrepo::show(&wks(app).join("project"), &hash)
        .unwrap_or_else(|| app.commit_lines[idx].clone())
}

// ─────────────────────────────── LLM activity tab ───────────────────────────

const STATUS_ICON_LLM: [(&str, &str, ratatui::style::Color); 5] = [
    ("queued", "⏳", ratatui::style::Color::Gray),
    ("computing", "✱", ratatui::style::Color::Yellow),
    ("discussing", "✱", ratatui::style::Color::Yellow),
    ("done", "✔", ratatui::style::Color::Green),
    ("failed", "✖", ratatui::style::Color::Red),
];

fn llm_row_vol(row: &serde_json::Value) -> (String, &'static str, ratatui::style::Color) {
    let status = row["status"].as_str().unwrap_or("");
    for (tag, icon, color) in STATUS_ICON_LLM {
        if status.starts_with(tag) {
            return (status.to_string(), icon, color);
        }
    }
    (status.to_string(), "?", ratatui::style::Color::Gray)
}

fn stamp_of(row: &serde_json::Value, now: f64) -> String {
    let sec = row["started"].as_f64().unwrap_or(0.0);
    let age = (now - sec).max(0.0) as u64;
    format!("{:>3}s ago", age.min(999))
}

fn draw_llm(f: &mut Frame, area: Rect, app: &mut App) {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs_f64())
        .unwrap_or(0.0);
    let items: Vec<String> = app
        .llm_flights
        .iter()
        .map(|row| {
            let (status, icon, _c) = llm_row_vol(row);
            let person = row["person"].as_str().unwrap_or("?");
            let kind = row["kind"].as_str().unwrap_or("");
            let model = row["model"].as_str().unwrap_or("—");
            let model_short: String = model
                .split(']')
                .next()
                .unwrap_or("queued")
                .to_string();
            format!(
                "{} {:>7} {:18} {:14}  {}",
                icon,
                stamp_of(row, now),
                kind,
                rail_name(person),
                if status.starts_with("queued") { "⌁ waiting".to_string() } else { model_short }
            )
        })
        .collect();
    rendered(
        f,
        area,
        " llm · what the GPUs are generating, for whom (live)",
        items,
        select_index(app.sel[6], app.llm_flights.len()),
        ratatui::style::Color::Yellow,
    );
}

fn llm_detail(app: &App) -> String {
    match select_index(app.sel[6], app.llm_flights.len()) {
        Some(i) => {
            let row = &app.llm_flights[i];
            let prompt = row["prompt"].as_str().unwrap_or("");
            let reply = row["reply"].as_str().unwrap_or("");
            format!(
                "{} — {} on {}\nStatus: {}\n\n── prompt ──\n{}\n\n── model output ──\n{}",
                row["person"].as_str().unwrap_or("?"),
                row["kind"].as_str().unwrap_or(""),
                row["model"].as_str().unwrap_or("queued"),
                row["status"].as_str().unwrap_or(""),
                prompt,
                reply
            )
            .replace("\\n", "\n")
        }
        None => "(no LLM jobs recorded yet)".into(),
    }
}