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:
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
322
crates/app/ui/src/components/plan_panel.rs
Normal file
322
crates/app/ui/src/components/plan_panel.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user