feat(plan): Add plan execution system with external tool support
Plan Execution System: - Add PlanStep, AccumulatedPlan types for multi-turn tool call accumulation - Implement AccumulatedPlanStatus for tracking plan lifecycle - Support selective approval of proposed tool calls before execution External Tools Integration: - Add ExternalToolDefinition and ExternalToolTransport to plugins crate - Extend ToolContext with external_tools registry - Add external_tool_to_llm_tool conversion for LLM compatibility JSON-RPC Communication: - Add jsonrpc crate for JSON-RPC 2.0 protocol support - Enable stdio-based communication with external tool servers UI & Engine Updates: - Add plan_panel.rs component for displaying accumulated plans - Wire plan mode into engine loop - Add plan mode integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
use crate::{
|
||||
components::{
|
||||
Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox,
|
||||
ModelPicker, PermissionPopup, PickerResult, ProviderTabs, StatusBar, TodoPanel,
|
||||
ModelPicker, PermissionPopup, PickerResult, PlanPanel, ProviderTabs, StatusBar, TodoPanel,
|
||||
},
|
||||
events::{handle_key_event, AppEvent},
|
||||
layout::AppLayout,
|
||||
provider_manager::ProviderManager,
|
||||
theme::{Provider, Theme, VimMode},
|
||||
};
|
||||
use tools_plan::AccumulatedPlanStatus;
|
||||
use tools_todo::TodoList;
|
||||
use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions};
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -59,6 +60,7 @@ pub struct TuiApp {
|
||||
input_box: InputBox,
|
||||
status_bar: StatusBar,
|
||||
todo_panel: TodoPanel,
|
||||
plan_panel: PlanPanel,
|
||||
permission_popup: Option<PermissionPopup>,
|
||||
autocomplete: Autocomplete,
|
||||
command_help: CommandHelp,
|
||||
@@ -128,6 +130,7 @@ impl TuiApp {
|
||||
input_box: InputBox::new(theme.clone()),
|
||||
status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()),
|
||||
todo_panel: TodoPanel::new(theme.clone()),
|
||||
plan_panel: PlanPanel::new(theme.clone()),
|
||||
permission_popup: None,
|
||||
autocomplete: Autocomplete::new(theme.clone()),
|
||||
command_help: CommandHelp::new(theme.clone()),
|
||||
@@ -187,6 +190,7 @@ impl TuiApp {
|
||||
input_box: InputBox::new(theme.clone()),
|
||||
status_bar,
|
||||
todo_panel: TodoPanel::new(theme.clone()),
|
||||
plan_panel: PlanPanel::new(theme.clone()),
|
||||
permission_popup: None,
|
||||
autocomplete: Autocomplete::new(theme.clone()),
|
||||
command_help: CommandHelp::new(theme.clone()),
|
||||
@@ -248,6 +252,7 @@ impl TuiApp {
|
||||
input_box: InputBox::new(theme.clone()),
|
||||
status_bar,
|
||||
todo_panel: TodoPanel::new(theme.clone()),
|
||||
plan_panel: PlanPanel::new(theme.clone()),
|
||||
permission_popup: None,
|
||||
autocomplete: Autocomplete::new(theme.clone()),
|
||||
command_help: CommandHelp::new(theme.clone()),
|
||||
@@ -354,6 +359,7 @@ impl TuiApp {
|
||||
self.input_box = InputBox::new(theme.clone());
|
||||
self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme.clone());
|
||||
self.todo_panel.set_theme(theme.clone());
|
||||
self.plan_panel.set_theme(theme.clone());
|
||||
self.autocomplete.set_theme(theme.clone());
|
||||
self.command_help.set_theme(theme.clone());
|
||||
self.provider_tabs.set_theme(theme.clone());
|
||||
@@ -479,6 +485,26 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
&self.todo_list
|
||||
}
|
||||
|
||||
/// Get the current accumulated plan from shared state (if any)
|
||||
fn get_current_plan(&self) -> Option<tools_plan::AccumulatedPlan> {
|
||||
if let Some(state) = &self.shared_state {
|
||||
if let Ok(guard) = state.try_lock() {
|
||||
return guard.accumulated_plan.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Show the plan panel
|
||||
pub fn show_plan_panel(&mut self) {
|
||||
self.plan_panel.show();
|
||||
}
|
||||
|
||||
/// Hide the plan panel
|
||||
pub fn hide_plan_panel(&mut self) {
|
||||
self.plan_panel.hide();
|
||||
}
|
||||
|
||||
/// Render the header line: OWLEN left, model + vim mode right
|
||||
fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let vim_indicator = self.vim_mode.indicator(&self.theme.symbols);
|
||||
@@ -670,12 +696,22 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
self.model_picker.render(frame, size);
|
||||
}
|
||||
|
||||
// 3. Command help overlay (centered modal)
|
||||
// 3. Plan panel (when accumulating or reviewing a plan)
|
||||
if self.plan_panel.is_visible() {
|
||||
let plan = self.get_current_plan();
|
||||
// Calculate centered area for plan panel (60% width, 50% height)
|
||||
let plan_width = (size.width * 3 / 5).max(60).min(size.width - 4);
|
||||
let plan_height = (size.height / 2).max(15).min(size.height - 4);
|
||||
let plan_area = AppLayout::center_popup(size, plan_width, plan_height);
|
||||
self.plan_panel.render(frame, plan_area, plan.as_ref());
|
||||
}
|
||||
|
||||
// 4. Command help overlay (centered modal)
|
||||
if self.command_help.is_visible() {
|
||||
self.command_help.render(frame, size);
|
||||
}
|
||||
|
||||
// 4. Permission popup (highest priority)
|
||||
// 5. Permission popup (highest priority)
|
||||
if let Some(popup) = &self.permission_popup {
|
||||
popup.render(frame, size);
|
||||
}
|
||||
@@ -785,13 +821,130 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Command help overlay
|
||||
// 2. Plan panel (when reviewing accumulated plan)
|
||||
if self.plan_panel.is_visible() {
|
||||
match key.code {
|
||||
// Navigation
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
if let Some(plan) = self.get_current_plan() {
|
||||
self.plan_panel.select_next(plan.steps.len());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.plan_panel.select_prev();
|
||||
}
|
||||
// Toggle step approval
|
||||
KeyCode::Char(' ') => {
|
||||
if let Some(state) = &self.shared_state {
|
||||
if let Ok(mut guard) = state.try_lock() {
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
let idx = self.plan_panel.selected_index();
|
||||
if let Some(step) = plan.steps.get_mut(idx) {
|
||||
step.approved = match step.approved {
|
||||
None => Some(true),
|
||||
Some(true) => Some(false),
|
||||
Some(false) => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Finalize plan (stop accumulating, enter review)
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => {
|
||||
if let Some(tx) = &self.engine_tx {
|
||||
let _ = tx.send(Message::UserAction(UserAction::FinalizePlan)).await;
|
||||
}
|
||||
}
|
||||
// Approve all pending
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
if let Some(state) = &self.shared_state {
|
||||
if let Ok(mut guard) = state.try_lock() {
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
for step in &mut plan.steps {
|
||||
if step.approved.is_none() {
|
||||
step.approved = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject all pending
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => {
|
||||
if let Some(state) = &self.shared_state {
|
||||
if let Ok(mut guard) = state.try_lock() {
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
for step in &mut plan.steps {
|
||||
if step.approved.is_none() {
|
||||
step.approved = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Execute approved steps
|
||||
KeyCode::Enter => {
|
||||
if let Some(plan) = self.get_current_plan() {
|
||||
if plan.status == AccumulatedPlanStatus::Reviewing {
|
||||
// Collect approval decisions
|
||||
let approved: Vec<_> = plan.steps.iter()
|
||||
.filter(|s| s.approved == Some(true))
|
||||
.map(|s| s.id.clone())
|
||||
.collect();
|
||||
let rejected: Vec<_> = plan.steps.iter()
|
||||
.filter(|s| s.approved == Some(false))
|
||||
.map(|s| s.id.clone())
|
||||
.collect();
|
||||
|
||||
let approval = tools_plan::PlanApproval {
|
||||
approved_ids: approved,
|
||||
rejected_ids: rejected,
|
||||
};
|
||||
|
||||
if let Some(tx) = &self.engine_tx {
|
||||
let _ = tx.send(Message::UserAction(UserAction::PlanApproval(approval))).await;
|
||||
}
|
||||
self.plan_panel.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Save plan
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
if let Some(tx) = &self.engine_tx {
|
||||
// TODO: Prompt for plan name
|
||||
let name = format!("plan-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs());
|
||||
let _ = tx.send(Message::UserAction(UserAction::SavePlan(name))).await;
|
||||
}
|
||||
}
|
||||
// Cancel/close
|
||||
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||
if let Some(plan) = self.get_current_plan() {
|
||||
if plan.status == AccumulatedPlanStatus::Accumulating {
|
||||
// Cancel the plan entirely
|
||||
if let Some(tx) = &self.engine_tx {
|
||||
let _ = tx.send(Message::UserAction(UserAction::CancelPlan)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.plan_panel.hide();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Command help overlay
|
||||
if self.command_help.is_visible() {
|
||||
self.command_help.handle_key(key);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Model picker
|
||||
// 4. Model picker
|
||||
if self.model_picker.is_visible() {
|
||||
let current_model = self.opts.model.clone();
|
||||
match self.model_picker.handle_key(key, ¤t_model) {
|
||||
@@ -951,16 +1104,23 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
}
|
||||
);
|
||||
self.chat_panel.add_message(ChatMessage::System(msg));
|
||||
// Auto-show plan panel when steps are being accumulated
|
||||
if !self.plan_panel.is_visible() {
|
||||
self.plan_panel.show();
|
||||
}
|
||||
}
|
||||
AgentResponse::PlanComplete { total_steps, status: _ } => {
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
format!("--- PLAN COMPLETE ({} steps) ---", total_steps)
|
||||
));
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
"Review and approve plan (y/n in status bar)".to_string()
|
||||
"Press [Enter] to execute or [Esc] to cancel".to_string()
|
||||
));
|
||||
// Show plan panel for review
|
||||
self.plan_panel.show();
|
||||
self.plan_panel.reset_selection();
|
||||
self.status_bar.set_state(crate::components::AppState::WaitingPermission);
|
||||
self.status_bar.set_pending_permission(Some("PLAN".to_string()));
|
||||
self.status_bar.set_pending_permission(Some("PLAN REVIEW".to_string()));
|
||||
}
|
||||
AgentResponse::PlanExecuting { step_id: _, step_index, total_steps } => {
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
@@ -971,6 +1131,10 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
format!("Plan execution complete: {} executed, {} skipped", executed, skipped)
|
||||
));
|
||||
// Hide plan panel after execution
|
||||
self.plan_panel.hide();
|
||||
self.status_bar.set_state(crate::components::AppState::Idle);
|
||||
self.status_bar.set_pending_permission(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1606,6 +1770,18 @@ Commands: /help, /model <name>, /clear, /theme <name>
|
||||
"Context compaction not yet implemented".to_string()
|
||||
));
|
||||
}
|
||||
"/plan" => {
|
||||
// Toggle plan panel visibility
|
||||
if self.plan_panel.is_visible() {
|
||||
self.plan_panel.hide();
|
||||
} else if self.get_current_plan().is_some() {
|
||||
self.plan_panel.show();
|
||||
} else {
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
"No active plan. Start planning mode with a prompt.".to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
"/provider" => {
|
||||
// Show available providers
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
|
||||
Reference in New Issue
Block a user