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

@@ -28,3 +28,4 @@ llm-ollama = { path = "../../llm/ollama" }
llm-openai = { path = "../../llm/openai" }
config-agent = { path = "../../platform/config" }
tools-todo = { path = "../../tools/todo" }
tools-plan = { path = "../../tools/plan" }

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(

View File

@@ -6,6 +6,7 @@ mod command_help;
mod input_box;
mod model_picker;
mod permission_popup;
mod plan_panel;
mod provider_tabs;
mod status_bar;
mod todo_panel;
@@ -16,6 +17,7 @@ pub use command_help::{Command, CommandHelp};
pub use input_box::{InputBox, InputEvent};
pub use model_picker::{ModelPicker, PickerResult, PickerState};
pub use permission_popup::{PermissionOption, PermissionPopup};
pub use plan_panel::PlanPanel;
pub use provider_tabs::ProviderTabs;
pub use status_bar::{AppState, StatusBar};
pub use todo_panel::TodoPanel;

View File

@@ -0,0 +1,322 @@
//! Plan panel component for displaying accumulated plan steps
//!
//! Shows the current plan with steps, approval status, and keybindings.
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
use tools_plan::{AccumulatedPlan, AccumulatedPlanStatus, PlanStep};
use crate::theme::Theme;
/// Plan panel component for displaying and interacting with accumulated plans
pub struct PlanPanel {
theme: Theme,
/// Currently selected step index
selected: usize,
/// Scroll offset for long lists
scroll_offset: usize,
/// Whether the panel is visible
visible: bool,
}
impl PlanPanel {
pub fn new(theme: Theme) -> Self {
Self {
theme,
selected: 0,
scroll_offset: 0,
visible: false,
}
}
/// Show the panel
pub fn show(&mut self) {
self.visible = true;
self.selected = 0;
self.scroll_offset = 0;
}
/// Hide the panel
pub fn hide(&mut self) {
self.visible = false;
}
/// Check if visible
pub fn is_visible(&self) -> bool {
self.visible
}
/// Update theme
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Move selection up
pub fn select_prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
// Adjust scroll if needed
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
}
/// Move selection down
pub fn select_next(&mut self, total_steps: usize) {
if self.selected < total_steps.saturating_sub(1) {
self.selected += 1;
}
}
/// Get the currently selected step index
pub fn selected_index(&self) -> usize {
self.selected
}
/// Reset selection to first step
pub fn reset_selection(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
/// Get the minimum height needed for the panel
pub fn min_height(&self) -> u16 {
if self.visible { 10 } else { 0 }
}
/// Render the plan panel
pub fn render(&self, frame: &mut Frame, area: Rect, plan: Option<&AccumulatedPlan>) {
if !self.visible {
return;
}
let plan = match plan {
Some(p) => p,
None => {
// Show "no plan" message
let block = Block::default()
.borders(Borders::ALL)
.title(" PLAN ")
.border_style(self.theme.border);
let paragraph = Paragraph::new("No active plan")
.style(self.theme.status_dim)
.block(block);
frame.render_widget(paragraph, area);
return;
}
};
let mut lines: Vec<Line> = Vec::new();
// Title line with status
let status_str = match plan.status {
AccumulatedPlanStatus::Accumulating => "Accumulating",
AccumulatedPlanStatus::Reviewing => "Reviewing",
AccumulatedPlanStatus::Executing => "Executing",
AccumulatedPlanStatus::Completed => "Completed",
AccumulatedPlanStatus::Cancelled => "Cancelled",
};
let status_color = match plan.status {
AccumulatedPlanStatus::Accumulating => self.theme.palette.warning,
AccumulatedPlanStatus::Reviewing => self.theme.palette.info,
AccumulatedPlanStatus::Executing => self.theme.palette.success,
AccumulatedPlanStatus::Completed => self.theme.palette.success,
AccumulatedPlanStatus::Cancelled => self.theme.palette.error,
};
let name = plan.name.as_deref().unwrap_or("unnamed");
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", name),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(" | ", self.theme.status_dim),
Span::styled(status_str, Style::default().fg(status_color)),
Span::styled(
format!(" ({} steps)", plan.steps.len()),
self.theme.status_dim,
),
]));
// Separator
lines.push(Line::from("".repeat(area.width.saturating_sub(2) as usize)));
// Steps
if plan.steps.is_empty() {
lines.push(Line::from(Span::styled(
" No steps yet...",
self.theme.status_dim,
)));
} else {
let visible_height = area.height.saturating_sub(6) as usize; // Account for header, footer, borders
let start = self.scroll_offset;
let end = (start + visible_height).min(plan.steps.len());
for (idx, step) in plan.steps.iter().enumerate().skip(start).take(end - start) {
let is_selected = idx == self.selected;
let line = self.render_step(step, idx + 1, is_selected);
lines.push(line);
// Show rationale if selected and available
if is_selected {
if let Some(rationale) = &step.rationale {
let truncated = if rationale.len() > 60 {
format!("{}...", &rationale[..57])
} else {
rationale.clone()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\"{}\"", truncated),
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
),
]));
}
}
}
}
// Footer with keybindings
lines.push(Line::from("".repeat(area.width.saturating_sub(2) as usize)));
let keybinds = match plan.status {
AccumulatedPlanStatus::Accumulating => {
"[F]inalize [C]ancel"
}
AccumulatedPlanStatus::Reviewing => {
"[j/k]nav [Space]toggle [A]pprove all [R]eject all [Enter]execute [S]ave [C]ancel"
}
_ => "[C]lose",
};
lines.push(Line::from(Span::styled(keybinds, self.theme.status_dim)));
// Create block with border
let block = Block::default()
.borders(Borders::ALL)
.title(" PLAN ")
.border_style(self.theme.border);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
// Scrollbar if needed
if plan.steps.len() > (area.height.saturating_sub(6) as usize) {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""));
let mut scrollbar_state = ScrollbarState::new(plan.steps.len())
.position(self.selected);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin { vertical: 2, horizontal: 0 }),
&mut scrollbar_state,
);
}
}
/// Render a single step line
fn render_step(&self, step: &PlanStep, num: usize, selected: bool) -> Line<'static> {
let checkbox = match step.approved {
None => "", // Pending
Some(true) => "", // Approved
Some(false) => "", // Rejected
};
let checkbox_color = match step.approved {
None => self.theme.palette.warning,
Some(true) => self.theme.palette.success,
Some(false) => self.theme.palette.error,
};
// Truncate args for display
let args_str = step.args.to_string();
let args_display = if args_str.len() > 40 {
format!("{}...", &args_str[..37])
} else {
args_str
};
let mut spans = vec![
Span::styled(
if selected { ">" } else { " " },
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("[{}] ", num), self.theme.status_dim),
Span::styled(checkbox, Style::default().fg(checkbox_color)),
Span::raw(" "),
Span::styled(
format!("{:<10}", step.tool),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(args_display, self.theme.status_dim),
];
if selected {
// Highlight the entire line for selected step
for span in &mut spans {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
}
Line::from(spans)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
#[test]
fn test_plan_panel_creation() {
let theme = Theme::default();
let panel = PlanPanel::new(theme);
assert!(!panel.is_visible());
assert_eq!(panel.selected_index(), 0);
}
#[test]
fn test_plan_panel_navigation() {
let theme = Theme::default();
let mut panel = PlanPanel::new(theme);
panel.show();
// Can't go below 0
panel.select_prev();
assert_eq!(panel.selected_index(), 0);
// Navigate down
panel.select_next(5);
assert_eq!(panel.selected_index(), 1);
panel.select_next(5);
assert_eq!(panel.selected_index(), 2);
// Can't go past end
panel.select_next(3);
panel.select_next(3);
assert_eq!(panel.selected_index(), 2);
// Navigate back up
panel.select_prev();
assert_eq!(panel.selected_index(), 1);
}
#[test]
fn test_plan_panel_visibility() {
let theme = Theme::default();
let mut panel = PlanPanel::new(theme);
assert!(!panel.is_visible());
panel.show();
assert!(panel.is_visible());
panel.hide();
assert!(!panel.is_visible());
}
}