use serde::{Deserialize, Serialize};
use std::time::SystemTime;
use crate::accessibility::{get_children, AXUIElementRef};
use crate::copilot_extract::{
extract_app_context, extract_content_context, extract_navigation_context,
extract_selection_context, find_focused_window, first_window_ref,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AppContext {
pub name: Option<String>,
pub focused_window: Option<String>,
pub active_tab: Option<String>,
pub active_document: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SelectionContext {
pub selected_text: Option<String>,
pub selected_list_row: Option<usize>,
pub selected_table_cell: Option<(usize, usize)>,
pub selected_items: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct NavigationContext {
pub breadcrumb: Vec<String>,
pub sidebar_selection: Option<String>,
pub tab_bar_selection: Option<String>,
pub depth: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ContentContext {
pub document_title: Option<String>,
pub visible_text_excerpt: Option<String>,
pub form_fields: Vec<(String, String)>,
pub focused_element_role: Option<String>,
pub focused_element_title: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CopilotState {
pub app: AppContext,
pub selection: SelectionContext,
pub navigation: NavigationContext,
pub content: ContentContext,
pub timestamp: u64,
}
impl CopilotState {
#[must_use]
pub fn empty() -> Self {
Self {
app: AppContext::default(),
selection: SelectionContext::default(),
navigation: NavigationContext::default(),
content: ContentContext::default(),
timestamp: unix_now(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StateChange {
pub field: String,
pub old_value: String,
pub new_value: String,
}
impl StateChange {
fn new(field: &str, old: &impl Serialize, new: &impl Serialize) -> Self {
Self {
field: field.to_owned(),
old_value: serde_json::to_string(old).unwrap_or_default(),
new_value: serde_json::to_string(new).unwrap_or_default(),
}
}
}
#[must_use]
pub fn diff_states(old: &CopilotState, new: &CopilotState) -> Vec<StateChange> {
let mut changes = Vec::new();
diff_app(&old.app, &new.app, &mut changes);
diff_selection(&old.selection, &new.selection, &mut changes);
diff_navigation(&old.navigation, &new.navigation, &mut changes);
diff_content(&old.content, &new.content, &mut changes);
changes
}
#[must_use]
pub fn read_copilot_state(app_ref: AXUIElementRef) -> CopilotState {
let children = get_children(app_ref).unwrap_or_default();
let focused_window = find_focused_window(app_ref);
let window_ref = focused_window
.as_ref()
.and_then(|_| first_window_ref(app_ref, &children));
CopilotState {
app: extract_app_context(app_ref, &children),
selection: extract_selection_context(window_ref, &children),
navigation: extract_navigation_context(window_ref, &children),
content: extract_content_context(app_ref, window_ref, &children),
timestamp: unix_now(),
}
}
pub fn watch_state_changes<F>(app_ref: AXUIElementRef, interval_ms: u64, callback: F) -> WatchHandle
where
F: Fn(CopilotState, Vec<StateChange>) + Send + 'static,
{
let ptr = app_ref as usize;
let interval = std::time::Duration::from_millis(interval_ms.max(50));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = std::sync::Arc::clone(&stop);
std::thread::spawn(move || {
let element = ptr as AXUIElementRef;
let mut prev = read_copilot_state(element);
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(interval);
let current = read_copilot_state(element);
let changes = diff_states(&prev, ¤t);
if !changes.is_empty() {
callback(current.clone(), changes);
prev = current;
}
}
});
WatchHandle { stop }
}
pub struct WatchHandle {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl Drop for WatchHandle {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
fn diff_opt_str(
prefix: &str,
old: &Option<String>,
new: &Option<String>,
changes: &mut Vec<StateChange>,
) {
if old != new {
changes.push(StateChange::new(prefix, old, new));
}
}
fn diff_app(old: &AppContext, new: &AppContext, changes: &mut Vec<StateChange>) {
diff_opt_str("app.name", &old.name, &new.name, changes);
diff_opt_str(
"app.focused_window",
&old.focused_window,
&new.focused_window,
changes,
);
diff_opt_str("app.active_tab", &old.active_tab, &new.active_tab, changes);
diff_opt_str(
"app.active_document",
&old.active_document,
&new.active_document,
changes,
);
}
fn diff_selection(old: &SelectionContext, new: &SelectionContext, changes: &mut Vec<StateChange>) {
diff_opt_str(
"selection.selected_text",
&old.selected_text,
&new.selected_text,
changes,
);
if old.selected_list_row != new.selected_list_row {
changes.push(StateChange::new(
"selection.selected_list_row",
&old.selected_list_row,
&new.selected_list_row,
));
}
if old.selected_items != new.selected_items {
changes.push(StateChange::new(
"selection.selected_items",
&old.selected_items,
&new.selected_items,
));
}
}
fn diff_navigation(
old: &NavigationContext,
new: &NavigationContext,
changes: &mut Vec<StateChange>,
) {
if old.breadcrumb != new.breadcrumb {
changes.push(StateChange::new(
"navigation.breadcrumb",
&old.breadcrumb,
&new.breadcrumb,
));
}
diff_opt_str(
"navigation.sidebar_selection",
&old.sidebar_selection,
&new.sidebar_selection,
changes,
);
diff_opt_str(
"navigation.tab_bar_selection",
&old.tab_bar_selection,
&new.tab_bar_selection,
changes,
);
if old.depth != new.depth {
changes.push(StateChange::new("navigation.depth", &old.depth, &new.depth));
}
}
fn diff_content(old: &ContentContext, new: &ContentContext, changes: &mut Vec<StateChange>) {
diff_opt_str(
"content.document_title",
&old.document_title,
&new.document_title,
changes,
);
diff_opt_str(
"content.visible_text_excerpt",
&old.visible_text_excerpt,
&new.visible_text_excerpt,
changes,
);
if old.form_fields != new.form_fields {
changes.push(StateChange::new(
"content.form_fields",
&old.form_fields,
&new.form_fields,
));
}
diff_opt_str(
"content.focused_element_role",
&old.focused_element_role,
&new.focused_element_role,
changes,
);
diff_opt_str(
"content.focused_element_title",
&old.focused_element_title,
&new.focused_element_title,
changes,
);
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[must_use]
pub(crate) fn truncate_str(s: String, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s
} else {
s.chars().take(max_chars).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_state_has_default_contexts() {
let state = CopilotState::empty();
assert!(state.app.name.is_none());
assert!(state.selection.selected_items.is_empty());
assert!(state.navigation.breadcrumb.is_empty());
assert!(state.content.form_fields.is_empty());
}
#[test]
fn empty_state_has_nonzero_timestamp() {
let state = CopilotState::empty();
assert!(state.timestamp > 0);
}
#[test]
fn diff_identical_states_produces_empty_vec() {
let s = CopilotState::empty();
let changes = diff_states(&s, &s);
assert!(changes.is_empty());
}
#[test]
fn diff_detects_app_name_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.app.name = None;
new.app.name = Some("Safari".to_owned());
let changes = diff_states(&old, &new);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].field, "app.name");
assert!(changes[0].new_value.contains("Safari"));
}
#[test]
fn diff_detects_focused_window_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.app.focused_window = Some("Window A".to_owned());
new.app.focused_window = Some("Window B".to_owned());
let changes = diff_states(&old, &new);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].field, "app.focused_window");
}
#[test]
fn diff_detects_active_tab_change() {
let old = CopilotState::empty();
let mut new = CopilotState::empty();
new.app.active_tab = Some("Tab 2".to_owned());
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "app.active_tab"));
}
#[test]
fn diff_detects_selected_text_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.selection.selected_text = Some("hello".to_owned());
new.selection.selected_text = Some("hello world".to_owned());
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "selection.selected_text"));
}
#[test]
fn diff_detects_selected_list_row_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.selection.selected_list_row = Some(0);
new.selection.selected_list_row = Some(5);
let changes = diff_states(&old, &new);
assert!(changes
.iter()
.any(|c| c.field == "selection.selected_list_row"));
}
#[test]
fn diff_detects_selected_items_change() {
let old = CopilotState::empty();
let mut new = CopilotState::empty();
new.selection.selected_items = vec!["Item A".to_owned()];
let changes = diff_states(&old, &new);
assert!(changes
.iter()
.any(|c| c.field == "selection.selected_items"));
}
#[test]
fn diff_detects_breadcrumb_change() {
let old = CopilotState::empty();
let mut new = CopilotState::empty();
new.navigation.breadcrumb = vec!["Home".to_owned(), "Settings".to_owned()];
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "navigation.breadcrumb"));
}
#[test]
fn diff_detects_depth_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.navigation.depth = 0;
new.navigation.depth = 4;
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "navigation.depth"));
}
#[test]
fn diff_detects_document_title_change() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.content.document_title = Some("Old Doc".to_owned());
new.content.document_title = Some("New Doc".to_owned());
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "content.document_title"));
}
#[test]
fn diff_detects_form_fields_change() {
let old = CopilotState::empty();
let mut new = CopilotState::empty();
new.content.form_fields = vec![("Email".to_owned(), "a@b.com".to_owned())];
let changes = diff_states(&old, &new);
assert!(changes.iter().any(|c| c.field == "content.form_fields"));
}
#[test]
fn diff_multiple_simultaneous_changes() {
let old = CopilotState::empty();
let mut new = CopilotState::empty();
new.app.name = Some("Finder".to_owned());
new.navigation.depth = 3;
new.content.document_title = Some("Documents".to_owned());
let changes = diff_states(&old, &new);
assert_eq!(changes.len(), 3);
}
#[test]
fn state_change_carries_old_and_new_json() {
let mut old = CopilotState::empty();
let mut new = CopilotState::empty();
old.app.name = Some("Before".to_owned());
new.app.name = Some("After".to_owned());
let changes = diff_states(&old, &new);
assert_eq!(changes.len(), 1);
assert!(changes[0].old_value.contains("Before"));
assert!(changes[0].new_value.contains("After"));
}
#[test]
fn copilot_state_serialises_to_json() {
let mut state = CopilotState::empty();
state.app.name = Some("Xcode".to_owned());
state.navigation.breadcrumb = vec!["Project".to_owned()];
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("Xcode"));
assert!(json.contains("Project"));
}
#[test]
fn copilot_state_round_trips_through_json() {
let mut state = CopilotState::empty();
state.app.name = Some("TextEdit".to_owned());
state.content.form_fields = vec![("Name".to_owned(), "Alice".to_owned())];
let json = serde_json::to_string(&state).unwrap();
let restored: CopilotState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.app.name, state.app.name);
assert_eq!(restored.content.form_fields, state.content.form_fields);
}
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate_str("hello".to_owned(), 10), "hello");
}
#[test]
fn truncate_long_string_capped_at_max() {
let s = "a".repeat(100);
let result = truncate_str(s, 20);
assert_eq!(result.chars().count(), 20);
}
#[test]
fn unix_now_returns_reasonable_timestamp() {
let ts = unix_now();
assert!(ts > 1_577_836_800);
assert!(ts < 4_102_444_800);
}
#[test]
fn read_copilot_state_null_ref_returns_empty() {
let null_ref: AXUIElementRef = std::ptr::null();
let state = read_copilot_state(null_ref);
assert!(state.app.name.is_none());
}
}