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>
This commit is contained in:
296
crates/tools/plan/src/lib.rs
Normal file
296
crates/tools/plan/src/lib.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user