Implémenter l'affichage en temps réel du progrès des tâches, incluant les tool calls en cours, résultats intermédiaires, et updates TodoWrite live.
Priorité: 🟠 Important
Effort estimé: 3-4 jours développeur
Présent dans: Droid
// cortex-core/src/progress/events.rs
use tokio::sync::broadcast;
#[derive(Clone, Debug)]
pub enum ProgressEvent {
/// Début d'une nouvelle tâche
TaskStarted {
task_id: String,
description: String,
},
/// Appel d'outil en cours
ToolCallStarted {
task_id: String,
tool_name: String,
arguments: serde_json::Value,
},
/// Résultat d'outil
ToolCallCompleted {
task_id: String,
tool_name: String,
result: ToolResult,
duration_ms: u64,
},
/// Mise à jour de la todo list
TodoUpdated {
task_id: String,
todos: Vec<TodoItem>,
},
/// Token généré (pour streaming)
TokenGenerated {
task_id: String,
token: String,
},
/// Pensée en cours
ThinkingStarted {
task_id: String,
},
/// Tâche complétée
TaskCompleted {
task_id: String,
result: TaskResult,
duration_ms: u64,
},
/// Erreur
TaskError {
task_id: String,
error: String,
},
}
#[derive(Clone, Debug)]
pub struct ToolResult {
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
}
#[derive(Clone, Debug)]
pub struct TodoItem {
pub text: String,
pub status: TodoStatus,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
}
pub struct ProgressEmitter {
tx: broadcast::Sender<ProgressEvent>,
}
impl ProgressEmitter {
pub fn new() -> (Self, ProgressSubscriber) {
let (tx, rx) = broadcast::channel(1000);
(Self { tx }, ProgressSubscriber { rx })
}
pub fn emit(&self, event: ProgressEvent) {
let _ = self.tx.send(event);
}
pub fn subscribe(&self) -> ProgressSubscriber {
ProgressSubscriber {
rx: self.tx.subscribe(),
}
}
}
pub struct ProgressSubscriber {
rx: broadcast::Receiver<ProgressEvent>,
}
impl ProgressSubscriber {
pub async fn recv(&mut self) -> Option<ProgressEvent> {
self.rx.recv().await.ok()
}
}// cortex-tui/src/widgets/progress.rs
use ratatui::prelude::*;
pub struct ProgressWidget {
tasks: Vec<TaskProgress>,
current_tool: Option<CurrentTool>,
todos: Vec<TodoItem>,
}
#[derive(Clone)]
struct TaskProgress {
id: String,
description: String,
status: TaskStatus,
started_at: Instant,
}
#[derive(Clone)]
struct CurrentTool {
name: String,
arguments: String,
started_at: Instant,
}
impl Widget for ProgressWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Current task
Constraint::Length(4), // Current tool
Constraint::Min(5), // Todo list
])
.split(area);
// Current task
self.render_current_task(chunks[0], buf);
// Current tool call
self.render_current_tool(chunks[1], buf);
// Todo list
self.render_todos(chunks[2], buf);
}
}
impl ProgressWidget {
fn render_current_task(&self, area: Rect, buf: &mut Buffer) {
if let Some(task) = self.tasks.last() {
let elapsed = task.started_at.elapsed();
let block = Block::default()
.title(Span::styled("Current Task", Style::default().fg(Color::Cyan)))
.borders(Borders::ALL);
let text = Paragraph::new(vec![
Line::from(vec![
Span::styled("● ", Style::default().fg(Color::Yellow)),
Span::raw(&task.description),
]),
Line::from(Span::styled(
format!(" {} elapsed", format_duration(elapsed)),
Style::default().fg(Color::DarkGray),
)),
])
.block(block);
text.render(area, buf);
}
}
fn render_current_tool(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.title(Span::styled("Tool Calls", Style::default().fg(Color::Green)))
.borders(Borders::ALL);
let content = if let Some(tool) = &self.current_tool {
let elapsed = tool.started_at.elapsed();
vec![
Line::from(vec![
Span::styled("⚙ ", Style::default().fg(Color::Yellow)),
Span::styled(&tool.name, Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(Span::styled(
format!(" {}", truncate(&tool.arguments, 50)),
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
format!(" Running for {}...", format_duration(elapsed)),
Style::default().fg(Color::Yellow),
)),
]
} else {
vec![Line::from(Span::styled("No active tool calls", Style::default().fg(Color::DarkGray)))]
};
Paragraph::new(content).block(block).render(area, buf);
}
fn render_todos(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.title(Span::styled("Todo List", Style::default().fg(Color::Magenta)))
.borders(Borders::ALL);
let items: Vec<ListItem> = self.todos.iter().map(|todo| {
let (icon, style) = match todo.status {
TodoStatus::Pending => ("○", Style::default().fg(Color::Gray)),
TodoStatus::InProgress => ("◐", Style::default().fg(Color::Yellow)),
TodoStatus::Completed => ("●", Style::default().fg(Color::Green)),
};
ListItem::new(Line::from(vec![
Span::styled(format!("{} ", icon), style),
Span::raw(&todo.text),
]))
}).collect();
List::new(items).block(block).render(area, buf);
}
}
fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{}s", secs)
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}// cortex-tui/src/progress/collector.rs
pub struct ProgressCollector {
subscriber: ProgressSubscriber,
state: ProgressState,
}
#[derive(Default)]
struct ProgressState {
active_tasks: HashMap<String, TaskProgress>,
current_tool: Option<CurrentTool>,
todos: Vec<TodoItem>,
tool_history: VecDeque<ToolCallSummary>,
}
impl ProgressCollector {
pub fn new(subscriber: ProgressSubscriber) -> Self {
Self {
subscriber,
state: ProgressState::default(),
}
}
/// Traite les événements et met à jour l'état
pub async fn poll(&mut self) {
while let Ok(event) = self.subscriber.rx.try_recv() {
self.handle_event(event);
}
}
fn handle_event(&mut self, event: ProgressEvent) {
match event {
ProgressEvent::TaskStarted { task_id, description } => {
self.state.active_tasks.insert(task_id.clone(), TaskProgress {
id: task_id,
description,
status: TaskStatus::Running,
started_at: Instant::now(),
});
}
ProgressEvent::ToolCallStarted { tool_name, arguments, .. } => {
self.state.current_tool = Some(CurrentTool {
name: tool_name,
arguments: serde_json::to_string(&arguments).unwrap_or_default(),
started_at: Instant::now(),
});
}
ProgressEvent::ToolCallCompleted { tool_name, result, duration_ms, .. } => {
self.state.current_tool = None;
self.state.tool_history.push_back(ToolCallSummary {
name: tool_name,
success: result.success,
duration_ms,
});
// Garder seulement les 10 derniers
while self.state.tool_history.len() > 10 {
self.state.tool_history.pop_front();
}
}
ProgressEvent::TodoUpdated { todos, .. } => {
self.state.todos = todos;
}
ProgressEvent::TaskCompleted { task_id, .. } => {
self.state.active_tasks.remove(&task_id);
}
ProgressEvent::TaskError { task_id, .. } => {
if let Some(task) = self.state.active_tasks.get_mut(&task_id) {
task.status = TaskStatus::Failed;
}
}
_ => {}
}
}
/// Retourne le widget à afficher
pub fn widget(&self) -> ProgressWidget {
ProgressWidget {
tasks: self.state.active_tasks.values().cloned().collect(),
current_tool: self.state.current_tool.clone(),
todos: self.state.todos.clone(),
}
}
}// cortex-engine/src/agent_loop.rs
impl AgentLoop {
pub async fn run_with_progress(
&mut self,
prompt: &str,
emitter: &ProgressEmitter,
) -> Result<AgentResult> {
let task_id = uuid::Uuid::new_v4().to_string();
emitter.emit(ProgressEvent::TaskStarted {
task_id: task_id.clone(),
description: truncate(prompt, 100).to_string(),
});
let result = self.run_inner(prompt, |event| {
match event {
AgentEvent::ToolCall { name, arguments } => {
emitter.emit(ProgressEvent::ToolCallStarted {
task_id: task_id.clone(),
tool_name: name,
arguments,
});
}
AgentEvent::ToolResult { name, result, duration } => {
emitter.emit(ProgressEvent::ToolCallCompleted {
task_id: task_id.clone(),
tool_name: name,
result,
duration_ms: duration.as_millis() as u64,
});
}
AgentEvent::TodoWrite { todos } => {
emitter.emit(ProgressEvent::TodoUpdated {
task_id: task_id.clone(),
todos,
});
}
_ => {}
}
}).await?;
emitter.emit(ProgressEvent::TaskCompleted {
task_id,
result: result.clone(),
duration_ms: self.elapsed().as_millis() as u64,
});
Ok(result)
}
}#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_progress_events() {
let (emitter, mut subscriber) = ProgressEmitter::new();
emitter.emit(ProgressEvent::TaskStarted {
task_id: "1".into(),
description: "Test task".into(),
});
let event = subscriber.recv().await.unwrap();
assert!(matches!(event, ProgressEvent::TaskStarted { .. }));
}
#[test]
fn test_progress_widget_render() {
let widget = ProgressWidget {
tasks: vec![TaskProgress {
id: "1".into(),
description: "Test".into(),
status: TaskStatus::Running,
started_at: Instant::now(),
}],
current_tool: None,
todos: vec![],
};
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
widget.render(Rect::new(0, 0, 80, 20), &mut buf);
// Check output contains expected text
let content = buf.content.iter().map(|c| c.symbol()).collect::<String>();
assert!(content.contains("Test"));
}
}- Events émis pour chaque étape
- Widget TUI affiche le progrès
- Tool calls affichés en temps réel
- Todo list mise à jour live
- Tests passent
| Tâche | Durée |
|---|---|
| Event system | 0.5 jour |
| Progress widget | 1 jour |
| Progress collector | 0.5 jour |
| Agent loop integration | 1 jour |
| Tests et documentation | 0.5 jour |
| Total | 3.5 jours |
Agent autonome - Pas de dépendance externe