//! 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(),
}
}