diff --git a/Cargo.lock b/Cargo.lock index 70841e7..467f126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -279,6 +279,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.35" @@ -408,6 +423,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "config" version = "0.14.1" @@ -490,7 +519,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" dependencies = [ - "crossterm", + "crossterm 0.29.0", ] [[package]] @@ -535,7 +564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51360853ebbeb3df20c76c82aecf43d387a62860f1a59ba65ab51f00eea85aad" dependencies = [ "crokey-proc_macros", - "crossterm", + "crossterm 0.29.0", "once_cell", "serde", "strict", @@ -547,7 +576,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bf1a727caeb5ee5e0a0826a97f205a9cf84ee964b0b48239fef5214a00ae439" dependencies = [ - "crossterm", + "crossterm 0.29.0", "proc-macro2", "quote", "strict", @@ -610,6 +639,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -653,6 +698,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -846,6 +926,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -975,11 +1061,12 @@ dependencies = [ "anyhow", "chrono", "clap", - "crossterm", + "crossterm 0.29.0", "dirs 5.0.1", "g3-config", "g3-core", "indicatif", + "ratatui", "rustyline", "serde", "serde_json", @@ -1147,6 +1234,11 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -1430,6 +1522,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1474,6 +1572,25 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -1506,6 +1623,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1702,6 +1828,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1991,6 +2126,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2163,6 +2304,27 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.9.4", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -2607,6 +2769,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict" version = "0.2.0" @@ -2619,6 +2787,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.106" @@ -2997,6 +3187,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/crates/g3-cli/Cargo.toml b/crates/g3-cli/Cargo.toml index 9c15e3e..79f369b 100644 --- a/crates/g3-cli/Cargo.toml +++ b/crates/g3-cli/Cargo.toml @@ -20,4 +20,5 @@ tokio-util = "0.7" indicatif = "0.17" chrono = { version = "0.4", features = ["serde"] } crossterm = "0.29.0" +ratatui = "0.29" termimad = "0.34.0" diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 9de06e1..7f4e3da 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -10,8 +10,10 @@ use tracing::{error, info}; mod tui; mod ui_writer_impl; +mod retro_tui; use tui::SimpleOutput; -use ui_writer_impl::ConsoleUiWriter; +use ui_writer_impl::{ConsoleUiWriter, RetroTuiWriter}; +use retro_tui::RetroTui; #[derive(Parser)] #[command(name = "g3")] @@ -48,39 +50,60 @@ pub struct Cli { /// Maximum number of turns in autonomous mode (default: 5) #[arg(long, default_value = "5")] pub max_turns: usize, + + /// Use retro terminal UI (inspired by 80s sci-fi) + #[arg(long)] + pub retro: bool, } pub async fn run() -> Result<()> { let cli = Cli::parse(); - // Initialize logging with filtering - use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + // Only initialize logging if not in retro mode + if !cli.retro { + // Initialize logging with filtering + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - // Create a filter that suppresses llama_cpp logs unless in verbose mode - let filter = if cli.verbose { - EnvFilter::from_default_env() - .add_directive(format!("{}=debug", env!("CARGO_PKG_NAME")).parse().unwrap()) - .add_directive("g3_core=debug".parse().unwrap()) - .add_directive("g3_cli=debug".parse().unwrap()) - .add_directive("g3_execution=debug".parse().unwrap()) - .add_directive("g3_providers=debug".parse().unwrap()) + // Create a filter that suppresses llama_cpp logs unless in verbose mode + let filter = if cli.verbose { + EnvFilter::from_default_env() + .add_directive(format!("{}=debug", env!("CARGO_PKG_NAME")).parse().unwrap()) + .add_directive("g3_core=debug".parse().unwrap()) + .add_directive("g3_cli=debug".parse().unwrap()) + .add_directive("g3_execution=debug".parse().unwrap()) + .add_directive("g3_providers=debug".parse().unwrap()) + } else { + EnvFilter::from_default_env() + .add_directive(format!("{}=info", env!("CARGO_PKG_NAME")).parse().unwrap()) + .add_directive("g3_core=info".parse().unwrap()) + .add_directive("g3_cli=info".parse().unwrap()) + .add_directive("g3_execution=info".parse().unwrap()) + .add_directive("g3_providers=info".parse().unwrap()) + .add_directive("llama_cpp=off".parse().unwrap()) // Suppress all llama_cpp logs + .add_directive("llama=off".parse().unwrap()) // Suppress all llama.cpp logs + }; + + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(filter) + .init(); } else { - EnvFilter::from_default_env() - .add_directive(format!("{}=info", env!("CARGO_PKG_NAME")).parse().unwrap()) - .add_directive("g3_core=info".parse().unwrap()) - .add_directive("g3_cli=info".parse().unwrap()) - .add_directive("g3_execution=info".parse().unwrap()) - .add_directive("g3_providers=info".parse().unwrap()) - .add_directive("llama_cpp=off".parse().unwrap()) // Suppress all llama_cpp logs - .add_directive("llama=off".parse().unwrap()) // Suppress all llama.cpp logs - }; + // In retro mode, we don't want any logging output to interfere with the TUI + // We'll use a no-op subscriber + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + + // Create a filter that suppresses ALL logs in retro mode + let filter = EnvFilter::from_default_env() + .add_directive("off".parse().unwrap()); // Turn off all logging + + tracing_subscriber::registry() + .with(filter) + .init(); + } - tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) - .with(filter) - .init(); - - info!("Starting G3 AI Coding Agent"); + if !cli.retro { + info!("Starting G3 AI Coding Agent"); + } // Set up workspace directory let workspace_dir = if let Some(ws) = cli.workspace { @@ -104,19 +127,23 @@ pub async fn run() -> Result<()> { project.ensure_workspace_exists()?; project.enter_workspace()?; - info!("Using workspace: {}", project.workspace().display()); + if !cli.retro { + info!("Using workspace: {}", project.workspace().display()); + } // Load configuration let config = Config::load(cli.config.as_deref())?; // Initialize agent let ui_writer = ConsoleUiWriter::new(); - let mut agent = Agent::new(config, ui_writer).await?; + let mut agent = Agent::new(config.clone(), ui_writer).await?; // Execute task, autonomous mode, or start interactive mode if cli.autonomous { // Autonomous mode with coach-player feedback loop - info!("Starting autonomous mode"); + if !cli.retro { + info!("Starting autonomous mode"); + } run_autonomous( agent, project, @@ -127,23 +154,187 @@ pub async fn run() -> Result<()> { .await?; } else if let Some(task) = cli.task { // Single-shot mode - info!("Executing task: {}", task); + if !cli.retro { + info!("Executing task: {}", task); + } let output = SimpleOutput::new(); let result = agent .execute_task_with_timing(&task, None, false, cli.show_prompt, cli.show_code, true) .await?; output.print_markdown(&result); } else { - let output = SimpleOutput::new(); // Interactive mode (default) - info!("Starting interactive mode"); - output.print(&format!("📁 Workspace: {}", project.workspace().display())); - run_interactive(agent, cli.show_prompt, cli.show_code).await?; + if !cli.retro { + info!("Starting interactive mode"); + } + + if cli.retro { + // Use retro terminal UI + run_interactive_retro(config, cli.show_prompt, cli.show_code).await?; + } else { + // Use standard terminal UI + let output = SimpleOutput::new(); + output.print(&format!("📁 Workspace: {}", project.workspace().display())); + run_interactive(agent, cli.show_prompt, cli.show_code).await?; + } } Ok(()) } +async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: bool) -> Result<()> { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + use std::time::Duration; + + // Set environment variable to suppress println in other crates + std::env::set_var("G3_RETRO_MODE", "1"); + + // Initialize the retro terminal UI + let tui = RetroTui::start().await?; + + // Create agent with RetroTuiWriter + let ui_writer = RetroTuiWriter::new(tui.clone()); + let mut agent = Agent::new(config, ui_writer).await?; + + // Display initial system messages + tui.output("SYSTEM: G3 AI CODING AGENT ONLINE"); + tui.output("SYSTEM: READY FOR INPUT"); + tui.output(""); + + // Display provider and model information + match agent.get_provider_info() { + Ok((provider, model)) => { + tui.output(&format!("SYSTEM: PROVIDER: {} | MODEL: {}", provider, model)); + } + Err(e) => { + tui.error(&format!("Failed to get provider info: {}", e)); + } + } + + tui.output(""); + tui.output("Type 'exit' or 'quit' to exit, use Up/Down arrows to scroll output"); + tui.output("For multiline input: use \\ at the end of a line to continue"); + tui.output(""); + + // Track multiline input + let mut multiline_buffer = String::new(); + let mut in_multiline = false; + let mut input_buffer = String::new(); + + // Main event loop + loop { + // Update context window display + let context = agent.get_context_window(); + tui.update_context(context.used_tokens, context.total_tokens, context.percentage_used()); + + // Update the displayed input buffer + tui.update_input(&input_buffer); + + // Poll for keyboard events + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.exit(); + break; + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.exit(); + break; + } + KeyCode::Enter => { + if !input_buffer.is_empty() { + let trimmed = input_buffer.trim_end(); + + // Check if line ends with backslash for continuation + if trimmed.ends_with('\\') { + // Remove the backslash and add to buffer + let without_backslash = &trimmed[..trimmed.len() - 1]; + multiline_buffer.push_str(without_backslash); + multiline_buffer.push('\n'); + in_multiline = true; + input_buffer.clear(); + tui.status("MULTILINE INPUT"); + continue; + } + + // If we're in multiline mode and no backslash, this is the final line + let final_input = if in_multiline { + multiline_buffer.push_str(&input_buffer); + in_multiline = false; + let result = multiline_buffer.clone(); + multiline_buffer.clear(); + tui.status("READY"); + result + } else { + input_buffer.clone() + }; + + input_buffer.clear(); + + let input = final_input.trim().to_string(); + if input.is_empty() { + continue; + } + + if input == "exit" || input == "quit" { + tui.exit(); + break; + } + + // Execute the task + tui.output(&format!("> {}", input)); + tui.status("PROCESSING"); + + match agent.execute_task_with_timing(&input, None, false, show_prompt, show_code, true).await { + Ok(response) => { + tui.output(&response); + tui.status("READY"); + } + Err(e) => { + tui.error(&format!("Task execution failed: {}", e)); + tui.status("ERROR"); + } + } + } + } + KeyCode::Char(c) => { + input_buffer.push(c); + } + KeyCode::Backspace => { + input_buffer.pop(); + } + KeyCode::Up => { + tui.scroll_up(); + } + KeyCode::Down => { + tui.scroll_down(); + } + KeyCode::PageUp => { + tui.scroll_page_up(); + } + KeyCode::PageDown => { + tui.scroll_page_down(); + } + KeyCode::Home => { + tui.scroll_home(); + } + KeyCode::End => { + tui.scroll_end(); + } + _ => {} + } + } + } + + // Small delay to prevent CPU spinning + tokio::time::sleep(Duration::from_millis(10)).await; + } + + tui.output("SYSTEM: SHUTDOWN INITIATED"); + Ok(()) +} + async fn run_interactive(mut agent: Agent, show_prompt: bool, show_code: bool) -> Result<()> { let output = SimpleOutput::new(); diff --git a/crates/g3-cli/src/retro_tui.rs b/crates/g3-cli/src/retro_tui.rs new file mode 100644 index 0000000..3762e9d --- /dev/null +++ b/crates/g3-cli/src/retro_tui.rs @@ -0,0 +1,448 @@ +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + Frame, Terminal, +}; +use std::io; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +// Retro sci-fi color scheme inspired by Alien terminals +const TERMINAL_GREEN: Color = Color::Rgb(0, 255, 65); // Bright phosphor green +const TERMINAL_AMBER: Color = Color::Rgb(255, 176, 0); // Amber for warnings +const TERMINAL_DIM_GREEN: Color = Color::Rgb(0, 128, 32); // Dimmed green for borders +const TERMINAL_BG: Color = Color::Rgb(0, 10, 0); // Very dark green background +const TERMINAL_CYAN: Color = Color::Rgb(0, 255, 255); // Cyan for highlights +const TERMINAL_RED: Color = Color::Rgb(255, 0, 0); // Red for errors + +/// Message types for communication between threads +#[derive(Debug, Clone)] +pub enum TuiMessage { + AgentOutput(String), + SystemStatus(String), + ContextUpdate { + used: u32, + total: u32, + percentage: f32, + }, + Error(String), + Exit, +} + +/// Shared state for the retro terminal +struct TerminalState { + /// Current input buffer + input_buffer: String, + /// Output history + output_history: Vec, + /// Scroll position in output + scroll_offset: usize, + /// Cursor blink state + cursor_blink: bool, + /// Last cursor blink time + last_blink: Instant, + /// System status line + status_line: String, + /// Context window info + context_info: (u32, u32, f32), + /// Should exit + should_exit: bool, +} + +impl TerminalState { + fn new() -> Self { + Self { + input_buffer: String::new(), + output_history: vec![ + "WEYLAND-YUTANI SYSTEMS".to_string(), + "MU/TH/UR 6000 - INTERFACE 2.4.1".to_string(), + "".to_string(), + "SYSTEM INITIALIZED".to_string(), + "AWAITING COMMAND...".to_string(), + "".to_string(), + ], + scroll_offset: 0, + cursor_blink: true, + last_blink: Instant::now(), + status_line: "READY".to_string(), + context_info: (0, 0, 0.0), + should_exit: false, + } + } + + /// Add text to output history + fn add_output(&mut self, text: &str) { + // Split text by newlines and add each line + for line in text.lines() { + self.output_history.push(line.to_string()); + } + // Auto-scroll to bottom + self.scroll_offset = self.output_history.len().saturating_sub(1); + } +} + +/// Public interface for the retro terminal +#[derive(Clone)] +pub struct RetroTui { + tx: mpsc::UnboundedSender, + state: Arc>, + terminal: Arc>>>, +} + +impl RetroTui { + /// Create and start the retro terminal UI + pub async fn start() -> Result { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + // Create message channel + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let state = Arc::new(Mutex::new(TerminalState::new())); + let terminal = Arc::new(Mutex::new(terminal)); + + // Clone for the background task + let state_clone = state.clone(); + let terminal_clone = terminal.clone(); + + // Spawn background task to handle messages and redraw + tokio::spawn(async move { + let mut last_draw = Instant::now(); + + loop { + // Check for messages + while let Ok(msg) = rx.try_recv() { + let mut state = state_clone.lock().unwrap(); + match msg { + TuiMessage::AgentOutput(text) => { + state.add_output(&text); + } + TuiMessage::SystemStatus(status) => { + state.status_line = status; + } + TuiMessage::ContextUpdate { + used, + total, + percentage, + } => { + state.context_info = (used, total, percentage); + } + TuiMessage::Error(err) => { + state.add_output(&format!("ERROR: {}", err)); + } + TuiMessage::Exit => { + state.should_exit = true; + break; + } + } + } + + // Check if we should exit + if state_clone.lock().unwrap().should_exit { + break; + } + + // Update cursor blink + { + let mut state = state_clone.lock().unwrap(); + if state.last_blink.elapsed() > Duration::from_millis(500) { + state.cursor_blink = !state.cursor_blink; + state.last_blink = Instant::now(); + } + } + + // Redraw at ~60fps + if last_draw.elapsed() > Duration::from_millis(16) { + let state = state_clone.lock().unwrap(); + let mut term = terminal_clone.lock().unwrap(); + let _ = Self::draw(&mut term, &state); + last_draw = Instant::now(); + } + + // Small sleep to prevent busy waiting + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + + // Initial draw + { + let state = state.lock().unwrap(); + let mut term = terminal.lock().unwrap(); + Self::draw(&mut term, &state)?; + } + + Ok(Self { + tx, + state, + terminal, + }) + } + + /// Draw the terminal UI + fn draw( + terminal: &mut Terminal>, + state: &TerminalState, + ) -> Result<()> { + terminal.draw(|f| { + let size = f.area(); + + // Create main layout - header, input, output + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), // Header/input area + Constraint::Min(10), // Main output area + Constraint::Length(1), // Status bar + ]) + .split(size); + + // Draw header/input area + Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_blink); + + // Draw main output area + Self::draw_output_area(f, chunks[1], &state.output_history, state.scroll_offset); + + // Draw status bar + Self::draw_status_bar(f, chunks[2], &state.status_line, state.context_info); + })?; + + Ok(()) + } + + /// Draw the input area with prompt + fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_blink: bool) { + // Show the actual input buffer content with prompt + let input_text = if cursor_blink { + format!("g3> {}█", input_buffer) + } else { + format!("g3> {} ", input_buffer) + }; + + let input = Paragraph::new(input_text) + .style(Style::default().fg(TERMINAL_GREEN)) + .block( + Block::default() + .title(" COMMAND INPUT ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(TERMINAL_DIM_GREEN)) + .style(Style::default().bg(TERMINAL_BG)), + ); + + f.render_widget(input, area); + } + + /// Draw the main output area + fn draw_output_area( + f: &mut Frame, + area: Rect, + output_history: &[String], + scroll_offset: usize, + ) { + // Calculate visible lines + let visible_height = area.height.saturating_sub(2) as usize; // Account for borders + let total_lines = output_history.len(); + + // Adjust scroll offset to ensure it's valid + let max_scroll = total_lines.saturating_sub(visible_height); + let scroll = scroll_offset.min(max_scroll); + + // Get visible lines + let visible_lines: Vec = output_history + .iter() + .skip(scroll) + .take(visible_height) + .map(|line| { + // Apply different colors based on content + let style = if line.starts_with("ERROR:") { + Style::default() + .fg(TERMINAL_RED) + .add_modifier(Modifier::BOLD) + } else if line.starts_with('>') { + Style::default().fg(TERMINAL_CYAN) + } else if line.starts_with("SYSTEM:") + || line.starts_with("WEYLAND") + || line.starts_with("MU/TH/UR") + { + Style::default() + .fg(TERMINAL_AMBER) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TERMINAL_GREEN) + }; + + Line::from(Span::styled(line.clone(), style)) + }) + .collect(); + + let output = Paragraph::new(visible_lines) + .block( + Block::default() + .title(" SYSTEM OUTPUT ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(TERMINAL_DIM_GREEN)) + .style(Style::default().bg(TERMINAL_BG)), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(output, area); + + // Draw scrollbar if needed + if total_lines > visible_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .track_symbol(Some("│")) + .thumb_symbol("█") + .style(Style::default().fg(TERMINAL_DIM_GREEN)); + + let mut scrollbar_state = ScrollbarState::new(total_lines) + .position(scroll) + .viewport_content_length(visible_height); + + f.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } + } + + /// Draw the status bar + fn draw_status_bar( + f: &mut Frame, + area: Rect, + status_line: &str, + context_info: (u32, u32, f32), + ) { + let (used, total, percentage) = context_info; + + // Create context meter + let bar_width = 10; + let filled = ((percentage / 100.0) * bar_width as f32) as usize; + let meter = format!("[{}{}]", "█".repeat(filled), "░".repeat(bar_width - filled)); + + let status_text = format!( + " STATUS: {} | CONTEXT: {} {:.1}% ({}/{} tokens) | ↑↓ SCROLL | CTRL-C EXIT ", + status_line, meter, percentage, used, total + ); + + let status = Paragraph::new(status_text) + .style( + Style::default() + .fg(TERMINAL_AMBER) + .bg(TERMINAL_BG) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Left); + + f.render_widget(status, area); + } + + /// Send output to the terminal + pub fn output(&self, text: &str) { + let _ = self.tx.send(TuiMessage::AgentOutput(text.to_string())); + } + + /// Update system status + pub fn status(&self, status: &str) { + let _ = self.tx.send(TuiMessage::SystemStatus(status.to_string())); + } + + /// Update context window information + pub fn update_context(&self, used: u32, total: u32, percentage: f32) { + let _ = self.tx.send(TuiMessage::ContextUpdate { + used, + total, + percentage, + }); + } + + /// Send error message + pub fn error(&self, error: &str) { + let _ = self.tx.send(TuiMessage::Error(error.to_string())); + } + + /// Signal exit + pub fn exit(&self) { + let _ = self.tx.send(TuiMessage::Exit); + } + + /// Update input buffer (for display) + pub fn update_input(&self, input: &str) { + if let Ok(mut state) = self.state.lock() { + state.input_buffer = input.to_string(); + } + } + + /// Handle scrolling + pub fn scroll_up(&self) { + if let Ok(mut state) = self.state.lock() { + if state.scroll_offset > 0 { + state.scroll_offset -= 1; + } + } + } + + pub fn scroll_down(&self) { + if let Ok(mut state) = self.state.lock() { + state.scroll_offset += 1; + } + } + + pub fn scroll_page_up(&self) { + if let Ok(mut state) = self.state.lock() { + state.scroll_offset = state.scroll_offset.saturating_sub(10); + } + } + + pub fn scroll_page_down(&self) { + if let Ok(mut state) = self.state.lock() { + state.scroll_offset += 10; + } + } + + pub fn scroll_home(&self) { + if let Ok(mut state) = self.state.lock() { + state.scroll_offset = 0; + } + } + + pub fn scroll_end(&self) { + if let Ok(mut state) = self.state.lock() { + state.scroll_offset = state.output_history.len().saturating_sub(1); + } + } +} + +impl Drop for RetroTui { + fn drop(&mut self) { + // Restore terminal + let _ = disable_raw_mode(); + if let Ok(mut term) = self.terminal.lock() { + let _ = execute!( + term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + } + } +} diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index c79e57d..5f09eaa 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,5 +1,6 @@ use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; +use crate::retro_tui::RetroTui; /// Console implementation of UiWriter that prints to stdout pub struct ConsoleUiWriter; @@ -78,4 +79,85 @@ impl UiWriter for ConsoleUiWriter { fn flush(&self) { let _ = io::stdout().flush(); } +} + +/// RetroTui implementation of UiWriter that sends output to the TUI +pub struct RetroTuiWriter { + tui: RetroTui, +} + +impl RetroTuiWriter { + pub fn new(tui: RetroTui) -> Self { + Self { tui } + } +} + +impl UiWriter for RetroTuiWriter { + fn print(&self, message: &str) { + self.tui.output(message); + } + + fn println(&self, message: &str) { + self.tui.output(message); + } + + fn print_inline(&self, message: &str) { + // For inline printing, we'll just append to the output + self.tui.output(message); + } + + fn print_system_prompt(&self, prompt: &str) { + self.tui.output("🔍 System Prompt:"); + self.tui.output("================"); + for line in prompt.lines() { + self.tui.output(line); + } + self.tui.output("================"); + self.tui.output(""); + } + + fn print_context_status(&self, message: &str) { + self.tui.output(message); + } + + fn print_tool_header(&self, tool_name: &str) { + self.tui.output(&format!("┌─ {}", tool_name)); + } + + fn print_tool_arg(&self, key: &str, value: &str) { + self.tui.output(&format!("│ {}: {}", key, value)); + } + + fn print_tool_output_header(&self) { + self.tui.output("├─ output:"); + } + + fn print_tool_output_line(&self, line: &str) { + self.tui.output(&format!("│ {}", line)); + } + + fn print_tool_output_summary(&self, hidden_count: usize) { + self.tui.output(&format!( + "│ ... ({} more line{} hidden)", + hidden_count, + if hidden_count == 1 { "" } else { "s" } + )); + } + + fn print_tool_timing(&self, duration_str: &str) { + self.tui.output(&format!("└─ ⚡️ {}", duration_str)); + self.tui.output(""); + } + + fn print_agent_prompt(&self) { + self.tui.output("🤖 "); + } + + fn print_agent_response(&self, content: &str) { + self.tui.output(content); + } + + fn flush(&self) { + // No-op for TUI since it handles its own rendering + } } \ No newline at end of file diff --git a/crates/g3-providers/src/oauth.rs b/crates/g3-providers/src/oauth.rs index 3508a6e..69a3986 100644 --- a/crates/g3-providers/src/oauth.rs +++ b/crates/g3-providers/src/oauth.rs @@ -320,7 +320,9 @@ impl OAuthFlow { // Open the browser which will redirect with the code to the server let authorization_url = self.get_authorization_url(); - println!("🔐 Opening browser for Databricks authentication..."); + if std::env::var("G3_RETRO_MODE").is_err() { + println!("🔐 Opening browser for Databricks authentication..."); + } if webbrowser::open(&authorization_url).is_err() { println!( "Please open this URL in your browser:\n{}", @@ -339,7 +341,9 @@ impl OAuthFlow { // Stop the server server_handle.abort(); - println!("✅ Authentication successful! Exchanging code for token..."); + if std::env::var("G3_RETRO_MODE").is_err() { + println!("✅ Authentication successful! Exchanging code for token..."); + } // Exchange the code for a token self.exchange_code_for_token(&code).await @@ -422,7 +426,9 @@ pub async fn get_oauth_token_async( // Cache and return token_cache.save_token(&token)?; - println!("🎉 Databricks authentication complete!"); + if std::env::var("G3_RETRO_MODE").is_err() { + println!("🎉 Databricks authentication complete!"); + } Ok(token.access_token) }