Skip to content

Commit e95b74a

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 e95b74a

File tree

5 files changed

+232
-7
lines changed

5 files changed

+232
-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: 80 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,78 @@ 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
703+
.get("status")
704+
.and_then(|v| v.as_str())
705+
.unwrap_or("pending");
706+
let status = match status_str {
707+
"in_progress" => MainAgentTodoStatus::InProgress,
708+
"completed" => MainAgentTodoStatus::Completed,
709+
_ => MainAgentTodoStatus::Pending,
710+
};
711+
Some(MainAgentTodoItem {
712+
content: content.to_string(),
713+
status,
714+
})
715+
})
716+
.collect();
717+
718+
if !todos.is_empty() {
719+
tracing::debug!("Main agent todo list updated: {} items", todos.len());
720+
self.app_state.update_main_todos(todos);
721+
}
722+
}
723+
}
644724
}

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ 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::{
18+
AppState, MainAgentTodoItem, MainAgentTodoStatus, SubagentDisplayStatus, SubagentTaskDisplay,
19+
};
1820
use crate::ui::colors::AdaptiveColors;
1921
use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus};
2022

@@ -433,6 +435,81 @@ pub fn render_subagent(
433435
lines
434436
}
435437

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