TUI Enhancements: - Add autocomplete dropdown with fuzzy filtering for slash commands - Fix autocomplete: Tab confirms selection, Enter submits message - Add command help overlay with scroll support (j/k, arrows, Page Up/Down) - Brighten Tokyo Night theme colors for better readability - Add todo panel component for task display - Add rich command output formatting (tables, trees, lists) Streaming Fixes: - Refactor to non-blocking background streaming with channel events - Add StreamStart/StreamEnd/StreamError events - Fix LlmChunk to append instead of creating new messages - Display user message immediately before LLM call New Components: - completions.rs: Command completion engine with fuzzy matching - autocomplete.rs: Inline autocomplete dropdown - command_help.rs: Modal help overlay with scrolling - todo_panel.rs: Todo list display panel - output.rs: Rich formatted output (tables, trees, code blocks) - commands.rs: Built-in command implementations Planning Mode Groundwork: - Add EnterPlanMode/ExitPlanMode tools scaffolding - Add Skill tool for plugin skill invocation - Extend permissions with planning mode support - Add compact.rs stub for context compaction 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
297 lines
8.8 KiB
Rust
297 lines
8.8 KiB
Rust
//! Planning mode tools for the Owlen agent
|
|
//!
|
|
//! Provides EnterPlanMode and ExitPlanMode tools that allow the agent
|
|
//! to enter a planning phase where only read-only operations are allowed,
|
|
//! and then present a plan for user approval.
|
|
|
|
use color_eyre::eyre::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use chrono::{DateTime, Utc};
|
|
use uuid::Uuid;
|
|
|
|
/// Agent mode - normal execution or planning
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum AgentMode {
|
|
/// Normal mode - all tools available per permission settings
|
|
Normal,
|
|
/// Planning mode - only read-only tools allowed
|
|
Planning {
|
|
/// Path to the plan file being written
|
|
plan_file: PathBuf,
|
|
/// When planning mode was entered
|
|
started_at: DateTime<Utc>,
|
|
},
|
|
}
|
|
|
|
impl Default for AgentMode {
|
|
fn default() -> Self {
|
|
Self::Normal
|
|
}
|
|
}
|
|
|
|
impl AgentMode {
|
|
/// Check if we're in planning mode
|
|
pub fn is_planning(&self) -> bool {
|
|
matches!(self, AgentMode::Planning { .. })
|
|
}
|
|
|
|
/// Get the plan file path if in planning mode
|
|
pub fn plan_file(&self) -> Option<&PathBuf> {
|
|
match self {
|
|
AgentMode::Planning { plan_file, .. } => Some(plan_file),
|
|
AgentMode::Normal => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plan file metadata
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlanMetadata {
|
|
pub id: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub status: PlanStatus,
|
|
pub title: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum PlanStatus {
|
|
/// Plan is being written
|
|
Draft,
|
|
/// Plan is awaiting user approval
|
|
PendingApproval,
|
|
/// Plan was approved by user
|
|
Approved,
|
|
/// Plan was rejected by user
|
|
Rejected,
|
|
}
|
|
|
|
/// Manager for plan files
|
|
pub struct PlanManager {
|
|
plans_dir: PathBuf,
|
|
}
|
|
|
|
impl PlanManager {
|
|
/// Create a new plan manager
|
|
pub fn new(project_root: PathBuf) -> Self {
|
|
let plans_dir = project_root.join(".owlen").join("plans");
|
|
Self { plans_dir }
|
|
}
|
|
|
|
/// Create a new plan manager with custom directory
|
|
pub fn with_dir(plans_dir: PathBuf) -> Self {
|
|
Self { plans_dir }
|
|
}
|
|
|
|
/// Get the plans directory
|
|
pub fn plans_dir(&self) -> &PathBuf {
|
|
&self.plans_dir
|
|
}
|
|
|
|
/// Ensure the plans directory exists
|
|
pub async fn ensure_dir(&self) -> Result<()> {
|
|
tokio::fs::create_dir_all(&self.plans_dir).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate a unique plan file name
|
|
/// Uses a format like: <adjective>-<verb>-<noun>.md
|
|
pub fn generate_plan_name(&self) -> String {
|
|
// Simple word lists for readable names
|
|
let adjectives = ["cozy", "swift", "clever", "bright", "calm", "eager", "gentle", "happy"];
|
|
let verbs = ["dancing", "jumping", "running", "flying", "singing", "coding", "building", "thinking"];
|
|
let nouns = ["owl", "fox", "bear", "wolf", "hawk", "deer", "lion", "tiger"];
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
let uuid = Uuid::new_v4();
|
|
let mut hasher = DefaultHasher::new();
|
|
uuid.hash(&mut hasher);
|
|
let hash = hasher.finish();
|
|
|
|
let adj = adjectives[(hash % adjectives.len() as u64) as usize];
|
|
let verb = verbs[((hash >> 8) % verbs.len() as u64) as usize];
|
|
let noun = nouns[((hash >> 16) % nouns.len() as u64) as usize];
|
|
|
|
format!("{}-{}-{}.md", adj, verb, noun)
|
|
}
|
|
|
|
/// Create a new plan file and return the path
|
|
pub async fn create_plan(&self) -> Result<PathBuf> {
|
|
self.ensure_dir().await?;
|
|
|
|
let filename = self.generate_plan_name();
|
|
let plan_path = self.plans_dir.join(&filename);
|
|
|
|
// Create initial plan file with metadata
|
|
let metadata = PlanMetadata {
|
|
id: Uuid::new_v4().to_string(),
|
|
created_at: Utc::now(),
|
|
status: PlanStatus::Draft,
|
|
title: None,
|
|
};
|
|
|
|
let initial_content = format!(
|
|
"<!-- plan-id: {} -->\n<!-- status: draft -->\n\n# Implementation Plan\n\n",
|
|
metadata.id
|
|
);
|
|
|
|
tokio::fs::write(&plan_path, initial_content).await?;
|
|
|
|
Ok(plan_path)
|
|
}
|
|
|
|
/// Write content to a plan file
|
|
pub async fn write_plan(&self, path: &PathBuf, content: &str) -> Result<()> {
|
|
// Preserve the metadata header if it exists
|
|
let existing = tokio::fs::read_to_string(path).await.unwrap_or_default();
|
|
|
|
// Extract metadata lines (lines starting with <!--)
|
|
let metadata_lines: Vec<&str> = existing
|
|
.lines()
|
|
.take_while(|line| line.starts_with("<!--"))
|
|
.collect();
|
|
|
|
// Update status to pending approval
|
|
let mut new_content = String::new();
|
|
for line in &metadata_lines {
|
|
if line.contains("status:") {
|
|
new_content.push_str("<!-- status: pending_approval -->\n");
|
|
} else {
|
|
new_content.push_str(line);
|
|
new_content.push('\n');
|
|
}
|
|
}
|
|
new_content.push('\n');
|
|
new_content.push_str(content);
|
|
|
|
tokio::fs::write(path, new_content).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read a plan file
|
|
pub async fn read_plan(&self, path: &PathBuf) -> Result<String> {
|
|
let content = tokio::fs::read_to_string(path).await?;
|
|
Ok(content)
|
|
}
|
|
|
|
/// Update plan status
|
|
pub async fn set_status(&self, path: &PathBuf, status: PlanStatus) -> Result<()> {
|
|
let content = tokio::fs::read_to_string(path).await?;
|
|
|
|
let status_str = match status {
|
|
PlanStatus::Draft => "draft",
|
|
PlanStatus::PendingApproval => "pending_approval",
|
|
PlanStatus::Approved => "approved",
|
|
PlanStatus::Rejected => "rejected",
|
|
};
|
|
|
|
// Replace status line
|
|
let updated: String = content
|
|
.lines()
|
|
.map(|line| {
|
|
if line.contains("<!-- status:") {
|
|
format!("<!-- status: {} -->", status_str)
|
|
} else {
|
|
line.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
tokio::fs::write(path, updated).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// List all plan files
|
|
pub async fn list_plans(&self) -> Result<Vec<PathBuf>> {
|
|
let mut plans = Vec::new();
|
|
|
|
if !self.plans_dir.exists() {
|
|
return Ok(plans);
|
|
}
|
|
|
|
let mut entries = tokio::fs::read_dir(&self.plans_dir).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.extension().map_or(false, |ext| ext == "md") {
|
|
plans.push(path);
|
|
}
|
|
}
|
|
|
|
plans.sort();
|
|
Ok(plans)
|
|
}
|
|
}
|
|
|
|
/// Enter planning mode
|
|
pub fn enter_plan_mode(plan_file: PathBuf) -> AgentMode {
|
|
AgentMode::Planning {
|
|
plan_file,
|
|
started_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Exit planning mode and return to normal
|
|
pub fn exit_plan_mode() -> AgentMode {
|
|
AgentMode::Normal
|
|
}
|
|
|
|
/// Check if a tool is allowed in planning mode
|
|
/// Only read-only tools are allowed
|
|
pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool {
|
|
matches!(
|
|
tool_name,
|
|
"read" | "glob" | "grep" | "ls" | "web_fetch" | "web_search" |
|
|
"todo_write" | "ask_user" | "exit_plan_mode"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[tokio::test]
|
|
async fn test_create_plan() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let manager = PlanManager::new(temp_dir.path().to_path_buf());
|
|
|
|
let plan_path = manager.create_plan().await.unwrap();
|
|
assert!(plan_path.exists());
|
|
assert!(plan_path.extension().map_or(false, |ext| ext == "md"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_and_read_plan() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let manager = PlanManager::new(temp_dir.path().to_path_buf());
|
|
|
|
let plan_path = manager.create_plan().await.unwrap();
|
|
|
|
manager.write_plan(&plan_path, "# My Plan\n\nStep 1: Do something").await.unwrap();
|
|
|
|
let content = manager.read_plan(&plan_path).await.unwrap();
|
|
assert!(content.contains("My Plan"));
|
|
assert!(content.contains("pending_approval"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_plan_mode_check() {
|
|
assert!(is_tool_allowed_in_plan_mode("read"));
|
|
assert!(is_tool_allowed_in_plan_mode("glob"));
|
|
assert!(is_tool_allowed_in_plan_mode("grep"));
|
|
assert!(!is_tool_allowed_in_plan_mode("write"));
|
|
assert!(!is_tool_allowed_in_plan_mode("bash"));
|
|
assert!(!is_tool_allowed_in_plan_mode("edit"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_agent_mode_default() {
|
|
let mode = AgentMode::default();
|
|
assert!(!mode.is_planning());
|
|
assert!(mode.plan_file().is_none());
|
|
}
|
|
}
|