diff --git a/cortex-tui/src/agent/system_prompt.rs b/cortex-tui/src/agent/system_prompt.rs index ddaab7e2..7981392c 100644 --- a/cortex-tui/src/agent/system_prompt.rs +++ b/cortex-tui/src/agent/system_prompt.rs @@ -33,6 +33,13 @@ pub const SYSTEM_PROMPT_TEMPLATE: &str = r#"You are Cortex, an expert AI coding - For shell commands, explain what they do before executing - Call multiple tools in parallel when operations are independent +# Todo List (IMPORTANT) +For any non-trivial task that requires multiple steps: +- Use the TodoWrite tool immediately to create a todo list tracking your progress +- Update the todo list as you complete each step (mark items as in_progress or completed) +- This provides real-time visibility to the user on what you're working on +- Keep only ONE item as in_progress at a time + # Guidelines - Always verify paths exist before operations - Handle errors gracefully and suggest alternatives diff --git a/cortex-tui/src/app.rs b/cortex-tui/src/app.rs index 3ef8cf1d..27c7c88a 100644 --- a/cortex-tui/src/app.rs +++ b/cortex-tui/src/app.rs @@ -49,6 +49,11 @@ pub struct StreamingState { pub executing_tool: Option, /// When the tool started executing (for elapsed time display) pub tool_started_at: Option, + /// When the last user prompt was sent (for total elapsed time from user's perspective) + /// This persists across streaming restarts (e.g., after tool execution) + pub prompt_started_at: Option, + /// Whether a subagent (Task) is currently running + pub is_delegating: bool, } impl Default for StreamingState { @@ -61,6 +66,8 @@ impl Default for StreamingState { task_started_at: None, executing_tool: None, tool_started_at: None, + prompt_started_at: None, + is_delegating: false, } } } @@ -71,6 +78,10 @@ impl StreamingState { self.thinking = true; self.current_tool = tool; self.task_started_at = Some(Instant::now()); + // Only set prompt_started_at if not already set (first call in a turn) + if self.prompt_started_at.is_none() { + self.prompt_started_at = Some(Instant::now()); + } } /// Get the elapsed seconds since the task started @@ -80,6 +91,14 @@ impl StreamingState { .unwrap_or(0) } + /// Get the elapsed seconds since the original prompt was sent + /// This is the total time from the user's perspective + pub fn prompt_elapsed_seconds(&self) -> u64 { + self.prompt_started_at + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0) + } + /// Reset streaming state when task completes pub fn stop(&mut self) { self.is_streaming = false; @@ -87,6 +106,25 @@ impl StreamingState { self.current_tool = None; self.tool_status = None; self.task_started_at = None; + // Note: prompt_started_at is NOT reset here - it persists until full_reset() + } + + /// Full reset when the entire conversation turn is complete + /// (no more tool executions or continuations expected) + pub fn full_reset(&mut self) { + self.stop(); + self.prompt_started_at = None; + self.is_delegating = false; + } + + /// Start delegation mode (subagent is running) + pub fn start_delegation(&mut self) { + self.is_delegating = true; + } + + /// Stop delegation mode + pub fn stop_delegation(&mut self) { + self.is_delegating = false; } /// Start tool execution in background @@ -1347,6 +1385,8 @@ impl AppState { pub fn clear_tool_calls(&mut self) { self.tool_calls.clear(); self.clear_content_segments(); + // Also clear completed/failed subagents from the previous turn + self.active_subagents.retain(|t| !t.status.is_terminal()); } /// Advance spinner frames for all running tool calls diff --git a/cortex-tui/src/runner/event_loop.rs b/cortex-tui/src/runner/event_loop.rs index d5e4ead5..422a6a69 100644 --- a/cortex-tui/src/runner/event_loop.rs +++ b/cortex-tui/src/runner/event_loop.rs @@ -541,6 +541,9 @@ impl EventLoop { subagent_type.clone(), )); + // Mark that we're in delegation mode for UI status indicator + self.app_state.streaming.start_delegation(); + // Spawn background task with full agentic loop (like OpenCode) let task = tokio::spawn(async move { let started_at = Instant::now(); @@ -555,10 +558,16 @@ impl EventLoop { .await; // Build subagent system prompt (like OpenCode) + // Note: Subagents now have access to TodoWrite to provide real-time progress updates let system_prompt = format!( "You are a specialized {} subagent working on: {}\n\n\ - You have access to tools like Read, Edit, Grep, Glob, LS, Execute, Batch, etc.\n\ - Note: You cannot use Task, TodoWrite, or TodoRead tools (they are disabled for subagents).\n\ + You have access to tools like Read, Edit, Grep, Glob, LS, Execute, Batch, TodoWrite, etc.\n\ + Note: You cannot use the Task tool (no nested delegation).\n\n\ + IMPORTANT - Todo List:\n\ + - For any multi-step task, IMMEDIATELY use TodoWrite to create a todo list\n\ + - Update the todo list as you progress (mark items in_progress or completed)\n\ + - This provides real-time visibility to the user\n\ + - Keep only ONE item as in_progress at a time\n\n\ Use Batch to execute multiple tools in parallel for efficiency.\n\ If a tool fails, try an alternative approach instead of giving up.\n\ Complete the task and provide a clear summary when done.", @@ -568,13 +577,15 @@ impl EventLoop { // Build initial messages for subagent let mut messages = vec![Message::system(system_prompt), Message::user(&prompt)]; - // Get tool definitions - filter based on subagent permissions (like OpenCode) + // Get tool definitions - filter based on subagent permissions + // Allow TodoWrite for progress tracking, but not Task (no nested delegation) let tools: Vec = registry .get_definitions() .into_iter() .filter(|t| { let name_lower = t.name.to_lowercase(); - name_lower != "task" && name_lower != "todowrite" && name_lower != "todoread" + // Only filter out Task - allow TodoWrite and TodoRead for progress tracking + name_lower != "task" }) .map(|t| ClientToolDefinition::function(t.name, t.description, t.parameters)) .collect(); @@ -2848,10 +2859,15 @@ impl EventLoop { if self.app_state.has_pending_tool_results() { tracing::info!("Calling continue_with_tool_results from StreamEvent::Done"); self.continue_with_tool_results().await; - } else { - tracing::info!("No pending results, processing message queue"); - // No tool results pending - check for queued messages + } else if self.app_state.has_queued_messages() { + tracing::info!("Processing message queue"); self.process_message_queue().await; + } else { + // No more work to do - full reset the prompt timer + tracing::info!( + "Conversation turn complete, full resetting streaming state" + ); + self.app_state.streaming.full_reset(); } } else { tracing::info!("Tools still running, will continue when they complete"); @@ -3189,6 +3205,10 @@ impl EventLoop { let session_id = format!("subagent_{}", id); // Remove from active subagents - the task result is in the tool output self.app_state.remove_subagent(&session_id); + // Stop delegation mode if no more active subagents + if !self.app_state.has_active_subagents() { + self.app_state.streaming.stop_delegation(); + } } // Store for agentic continuation @@ -3242,9 +3262,13 @@ impl EventLoop { "Calling continue_with_tool_results from ToolEvent::Completed" ); self.continue_with_tool_results().await; - } else { - tracing::info!("No pending results after tool completion"); + } else if self.app_state.has_queued_messages() { + tracing::info!("Processing message queue after tool completion"); self.process_message_queue().await; + } else { + // No more work to do - full reset the prompt timer + tracing::info!("All tools done, full resetting streaming state"); + self.app_state.streaming.full_reset(); } } else { tracing::info!("More tools still running after this completion"); @@ -3318,6 +3342,10 @@ impl EventLoop { if name == "Task" || name == "task" { let session_id = format!("subagent_{}", id); self.app_state.remove_subagent(&session_id); + // Stop delegation mode if no more active subagents + if !self.app_state.has_active_subagents() { + self.app_state.streaming.stop_delegation(); + } } self.app_state @@ -3348,8 +3376,11 @@ impl EventLoop { if self.running_tool_tasks.is_empty() && self.running_subagents.is_empty() { if self.app_state.has_pending_tool_results() { self.continue_with_tool_results().await; - } else { + } else if self.app_state.has_queued_messages() { self.process_message_queue().await; + } else { + // No more work to do - full reset the prompt timer + self.app_state.streaming.full_reset(); } } } @@ -3486,11 +3517,25 @@ impl EventLoop { task.current_activity = "Completed".to_string(); }); - // Remove from active subagents and get task info - if let Some(task) = self.app_state.remove_subagent(&session_id) { - // Remove from running tasks - self.running_subagents.remove(&session_id); + // Get task info for result formatting (keep the task visible in UI) + let task_info = self + .app_state + .active_subagents + .iter() + .find(|t| t.session_id == session_id) + .map(|t| { + ( + t.agent_type.clone(), + t.description.clone(), + t.elapsed(), + t.tool_calls.len(), + ) + }); + + // Remove from running tasks (background handle tracking) + self.running_subagents.remove(&session_id); + if let Some((agent_type, description, elapsed, tool_count)) = task_info { // Format result for the LLM let result_output = format!( "## Subagent Completed\n\n\ @@ -3499,10 +3544,10 @@ impl EventLoop { **Duration:** {:.1}s\n\ **Tool Calls:** {}\n\n\ ## Output\n\n{}", - task.agent_type, - task.description, - task.elapsed().as_secs_f64(), - task.tool_calls.len(), + agent_type, + description, + elapsed.as_secs_f64(), + tool_count, output ); @@ -3513,33 +3558,41 @@ impl EventLoop { result_output, true, ); + } - // Force-save assistant message if stream not done - if !self.stream_done_received && !self.pending_assistant_tool_calls.is_empty() { - if let Some(ref mut session) = self.cortex_session { - let tool_calls_for_message = - std::mem::take(&mut self.pending_assistant_tool_calls); - // Use full_text() to get ALL text (committed + pending) - let content = self.stream_controller.full_text(); - - let mut stored_msg = crate::session::StoredMessage::assistant(&content); - for tc in &tool_calls_for_message { - let tool_call = - StoredToolCall::new(&tc.id, &tc.name, tc.arguments.clone()); - stored_msg = stored_msg.with_tool_call(tool_call); - } - session.add_message_raw(stored_msg); + // Force-save assistant message if stream not done + if !self.stream_done_received && !self.pending_assistant_tool_calls.is_empty() { + if let Some(ref mut session) = self.cortex_session { + let tool_calls_for_message = + std::mem::take(&mut self.pending_assistant_tool_calls); + // Use full_text() to get ALL text (committed + pending) + let content = self.stream_controller.full_text(); + + let mut stored_msg = crate::session::StoredMessage::assistant(&content); + for tc in &tool_calls_for_message { + let tool_call = + StoredToolCall::new(&tc.id, &tc.name, tc.arguments.clone()); + stored_msg = stored_msg.with_tool_call(tool_call); } - self.stream_done_received = true; + session.add_message_raw(stored_msg); } + self.stream_done_received = true; + } - // Continue agentic loop if no more subagents or tools running - if self.running_subagents.is_empty() && self.running_tool_tasks.is_empty() { - if self.app_state.has_pending_tool_results() { - self.continue_with_tool_results().await; - } else { - self.process_message_queue().await; - } + // Stop delegation mode if no more active subagents + if !self.app_state.has_active_subagents() { + self.app_state.streaming.stop_delegation(); + } + + // Continue agentic loop if no more subagents or tools running + if self.running_subagents.is_empty() && self.running_tool_tasks.is_empty() { + if self.app_state.has_pending_tool_results() { + self.continue_with_tool_results().await; + } else if self.app_state.has_queued_messages() { + self.process_message_queue().await; + } else { + // No more work to do - full reset the prompt timer + self.app_state.streaming.full_reset(); } } } @@ -3557,11 +3610,18 @@ impl EventLoop { task.current_activity = format!("Failed: {}", error); }); - // Remove from active subagents and get task info - if let Some(task) = self.app_state.remove_subagent(&session_id) { - // Remove from running tasks - self.running_subagents.remove(&session_id); + // Get task info for result formatting (keep the task visible in UI) + let task_info = self + .app_state + .active_subagents + .iter() + .find(|t| t.session_id == session_id) + .map(|t| (t.agent_type.clone(), t.description.clone(), t.elapsed())); + // Remove from running tasks (background handle tracking) + self.running_subagents.remove(&session_id); + + if let Some((agent_type, description, elapsed)) = task_info { // Format error for the LLM let error_output = format!( "## Subagent Failed\n\n\ @@ -3569,9 +3629,9 @@ impl EventLoop { **Description:** {}\n\ **Duration:** {:.1}s\n\n\ ## Error\n\n{}", - task.agent_type, - task.description, - task.elapsed().as_secs_f64(), + agent_type, + description, + elapsed.as_secs_f64(), error ); @@ -3582,33 +3642,41 @@ impl EventLoop { error_output, false, ); + } - // Force-save assistant message if stream not done - if !self.stream_done_received && !self.pending_assistant_tool_calls.is_empty() { - if let Some(ref mut session) = self.cortex_session { - let tool_calls_for_message = - std::mem::take(&mut self.pending_assistant_tool_calls); - // Use full_text() to get ALL text (committed + pending) - let content = self.stream_controller.full_text(); - - let mut stored_msg = crate::session::StoredMessage::assistant(&content); - for tc in &tool_calls_for_message { - let tool_call = - StoredToolCall::new(&tc.id, &tc.name, tc.arguments.clone()); - stored_msg = stored_msg.with_tool_call(tool_call); - } - session.add_message_raw(stored_msg); + // Force-save assistant message if stream not done + if !self.stream_done_received && !self.pending_assistant_tool_calls.is_empty() { + if let Some(ref mut session) = self.cortex_session { + let tool_calls_for_message = + std::mem::take(&mut self.pending_assistant_tool_calls); + // Use full_text() to get ALL text (committed + pending) + let content = self.stream_controller.full_text(); + + let mut stored_msg = crate::session::StoredMessage::assistant(&content); + for tc in &tool_calls_for_message { + let tool_call = + StoredToolCall::new(&tc.id, &tc.name, tc.arguments.clone()); + stored_msg = stored_msg.with_tool_call(tool_call); } - self.stream_done_received = true; + session.add_message_raw(stored_msg); } + self.stream_done_received = true; + } - // Continue agentic loop if no more subagents or tools running - if self.running_subagents.is_empty() && self.running_tool_tasks.is_empty() { - if self.app_state.has_pending_tool_results() { - self.continue_with_tool_results().await; - } else { - self.process_message_queue().await; - } + // Stop delegation mode if no more active subagents + if !self.app_state.has_active_subagents() { + self.app_state.streaming.stop_delegation(); + } + + // Continue agentic loop if no more subagents or tools running + if self.running_subagents.is_empty() && self.running_tool_tasks.is_empty() { + if self.app_state.has_pending_tool_results() { + self.continue_with_tool_results().await; + } else if self.app_state.has_queued_messages() { + self.process_message_queue().await; + } else { + // No more work to do - full reset the prompt timer + self.app_state.streaming.full_reset(); } } } diff --git a/cortex-tui/src/views/minimal_session.rs b/cortex-tui/src/views/minimal_session.rs index 359e28f7..791aff92 100644 --- a/cortex-tui/src/views/minimal_session.rs +++ b/cortex-tui/src/views/minimal_session.rs @@ -740,21 +740,25 @@ impl<'a> MinimalSessionView<'a> { /// Returns whether a task is currently running. fn is_task_running(&self) -> bool { - self.app_state.streaming.is_streaming || self.app_state.streaming.is_tool_executing() + self.app_state.streaming.is_streaming + || self.app_state.streaming.is_tool_executing() + || self.app_state.streaming.is_delegating + || self.app_state.has_active_subagents() } /// Returns the status header text based on current state. fn status_header(&self) -> String { - // Check for tool execution first (highest priority) - if self.app_state.streaming.is_tool_executing() { + // Check for delegation/subagent first (highest priority) + if self.app_state.streaming.is_delegating || self.app_state.has_active_subagents() { + "Delegation".to_string() + } else if self.app_state.streaming.is_tool_executing() { let tool_name = self .app_state .streaming .executing_tool .as_deref() .unwrap_or("tool"); - let elapsed = self.app_state.streaming.tool_elapsed_seconds(); - format!("Executing {}... ({}s)", tool_name, elapsed) + format!("Executing {}", tool_name) } else if self.app_state.streaming.is_streaming { "Working".to_string() } else { @@ -1218,7 +1222,9 @@ impl<'a> Widget for MinimalSessionView<'a> { chunk_idx += 1; let header = self.status_header(); - let elapsed = self.app_state.streaming.elapsed_seconds(); + // Use prompt_elapsed_seconds for total time from user's last prompt + // This gives a consistent view of how long the entire operation has been running + let elapsed = self.app_state.streaming.prompt_elapsed_seconds(); let status = StatusIndicator::new(header) .with_elapsed_secs(elapsed) .with_interrupt_hint(true);