Skip to content

Commit 441b95b

Browse files
committed
feat(tui): display main agent TodoWrite items above input field
- Add MainAgentTodoItem and MainAgentTodoStatus types in state.rs - Add update_main_todos, clear_main_todos, has_main_todos methods - Parse TodoWrite tool calls in spawn_tool_execution for real-time display - Add render_main_agent_todos function with styled output - Integrate todos display in minimal session view layout - Re-export new types from app module
1 parent ac6398e commit 441b95b

File tree

5 files changed

+227
-7
lines changed

5 files changed

+227
-7
lines changed

src/cortex-tui/src/app/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ mod types;
1515
pub use approval::{ApprovalState, PendingToolResult};
1616
pub use autocomplete::{AutocompleteItem, AutocompleteState};
1717
pub use session::{ActiveModal, SessionSummary};
18-
pub use state::AppState;
18+
pub use state::{AppState, MainAgentTodoItem, MainAgentTodoStatus};
1919
pub use streaming::StreamingState;
2020
pub use subagent::{
2121
SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus,

src/cortex-tui/src/app/state.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ use super::streaming::StreamingState;
2222
use super::subagent::SubagentTaskDisplay;
2323
use super::types::{AppView, FocusTarget, OperationMode};
2424

25+
/// A todo item for the main agent's todo list display.
26+
#[derive(Debug, Clone, PartialEq, Eq)]
27+
pub struct MainAgentTodoItem {
28+
/// Content/description of the todo.
29+
pub content: String,
30+
/// Status of this todo item.
31+
pub status: MainAgentTodoStatus,
32+
}
33+
34+
/// Status of a main agent todo item.
35+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36+
pub enum MainAgentTodoStatus {
37+
/// Not started yet.
38+
Pending,
39+
/// Currently being worked on.
40+
InProgress,
41+
/// Completed.
42+
Completed,
43+
}
44+
2545
/// Main application state
2646
pub struct AppState {
2747
pub view: AppView,
@@ -172,6 +192,8 @@ pub struct AppState {
172192
pub user_email: Option<String>,
173193
/// Organization name for welcome screen
174194
pub org_name: Option<String>,
195+
/// Main agent's todo list items (from TodoWrite tool calls).
196+
pub main_agent_todos: Vec<MainAgentTodoItem>,
175197
}
176198

177199
impl AppState {
@@ -272,6 +294,7 @@ impl AppState {
272294
user_name: None,
273295
user_email: None,
274296
org_name: None,
297+
main_agent_todos: Vec::new(),
275298
}
276299
}
277300

@@ -677,3 +700,24 @@ impl AppState {
677700
self.diff_scroll = (self.diff_scroll + delta).max(0);
678701
}
679702
}
703+
704+
// ============================================================================
705+
// APPSTATE METHODS - Main Agent Todos
706+
// ============================================================================
707+
708+
impl AppState {
709+
/// Update the main agent's todo list.
710+
pub fn update_main_todos(&mut self, todos: Vec<MainAgentTodoItem>) {
711+
self.main_agent_todos = todos;
712+
}
713+
714+
/// Clear the main agent's todo list.
715+
pub fn clear_main_todos(&mut self) {
716+
self.main_agent_todos.clear();
717+
}
718+
719+
/// Check if the main agent has any todos.
720+
pub fn has_main_todos(&self) -> bool {
721+
!self.main_agent_todos.is_empty()
722+
}
723+
}

src/cortex-tui/src/runner/event_loop/tools.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use std::time::{Duration, Instant};
44

5+
use crate::app::{MainAgentTodoItem, MainAgentTodoStatus};
56
use crate::events::ToolEvent;
67
use crate::session::StoredToolCall;
78
use crate::views::tool_call::format_result_summary;
@@ -18,6 +19,11 @@ impl EventLoop {
1819
) {
1920
tracing::info!("Spawning tool execution: {} ({})", tool_name, tool_call_id);
2021

22+
// Handle TodoWrite tool - update main agent todos immediately for real-time display
23+
if tool_name == "TodoWrite" {
24+
self.handle_main_agent_todo_write(&args);
25+
}
26+
2127
// Get tool registry
2228
let Some(registry) = self.tool_registry.clone() else {
2329
tracing::warn!(
@@ -641,4 +647,75 @@ impl EventLoop {
641647
}
642648
}
643649
}
650+
651+
/// Handle main agent's TodoWrite tool call.
652+
/// Parses the todos argument and updates app_state for real-time display.
653+
fn handle_main_agent_todo_write(&mut self, args: &serde_json::Value) {
654+
// TodoWrite format: { todos: "1. [status] content\n2. [status] content\n..." }
655+
// OR the newer format: { todos: [{ content, status, ... }] }
656+
657+
// Try to parse as string format first (numbered list)
658+
if let Some(todos_str) = args.get("todos").and_then(|v| v.as_str()) {
659+
let todos: Vec<MainAgentTodoItem> = todos_str
660+
.lines()
661+
.filter_map(|line| {
662+
// Parse lines like "1. [completed] First task"
663+
let line = line.trim();
664+
if line.is_empty() {
665+
return None;
666+
}
667+
668+
// Skip the number prefix (e.g., "1. ")
669+
let content_start = line.find(']').map(|i| i + 1)?;
670+
let status_start = line.find('[')?;
671+
672+
let status_str = &line[status_start + 1..content_start - 1];
673+
let content = line[content_start..].trim().to_string();
674+
675+
if content.is_empty() {
676+
return None;
677+
}
678+
679+
let status = match status_str {
680+
"in_progress" => MainAgentTodoStatus::InProgress,
681+
"completed" => MainAgentTodoStatus::Completed,
682+
_ => MainAgentTodoStatus::Pending,
683+
};
684+
685+
Some(MainAgentTodoItem { content, status })
686+
})
687+
.collect();
688+
689+
if !todos.is_empty() {
690+
tracing::debug!("Main agent todo list updated: {} items", todos.len());
691+
self.app_state.update_main_todos(todos);
692+
}
693+
return;
694+
}
695+
696+
// Try array format (legacy or alternative)
697+
if let Some(todos_arr) = args.get("todos").and_then(|v| v.as_array()) {
698+
let todos: Vec<MainAgentTodoItem> = todos_arr
699+
.iter()
700+
.filter_map(|t| {
701+
let content = t.get("content").and_then(|v| v.as_str())?;
702+
let status_str = t.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
703+
let status = match status_str {
704+
"in_progress" => MainAgentTodoStatus::InProgress,
705+
"completed" => MainAgentTodoStatus::Completed,
706+
_ => MainAgentTodoStatus::Pending,
707+
};
708+
Some(MainAgentTodoItem {
709+
content: content.to_string(),
710+
status,
711+
})
712+
})
713+
.collect();
714+
715+
if !todos.is_empty() {
716+
tracing::debug!("Main agent todo list updated: {} items", todos.len());
717+
self.app_state.update_main_todos(todos);
718+
}
719+
}
720+
}
644721
}

src/cortex-tui/src/views/minimal_session/rendering.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use cortex_core::markdown::MarkdownTheme;
1414
use cortex_core::widgets::{Brain, Message, MessageRole};
1515
use cortex_tui_components::welcome_card::{InfoCard, InfoCardPair, ToLines, WelcomeCard};
1616

17-
use crate::app::{AppState, SubagentDisplayStatus, SubagentTaskDisplay};
17+
use crate::app::{AppState, MainAgentTodoItem, MainAgentTodoStatus, SubagentDisplayStatus, SubagentTaskDisplay};
1818
use crate::ui::colors::AdaptiveColors;
1919
use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus};
2020

