use crate::ui_protocol::transport;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
const POPUP_TIMEOUT: Duration = Duration::from_secs(300);
const DEFAULT_PLUGIN_PATH: &str = "~/.config/zellij/plugins/exomonad-plugin.wasm";
fn get_plugin_path() -> Result<String> {
let path =
std::env::var("EXOMONAD_PLUGIN_PATH").unwrap_or_else(|_| DEFAULT_PLUGIN_PATH.to_string());
let expanded = if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(rest).to_string_lossy().to_string()
} else {
path.clone()
}
} else {
path.clone()
};
if !std::path::Path::new(&expanded).exists() {
anyhow::bail!(
"Zellij plugin not found at '{}'. Run 'just install-all' to install it.",
expanded
);
}
Ok(format!("file:{}", expanded))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PopupInput {
pub title: String,
pub components: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PopupOutput {
pub button: String,
pub values: serde_json::Value,
}
pub struct PopupService {
zellij_session: String,
}
impl PopupService {
pub fn new(zellij_session: String) -> Self {
Self { zellij_session }
}
#[tracing::instrument(skip(self))]
pub fn show_popup(&self, input: &PopupInput) -> Result<PopupOutput> {
tracing::info!(title = %input.title, components = input.components.len(), "Showing popup");
let request_id = uuid::Uuid::new_v4().to_string();
let items = extract_choice_items(&input.components);
if items.is_empty() {
anyhow::bail!("Popup must have at least one choice item");
}
let plugin_path = get_plugin_path()?;
let session = &self.zellij_session;
let safe_title = sanitize_payload_field(&input.title);
let safe_items: Vec<String> = items.iter().map(|s| sanitize_payload_field(s)).collect();
let payload = format!("{}|{}|{}", request_id, safe_title, safe_items.join(","));
tracing::debug!(
request_id = %request_id,
plugin = %plugin_path,
session = %session,
items = ?items,
"Sending popup request via zellij pipe --plugin"
);
let mut child = Command::new("zellij")
.arg("--session")
.arg(session)
.arg("pipe")
.arg("--plugin")
.arg(&plugin_path)
.arg("--name")
.arg(transport::POPUP_PIPE)
.arg("--")
.arg(&payload)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn zellij pipe command")?;
let (tx, rx) = mpsc::channel();
let mut stdout = child.stdout.take().expect("stdout was piped");
let mut stderr = child.stderr.take().expect("stderr was piped");
let stderr_handle = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = stderr.read_to_end(&mut buffer);
buffer
});
std::thread::spawn(move || {
let mut buffer = Vec::new();
let result = stdout.read_to_end(&mut buffer);
let _ = tx.send((result, buffer));
});
tracing::debug!(request_id = %request_id, "Waiting for popup response (timeout: {:?})", POPUP_TIMEOUT);
let response_str = match rx.recv_timeout(POPUP_TIMEOUT) {
Ok((Ok(_), buffer)) => {
let _ = child.wait();
if let Ok(stderr_buf) = stderr_handle.join() {
if !stderr_buf.is_empty() {
let stderr_str = String::from_utf8_lossy(&stderr_buf);
tracing::warn!(request_id = %request_id, stderr = %stderr_str, "zellij pipe stderr");
}
}
String::from_utf8(buffer).context("Invalid UTF-8 in popup response")?
}
Ok((Err(e), _)) => {
let _ = child.kill();
let _ = child.wait();
if let Ok(stderr_buf) = stderr_handle.join() {
if !stderr_buf.is_empty() {
let stderr_str = String::from_utf8_lossy(&stderr_buf);
tracing::error!(request_id = %request_id, stderr = %stderr_str, "zellij pipe failed");
}
}
return Err(anyhow::Error::from(e).context("Failed to read popup response"));
}
Err(mpsc::RecvTimeoutError::Timeout) => {
tracing::warn!(request_id = %request_id, "Popup timed out after {:?}", POPUP_TIMEOUT);
let _ = child.kill();
let _ = child.wait();
let _ = stderr_handle.join();
anyhow::bail!("Popup timed out after {} seconds", POPUP_TIMEOUT.as_secs());
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = child.kill();
let _ = child.wait();
let _ = stderr_handle.join();
anyhow::bail!("Popup response channel disconnected unexpectedly");
}
};
tracing::info!(
request_id = %request_id,
response = %response_str,
response_len = response_str.len(),
response_bytes = ?response_str.as_bytes(),
"Received popup response"
);
let response_str = response_str.trim();
if response_str.is_empty() {
anyhow::bail!(
"Empty response from zellij pipe - plugin may have failed to load or respond"
);
}
let (resp_request_id, selection) = response_str.split_once(':').context(format!(
"Invalid popup response format: expected 'request_id:selection', got: {:?}",
response_str
))?;
if resp_request_id != request_id {
tracing::warn!(
expected = %request_id,
received = %resp_request_id,
"Request ID mismatch in popup response"
);
}
let (button, values) = if selection == "CANCELLED" {
("cancel".to_string(), serde_json::json!({}))
} else {
(
"submit".to_string(),
serde_json::json!({"selected": selection}),
)
};
tracing::info!(
request_id = %request_id,
button = %button,
"Popup completed"
);
Ok(PopupOutput { button, values })
}
}
fn sanitize_payload_field(s: &str) -> String {
s.replace('|', "-").replace(',', ";")
}
fn extract_choice_items(components: &[serde_json::Value]) -> Vec<String> {
for component in components {
if component.get("type").and_then(|t| t.as_str()) == Some("choice") {
if let Some(options) = component.get("options").and_then(|o| o.as_array()) {
return options
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
}
components
.iter()
.filter_map(|c| {
if c.get("type").and_then(|t| t.as_str()) == Some("text") {
c.get("content").and_then(|v| v.as_str().map(String::from))
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_popup_input_serialization() {
let input = PopupInput {
title: "Test".to_string(),
components: vec![serde_json::json!({
"type": "text",
"id": "msg",
"content": "Hello"
})],
};
let json = serde_json::to_string(&input).unwrap();
let parsed: PopupInput = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, "Test");
assert_eq!(parsed.components.len(), 1);
}
#[test]
fn test_popup_output_serialization() {
let output = PopupOutput {
button: "submit".to_string(),
values: serde_json::json!({"choice": "option1"}),
};
let json = serde_json::to_string(&output).unwrap();
let parsed: PopupOutput = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.button, "submit");
}
#[test]
fn test_extract_choice_items_from_choice_component() {
let components = vec![serde_json::json!({
"type": "choice",
"id": "action",
"label": "Select action",
"options": ["Option A", "Option B", "Option C"]
})];
let items = extract_choice_items(&components);
assert_eq!(items, vec!["Option A", "Option B", "Option C"]);
}
#[test]
fn test_extract_choice_items_fallback_to_text() {
let components = vec![
serde_json::json!({
"type": "text",
"id": "t1",
"content": "First Item"
}),
serde_json::json!({
"type": "text",
"id": "t2",
"content": "Second Item"
}),
];
let items = extract_choice_items(&components);
assert_eq!(items, vec!["First Item", "Second Item"]);
}
#[test]
fn test_extract_choice_items_empty() {
let components: Vec<serde_json::Value> = vec![];
let items = extract_choice_items(&components);
assert!(items.is_empty());
}
#[test]
fn test_extract_choice_items_prefers_choice_over_text() {
let components = vec![
serde_json::json!({
"type": "text",
"id": "header",
"content": "Please select:"
}),
serde_json::json!({
"type": "choice",
"id": "action",
"label": "Select action",
"options": ["A", "B"]
}),
];
let items = extract_choice_items(&components);
assert_eq!(items, vec!["A", "B"]);
}
#[test]
fn test_sanitize_payload_field() {
assert_eq!(sanitize_payload_field("Select | Insert"), "Select - Insert");
assert_eq!(sanitize_payload_field("A, B, C"), "A; B; C");
assert_eq!(sanitize_payload_field("X|Y,Z"), "X-Y;Z");
assert_eq!(sanitize_payload_field("Normal Text"), "Normal Text");
}
}