use std::path::PathBuf;
use crate::agent::tools::todo::TodoItem;
use crate::session::{Session, ToolCallState};
pub struct PanelState {
pub todos: Vec<TodoItem>,
pub modified: Vec<PathBuf>,
}
pub fn derive_panel_state(session: &Session) -> PanelState {
let mut todos: Vec<TodoItem> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut touch = |raw: &str| {
let pb = PathBuf::from(raw);
if let Some(idx) = modified.iter().position(|e| e == &pb) {
modified.remove(idx);
}
modified.push(pb);
};
for msg in &session.messages {
for tc in &msg.tool_calls {
if !matches!(tc.state, ToolCallState::Completed { .. }) {
continue;
}
match tc.name.as_str() {
"write" | "edit" | "edit_minified" => {
if let Some(p) = tc.args.get("path").and_then(|v| v.as_str()) {
touch(p);
}
}
"apply_patch" => {
if let Some(ops) = tc.args.get("operations").and_then(|v| v.as_array()) {
for op in ops {
if let Some(p) = op.get("path").and_then(|v| v.as_str()) {
touch(p);
}
if let Some(np) = op.get("new_path").and_then(|v| v.as_str()) {
touch(np);
}
}
}
}
"write_todo_list" => {
if let Some(items) = tc.args.get("todos")
&& let Ok(parsed) = serde_json::from_value::<Vec<TodoItem>>(items.clone())
{
todos = parsed;
}
}
_ => {}
}
}
}
PanelState { todos, modified }
}
pub fn restore_panels(session: &Session) {
use crate::sync_util::LockExt;
let state = selected_panel_state(session);
*crate::agent::tools::todo::TODO_LIST.lock_ignore_poison() = state.todos;
crate::agent::tools::modified::clear_modified();
for p in &state.modified {
crate::agent::tools::modified::mark_modified(p);
}
}
pub fn selected_panel_state(session: &Session) -> PanelState {
if !session.todo_list.is_empty() || !session.modified_files.is_empty() {
PanelState {
todos: session.todo_list.clone(),
modified: session.modified_files.iter().map(PathBuf::from).collect(),
}
} else {
derive_panel_state(session)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{MessageRole, Session, SessionMessage, ToolCallEntry, ToolCallState};
use compact_str::CompactString;
fn assistant_with_calls(calls: Vec<ToolCallEntry>) -> SessionMessage {
SessionMessage {
role: MessageRole::Assistant,
content: CompactString::from(""),
estimated_tokens: 0,
id: crate::session::new_message_id(),
timestamp: 0,
tool_calls: calls,
}
}
fn completed(name: &str, args: serde_json::Value) -> ToolCallEntry {
ToolCallEntry {
id: "tc".to_string(),
name: name.to_string(),
args,
state: ToolCallState::Completed {
result: String::new(),
},
}
}
fn session_with(messages: Vec<SessionMessage>) -> Session {
let mut s = Session::new("test", "test-model", 1000);
s.messages = messages;
s
}
#[test]
fn last_write_todo_list_wins() {
let first = completed(
"write_todo_list",
serde_json::json!({"todos": [
{"content": "a", "status": "pending", "priority": "high"}
]}),
);
let second = completed(
"write_todo_list",
serde_json::json!({"todos": [
{"content": "a", "status": "completed", "priority": "high"},
{"content": "b", "status": "in_progress", "priority": "low"}
]}),
);
let s = session_with(vec![assistant_with_calls(vec![first, second])]);
let state = derive_panel_state(&s);
assert_eq!(state.todos.len(), 2);
assert_eq!(state.todos[0].status, "completed");
assert_eq!(state.todos[1].content, "b");
}
#[test]
fn collects_modified_from_write_edit_patch_in_recency_order() {
let msgs = vec![
assistant_with_calls(vec![
completed("write", serde_json::json!({"path": "/proj/a.rs"})),
completed("edit", serde_json::json!({"path": "/proj/b.rs"})),
]),
assistant_with_calls(vec![
completed("edit_minified", serde_json::json!({"path": "/proj/c.rs"})),
completed(
"apply_patch",
serde_json::json!({"operations": [
{"type": "update", "path": "/proj/d.rs"},
{"type": "rename", "path": "/proj/e.rs", "new_path": "/proj/f.rs"}
]}),
),
completed("edit", serde_json::json!({"path": "/proj/a.rs"})),
]),
];
let state = derive_panel_state(&session_with(msgs));
let paths: Vec<String> = state
.modified
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
paths,
vec![
"/proj/b.rs",
"/proj/c.rs",
"/proj/d.rs",
"/proj/e.rs",
"/proj/f.rs",
"/proj/a.rs", ]
);
}
#[test]
fn ignores_interrupted_and_failed_calls() {
let interrupted = ToolCallEntry {
id: "x".to_string(),
name: "write".to_string(),
args: serde_json::json!({"path": "/proj/skipped.rs"}),
state: ToolCallState::Interrupted,
};
let failed = ToolCallEntry {
id: "y".to_string(),
name: "write_todo_list".to_string(),
args: serde_json::json!({"todos": [
{"content": "nope", "status": "pending", "priority": "high"}
]}),
state: ToolCallState::Failed {
error: "denied".to_string(),
},
};
let s = session_with(vec![assistant_with_calls(vec![interrupted, failed])]);
let state = derive_panel_state(&s);
assert!(state.modified.is_empty());
assert!(state.todos.is_empty());
}
#[test]
fn empty_session_yields_empty_state() {
let state = derive_panel_state(&session_with(vec![]));
assert!(state.todos.is_empty());
assert!(state.modified.is_empty());
}
#[test]
fn snapshot_is_preferred_over_history_replay() {
let mut s = session_with(vec![assistant_with_calls(vec![
completed(
"write",
serde_json::json!({"path": "/proj/from_history.rs"}),
),
completed(
"write_todo_list",
serde_json::json!({"todos": [
{"content": "history task", "status": "pending", "priority": "low"}
]}),
),
])]);
s.todo_list = vec![TodoItem {
content: "snapshot task".into(),
status: "in_progress".into(),
priority: "high".into(),
}];
s.modified_files = vec!["/proj/from_snapshot.rs".into()];
let state = selected_panel_state(&s);
assert_eq!(state.todos.len(), 1);
assert_eq!(state.todos[0].content, "snapshot task");
assert_eq!(state.modified.len(), 1);
assert!(state.modified[0].ends_with("from_snapshot.rs"));
}
#[test]
fn snapshot_survives_compaction_that_emptied_history() {
let mut s = session_with(vec![]);
s.modified_files = vec!["/proj/a.rs".into(), "/proj/b.rs".into()];
s.todo_list = vec![TodoItem {
content: "still here".into(),
status: "in_progress".into(),
priority: "high".into(),
}];
let state = selected_panel_state(&s);
assert_eq!(state.todos.len(), 1);
assert_eq!(state.modified.len(), 2);
assert!(state.modified[1].ends_with("b.rs"));
}
#[test]
fn falls_back_to_replay_when_snapshot_empty() {
let s = session_with(vec![assistant_with_calls(vec![completed(
"edit",
serde_json::json!({"path": "/proj/legacy.rs"}),
)])]);
assert!(s.todo_list.is_empty() && s.modified_files.is_empty());
let state = selected_panel_state(&s);
assert_eq!(state.modified.len(), 1);
assert!(state.modified[0].ends_with("legacy.rs"));
}
#[test]
fn non_file_tools_are_ignored() {
let s = session_with(vec![assistant_with_calls(vec![
completed("bash", serde_json::json!({"cmd": "ls"})),
completed("read", serde_json::json!({"path": "/proj/readonly.rs"})),
])]);
let state = derive_panel_state(&s);
assert!(state.modified.is_empty());
}
}