use crate::client;
use crate::config::Paths;
use serde_json::Value;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, PartialEq)]
pub struct Seen {
pub session: Option<String>,
pub cwd: Option<String>,
pub model: String,
pub pct: Option<u8>,
pub size: Option<u64>,
pub input: Option<u64>,
}
pub fn read(v: &Value) -> Seen {
let s = |p: &str| v.pointer(p).and_then(Value::as_str).map(str::to_string);
let n = |p: &str| v.pointer(p).and_then(Value::as_f64);
let agent = s("/agent/name").filter(|a| !a.trim().is_empty());
let model = s("/model/display_name").unwrap_or_default();
let model = match agent {
Some(a) if !model.is_empty() => format!("{a} · {model}"),
Some(a) => a,
None => model,
};
Seen {
session: s("/session_id").filter(|x| crate::desk::valid_session(x)),
cwd: s("/cwd").or_else(|| s("/workspace/current_dir")),
model: clean(&model),
pct: n("/context_window/used_percentage")
.filter(|p| p.is_finite())
.map(|p| p.clamp(0.0, 100.0).round() as u8),
size: n("/context_window/context_window_size")
.filter(|x| x.is_finite() && *x > 0.0)
.map(|x| x as u64),
input: n("/context_window/total_input_tokens")
.filter(|x| x.is_finite() && *x >= 0.0)
.map(|x| x as u64),
}
}
pub fn clean(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.take(48)
.collect::<String>()
.trim()
.to_string()
}
pub fn run(paths: &Paths) -> anyhow::Result<()> {
let mut input = Vec::new();
if std::io::stdin().read_to_end(&mut input).is_err() {
return Ok(());
}
if let Some(cmd) = saved_command(&before_path(paths)) {
chain(&cmd, &input);
}
let Ok(v) = serde_json::from_slice::<Value>(&input) else {
return Ok(());
};
let seen = read(&v);
if let (Some(cwd), Some(sid)) = (&seen.cwd, &seen.session) {
crate::session::record(paths, cwd, sid);
}
if let Some(pane) = std::env::var("SNYVI_SESSION")
.ok()
.filter(|p| crate::pane::valid_id(p))
{
client::agent_context(paths, &pane, &seen);
}
Ok(())
}
pub fn before_path(paths: &Paths) -> PathBuf {
paths.config_dir.join("statusline-before.json")
}
pub fn saved_command(path: &Path) -> Option<String> {
let v: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
let c = v.get("command")?.as_str()?.trim();
(!c.is_empty()).then(|| c.to_string())
}
fn chain(cmd: &str, input: &[u8]) {
#[cfg(windows)]
let mut c = std::process::Command::new("cmd");
#[cfg(windows)]
c.args(["/C", cmd]);
#[cfg(not(windows))]
let mut c = std::process::Command::new("sh");
#[cfg(not(windows))]
c.args(["-c", cmd]);
let Ok(mut child) = c
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
else {
return;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input);
}
if let Ok(out) = child.wait_with_output() {
let _ = std::io::stdout().write_all(&out.stdout);
}
}
pub fn ours(entry: &Value) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|c| c.ends_with(" statusline") && c.contains("snyvi"))
}
pub fn install_into(settings: &mut Value, command: &str, before: &Path) -> anyhow::Result<bool> {
let obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json is not an object"))?;
let mut ours_now = serde_json::json!({ "type": "command", "command": command });
match obj.get("statusLine") {
Some(e) if ours(e) => {
if e.get("command").and_then(Value::as_str) == Some(command) {
return Ok(false);
}
ours_now = e.clone();
ours_now["command"] = Value::from(command);
}
Some(e) => {
if let Some(dir) = before.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(before, serde_json::to_string_pretty(e)?)?;
if let Some(p) = e.get("padding") {
ours_now["padding"] = p.clone();
}
}
None => {}
}
obj.insert("statusLine".into(), ours_now);
Ok(true)
}
pub fn remove_from(settings: &mut Value, before: &Path) -> bool {
let Some(obj) = settings.as_object_mut() else {
return false;
};
if !obj.get("statusLine").is_some_and(ours) {
return false;
}
let kept = std::fs::read_to_string(before)
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
match kept {
Some(k) => obj.insert("statusLine".into(), k),
None => obj.remove("statusLine"),
};
let _ = std::fs::remove_file(before);
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const PAYLOAD: &str = include_str!("../tests/fixtures/statusline.json");
#[test]
fn a_real_payload_gives_the_model_and_how_full_the_window_is() {
let v: Value = serde_json::from_str(PAYLOAD).unwrap();
let s = read(&v);
assert_eq!(s.model, "Fable 5.1");
assert_eq!(s.pct, Some(43));
assert_eq!(s.size, Some(200_000));
assert_eq!(s.input, Some(86_120));
assert_eq!(
s.session.as_deref(),
Some("f94bace6-1a2b-4c3d-8e9f-0123456789ab")
);
assert_eq!(s.cwd.as_deref(), Some("/home/reader/Projects/bos_dog"));
}
#[test]
fn a_thin_or_odd_payload_says_what_it_can() {
assert_eq!(read(&json!({})), Seen::default());
let s = read(&json!({
"session_id": "not-a-uuid",
"model": { "display_name": "Fable\n5.1\u{7}" },
"agent": { "name": "reviewer" },
"context_window": { "used_percentage": 131.6, "context_window_size": -5 }
}));
assert_eq!(s.session, None, "only a UUID is kept");
assert_eq!(s.model, "reviewer · Fable5.1");
assert_eq!(s.pct, Some(100));
assert_eq!(s.size, None);
}
#[test]
fn a_readers_own_line_is_kept_and_given_back() {
let dir = crate::store::tempdir::Dir::new("snyvi-statusline");
let before = dir.path.join("statusline-before.json");
let mine =
json!({ "type": "command", "command": "~/bin/line.sh --short 'x y'", "padding": 1 });
let mut settings = json!({ "statusLine": mine.clone(), "model": "opus" });
assert!(install_into(&mut settings, "snyvi statusline", &before).unwrap());
assert_eq!(settings["statusLine"]["command"], "snyvi statusline");
assert_eq!(settings["statusLine"]["padding"], 1);
assert_eq!(
saved_command(&before).as_deref(),
Some("~/bin/line.sh --short 'x y'")
);
assert!(!install_into(&mut settings, "snyvi statusline", &before).unwrap());
assert!(install_into(&mut settings, "/opt/snyvi statusline", &before).unwrap());
assert_eq!(
saved_command(&before).as_deref(),
Some("~/bin/line.sh --short 'x y'")
);
assert!(remove_from(&mut settings, &before));
assert_eq!(settings["statusLine"], mine);
assert!(!before.exists());
assert!(!remove_from(&mut settings, &before), "theirs is left alone");
}
#[test]
fn with_no_line_before_ours_comes_and_goes_alone() {
let dir = crate::store::tempdir::Dir::new("snyvi-statusline");
let before = dir.path.join("statusline-before.json");
let mut settings = json!({});
assert!(install_into(&mut settings, "snyvi statusline", &before).unwrap());
assert!(!before.exists());
assert!(remove_from(&mut settings, &before));
assert_eq!(settings, json!({}));
}
}