@@ -433,6 +433,81 @@ pub fn render_subagent(
433433
lines
434434
}
435435

436+
/// Renders the main agent's todo list above the input field.
437+
///
438+
/// Format:
439+
/// ```text
440+
/// 📋 Plan
441+
/// ⎿ ○ First task
442+
/// ● Second task (highlighted for in_progress)
443+
/// ✓ Third task (strikethrough for completed)
444+
/// ```
445+
pub fn render_main_agent_todos(
446+
todos: &[MainAgentTodoItem],
447+
width: u16,
448+
colors: &AdaptiveColors,
449+
) -> Vec<Line<'static>> {
450+
let mut lines = Vec::new();
451+
452+
if todos.is_empty() {
453+
return lines;
454+
}
455+
456+
// Header line
457+
lines.push(Line::from(vec![
458+
Span::styled("📋 ", Style::default().fg(colors.accent)),
459+
Span::styled(
460+
"Plan",
461+
Style::default()
462+
.fg(colors.accent)
463+
.add_modifier(Modifier::BOLD),
464+
),
465+
]));
466+
467+
// Calculate content width (accounting for indentation)
468+
let content_width = (width as usize).saturating_sub(8); // 8 chars for " ⎿ ○ "
469+
470+
// Todo items
471+
for (i, todo) in todos.iter().enumerate() {
472+
let prefix = if i == 0 { " ⎿ " } else { " " };
473+
474+
let (status_marker, status_color, text_modifier) = match todo.status {
475+
MainAgentTodoStatus::Completed => ("✓", colors.success, Modifier::CROSSED_OUT),
476+
MainAgentTodoStatus::InProgress => ("●", colors.accent, Modifier::empty()),
477+
MainAgentTodoStatus::Pending => ("○", colors.text_muted, Modifier::empty()),
478+
};
479+
480+
// Truncate content if too long
481+
let content = if todo.content.len() > content_width {
482+
format!(
483+
"{}...",
484+
&todo
485+
.content
486+
.chars()
487+
.take(content_width.saturating_sub(3))
488+
.collect::<String>()
489+
)
490+
} else {
491+
todo.content.clone()
492+
};
493+
494+
lines.push(Line::from(vec![
495+
Span::styled(prefix, Style::default().fg(colors.text_muted)),
496+
Span::styled(status_marker, Style::default().fg(status_color)),
497+
Span::styled(" ", Style::default()),
498+
Span::styled(
499+
content,
500+
Style::default()
501+
.fg(colors.text_dim)
502+
.add_modifier(text_modifier),
503+
),
504+
]));
505+
}
506+
507+
lines.push(Line::from("")); // Spacing
508+
lines
509+
}
510+
436511
/// Generates welcome card as styled lines using TUI components.
437512
pub fn generate_welcome_lines(
438513
width: u16,

src/cortex-tui/src/views/minimal_session/view.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ use crate::widgets::{HintContext, KeyHints, StatusIndicator};
1717

1818
use super::layout::LayoutManager;
1919
use super::rendering::{
20-
_render_motd, generate_message_lines, generate_welcome_lines, render_message,
21-
render_scroll_to_bottom_hint, render_scrollbar, render_subagent, render_tool_call,
20+
_render_motd, generate_message_lines, generate_welcome_lines, render_main_agent_todos,
21+
render_message, render_scroll_to_bottom_hint, render_scrollbar, render_subagent,
22+
render_tool_call,
2223
};
2324

2425
// Re-export for convenience
@@ -572,6 +573,14 @@ impl<'a> Widget for MinimalSessionView<'a> {
572573
let input_height: u16 = 3;
573574
let hints_height: u16 = 1;
574575

576+
// Calculate main agent todos height (header + items + spacing)
577+
let main_todos_height: u16 = if self.app_state.has_main_todos() {
578+
// 1 for header + number of todos + 1 for spacing
579+
(self.app_state.main_agent_todos.len() as u16) + 2
580+
} else {
581+
0
582+
};
583+
575584
// Calculate welcome card heights from render_motd constants
576585
let welcome_card_height = 11_u16;
577586
let info_cards_height = 4_u16;
@@ -584,7 +593,12 @@ impl<'a> Widget for MinimalSessionView<'a> {
584593
layout.gap(1);
585594

586595
// Calculate available height for scrollable content (before input/hints)
587-
let bottom_reserved = status_height + input_height + autocomplete_height + hints_height + 2; // +2 for gaps
596+
let bottom_reserved = main_todos_height
597+
+ status_height
598+
+ input_height
599+
+ autocomplete_height
600+
+ hints_height
601+
+ 2; // +2 for gaps
588602
let available_height = area.height.saturating_sub(1 + bottom_reserved); // 1 for top margin
589603

590604
// Render scrollable content area (welcome cards + messages together)
@@ -596,7 +610,17 @@ impl<'a> Widget for MinimalSessionView<'a> {
596610
let content_end_y = content_area.y + actual_content_height;
597611
let mut next_y = content_end_y + 1; // +1 gap after content
598612

599-
// 5. Status indicator (if task running) - follows content
613+
// 4.5. Main agent todos (if any) - above status indicator
614+
if self.app_state.has_main_todos() {
615+
let todo_lines =
616+
render_main_agent_todos(&self.app_state.main_agent_todos, area.width, &self.colors);
617+
let todo_area = Rect::new(area.x, next_y, area.width, main_todos_height);
618+
let paragraph = Paragraph::new(todo_lines);
619+
paragraph.render(todo_area, buf);
620+
next_y += main_todos_height;
621+
}
622+
623+
// 5. Status indicator (if task running) - follows todos (or content if no todos)
600624
if is_task_running {
601625
let status_area = Rect::new(area.x, next_y, area.width, status_height);
602626
let header = self.status_header();
@@ -608,7 +632,7 @@ impl<'a> Widget for MinimalSessionView<'a> {
608632
next_y += status_height;
609633
}
610634

611-
// 6. Input area - follows status (or content if no status)
635+
// 6. Input area - follows status (or todos/content if no status)
612636
let input_y = next_y;
613637
let input_area = Rect::new(area.x, input_y, area.width, input_height);
614638

0 commit comments

Comments
 (0)