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:
2025-12-02 17:24:14 +01:00
parent 09c8c9d83e
commit 10c8e2baae
67 changed files with 11444 additions and 626 deletions

View File

@@ -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"

View File

@@ -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(),

View File

@@ -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);