use crate::core::provider::AgentMessage;
use serde_json::Value;
use std::{
collections::{HashMap, VecDeque},
sync::{
Mutex, MutexGuard, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::oneshot::Sender;
static UI_READY: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiNotifyLevel {
Info,
Success,
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RichSpan {
pub fg: Option<String>,
pub text: String,
}
impl RichSpan {
pub fn plain(text: impl Into<String>) -> Self {
RichSpan {
fg: None,
text: text.into(),
}
}
pub fn fg(key: &str, text: impl Into<String>) -> Self {
RichSpan {
fg: Some(key.to_string()),
text: text.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectOption {
pub text: String,
pub fg: Option<String>,
}
impl SelectOption {
pub fn new(text: impl Into<String>) -> Self {
SelectOption {
text: text.into(),
fg: None,
}
}
pub fn styled(text: impl Into<String>, fg: &str) -> Self {
SelectOption {
text: text.into(),
fg: Some(fg.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub enum ExtensionUiRequest {
Select {
id: u64,
title: String,
options: Vec<SelectOption>,
},
Notify { text: String, level: UiNotifyLevel },
NotifyRich {
spans: Vec<RichSpan>,
level: UiNotifyLevel,
},
CustomMessage {
label: String,
custom_type: String,
data: Value,
},
ShowOverlay { id: u64 },
HideOverlay { id: u64 },
ShowDock,
ShowSettings { ext: String },
Continuation { message: Box<AgentMessage> },
AbortRun,
RestartRun { message: Box<AgentMessage> },
RewindLastUserInput { restore_to_editor: bool },
RebuildTools,
PersistSessionEntry { custom_type: String, data: Value },
}
fn queue() -> &'static Mutex<VecDeque<ExtensionUiRequest>> {
static Q: OnceLock<Mutex<VecDeque<ExtensionUiRequest>>> = OnceLock::new();
Q.get_or_init(Default::default)
}
pub fn next_ui_id() -> u64 {
static ID: AtomicU64 = AtomicU64::new(1);
ID.fetch_add(1, Ordering::Relaxed)
}
pub fn request_ui(req: ExtensionUiRequest) {
queue().lock().unwrap().push_back(req);
}
pub fn request_show_dock() {
let mut q = queue().lock().unwrap();
if q.iter().any(|r| matches!(r, ExtensionUiRequest::ShowDock)) {
return;
}
q.push_back(ExtensionUiRequest::ShowDock);
}
pub fn take_pending_ui() -> Option<ExtensionUiRequest> {
queue().lock().unwrap().pop_front()
}
pub fn render_custom_message(custom_type: &str, data: &Value) -> Option<Vec<RichSpan>> {
for ext in super::registered() {
if let Some(spans) = ext.render_custom_message(custom_type, data) {
return Some(spans);
}
}
None
}
pub fn set_ui_ready(ready: bool) {
UI_READY.store(ready, Ordering::Relaxed);
}
pub fn ui_ready() -> bool {
UI_READY.load(Ordering::Relaxed)
}
fn waiters_guard() -> MutexGuard<'static, HashMap<u64, Sender<Option<String>>>> {
waiters().lock().unwrap_or_else(|e| e.into_inner())
}
pub fn ask_pending() -> bool {
!waiters_guard().is_empty()
}
fn waiters() -> &'static Mutex<HashMap<u64, Sender<Option<String>>>> {
static W: OnceLock<Mutex<HashMap<u64, Sender<Option<String>>>>> = OnceLock::new();
W.get_or_init(Default::default)
}
struct WaiterGuard(u64);
impl Drop for WaiterGuard {
fn drop(&mut self) {
waiters_guard().remove(&self.0);
}
}
pub async fn ask_prompt(
title: impl Into<String>,
options: Vec<SelectOption>,
timeout: Option<Duration>,
) -> Option<String> {
if !ui_ready() || options.is_empty() {
return None;
}
let id = next_ui_id();
let (tx, rx) = tokio::sync::oneshot::channel();
waiters_guard().insert(id, tx);
let _guard = WaiterGuard(id);
request_ui(ExtensionUiRequest::Select {
id,
title: title.into(),
options,
});
let wait = async { rx.await.unwrap_or_default() };
match timeout {
Some(limit) => tokio::time::timeout(limit, wait).await.ok().flatten(),
None => wait.await,
}
}
pub fn submit_ui_choice(id: u64, choice: Option<String>) -> Option<String> {
if let Some(tx) = waiters_guard().remove(&id) {
_ = tx.send(choice);
return None;
}
for ext in super::registered() {
if let Some(text) = ext.on_ui_choice(id, choice.clone()) {
return Some(text);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_dock_requests_are_deduped() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while take_pending_ui().is_some() {}
request_show_dock();
request_show_dock();
request_ui(ExtensionUiRequest::RebuildTools);
request_show_dock();
let mut dock = 0;
let mut others = 0;
while let Some(req) = take_pending_ui() {
match req {
ExtensionUiRequest::ShowDock => dock += 1,
_ => others += 1,
}
}
assert_eq!(dock, 1, "重复的展示意图合并为一条");
assert_eq!(others, 1, "其它请求不受影响");
request_show_dock();
assert!(matches!(
take_pending_ui(),
Some(ExtensionUiRequest::ShowDock)
));
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Runtime::new()
.expect("runtime")
.block_on(fut)
}
async fn take_select_id() -> u64 {
for _ in 0..200 {
if let Some(req) = take_pending_ui() {
if let ExtensionUiRequest::Select { id, .. } = req {
return id;
}
continue;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("no Select request was queued");
}
#[test]
fn ask_prompt_resolves_through_submit() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while take_pending_ui().is_some() {}
set_ui_ready(true);
let answer = block_on(async {
let waiter = tokio::spawn(async {
ask_prompt(
"title",
vec![SelectOption::new("a"), SelectOption::new("b")],
Some(Duration::from_secs(5)),
)
.await
});
let id = take_select_id().await;
assert!(ask_pending(), "等待表应已登记");
assert_eq!(submit_ui_choice(id, Some("b".to_string())), None);
waiter.await.expect("waiter task")
});
assert_eq!(answer.as_deref(), Some("b"));
assert!(!ask_pending(), "答复送达后等待表应清空");
set_ui_ready(false);
}
#[test]
fn ask_prompt_cancel_returns_none() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while take_pending_ui().is_some() {}
set_ui_ready(true);
let answer = block_on(async {
let waiter = tokio::spawn(async {
ask_prompt("title", vec![SelectOption::new("a")], None).await
});
let id = take_select_id().await;
submit_ui_choice(id, None);
waiter.await.expect("waiter task")
});
assert_eq!(answer, None);
set_ui_ready(false);
}
#[test]
fn ask_prompt_without_ui_returns_immediately() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
set_ui_ready(false);
let answer = block_on(ask_prompt(
"title",
vec![SelectOption::new("a")],
Some(Duration::from_secs(5)),
));
assert_eq!(answer, None);
}
#[test]
fn ask_prompt_times_out() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while take_pending_ui().is_some() {}
set_ui_ready(true);
let answer = block_on(ask_prompt(
"title",
vec![SelectOption::new("a")],
Some(Duration::from_millis(10)),
));
assert_eq!(answer, None);
assert!(!ask_pending(), "超时后等待表应清空");
set_ui_ready(false);
}
}