use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use crate::log::now_ms;
use crate::session::event::SessionEvent;
use crate::session::views::{SessionView, ViewError};
const MAX_QUEUED: usize = 1_000;
pub type NameLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
pub fn without_mentions(text: &str, names: Option<&NameLookup>) -> String {
let mut out = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut at = 0;
while let Some(open) = text[at..].find("<@") {
let start = at + open;
let body_start = start + 2;
let body_start = if bytes.get(body_start) == Some(&b'!') {
body_start + 1
} else {
body_start
};
let Some(close) = text[body_start..].find('>') else {
break;
};
let id = &text[body_start..body_start + close];
out.push_str(&text[at..start]);
let known = id
.parse::<u64>()
.ok()
.and_then(|numeric| names.and_then(|names| (names)(&numeric.to_string())));
if let Some(name) = known {
out.push('@');
out.push_str(&name);
out.push(' ');
}
at = body_start + close + 1;
while bytes.get(at) == Some(&b' ') {
at += 1;
}
}
out.push_str(&text[at..]);
out.trim().to_owned()
}
pub struct WebView {
names: Option<NameLookup>,
sender: tokio::sync::mpsc::Sender<String>,
turn: Mutex<Option<u32>>,
}
impl WebView {
pub fn new(names: Option<NameLookup>) -> (Arc<Self>, tokio::sync::mpsc::Receiver<String>) {
let (sender, receiver) = tokio::sync::mpsc::channel(MAX_QUEUED);
let view = Arc::new(Self {
names,
sender,
turn: Mutex::new(None),
});
view.push("reset", &serde_json::json!({}));
(Arc::clone(&view), receiver)
}
pub fn is_closed(&self) -> bool {
self.sender.is_closed()
}
fn push(&self, event: &str, payload: &Value) {
let _ = self
.sender
.try_send(format!("event: {event}\ndata: {payload}\n\n"));
}
fn entry(&self, mut entry: Value) {
if let Some(turn) = *self.turn.lock().expect("the view turn lock") {
entry["turn"] = Value::from(turn);
}
self.push("entry", &entry);
}
#[expect(clippy::needless_pass_by_value)]
fn state(&self, state: Value) {
self.push("state", &state);
}
fn clean(&self, text: &str) -> String {
without_mentions(text, self.names.as_ref())
}
}
impl SessionView for WebView {
fn observe<'a>(
&'a self,
event: &'a SessionEvent,
) -> Pin<Box<dyn Future<Output = Result<(), ViewError>> + Send + 'a>> {
Box::pin(async move {
let at = now_ms();
match event {
SessionEvent::BeginTurn { turn } => {
*self.turn.lock().expect("the view turn lock") = Some(*turn);
}
SessionEvent::Post { text, .. } => self.entry(serde_json::json!({
"kind": "message",
"text": self.clean(text),
"at": at,
})),
SessionEvent::Prompt { author, text, .. } => self.entry(serde_json::json!({
"kind": "prompt",
"author": author,
"text": self.clean(text),
"at": at,
})),
SessionEvent::Aside { author, text, .. } => self.entry(serde_json::json!({
"kind": "aside",
"author": author,
"text": self.clean(text),
"at": at,
})),
SessionEvent::Notice { text, level } => self.entry(serde_json::json!({
"kind": "notice",
"text": self.clean(text),
"level": level,
"at": at,
})),
SessionEvent::Thinking { text, .. } => self.entry(serde_json::json!({
"kind": "thinking",
"text": text,
"at": at,
})),
SessionEvent::Reply { .. } | SessionEvent::Reaction { .. } => {}
SessionEvent::ToolResult { result } => self.entry(serde_json::json!({
"kind": "toolResult",
"result": result,
"at": at,
})),
SessionEvent::Activity { line, tool } => self.entry(serde_json::json!({
"kind": "activity",
"line": line,
"tool": tool,
"at": at,
})),
SessionEvent::Delegation { delegated } => self.entry(serde_json::json!({
"kind": "delegation",
"delegated": delegated,
"at": at,
})),
SessionEvent::Diff {
path,
added,
removed,
body,
cause,
..
} => self.entry(serde_json::json!({
"kind": "diff",
"path": path,
"added": added,
"removed": removed,
"body": body,
"cause": cause,
"at": at,
})),
SessionEvent::Attachment { name, size } => self.entry(serde_json::json!({
"kind": "file",
"name": name,
"size": size,
"at": at,
})),
SessionEvent::Upload { name, bytes, .. } => self.entry(serde_json::json!({
"kind": "file",
"name": name,
"size": bytes.len(),
"at": at,
})),
SessionEvent::Usage { usage } => {
self.state(serde_json::json!({ "usage": usage }));
}
SessionEvent::Waiting { text } => {
self.state(serde_json::json!({ "waiting": text }));
}
SessionEvent::Busy { busy } => {
self.state(serde_json::json!({ "busy": busy }));
}
SessionEvent::Close { .. } => {
self.state(serde_json::json!({ "ended": true, "busy": false }));
}
}
Ok(())
})
}
}
pub fn wire(entry: &SessionEvent, at: i64, turn: Option<u32>, names: Option<&NameLookup>) -> Value {
let clean = |text: &str| without_mentions(text, names);
let mut shown = match entry {
SessionEvent::Post { text, .. } => serde_json::json!({
"kind": "message",
"text": clean(text),
"at": at,
}),
SessionEvent::Prompt { author, text, .. } => serde_json::json!({
"kind": "prompt",
"author": author,
"text": clean(text),
"at": at,
}),
SessionEvent::Aside { author, text, .. } => serde_json::json!({
"kind": "aside",
"author": author,
"text": clean(text),
"at": at,
}),
SessionEvent::Notice { text, level } => serde_json::json!({
"kind": "notice",
"text": clean(text),
"level": level,
"at": at,
}),
SessionEvent::Thinking { text, .. } => serde_json::json!({
"kind": "thinking",
"text": text,
"at": at,
}),
SessionEvent::Reply { text, command, .. } => serde_json::json!({
"kind": "reply",
"text": clean(text),
"command": command,
"at": at,
}),
SessionEvent::ToolResult { result } => serde_json::json!({
"kind": "toolResult",
"result": result,
"at": at,
}),
SessionEvent::Activity { line, tool } => serde_json::json!({
"kind": "activity",
"line": line,
"tool": tool,
"at": at,
}),
SessionEvent::Delegation { delegated } => serde_json::json!({
"kind": "delegation",
"delegated": delegated,
"at": at,
}),
SessionEvent::Diff {
path,
added,
removed,
body,
cause,
..
} => serde_json::json!({
"kind": "diff",
"path": path,
"added": added,
"removed": removed,
"body": body,
"cause": cause,
"at": at,
}),
SessionEvent::Attachment { name, size } => serde_json::json!({
"kind": "file",
"name": name,
"size": size,
"at": at,
}),
SessionEvent::Upload { .. }
| SessionEvent::Usage { .. }
| SessionEvent::Waiting { .. }
| SessionEvent::Reaction { .. }
| SessionEvent::Busy { .. }
| SessionEvent::BeginTurn { .. }
| SessionEvent::Close { .. } => Value::Null,
};
if let Some(turn) = turn {
shown["turn"] = Value::from(turn);
}
shown
}
#[cfg(test)]
mod tests;