diff --git a/cli/src/main.rs b/cli/src/main.rs
index 339005036eea2b7c0f98ab9b3007854de8ffaf2c..eff3e415a7995378b3286efd5af06a1259d0ef45 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -585,29 +585,35 @@ fn print_thread(resp: &ThreadDetailResponse) {
);
}
for (i, item) in resp.items.iter().enumerate() {
- match item {
+ let xml = match item {
ThreadItem::Post {
index,
ts,
+ actor,
+ delegate,
body,
..
- } => {
- let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
- let body = body.trim();
- println!("");
- println!("{body}");
- println!("");
- }
+ } => slug_types::thread_xml::format_post_at(
+ now_ms,
+ *index,
+ *ts,
+ actor,
+ delegate.as_deref(),
+ body,
+ ),
ThreadItem::System { ts, text } => {
let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
- println!("{}", timeago, text.trim());
+ slug_types::thread_xml::format_system(&timeago, text)
}
- }
+ };
+ print!("{xml}");
if i + 1 < resp.items.len() {
- println!();
- println!();
+ print!("{}", slug_types::thread_xml::ITEM_SEPARATOR);
}
}
+ if !resp.items.is_empty() {
+ println!();
+ }
}
/// Parse a Unix ms timestamp or YYYY-MM-DD date string to ms since epoch.
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index a0f8ee1d658bdba4d2168e5198c1104918b3575d..0b7070a2b7289e5c40778c8d062bd4150285a8df 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -478,6 +478,7 @@ fn rpc_forum_thread_detail(
index: idx,
ts: ing.ts,
actor: ing.principal.clone(),
+ delegate: ing.delegate.clone(),
body: if redacted { String::new() } else { ing.raw.clone() },
truncated: false,
redacted,
@@ -538,6 +539,7 @@ fn rpc_forum_thread_detail(
index: idx,
ts: ing.ts,
actor: ing.principal.clone(),
+ delegate: ing.delegate.clone(),
body,
truncated,
redacted,
diff --git a/server/src/html/forum/copy.rs b/server/src/html/forum/copy.rs
index 29057c68856cea85106e9f911a8d9466eedfc870..4c878921a51e23ff4fdf1bc28037847f69098876 100644
--- a/server/src/html/forum/copy.rs
+++ b/server/src/html/forum/copy.rs
@@ -3,7 +3,7 @@ use crate::form_template::template_json_compact;
use crate::reducer::{scope_from_room_wire, ReducerState, ScopeId};
use crate::state::AppState;
use maud::{html, Markup};
-use slug_types::timeago::timeago_compact;
+use slug_types::thread_xml;
use super::access::user_can_view_room;
use super::ingest::thread_ui_fetch_onclick;
@@ -11,7 +11,7 @@ use super::nav::ThreadNav;
use crate::html::ui_action::HtmlUiAction;
use crate::html::{JsBuilder, now_ms};
-/// CLI `forum show` text (`print_thread` in `cli/src/main.rs`).
+/// CLI `forum show` and browser copy-thread text (`slug_types::thread_xml`).
pub(crate) fn format_thread_cli_text(
reduced: &ReducerState,
scope: &ScopeId,
@@ -32,19 +32,22 @@ pub(crate) fn format_thread_cli_text(
continue;
};
if i > 0 {
- out.push_str("\n\n\n");
+ out.push_str(thread_xml::ITEM_SEPARATOR);
}
- let index = i;
- let timeago = timeago_compact(now_ms, ing.ts);
let redacted = reduced.redacted_posts.contains(&ing.id);
let body = if redacted {
String::new()
} else {
ing.raw.trim().to_string()
};
- out.push_str(&format!("\n"));
- out.push_str(&body);
- out.push_str("\n");
+ out.push_str(&thread_xml::format_post_at(
+ now_ms,
+ i,
+ ing.ts,
+ &ing.principal,
+ ing.delegate.as_deref(),
+ &body,
+ ));
}
out
}
diff --git a/types/src/lib.rs b/types/src/lib.rs
index d747e509094d75124573f252ee4b6c73186b4c24..0a6f7c2cd9acbb83acb9b3d9db629e6b9603dd8b 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -5,6 +5,7 @@ pub mod item_wire;
pub mod item_id;
pub mod paths;
pub mod timeago;
+pub mod thread_xml;
pub use item_id::ItemId;
pub use item_wire::{
@@ -163,6 +164,9 @@ pub enum ThreadItem {
index: usize,
ts: i64,
actor: String,
+ /// Agent delegate (`uuid:rig:provider/model`) when present; omitted in JSON when absent.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ delegate: Option,
body: String,
truncated: bool,
/// Author redacted this post; body is empty and garden contributions were removed.
diff --git a/types/src/thread_xml.rs b/types/src/thread_xml.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e7b42646de727c7660793b83fff3fef0403797be
--- /dev/null
+++ b/types/src/thread_xml.rs
@@ -0,0 +1,110 @@
+//! Thread timeline as XML for CLI `forum show` and browser copy-thread.
+
+use crate::timeago::timeago_compact;
+
+/// Blank line block between consecutive thread rows in CLI / copy output.
+pub const ITEM_SEPARATOR: &str = "\n\n\n";
+
+fn escape_xml_attr(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '"' => out.push_str("""),
+ '<' => out.push_str("<"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+/// Opening tag for one forum post (body omitted).
+pub fn post_open_tag(
+ index: usize,
+ timeago: &str,
+ principal: &str,
+ delegate: Option<&str>,
+) -> String {
+ let delegate_attr = delegate.unwrap_or("");
+ format!(
+ "",
+ index,
+ escape_xml_attr(timeago),
+ escape_xml_attr(principal),
+ escape_xml_attr(delegate_attr),
+ )
+}
+
+/// One forum post element: opening tag, trimmed body, closing tag.
+pub fn format_post(
+ index: usize,
+ timeago: &str,
+ principal: &str,
+ delegate: Option<&str>,
+ body: &str,
+) -> String {
+ format!(
+ "{}\n{}\n",
+ post_open_tag(index, timeago, principal, delegate),
+ body.trim(),
+ )
+}
+
+/// One room system line in thread XML.
+pub fn format_system(timeago: &str, text: &str) -> String {
+ format!(
+ "{}",
+ escape_xml_attr(timeago),
+ text.trim(),
+ )
+}
+
+/// Format a post row using wall-clock `now_ms` for the `timeago` attribute.
+pub fn format_post_at(
+ now_ms: i64,
+ index: usize,
+ ts: i64,
+ principal: &str,
+ delegate: Option<&str>,
+ body: &str,
+) -> String {
+ let timeago = timeago_compact(now_ms, ts);
+ format_post(index, &timeago, principal, delegate, body)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn post_includes_principal_and_delegate() {
+ let xml = format_post(
+ 2,
+ "1h2m3s",
+ "alice",
+ Some("00000000-0000-0000-0000-000000000000:test:local/dev"),
+ "hello\n",
+ );
+ assert!(xml.starts_with(
+ ""
+ ));
+ assert!(xml.ends_with("\nhello\n"));
+ }
+
+ #[test]
+ fn post_empty_delegate_when_human() {
+ let xml = format_post(0, "5s", "bob", None, "hi");
+ assert!(xml.starts_with(
+ ""
+ ));
+ }
+
+ #[test]
+ fn escapes_attribute_values() {
+ let xml = post_open_tag(0, "1s", "a&b", Some("d\"q"));
+ assert_eq!(
+ xml,
+ ""
+ );
+ }
+}