diff --git a/src/cortex-tui/src/app/mod.rs b/src/cortex-tui/src/app/mod.rs index 7b9c9fe..c828e1c 100644 --- a/src/cortex-tui/src/app/mod.rs +++ b/src/cortex-tui/src/app/mod.rs @@ -15,7 +15,7 @@ mod types; pub use approval::{ApprovalState, PendingToolResult}; pub use autocomplete::{AutocompleteItem, AutocompleteState}; pub use session::{ActiveModal, SessionSummary}; -pub use state::AppState; +pub use state::{AppState, MainAgentTodoItem, MainAgentTodoStatus}; pub use streaming::StreamingState; pub use subagent::{ SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus, diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index ba72ede..9d29c95 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -22,6 +22,26 @@ use super::streaming::StreamingState; use super::subagent::SubagentTaskDisplay; use super::types::{AppView, FocusTarget, OperationMode}; +/// A todo item for the main agent's todo list display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MainAgentTodoItem { + /// Content/description of the todo. + pub content: String, + /// Status of this todo item. + pub status: MainAgentTodoStatus, +} + +/// Status of a main agent todo item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MainAgentTodoStatus { + /// Not started yet. + Pending, + /// Currently being worked on. + InProgress, + /// Completed. + Completed, +} + /// Main application state pub struct AppState { pub view: AppView, @@ -172,6 +192,8 @@ pub struct AppState { pub user_email: Option, /// Organization name for welcome screen pub org_name: Option, + /// Main agent's todo list items (from TodoWrite tool calls). + pub main_agent_todos: Vec, } impl AppState { @@ -272,6 +294,7 @@ impl AppState { user_name: None, user_email: None, org_name: None, + main_agent_todos: Vec::new(), } } @@ -677,3 +700,24 @@ impl AppState { self.diff_scroll = (self.diff_scroll + delta).max(0); } } + +// ============================================================================ +// APPSTATE METHODS - Main Agent Todos +// ============================================================================ + +impl AppState { + /// Update the main agent's todo list. + pub fn update_main_todos(&mut self, todos: Vec) { + self.main_agent_todos = todos; + } + + /// Clear the main agent's todo list. + pub fn clear_main_todos(&mut self) { + self.main_agent_todos.clear(); + } + + /// Check if the main agent has any todos. + pub fn has_main_todos(&self) -> bool { + !self.main_agent_todos.is_empty() + } +} diff --git a/src/cortex-tui/src/runner/event_loop/tools.rs b/src/cortex-tui/src/runner/event_loop/tools.rs index c6a1b30..fde616e 100644 --- a/src/cortex-tui/src/runner/event_loop/tools.rs +++ b/src/cortex-tui/src/runner/event_loop/tools.rs @@ -2,6 +2,7 @@ use std::time::{Duration, Instant}; +use crate::app::{MainAgentTodoItem, MainAgentTodoStatus}; use crate::events::ToolEvent; use crate::session::StoredToolCall; use crate::views::tool_call::format_result_summary; @@ -18,6 +19,11 @@ impl EventLoop { ) { tracing::info!("Spawning tool execution: {} ({})", tool_name, tool_call_id); + // Handle TodoWrite tool - update main agent todos immediately for real-time display + if tool_name == "TodoWrite" { + self.handle_main_agent_todo_write(&args); + } + // Get tool registry let Some(registry) = self.tool_registry.clone() else { tracing::warn!( @@ -641,4 +647,78 @@ impl EventLoop { } } } + + /// Handle main agent's TodoWrite tool call. + /// Parses the todos argument and updates app_state for real-time display. + fn handle_main_agent_todo_write(&mut self, args: &serde_json::Value) { + // TodoWrite format: { todos: "1. [status] content\n2. [status] content\n..." } + // OR the newer format: { todos: [{ content, status, ... }] } + + // Try to parse as string format first (numbered list) + if let Some(todos_str) = args.get("todos").and_then(|v| v.as_str()) { + let todos: Vec = todos_str + .lines() + .filter_map(|line| { + // Parse lines like "1. [completed] First task" + let line = line.trim(); + if line.is_empty() { + return None; + } + + // Skip the number prefix (e.g., "1. ") + let content_start = line.find(']').map(|i| i + 1)?; + let status_start = line.find('[')?; + + let status_str = &line[status_start + 1..content_start - 1]; + let content = line[content_start..].trim().to_string(); + + if content.is_empty() { + return None; + } + + let status = match status_str { + "in_progress" => MainAgentTodoStatus::InProgress, + "completed" => MainAgentTodoStatus::Completed, + _ => MainAgentTodoStatus::Pending, + }; + + Some(MainAgentTodoItem { content, status }) + }) + .collect(); + + if !todos.is_empty() { + tracing::debug!("Main agent todo list updated: {} items", todos.len()); + self.app_state.update_main_todos(todos); + } + return; + } + + // Try array format (legacy or alternative) + if let Some(todos_arr) = args.get("todos").and_then(|v| v.as_array()) { + let todos: Vec = todos_arr + .iter() + .filter_map(|t| { + let content = t.get("content").and_then(|v| v.as_str())?; + let status_str = t + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + let status = match status_str { + "in_progress" => MainAgentTodoStatus::InProgress, + "completed" => MainAgentTodoStatus::Completed, + _ => MainAgentTodoStatus::Pending, + }; + Some(MainAgentTodoItem { + content: content.to_string(), + status, + }) + }) + .collect(); + + if !todos.is_empty() { + tracing::debug!("Main agent todo list updated: {} items", todos.len()); + self.app_state.update_main_todos(todos); + } + } + } } diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index 6c8c972..13c843a 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -14,7 +14,9 @@ use cortex_core::markdown::MarkdownTheme; use cortex_core::widgets::{Brain, Message, MessageRole}; use cortex_tui_components::welcome_card::{InfoCard, InfoCardPair, ToLines, WelcomeCard}; -use crate::app::{AppState, SubagentDisplayStatus, SubagentTaskDisplay}; +use crate::app::{ + AppState, MainAgentTodoItem, MainAgentTodoStatus, SubagentDisplayStatus, SubagentTaskDisplay, +}; use crate::ui::colors::AdaptiveColors; use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus}; @@ -433,6 +435,81 @@ pub fn render_subagent( lines } +/// Renders the main agent's todo list above the input field. +/// +/// Format: +/// ```text +/// 📋 Plan +/// ⎿ ○ First task +/// ● Second task (highlighted for in_progress) +/// ✓ Third task (strikethrough for completed) +/// ``` +pub fn render_main_agent_todos( + todos: &[MainAgentTodoItem], + width: u16, + colors: &AdaptiveColors, +) -> Vec> { + let mut lines = Vec::new(); + + if todos.is_empty() { + return lines; + } + + // Header line + lines.push(Line::from(vec![ + Span::styled("📋 ", Style::default().fg(colors.accent)), + Span::styled( + "Plan", + Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD), + ), + ])); + + // Calculate content width (accounting for indentation) + let content_width = (width as usize).saturating_sub(8); // 8 chars for " ⎿ ○ " + + // Todo items + for (i, todo) in todos.iter().enumerate() { + let prefix = if i == 0 { " ⎿ " } else { " " }; + + let (status_marker, status_color, text_modifier) = match todo.status { + MainAgentTodoStatus::Completed => ("✓", colors.success, Modifier::CROSSED_OUT), + MainAgentTodoStatus::InProgress => ("●", colors.accent, Modifier::empty()), + MainAgentTodoStatus::Pending => ("○", colors.text_muted, Modifier::empty()), + }; + + // Truncate content if too long + let content = if todo.content.len() > content_width { + format!( + "{}...", + &todo + .content + .chars() + .take(content_width.saturating_sub(3)) + .collect::() + ) + } else { + todo.content.clone() + }; + + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(colors.text_muted)), + Span::styled(status_marker, Style::default().fg(status_color)), + Span::styled(" ", Style::default()), + Span::styled( + content, + Style::default() + .fg(colors.text_dim) + .add_modifier(text_modifier), + ), + ])); + } + + lines.push(Line::from("")); // Spacing + lines +} + /// Generates welcome card as styled lines using TUI components. pub fn generate_welcome_lines( width: u16, diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 725dd0e..234abeb 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -17,8 +17,9 @@ use crate::widgets::{HintContext, KeyHints, StatusIndicator}; use super::layout::LayoutManager; use super::rendering::{ - _render_motd, generate_message_lines, generate_welcome_lines, render_message, - render_scroll_to_bottom_hint, render_scrollbar, render_subagent, render_tool_call, + _render_motd, generate_message_lines, generate_welcome_lines, render_main_agent_todos, + render_message, render_scroll_to_bottom_hint, render_scrollbar, render_subagent, + render_tool_call, }; // Re-export for convenience @@ -572,6 +573,14 @@ impl<'a> Widget for MinimalSessionView<'a> { let input_height: u16 = 3; let hints_height: u16 = 1; + // Calculate main agent todos height (header + items + spacing) + let main_todos_height: u16 = if self.app_state.has_main_todos() { + // 1 for header + number of todos + 1 for spacing + (self.app_state.main_agent_todos.len() as u16) + 2 + } else { + 0 + }; + // Calculate welcome card heights from render_motd constants let welcome_card_height = 11_u16; let info_cards_height = 4_u16; @@ -584,7 +593,12 @@ impl<'a> Widget for MinimalSessionView<'a> { layout.gap(1); // Calculate available height for scrollable content (before input/hints) - let bottom_reserved = status_height + input_height + autocomplete_height + hints_height + 2; // +2 for gaps + let bottom_reserved = main_todos_height + + status_height + + input_height + + autocomplete_height + + hints_height + + 2; // +2 for gaps let available_height = area.height.saturating_sub(1 + bottom_reserved); // 1 for top margin // Render scrollable content area (welcome cards + messages together) @@ -596,7 +610,17 @@ impl<'a> Widget for MinimalSessionView<'a> { let content_end_y = content_area.y + actual_content_height; let mut next_y = content_end_y + 1; // +1 gap after content - // 5. Status indicator (if task running) - follows content + // 4.5. Main agent todos (if any) - above status indicator + if self.app_state.has_main_todos() { + let todo_lines = + render_main_agent_todos(&self.app_state.main_agent_todos, area.width, &self.colors); + let todo_area = Rect::new(area.x, next_y, area.width, main_todos_height); + let paragraph = Paragraph::new(todo_lines); + paragraph.render(todo_area, buf); + next_y += main_todos_height; + } + + // 5. Status indicator (if task running) - follows todos (or content if no todos) if is_task_running { let status_area = Rect::new(area.x, next_y, area.width, status_height); let header = self.status_header(); @@ -608,7 +632,7 @@ impl<'a> Widget for MinimalSessionView<'a> { next_y += status_height; } - // 6. Input area - follows status (or content if no status) + // 6. Input area - follows status (or todos/content if no status) let input_y = next_y; let input_area = Rect::new(area.x, input_y, area.width, input_height);