Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,6 @@
"spec_change_path": "/home/deadpool/Documents/codex-fleet/openspec/changes/codex-fleet-overlays-phase5-2026-05-14/CHANGE.md",
"auto_archive": false
},
"created_at": "2026-05-14T16:03:52.131Z",
"updated_at": "2026-05-14T16:03:52.131Z"
"created_at": "2026-05-14T16:17:20.446Z",
"updated_at": "2026-05-14T16:17:20.446Z"
}
23 changes: 23 additions & 0 deletions rust/fleet-data/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ pub fn load_live() -> std::io::Result<Vec<Account>> {
Ok(parse(&String::from_utf8_lossy(&output.stdout)))
}

fn cache() -> &'static crate::cache::TtlCache<Vec<Account>> {
static CACHE: std::sync::OnceLock<crate::cache::TtlCache<Vec<Account>>> =
std::sync::OnceLock::new();
CACHE.get_or_init(|| crate::cache::TtlCache::new(std::time::Duration::from_secs(5)))
}

/// Cached variant of [`load_live`]. Dashboards on a 250 ms tick should call
/// this instead — the `codex-auth list` subprocess is the most expensive
/// part of a tick (account list shifts on the order of seconds, not frames),
/// so a 5 s TTL keeps the UI responsive without burning a fork per frame.
///
/// Cache is process-local; the four dashboard binaries each maintain their
/// own. Call [`invalidate_cache`] after an operation that mutates account
/// state (login/logout/swap) to force a refetch on the next read.
pub fn load_live_cached() -> std::io::Result<Vec<Account>> {
cache().get_or_refresh(load_live)
}

