use std::io::Write;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::Serialize;
use crate::core::status_json::StatusGraph;
static ENABLED: AtomicBool = AtomicBool::new(false);
static MESSAGES: Mutex<Vec<String>> = Mutex::new(Vec::new());
static PENDING: Mutex<Option<AgentResponse>> = Mutex::new(None);
static GRAPH: Mutex<Option<StatusGraph>> = Mutex::new(None);
pub fn set(enabled: bool) {
ENABLED.store(enabled, Ordering::SeqCst);
}
pub fn enabled() -> bool {
ENABLED.load(Ordering::SeqCst)
}
#[derive(Debug)]
pub struct NeedsInput;
impl std::fmt::Display for NeedsInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "input required — see the JSON status line")
}
}
impl std::error::Error for NeedsInput {}
#[derive(Serialize, Debug)]
pub struct HunkItem {
pub id: String,
pub path: String,
pub diff: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub staged: bool,
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum InputKind {
Select,
Text,
Multiselect,
}
#[derive(Serialize, Debug)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum AgentResponse {
Ok {
#[serde(skip_serializing_if = "Vec::is_empty")]
messages: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
graph: Option<Box<StatusGraph>>,
},
NeedsInput {
kind: InputKind,
prompt: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
options: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
allow_other: bool,
#[serde(skip_serializing_if = "Option::is_none")]
fingerprint: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<HunkItem>,
hint: String,
},
NeedsConfirmation {
prompt: String,
hint: String,
},
Paused {
message: String,
hint: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
messages: Vec<String>,
},
Error {
message: String,
},
}
impl AgentResponse {
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("AgentResponse serialization cannot fail")
}
pub fn exit_code(&self) -> i32 {
match self {
AgentResponse::Ok { .. } | AgentResponse::Paused { .. } => 0,
AgentResponse::Error { .. } => 1,
AgentResponse::NeedsInput { .. } | AgentResponse::NeedsConfirmation { .. } => 10,
}
}
}
pub fn record_message(message: &str) {
if enabled() {
MESSAGES.lock().unwrap().push(message.to_string());
}
}
pub fn set_graph(graph: StatusGraph) {
if enabled() {
*GRAPH.lock().unwrap() = Some(graph);
}
}
pub fn respond_needs_input(
kind: InputKind,
prompt: &str,
options: Vec<String>,
allow_other: bool,
hint: &str,
) -> anyhow::Error {
*PENDING.lock().unwrap() = Some(AgentResponse::NeedsInput {
kind,
prompt: prompt.to_string(),
options,
allow_other,
fingerprint: None,
items: Vec::new(),
hint: hint.to_string(),
});
anyhow::Error::new(NeedsInput)
}
pub fn respond_needs_hunks(items: Vec<HunkItem>, fingerprint: String, hint: &str) -> anyhow::Error {
*PENDING.lock().unwrap() = Some(AgentResponse::NeedsInput {
kind: InputKind::Multiselect,
prompt: "Select hunks".to_string(),
options: items.iter().map(|item| item.id.clone()).collect(),
allow_other: false,
fingerprint: Some(fingerprint),
items,
hint: hint.to_string(),
});
anyhow::Error::new(NeedsInput)
}
pub fn respond_needs_confirmation(prompt: &str, hint: &str) -> anyhow::Error {
*PENDING.lock().unwrap() = Some(AgentResponse::NeedsConfirmation {
prompt: prompt.to_string(),
hint: hint.to_string(),
});
anyhow::Error::new(NeedsInput)
}
pub fn note_paused(message: &str, hint: &str) {
if enabled() {
*PENDING.lock().unwrap() = Some(AgentResponse::Paused {
message: message.to_string(),
hint: hint.to_string(),
messages: Vec::new(),
});
}
}
pub fn finish(result: &anyhow::Result<()>) -> i32 {
let pending = PENDING.lock().unwrap().take();
let collected = std::mem::take(&mut *MESSAGES.lock().unwrap());
let graph = GRAPH.lock().unwrap().take().map(Box::new);
let response = match result {
Ok(()) => match pending {
Some(AgentResponse::Paused { message, hint, .. }) => AgentResponse::Paused {
message,
hint,
messages: collected,
},
_ => AgentResponse::Ok {
messages: collected,
graph,
},
},
Err(e) if e.downcast_ref::<NeedsInput>().is_some() => {
pending.unwrap_or_else(|| AgentResponse::Error {
message: e.to_string(),
})
}
Err(e) => AgentResponse::Error {
message: e.to_string(),
},
};
let _ = writeln!(std::io::stdout(), "{}", response.to_json());
response.exit_code()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ok_with_messages() {
let r = AgentResponse::Ok {
messages: vec!["Created commit `1a2b3c4`".to_string()],
graph: None,
};
assert_eq!(
r.to_json(),
r#"{"status":"ok","messages":["Created commit `1a2b3c4`"]}"#
);
assert_eq!(r.exit_code(), 0);
}
#[test]
fn ok_without_messages_omits_field() {
let r = AgentResponse::Ok {
messages: vec![],
graph: None,
};
assert_eq!(r.to_json(), r#"{"status":"ok"}"#);
}
#[test]
fn ok_carries_the_status_graph_after_messages() {
let r = AgentResponse::Ok {
messages: vec!["Done".to_string()],
graph: Some(Box::new(crate::core::test_helpers::status_graph(
crate::core::test_helpers::base_info(),
))),
};
let json = r.to_json();
assert!(json.starts_with(r#"{"status":"ok","messages":["Done"],"graph":{"#));
assert!(!json.contains('\n'));
assert_eq!(r.exit_code(), 0);
}
#[test]
fn needs_input_select() {
let r = AgentResponse::NeedsInput {
kind: InputKind::Select,
prompt: "Select target branch".to_string(),
options: vec!["feature-a".to_string(), "feature-b".to_string()],
allow_other: true,
fingerprint: None,
items: vec![],
hint: "re-run with: loom commit -b <branch>".to_string(),
};
assert_eq!(
r.to_json(),
r#"{"status":"needs_input","kind":"select","prompt":"Select target branch","options":["feature-a","feature-b"],"allow_other":true,"hint":"re-run with: loom commit -b <branch>"}"#
);
assert_eq!(r.exit_code(), 10);
}
#[test]
fn needs_input_text_omits_options_and_allow_other() {
let r = AgentResponse::NeedsInput {
kind: InputKind::Text,
prompt: "Commit message".to_string(),
options: vec![],
allow_other: false,
fingerprint: None,
items: vec![],
hint: "pass -m <message>".to_string(),
};
assert_eq!(
r.to_json(),
r#"{"status":"needs_input","kind":"text","prompt":"Commit message","hint":"pass -m <message>"}"#
);
}
#[test]
fn needs_confirmation() {
let r = AgentResponse::NeedsConfirmation {
prompt: "Discard changes?".to_string(),
hint: "re-run with: loom drop <target> -y".to_string(),
};
assert_eq!(
r.to_json(),
r#"{"status":"needs_confirmation","prompt":"Discard changes?","hint":"re-run with: loom drop <target> -y"}"#
);
assert_eq!(r.exit_code(), 10);
}
#[test]
fn paused_without_messages_omits_field() {
let r = AgentResponse::Paused {
message: "Conflicts detected".to_string(),
hint: "run loom continue".to_string(),
messages: vec![],
};
assert_eq!(
r.to_json(),
r#"{"status":"paused","message":"Conflicts detected","hint":"run loom continue"}"#
);
assert_eq!(r.exit_code(), 0);
}
#[test]
fn paused_with_messages() {
let r = AgentResponse::Paused {
message: "Conflicts detected".to_string(),
hint: "run loom continue".to_string(),
messages: vec!["Rebased onto `origin/main`".to_string()],
};
assert_eq!(
r.to_json(),
r#"{"status":"paused","message":"Conflicts detected","hint":"run loom continue","messages":["Rebased onto `origin/main`"]}"#
);
}
#[test]
fn error() {
let r = AgentResponse::Error {
message: "Branch 'x' not found".to_string(),
};
assert_eq!(
r.to_json(),
r#"{"status":"error","message":"Branch 'x' not found"}"#
);
assert_eq!(r.exit_code(), 1);
}
#[test]
fn multiselect_kind_serializes_lowercase() {
let r = AgentResponse::NeedsInput {
kind: InputKind::Multiselect,
prompt: "Select files".to_string(),
options: vec!["a.rs".to_string()],
allow_other: false,
fingerprint: None,
items: vec![],
hint: "pass files".to_string(),
};
assert!(r.to_json().contains(r#""kind":"multiselect""#));
}
#[test]
fn needs_hunks_lists_every_id_in_options() {
let err = respond_needs_hunks(
vec![
HunkItem {
id: "a.rs:1".to_string(),
path: "a.rs".to_string(),
diff: "@@ -1 +1 @@\n-a\n+b\n".to_string(),
staged: false,
},
HunkItem {
id: "logo.png:1".to_string(),
path: "logo.png".to_string(),
diff: "(binary file)".to_string(),
staged: false,
},
],
"a91c3f2be417".to_string(),
"re-run with: loom split ab -m <message> -p --hunks <id> --hunks-from a91c3f2be417",
);
assert!(err.downcast_ref::<NeedsInput>().is_some());
let json = PENDING.lock().unwrap().take().unwrap().to_json();
assert!(json.contains(r#""options":["a.rs:1","logo.png:1"]"#));
assert!(json.contains(r#""fingerprint":"a91c3f2be417""#));
assert!(!json.contains("allow_other"));
}
}