Files
owlen/crates/platform/hooks/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

554 lines
18 KiB
Rust

use color_eyre::eyre::{Result, eyre};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum HookEvent {
#[serde(rename_all = "camelCase")]
PreToolUse {
tool: String,
args: Value,
},
#[serde(rename_all = "camelCase")]
PostToolUse {
tool: String,
result: Value,
},
#[serde(rename_all = "camelCase")]
SessionStart {
session_id: String,
},
#[serde(rename_all = "camelCase")]
SessionEnd {
session_id: String,
},
#[serde(rename_all = "camelCase")]
UserPromptSubmit {
prompt: String,
},
PreCompact,
/// Called before the agent stops - allows validation of completion
#[serde(rename_all = "camelCase")]
Stop {
/// Reason for stopping (e.g., "task_complete", "max_iterations", "user_interrupt")
reason: String,
/// Number of messages in conversation
num_messages: usize,
/// Number of tool calls made
num_tool_calls: usize,
},
/// Called before a subagent stops
#[serde(rename_all = "camelCase")]
SubagentStop {
/// Unique identifier for the subagent
agent_id: String,
/// Type of subagent (e.g., "explore", "code-reviewer")
agent_type: String,
/// Reason for stopping
reason: String,
},
/// Called when a notification is sent to the user
#[serde(rename_all = "camelCase")]
Notification {
/// Notification message
message: String,
/// Notification type (e.g., "info", "warning", "error")
notification_type: String,
},
}
impl HookEvent {
/// Get the hook name for this event (used to find the hook script)
pub fn hook_name(&self) -> &str {
match self {
HookEvent::PreToolUse { .. } => "PreToolUse",
HookEvent::PostToolUse { .. } => "PostToolUse",
HookEvent::SessionStart { .. } => "SessionStart",
HookEvent::SessionEnd { .. } => "SessionEnd",
HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit",
HookEvent::PreCompact => "PreCompact",
HookEvent::Stop { .. } => "Stop",
HookEvent::SubagentStop { .. } => "SubagentStop",
HookEvent::Notification { .. } => "Notification",
}
}
}
/// Simple hook result for backwards compatibility
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookResult {
Allow,
Deny,
}
/// Extended hook output with additional control options
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookOutput {
/// Whether to continue execution (default: true if exit code 0)
#[serde(default = "default_continue")]
pub continue_execution: bool,
/// Whether to suppress showing the result to the user
#[serde(default)]
pub suppress_output: bool,
/// System message to inject into the conversation
#[serde(default)]
pub system_message: Option<String>,
/// Permission decision override
#[serde(default)]
pub permission_decision: Option<HookPermission>,
/// Modified input/args for the tool (PreToolUse only)
#[serde(default)]
pub updated_input: Option<Value>,
}
impl Default for HookOutput {
fn default() -> Self {
Self {
continue_execution: true,
suppress_output: false,
system_message: None,
permission_decision: None,
updated_input: None,
}
}
}
fn default_continue() -> bool {
true
}
/// Permission decision from a hook
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HookPermission {
Allow,
Deny,
Ask,
}
impl HookOutput {
pub fn new() -> Self {
Self::default()
}
pub fn allow() -> Self {
Self {
continue_execution: true,
..Default::default()
}
}
pub fn deny() -> Self {
Self {
continue_execution: false,
..Default::default()
}
}
pub fn with_system_message(mut self, message: impl Into<String>) -> Self {
self.system_message = Some(message.into());
self
}
pub fn with_permission(mut self, permission: HookPermission) -> Self {
self.permission_decision = Some(permission);
self
}
/// Convert to simple HookResult for backwards compatibility
pub fn to_result(&self) -> HookResult {
if self.continue_execution {
HookResult::Allow
} else {
HookResult::Deny
}
}
}
/// A registered hook that can be executed
#[derive(Debug, Clone)]
struct Hook {
event: String, // Event name like "PreToolUse", "PostToolUse", etc.
command: String,
pattern: Option<String>, // Optional regex pattern for matching tool names
timeout: Option<u64>,
}
pub struct HookManager {
project_root: PathBuf,
hooks: Vec<Hook>,
}
impl HookManager {
pub fn new(project_root: &str) -> Self {
Self {
project_root: PathBuf::from(project_root),
hooks: Vec::new(),
}
}
/// Register a single hook
pub fn register_hook(&mut self, event: String, command: String, pattern: Option<String>, timeout: Option<u64>) {
self.hooks.push(Hook {
event,
command,
pattern,
timeout,
});
}
/// Execute a hook for the given event
///
/// Returns:
/// - Ok(HookResult::Allow) if hook succeeds or doesn't exist (exit code 0 or no hook)
/// - Ok(HookResult::Deny) if hook denies (exit code 2)
/// - Err if hook fails (other exit codes) or times out
pub async fn execute(&self, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookResult> {
// First check for legacy file-based hooks
let hook_path = self.get_hook_path(event);
let has_file_hook = hook_path.exists();
// Get registered hooks for this event
let event_name = event.hook_name();
let mut matching_hooks: Vec<&Hook> = self.hooks.iter()
.filter(|h| h.event == event_name)
.collect();
// If we need to filter by pattern (for PreToolUse events)
if let HookEvent::PreToolUse { tool, .. } = event {
matching_hooks.retain(|h| {
if let Some(pattern) = &h.pattern {
// Use regex to match tool name
if let Ok(re) = regex::Regex::new(pattern) {
re.is_match(tool)
} else {
false
}
} else {
true // No pattern means match all
}
});
}
// If no hooks at all, allow by default
if !has_file_hook && matching_hooks.is_empty() {
return Ok(HookResult::Allow);
}
// Execute file-based hook first (if exists)
if has_file_hook {
let result = self.execute_hook_command(&hook_path.to_string_lossy(), event, timeout_ms).await?;
if result == HookResult::Deny {
return Ok(HookResult::Deny);
}
}
// Execute registered hooks
for hook in matching_hooks {
let hook_timeout = hook.timeout.or(timeout_ms);
let result = self.execute_hook_command(&hook.command, event, hook_timeout).await?;
if result == HookResult::Deny {
return Ok(HookResult::Deny);
}
}
Ok(HookResult::Allow)
}
/// Execute a single hook command
async fn execute_hook_command(&self, command: &str, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookResult> {
// Serialize event to JSON
let input_json = serde_json::to_string(event)?;
// Spawn the hook process
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&self.project_root)
.spawn()?;
// Write JSON input to stdin
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input_json.as_bytes()).await?;
stdin.flush().await?;
drop(stdin); // Close stdin
}
// Wait for process with timeout
let result = if let Some(ms) = timeout_ms {
timeout(Duration::from_millis(ms), child.wait_with_output()).await
} else {
Ok(child.wait_with_output().await)
};
match result {
Ok(Ok(output)) => {
// Check exit code
match output.status.code() {
Some(0) => Ok(HookResult::Allow),
Some(2) => Ok(HookResult::Deny),
Some(code) => Err(eyre!(
"Hook {} failed with exit code {}: {}",
event.hook_name(),
code,
String::from_utf8_lossy(&output.stderr)
)),
None => Err(eyre!("Hook {} terminated by signal", event.hook_name())),
}
}
Ok(Err(e)) => Err(eyre!("Failed to execute hook {}: {}", event.hook_name(), e)),
Err(_) => Err(eyre!("Hook {} timed out", event.hook_name())),
}
}
/// Execute a hook and return extended output
///
/// This method parses JSON output from stdout if the hook provides it,
/// otherwise falls back to exit code interpretation.
pub async fn execute_extended(&self, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
// First check for legacy file-based hooks
let hook_path = self.get_hook_path(event);
let has_file_hook = hook_path.exists();
// Get registered hooks for this event
let event_name = event.hook_name();
let mut matching_hooks: Vec<&Hook> = self.hooks.iter()
.filter(|h| h.event == event_name)
.collect();
// If we need to filter by pattern (for PreToolUse events)
if let HookEvent::PreToolUse { tool, .. } = event {
matching_hooks.retain(|h| {
if let Some(pattern) = &h.pattern {
if let Ok(re) = regex::Regex::new(pattern) {
re.is_match(tool)
} else {
false
}
} else {
true
}
});
}
// If no hooks at all, allow by default
if !has_file_hook && matching_hooks.is_empty() {
return Ok(HookOutput::allow());
}
let mut combined_output = HookOutput::allow();
// Execute file-based hook first (if exists)
if has_file_hook {
let output = self.execute_hook_extended(&hook_path.to_string_lossy(), event, timeout_ms).await?;
combined_output = Self::merge_outputs(combined_output, output);
if !combined_output.continue_execution {
return Ok(combined_output);
}
}
// Execute registered hooks
for hook in matching_hooks {
let hook_timeout = hook.timeout.or(timeout_ms);
let output = self.execute_hook_extended(&hook.command, event, hook_timeout).await?;
combined_output = Self::merge_outputs(combined_output, output);
if !combined_output.continue_execution {
return Ok(combined_output);
}
}
Ok(combined_output)
}
/// Execute a single hook command and return extended output
async fn execute_hook_extended(&self, command: &str, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
let input_json = serde_json::to_string(event)?;
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&self.project_root)
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input_json.as_bytes()).await?;
stdin.flush().await?;
drop(stdin);
}
let result = if let Some(ms) = timeout_ms {
timeout(Duration::from_millis(ms), child.wait_with_output()).await
} else {
Ok(child.wait_with_output().await)
};
match result {
Ok(Ok(output)) => {
let exit_code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout);
// Try to parse JSON output from stdout
if !stdout.trim().is_empty() {
if let Ok(hook_output) = serde_json::from_str::<HookOutput>(stdout.trim()) {
return Ok(hook_output);
}
}
// Fall back to exit code interpretation
match exit_code {
Some(0) => Ok(HookOutput::allow()),
Some(2) => Ok(HookOutput::deny()),
Some(code) => Err(eyre!(
"Hook {} failed with exit code {}: {}",
event.hook_name(),
code,
String::from_utf8_lossy(&output.stderr)
)),
None => Err(eyre!("Hook {} terminated by signal", event.hook_name())),
}
}
Ok(Err(e)) => Err(eyre!("Failed to execute hook {}: {}", event.hook_name(), e)),
Err(_) => Err(eyre!("Hook {} timed out", event.hook_name())),
}
}
/// Merge two hook outputs, with the second taking precedence
fn merge_outputs(base: HookOutput, new: HookOutput) -> HookOutput {
HookOutput {
continue_execution: base.continue_execution && new.continue_execution,
suppress_output: base.suppress_output || new.suppress_output,
system_message: new.system_message.or(base.system_message),
permission_decision: new.permission_decision.or(base.permission_decision),
updated_input: new.updated_input.or(base.updated_input),
}
}
fn get_hook_path(&self, event: &HookEvent) -> PathBuf {
self.project_root
.join(".owlen")
.join("hooks")
.join(event.hook_name())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_event_serializes_correctly() {
let event = HookEvent::PreToolUse {
tool: "Read".to_string(),
args: serde_json::json!({"path": "/tmp/test.txt"}),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"preToolUse\""));
assert!(json.contains("\"tool\":\"Read\""));
}
#[test]
fn hook_event_names() {
assert_eq!(
HookEvent::PreToolUse {
tool: "Read".to_string(),
args: serde_json::json!({}),
}
.hook_name(),
"PreToolUse"
);
assert_eq!(
HookEvent::SessionStart {
session_id: "123".to_string(),
}
.hook_name(),
"SessionStart"
);
assert_eq!(
HookEvent::Stop {
reason: "task_complete".to_string(),
num_messages: 10,
num_tool_calls: 5,
}
.hook_name(),
"Stop"
);
assert_eq!(
HookEvent::SubagentStop {
agent_id: "abc123".to_string(),
agent_type: "explore".to_string(),
reason: "completed".to_string(),
}
.hook_name(),
"SubagentStop"
);
}
#[test]
fn stop_event_serializes_correctly() {
let event = HookEvent::Stop {
reason: "task_complete".to_string(),
num_messages: 10,
num_tool_calls: 5,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"stop\""));
assert!(json.contains("\"reason\":\"task_complete\""));
assert!(json.contains("\"numMessages\":10"));
assert!(json.contains("\"numToolCalls\":5"));
}
#[test]
fn hook_output_defaults() {
let output = HookOutput::default();
assert!(output.continue_execution);
assert!(!output.suppress_output);
assert!(output.system_message.is_none());
assert!(output.permission_decision.is_none());
}
#[test]
fn hook_output_builders() {
let output = HookOutput::allow()
.with_system_message("Test message")
.with_permission(HookPermission::Allow);
assert!(output.continue_execution);
assert_eq!(output.system_message, Some("Test message".to_string()));
assert_eq!(output.permission_decision, Some(HookPermission::Allow));
let deny = HookOutput::deny();
assert!(!deny.continue_execution);
}
#[test]
fn hook_output_deserializes() {
let json = r#"{"continueExecution": true, "suppressOutput": false, "systemMessage": "Hello"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
assert!(output.continue_execution);
assert!(!output.suppress_output);
assert_eq!(output.system_message, Some("Hello".to_string()));
}
#[test]
fn hook_output_to_result() {
assert_eq!(HookOutput::allow().to_result(), HookResult::Allow);
assert_eq!(HookOutput::deny().to_result(), HookResult::Deny);
}
}