/// Drop the cached account list so the next [`load_live_cached`] re-shells.
pub fn invalidate_cache() {
cache().invalidate();
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
104 changes: 104 additions & 0 deletions rust/fleet-data/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Tiny TTL cache for the dashboard data loaders.
//!
//! The fleet has four separate dashboard binaries (`fleet-state`,
//! `fleet-watcher`, `fleet-ui`, `fleet-tui-poc`) that each poll account /
//! pane state on a ~250 ms tick. Without a cache, every loader call shells
//! out — `codex-auth list` is hundreds of ms, and `tmux capture-pane`
//! adds one fork per pane. With a small in-process TTL this collapses
//! to one real call per TTL window, regardless of how many widgets in the
//! same binary ask for the data.

use std::sync::Mutex;
use std::time::{Duration, Instant};

/// Single-slot TTL cache. Holds the most recent `T` plus its capture time;
/// `get_or_refresh` returns the cached value if it's younger than `ttl`,
/// otherwise calls `refresh` and stores the result.
pub struct TtlCache<T> {
slot: Mutex<Option<(Instant, T)>>,
ttl: Duration,
}

impl<T: Clone> TtlCache<T> {
pub const fn new(ttl: Duration) -> Self {
Self { slot: Mutex::new(None), ttl }
}

pub fn get_or_refresh<F, E>(&self, refresh: F) -> Result<T, E>
where
F: FnOnce() -> Result<T, E>,
{
{
let guard = self.slot.lock().unwrap();
if let Some((ts, val)) = guard.as_ref() {
if ts.elapsed() < self.ttl {
return Ok(val.clone());
}
}
}
// Drop the lock before the (possibly slow) refresh so concurrent
// readers of a still-fresh value aren't blocked behind us.
let fresh = refresh()?;
let mut guard = self.slot.lock().unwrap();
*guard = Some((Instant::now(), fresh.clone()));
Ok(fresh)
}

pub fn invalidate(&self) {
*self.slot.lock().unwrap() = None;
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};

#[test]
fn caches_within_ttl_and_refreshes_after() {
let cache: TtlCache<u32> = TtlCache::new(Duration::from_millis(50));
let calls = AtomicUsize::new(0);
let load = || -> Result<u32, ()> {
calls.fetch_add(1, Ordering::SeqCst);
Ok(42)
};

assert_eq!(cache.get_or_refresh(load).unwrap(), 42);
assert_eq!(cache.get_or_refresh(load).unwrap(), 42);
assert_eq!(calls.load(Ordering::SeqCst), 1, "second call inside TTL must reuse");

std::thread::sleep(Duration::from_millis(70));
assert_eq!(cache.get_or_refresh(load).unwrap(), 42);
assert_eq!(calls.load(Ordering::SeqCst), 2, "call after TTL must refresh");
}

#[test]
fn invalidate_forces_refresh() {
let cache: TtlCache<u32> = TtlCache::new(Duration::from_secs(60));
let calls = AtomicUsize::new(0);
let load = || -> Result<u32, ()> {
calls.fetch_add(1, Ordering::SeqCst);
Ok(7)
};

cache.get_or_refresh(load).unwrap();
cache.get_or_refresh(load).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);

cache.invalidate();
cache.get_or_refresh(load).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
}

#[test]
fn refresh_error_is_not_cached() {
let cache: TtlCache<u32> = TtlCache::new(Duration::from_secs(60));
assert!(cache
.get_or_refresh(|| -> Result<u32, &'static str> { Err("nope") })
.is_err());
let val = cache
.get_or_refresh(|| -> Result<u32, &'static str> { Ok(99) })
.unwrap();
assert_eq!(val, 99, "after an error the next call must run refresh, not return stale");
}
}
1 change: 1 addition & 0 deletions rust/fleet-data/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Shared data contracts for the codex-fleet dashboard port.

pub mod accounts;
pub mod cache;
pub mod panes;
pub mod plan;
35 changes: 29 additions & 6 deletions rust/fleet-data/src/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ pub fn classify(info: &PaneInfo) -> PaneState {

/// List the panes in `session:window` (or all panes if `window` is None).
/// Returns `PaneInfo` with scrollback already captured (`-S -200`).
///
/// Internally this issues one `tmux list-panes` call, then spawns the
/// `tmux capture-pane` subprocesses for every pane **concurrently** (each
/// one is non-blocking via `Command::spawn`) before collecting their
/// outputs. With ~20 panes this replaces ~21 sequential forks with one
/// sequential fork plus ~20 parallel forks — the wall-clock cost drops
/// from sum-of-forks to roughly the slowest single fork.
pub fn list_panes(session: &str, window: Option<&str>) -> std::io::Result<Vec<PaneInfo>> {
let target = match window {
Some(w) => format!("{session}:{w}"),
Expand All @@ -115,8 +122,11 @@ pub fn list_panes(session: &str, window: Option<&str>) -> std::io::Result<Vec<Pa
return Ok(Vec::new());
}

let mut out = Vec::new();
for line in String::from_utf8_lossy(&list.stdout).lines() {
// Parse pane metadata first so we have a stable ordering and don't have
// to re-walk the stdout buffer alongside child handles.
let stdout = String::from_utf8_lossy(&list.stdout).into_owned();
let mut metas: Vec<(String, Option<String>, String)> = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() != 3 {
continue;
Expand All @@ -125,12 +135,25 @@ pub fn list_panes(session: &str, window: Option<&str>) -> std::io::Result<Vec<Pa
let panel = parts[1].trim();
let panel_label = if panel.is_empty() { None } else { Some(panel.to_string()) };
let current_command = parts[2].to_string();
metas.push((pane_id, panel_label, current_command));
}

let cap = Command::new("tmux")
.args(["capture-pane", "-p", "-t", &pane_id, "-S", "-200"])
.output()?;
let scrollback_tail = String::from_utf8_lossy(&cap.stdout).into_owned();
// Spawn every capture-pane up front; the OS runs them in parallel.
let mut children = Vec::with_capacity(metas.len());
for (pane_id, _, _) in &metas {
let child = Command::new("tmux")
.args(["capture-pane", "-p", "-t", pane_id, "-S", "-200"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?;
children.push(child);
}

// Drain in submission order so each PaneInfo lines up with its metadata.
let mut out = Vec::with_capacity(metas.len());
for ((pane_id, panel_label, current_command), child) in metas.into_iter().zip(children) {
let captured = child.wait_with_output()?;
let scrollback_tail = String::from_utf8_lossy(&captured.stdout).into_owned();
out.push(PaneInfo { pane_id, panel_label, current_command, scrollback_tail });
}
Ok(out)
Expand Down
6 changes: 6 additions & 0 deletions rust/fleet-tui-poc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ struct App {
events: Vec<String>,
chip_rect: Option<Rect>,
overlay: Overlay,
/// (rect, shortcut_char) for each rendered context-menu item — populated
/// each frame in render_context_menu so the mouse handler can look up
/// which row was clicked and dispatch the same tmux command as the
/// keyboard shortcut.
ctx_menu_items: Vec<(Rect, char)>,
// Spotlight state — query the user is typing + which result row is
// selected (0 = top hit, 1..N = grouped results).
spotlight_query: String,
Expand All @@ -162,6 +167,7 @@ impl App {
events: vec!["click the systemBlue chip — coords land here".into()],
chip_rect: None,
overlay: Overlay::None,
ctx_menu_items: Vec::new(),
spotlight_query: String::new(),
spotlight_selected: 0,
spotlight_tick: 0,
Expand Down