feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
agent-core = { path = "../../core/agent" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
@@ -19,6 +20,7 @@ tools-slash = { path = "../../tools/slash" }
|
||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
hooks = { path = "../../platform/hooks" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
ui = { path = "../ui" }
|
||||
atty = "0.2"
|
||||
futures-util = "0.3.31"
|
||||
|
||||
@@ -2,8 +2,10 @@ use clap::{Parser, ValueEnum};
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use config_agent::load_settings;
|
||||
use hooks::{HookEvent, HookManager, HookResult};
|
||||
use llm_ollama::{OllamaClient, OllamaOptions};
|
||||
use llm_core::ChatOptions;
|
||||
use llm_ollama::OllamaClient;
|
||||
use permissions::{PermissionDecision, Tool};
|
||||
use plugins::PluginManager;
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -48,6 +50,51 @@ struct StreamEvent {
|
||||
stats: Option<Stats>,
|
||||
}
|
||||
|
||||
/// Application context shared across the session
|
||||
pub struct AppContext {
|
||||
pub plugin_manager: PluginManager,
|
||||
pub config: config_agent::Settings,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn new() -> Result<Self> {
|
||||
let config = load_settings(None).unwrap_or_default();
|
||||
|
||||
let mut plugin_manager = PluginManager::new();
|
||||
// Non-fatal: just log warnings, don't fail startup
|
||||
if let Err(e) = plugin_manager.load_all() {
|
||||
eprintln!("Warning: Failed to load some plugins: {}", e);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
plugin_manager,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Print loaded plugins and available commands
|
||||
pub fn print_plugin_info(&self) {
|
||||
let plugins = self.plugin_manager.plugins();
|
||||
if !plugins.is_empty() {
|
||||
println!("\nLoaded {} plugin(s):", plugins.len());
|
||||
for plugin in plugins {
|
||||
println!(" - {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||
if let Some(desc) = &plugin.manifest.description {
|
||||
println!(" {}", desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let commands = self.plugin_manager.all_commands();
|
||||
if !commands.is_empty() {
|
||||
println!("\nAvailable plugin commands:");
|
||||
for (name, _path) in &commands {
|
||||
println!(" /{}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -162,7 +209,10 @@ struct Args {
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
let args = Args::parse();
|
||||
let mut settings = load_settings(None).unwrap_or_default();
|
||||
|
||||
// Initialize application context with plugins
|
||||
let app_context = AppContext::new()?;
|
||||
let mut settings = app_context.config.clone();
|
||||
|
||||
// Override mode if specified via CLI
|
||||
if let Some(mode) = args.mode {
|
||||
@@ -173,7 +223,16 @@ async fn main() -> Result<()> {
|
||||
let perms = settings.create_permission_manager();
|
||||
|
||||
// Create hook manager
|
||||
let hook_mgr = HookManager::new(".");
|
||||
let mut hook_mgr = HookManager::new(".");
|
||||
|
||||
// Register plugin hooks
|
||||
for plugin in app_context.plugin_manager.plugins() {
|
||||
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
|
||||
for (event, command, pattern, timeout) in plugin.register_hooks_with_manager(&hooks_config) {
|
||||
hook_mgr.register_hook(event, command, pattern, timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
let session_id = generate_session_id();
|
||||
@@ -397,19 +456,20 @@ async fn main() -> Result<()> {
|
||||
HookResult::Allow => {}
|
||||
}
|
||||
|
||||
// Look for command file in .owlen/commands/
|
||||
let command_path = format!(".owlen/commands/{}.md", command_name);
|
||||
// Look for command file in .owlen/commands/ first
|
||||
let local_command_path = format!(".owlen/commands/{}.md", command_name);
|
||||
|
||||
// Read the command file
|
||||
let content = match tools_fs::read_file(&command_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Err(eyre!(
|
||||
"Slash command '{}' not found at {}",
|
||||
command_name,
|
||||
command_path
|
||||
));
|
||||
}
|
||||
// Try local commands first, then plugin commands
|
||||
let content = if let Ok(c) = tools_fs::read_file(&local_command_path) {
|
||||
c
|
||||
} else if let Some(plugin_path) = app_context.plugin_manager.all_commands().get(&command_name) {
|
||||
// Found in plugins
|
||||
tools_fs::read_file(&plugin_path.to_string_lossy())?
|
||||
} else {
|
||||
return Err(eyre!(
|
||||
"Slash command '{}' not found in .owlen/commands/ or plugins",
|
||||
command_name
|
||||
));
|
||||
};
|
||||
|
||||
// Parse with arguments
|
||||
@@ -452,16 +512,15 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
client
|
||||
};
|
||||
let opts = OllamaOptions {
|
||||
model,
|
||||
stream: true,
|
||||
};
|
||||
let opts = ChatOptions::new(model);
|
||||
|
||||
// Check if interactive mode (no prompt provided)
|
||||
if args.prompt.is_empty() {
|
||||
// Use TUI mode unless --no-tui flag is set or not a TTY
|
||||
if !args.no_tui && atty::is(atty::Stream::Stdout) {
|
||||
// Launch TUI
|
||||
// Note: For now, TUI doesn't use plugin manager directly
|
||||
// In the future, we'll integrate plugin commands into TUI
|
||||
return ui::run(client, opts, perms, settings).await;
|
||||
}
|
||||
|
||||
@@ -469,6 +528,13 @@ async fn main() -> Result<()> {
|
||||
println!("🤖 Owlen Interactive Mode");
|
||||
println!("Model: {}", opts.model);
|
||||
println!("Mode: {:?}", settings.mode);
|
||||
|
||||
// Show loaded plugins
|
||||
let plugins = app_context.plugin_manager.plugins();
|
||||
if !plugins.is_empty() {
|
||||
println!("Plugins: {} loaded", plugins.len());
|
||||
}
|
||||
|
||||
println!("Type your message or /help for commands. Press Ctrl+C to exit.\n");
|
||||
|
||||
use std::io::{stdin, BufRead};
|
||||
@@ -504,7 +570,17 @@ async fn main() -> Result<()> {
|
||||
println!(" /checkpoints - List all saved checkpoints");
|
||||
println!(" /rewind <id> - Restore session from checkpoint");
|
||||
println!(" /clear - Clear conversation history");
|
||||
println!(" /plugins - Show loaded plugins and commands");
|
||||
println!(" /exit - Exit interactive mode");
|
||||
|
||||
// Show plugin commands if any are loaded
|
||||
let plugin_commands = app_context.plugin_manager.all_commands();
|
||||
if !plugin_commands.is_empty() {
|
||||
println!("\n📦 Plugin Commands:");
|
||||
for (name, _path) in &plugin_commands {
|
||||
println!(" /{}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
"/status" => {
|
||||
println!("\n📊 Session Status:");
|
||||
@@ -615,6 +691,41 @@ async fn main() -> Result<()> {
|
||||
stats = agent_core::SessionStats::new();
|
||||
println!("\n🗑️ Session history cleared!");
|
||||
}
|
||||
"/plugins" => {
|
||||
let plugins = app_context.plugin_manager.plugins();
|
||||
if plugins.is_empty() {
|
||||
println!("\n📦 No plugins loaded");
|
||||
println!(" Place plugins in:");
|
||||
println!(" - ~/.config/owlen/plugins (user plugins)");
|
||||
println!(" - .owlen/plugins (project plugins)");
|
||||
} else {
|
||||
println!("\n📦 Loaded Plugins:");
|
||||
for plugin in plugins {
|
||||
println!("\n {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||
if let Some(desc) = &plugin.manifest.description {
|
||||
println!(" {}", desc);
|
||||
}
|
||||
if let Some(author) = &plugin.manifest.author {
|
||||
println!(" Author: {}", author);
|
||||
}
|
||||
|
||||
let commands = plugin.all_command_names();
|
||||
if !commands.is_empty() {
|
||||
println!(" Commands: {}", commands.join(", "));
|
||||
}
|
||||
|
||||
let agents = plugin.all_agent_names();
|
||||
if !agents.is_empty() {
|
||||
println!(" Agents: {}", agents.join(", "));
|
||||
}
|
||||
|
||||
let skills = plugin.all_skill_names();
|
||||
if !skills.is_empty() {
|
||||
println!(" Skills: {}", skills.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"/exit" => {
|
||||
println!("\n👋 Goodbye!");
|
||||
break;
|
||||
@@ -656,7 +767,8 @@ async fn main() -> Result<()> {
|
||||
history.add_user_message(input.to_string());
|
||||
let start = SystemTime::now();
|
||||
|
||||
match agent_core::run_agent_loop(&client, input, &opts, &perms).await {
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
match agent_core::run_agent_loop(&client, input, &opts, &perms, &ctx).await {
|
||||
Ok(response) => {
|
||||
println!("\n{}", response);
|
||||
history.add_assistant_message(response.clone());
|
||||
@@ -683,15 +795,16 @@ async fn main() -> Result<()> {
|
||||
let start_time = SystemTime::now();
|
||||
|
||||
// Handle different output formats
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
match output_format {
|
||||
OutputFormat::Text => {
|
||||
// Text format: Use agent orchestrator with tool calling
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
println!("{}", response);
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
// JSON format: Use agent loop and output as JSON
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
|
||||
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
|
||||
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
|
||||
@@ -724,7 +837,7 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
println!("{}", serde_json::to_string(&session_start)?);
|
||||
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
|
||||
let chunk_event = StreamEvent {
|
||||
event_type: "chunk".to_string(),
|
||||
|
||||
@@ -5,12 +5,6 @@ use predicates::prelude::PredicateBooleanExt;
|
||||
#[tokio::test]
|
||||
async fn headless_streams_ndjson() {
|
||||
let server = MockServer::start_async().await;
|
||||
// Mock /api/chat with NDJSON lines
|
||||
let body = serde_json::json!({
|
||||
"model": "qwen2.5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let response = concat!(
|
||||
r#"{"message":{"role":"assistant","content":"Hel"}}"#,"\n",
|
||||
@@ -18,10 +12,11 @@ async fn headless_streams_ndjson() {
|
||||
r#"{"done":true}"#,"\n",
|
||||
);
|
||||
|
||||
// The CLI includes tools in the request, so we need to match any request to /api/chat
|
||||
// instead of matching exact body (which includes tool definitions)
|
||||
let _m = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api/chat")
|
||||
.json_body(body.clone());
|
||||
.path("/api/chat");
|
||||
then.status(200)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(response);
|
||||
|
||||
Reference in New Issue
Block a user