Files
owlen/crates/tools/plan/src/lib.rs
vikingowl 4a07b97eab feat(ui): add autocomplete, command help, and streaming improvements
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>
2025-12-02 19:03:33 +01:00

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());
}
}