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:
2025-12-26 22:47:54 +01:00
parent f97bd44f05
commit 84fa08ab45
17 changed files with 2438 additions and 13 deletions

View File

@@ -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, &current_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(