use std::time::{Duration, SystemTime};
use ascii_agents_core::state::AgentSlot;
use ascii_agents_core::walkable::OccupancyOverlay;
use ascii_agents_core::AgentId;
pub use ascii_agents_core::pose::{
cycle_ms_for, derive, is_aimless_cycle, personality_for, takes_trip, waypoint_index_for_cycle,
Personality, Pose, ENTRY_ANIMATION_MS, TYPING_FRAMES, TYPING_FRAME_MS, WALKING_FRAMES,
WALKING_FRAME_MS, WANDER_CYCLE_BASE_MS, WANDER_CYCLE_RANGE_MS,
};
use crate::tui::layout::{Layout, Point};
use crate::tui::pathfind::Router;
#[derive(Debug, Default, Clone)]
pub struct PoseHistory {
last: std::collections::HashMap<AgentId, (Point, SystemTime)>,
}
impl PoseHistory {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, agent_id: AgentId, anchor: Point, now: SystemTime) {
self.last.insert(agent_id, (anchor, now));
}
pub fn recent(&self, agent_id: AgentId, max_age_ms: u64, now: SystemTime) -> Option<Point> {
let (pt, when) = self.last.get(&agent_id).copied()?;
let age = now.duration_since(when).ok()?.as_millis() as u64;
if age <= max_age_ms {
Some(pt)
} else {
None
}
}
}
const SNAP_BACK_MS: u64 = 900;
const SNAP_BACK_MIN_DIST: i32 = 8;
pub fn derive_with_routing(
slot: &AgentSlot,
now: SystemTime,
layout: &Layout,
router: &mut dyn Router,
overlay: &OccupancyOverlay,
history: &mut PoseHistory,
) -> Option<Pose> {
let raw = derive(slot, now, layout)?;
let desk_pose = matches!(
raw,
Pose::SeatedIdle | Pose::SeatedThinking | Pose::SeatedTyping { .. } | Pose::StandingAtDesk
);
let since_state = now
.duration_since(slot.state_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let pose = if desk_pose && since_state < SNAP_BACK_MS {
if let Some(prev) = history.recent(slot.agent_id, 300, now) {
let desk = *layout.home_desks.get(slot.desk_index)?;
let dist =
(prev.x as i32 - desk.x as i32).abs() + (prev.y as i32 - desk.y as i32).abs();
if dist >= SNAP_BACK_MIN_DIST {
let snap_target = Point {
x: desk.x + 6,
y: desk.y + 4,
};
let t = (since_state * 1000 / SNAP_BACK_MS).min(1000) as u16;
let frame = ((since_state / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
Pose::Walking {
from: prev,
to: snap_target,
t_x1000: t,
frame,
carrying_coffee: false,
}
} else {
raw
}
} else {
raw
}
} else {
raw
};
let Pose::Walking {
from,
to,
t_x1000,
frame,
carrying_coffee,
} = pose
else {
let pt = match &pose {
Pose::AtWaypoint { wp, .. } => layout.waypoints.get(*wp).map(|w| w.pos),
Pose::AimlessAt { dest } => Some(*dest),
_ => None,
};
if let Some(p) = pt {
history.record(slot.agent_id, p, now);
}
return Some(pose);
};
let h = slot.agent_id.raw();
let jx = ((h % 9) as i32 - 4) as i16;
let jy = (((h >> 16) % 9) as i32 - 4) as i16;
let to_jittered = Point {
x: to.x.saturating_add_signed(jx),
y: to.y.saturating_add_signed(jy),
};
let mut path = router.route(&layout.walkable, overlay, from, to_jittered);
if let Some(last) = path.last_mut() {
*last = to;
}
if path.len() <= 2 {
history.record(slot.agent_id, walking_position(from, to, t_x1000), now);
return Some(pose);
}
let mut leg_lens: Vec<u32> = Vec::with_capacity(path.len() - 1);
for w in path.windows(2) {
leg_lens.push(octile_distance(w[0], w[1]));
}
let total: u32 = leg_lens.iter().sum();
if total == 0 {
return Some(pose);
}
let traveled = (t_x1000 as u32 * total) / 1000;
let mut acc: u32 = 0;
for (i, &leg) in leg_lens.iter().enumerate() {
if acc + leg >= traveled {
let into_leg = traveled - acc;
let seg_t = (into_leg * 1000)
.checked_div(leg)
.map(|t| t.min(1000) as u16)
.unwrap_or(1000);
let cur_pos = walking_position(path[i], path[i + 1], seg_t);
history.record(slot.agent_id, cur_pos, now);
return Some(Pose::Walking {
from: path[i],
to: path[i + 1],
t_x1000: seg_t,
frame,
carrying_coffee,
});
}
acc += leg;
}
let last = path.len() - 1;
history.record(slot.agent_id, path[last], now);
Some(Pose::Walking {
from: path[last - 1],
to: path[last],
t_x1000: 1000,
frame,
carrying_coffee,
})
}
fn walking_position(from: Point, to: Point, t_x1000: u16) -> Point {
let t = t_x1000 as i32;
let x = (from.x as i32 + (to.x as i32 - from.x as i32) * t / 1000).max(0) as u16;
let y = (from.y as i32 + (to.y as i32 - from.y as i32) * t / 1000).max(0) as u16;
Point { x, y }
}
fn octile_distance(a: Point, b: Point) -> u32 {
let dx = (a.x as i32 - b.x as i32).unsigned_abs();
let dy = (a.y as i32 - b.y as i32).unsigned_abs();
14 * dx.min(dy) + 10 * (dx.max(dy) - dx.min(dy))
}
#[cfg(test)]
mod tests {
use super::*;
use ascii_agents_core::source::Activity;
use ascii_agents_core::state::ActivityState;
use ascii_agents_core::walkable::WalkableMask;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
struct StubRouter {
path: Vec<Point>,
}
impl StubRouter {
fn straight() -> Self {
Self { path: vec![] }
}
fn corners(path: Vec<Point>) -> Self {
Self { path }
}
}
impl Router for StubRouter {
fn route(
&mut self,
_: &WalkableMask,
_: &ascii_agents_core::walkable::OccupancyOverlay,
from: Point,
to: Point,
) -> Vec<Point> {
if self.path.is_empty() {
vec![from, to]
} else {
self.path.clone()
}
}
fn invalidate(&mut self) {}
}
fn layout() -> Layout {
Layout::compute(120, 96, 4).expect("fits")
}
fn active_slot(state_started_at: SystemTime, created_at: SystemTime) -> AgentSlot {
AgentSlot {
agent_id: AgentId::from_transcript_path("/snap.jsonl"),
source: Arc::from("claude-code"),
session_id: Arc::from("s"),
cwd: Arc::from(PathBuf::from("/p").as_path()),
label: Arc::from("cc"),
state: ActivityState::Active {
activity: Activity::Typing,
tool_use_id: Some(Arc::from("t")),
detail: Some(Arc::from("Edit")),
},
state_started_at,
last_event_at: created_at,
created_at,
exiting_at: None,
pending_idle_at: None,
desk_index: 0,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
}
}
fn entry_slot(created_at: SystemTime) -> AgentSlot {
let mut s = active_slot(created_at, created_at);
s.state = ActivityState::Idle;
s
}
#[test]
fn snap_back_walks_from_history_when_state_just_flipped() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = active_slot(now, now - Duration::from_secs(60));
let desk = l.home_desks[0];
let prev = Point {
x: desk.x + 50,
y: desk.y + 30,
};
let mut history = PoseHistory::new();
history.record(slot.agent_id, prev, now - Duration::from_millis(50));
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
match derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history) {
Some(Pose::Walking { from, .. }) => {
assert_eq!(from, prev, "snap-back walk should start from recorded prev");
}
other => panic!("expected snap-back Walking pose, got {other:?}"),
}
}
#[test]
fn snap_back_skipped_when_prev_within_min_distance() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = active_slot(now, now - Duration::from_secs(60));
let desk = l.home_desks[0];
let close = Point {
x: desk.x + 3,
y: desk.y,
};
let mut history = PoseHistory::new();
history.record(slot.agent_id, close, now - Duration::from_millis(50));
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
let p = derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history);
assert!(
matches!(p, Some(Pose::SeatedTyping { .. })),
"close prev should NOT trigger snap-back, got {p:?}"
);
}
#[test]
fn snap_back_skipped_after_900ms_window() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = active_slot(
now - Duration::from_millis(1_500),
now - Duration::from_secs(60),
);
let desk = l.home_desks[0];
let prev = Point {
x: desk.x + 50,
y: desk.y + 30,
};
let mut history = PoseHistory::new();
history.record(slot.agent_id, prev, now - Duration::from_millis(50));
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
let p = derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history);
assert!(
matches!(p, Some(Pose::SeatedTyping { .. })),
"snap-back window should be expired at 1.5s, got {p:?}"
);
}
#[test]
fn snap_back_skipped_without_recent_history() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = active_slot(now, now - Duration::from_secs(60));
let mut history = PoseHistory::new(); let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
let p = derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history);
assert!(
matches!(p, Some(Pose::SeatedTyping { .. })),
"no prev history → raw pose, got {p:?}"
);
}
#[test]
fn multi_segment_path_maps_t_to_segment_via_octile_distance() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = entry_slot(now - Duration::from_millis(1_000));
let mut history = PoseHistory::new();
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let door = l.door_threshold.expect("door");
let desk = l.home_desks[0];
let mid = Point {
x: (door.x + desk.x) / 2,
y: (door.y + desk.y) / 2,
};
let mut router = StubRouter::corners(vec![door, mid, desk]);
let p = derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history);
match p {
Some(Pose::Walking {
from, to, t_x1000, ..
}) => {
assert_eq!(from, door, "first segment starts at door, got {from:?}");
assert_eq!(to, mid, "first segment ends at mid, got {to:?}");
assert!(
(400..=600).contains(&t_x1000),
"expected mid-segment ~500, got t_x1000={t_x1000}"
);
assert!(history.recent(slot.agent_id, 1_000, now).is_some());
}
other => panic!("expected Walking on segment 0, got {other:?}"),
}
}
#[test]
fn at_waypoint_pose_records_position_to_history() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let slot = AgentSlot {
agent_id: AgentId::from_transcript_path("/idle.jsonl"),
source: Arc::from("claude-code"),
session_id: Arc::from("s"),
cwd: Arc::from(PathBuf::from("/p").as_path()),
label: Arc::from("cc"),
state: ActivityState::Idle,
state_started_at: now,
created_at: now - Duration::from_secs(60),
last_event_at: now - Duration::from_secs(60),
exiting_at: None,
pending_idle_at: None,
desk_index: 0,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
};
let mut history = PoseHistory::new();
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
let _ = derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history);
assert!(
history.recent(slot.agent_id, 1_000, now).is_none(),
"SeatedIdle should not write history"
);
}
#[test]
fn delegates_to_derive_for_oob_desk() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let l = layout();
let mut slot = active_slot(now, now - Duration::from_secs(60));
slot.desk_index = 999;
let mut history = PoseHistory::new();
let overlay = ascii_agents_core::walkable::OccupancyOverlay::new();
let mut router = StubRouter::straight();
assert!(derive_with_routing(&slot, now, &l, &mut router, &overlay, &mut history).is_none());
}
#[test]
fn pose_history_record_and_recent() {
let id = AgentId::from_transcript_path("/test/a.jsonl");
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let pt = Point { x: 42, y: 99 };
let mut history = PoseHistory::new();
assert!(history.recent(id, 500, now).is_none());
history.record(id, pt, now);
assert_eq!(history.recent(id, 500, now), Some(pt));
}
#[test]
fn pose_history_recent_expires() {
let id = AgentId::from_transcript_path("/test/b.jsonl");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let pt = Point { x: 10, y: 20 };
let mut history = PoseHistory::new();
history.record(id, pt, t0);
let t1 = t0 + Duration::from_millis(600);
assert_eq!(history.recent(id, 500, t1), None);
assert_eq!(history.recent(id, 700, t1), Some(pt));
}
}