diff --git a/Cargo.lock b/Cargo.lock index 7b3db1e49e..ed186fb497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1343,6 +1343,7 @@ dependencies = [ "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "ratatui", + "rustyline", "security-framework 3.5.1", "serde", "serde_json", diff --git a/crates/chat-cli-ui/Cargo.toml b/crates/chat-cli-ui/Cargo.toml index 1449199a10..f4f9066400 100644 --- a/crates/chat-cli-ui/Cargo.toml +++ b/crates/chat-cli-ui/Cargo.toml @@ -23,6 +23,7 @@ tokio.workspace = true eyre.workspace = true tokio-util.workspace = true futures.workspace = true +rustyline.workspace = true ratatui = "0.29.0" [target.'cfg(unix)'.dependencies] diff --git a/crates/chat-cli-ui/src/conduit.rs b/crates/chat-cli-ui/src/conduit.rs index e347b9da30..25ffe9ade8 100644 --- a/crates/chat-cli-ui/src/conduit.rs +++ b/crates/chat-cli-ui/src/conduit.rs @@ -1,5 +1,8 @@ +use std::future; use std::io::Write as _; use std::marker::PhantomData; +use std::path::PathBuf; +use std::pin::Pin; use crossterm::style::{ self, @@ -10,11 +13,20 @@ use crossterm::{ execute, queue, }; - -use crate::legacy_ui_util::ThemeSource; +use rustyline::EditMode; +use tokio::signal::ctrl_c; +use tracing::error; + +use crate::legacy_ui_util::{ + ThemeSource, + generate_prompt, + rl, +}; use crate::protocol::{ Event, + InputEvent, LegacyPassThroughOutput, + MetaEvent, ToolCallRejection, ToolCallStart, }; @@ -25,7 +37,7 @@ const CONTINUATION_LINE: &str = " ⋮ "; #[derive(thiserror::Error, Debug)] pub enum ConduitError { #[error(transparent)] - Send(#[from] Box>), + Send(#[from] Box>), #[error(transparent)] Utf8(#[from] std::string::FromUtf8Error), #[error("No event set")] @@ -40,23 +52,92 @@ pub enum ConduitError { /// - To deliver state changes from the control layer to the view layer pub struct ViewEnd { /// Used by the view to send input to the control - // TODO: later on we will need replace this byte array with an actual event type from ACP - pub sender: tokio::sync::mpsc::Sender>, + pub sender: tokio::sync::mpsc::Sender, /// To receive messages from control about state changes - pub receiver: std::sync::mpsc::Receiver, + pub receiver: tokio::sync::mpsc::UnboundedReceiver, } impl ViewEnd { - /// Method to facilitate in the interim - /// It takes possible messages from the old even loop and queues write to the output provided - /// This blocks the current thread and consumes the [ViewEnd] + /// Converts the ViewEnd into legacy mode operation. This mainly serves a purpose in the + /// following circumstances: + /// - To preserve the UX of the current event loop while abstracting away the impl Write it + /// writes to + /// - To serve as an interim UI for the new event loop while preserving the UX of the current + /// product while the new UI is being worked out + /// + /// # Parameters + /// + /// * `ui_managed_input` - When true, the UI layer will manage user input through readline. When + /// false, input handling is delegated to the event loop (via InputSource). + /// * `ui_managed_ctrl_c` - When true, the UI layer will handle Ctrl+C interrupts. When false, + /// interrupt handling is delegated to the event loop (via its own ctrl c handler). + /// * `theme_source` - Provider for terminal styling and theming information. + /// * `stderr` - Standard error stream for error output. + /// * `stdout` - Standard output stream for normal output. + /// + /// # Returns + /// + /// Returns `Ok(())` on successful initialization, or a `ConduitError` if setup fails. pub fn into_legacy_mode( - self, + mut self, + ui_managed_input: bool, + ui_managed_ctrl_c: bool, theme_source: impl ThemeSource, mut stderr: std::io::Stderr, mut stdout: std::io::Stdout, ) -> Result<(), ConduitError> { - while let Ok(event) = self.receiver.recv() { + #[derive(Debug)] + enum IncomingEvent { + Input(String), + Interrupt, + } + + #[derive(Clone, Debug)] + struct PromptSignal { + active_agent: Option, + trust_all: bool, + is_in_tangent_mode: bool, + available_prompts: Vec, + history_path: PathBuf, + available_commands: Vec, + edit_mode: EditMode, + history_hints_enabled: bool, + usage_percentage: Option, + } + + impl Default for PromptSignal { + fn default() -> Self { + Self { + active_agent: Default::default(), + trust_all: Default::default(), + available_prompts: Default::default(), + is_in_tangent_mode: Default::default(), + history_path: Default::default(), + available_commands: Default::default(), + history_hints_enabled: Default::default(), + usage_percentage: Default::default(), + edit_mode: EditMode::Emacs, + } + } + } + + #[derive(Default, Debug)] + enum DisplayState { + Prompting, + UserInsertingText, + StreamingOutput, + #[default] + Hidden, + } + + #[inline] + fn handle_session_event_legacy_mode( + event: Event, + stderr: &mut std::io::Stderr, + stdout: &mut std::io::Stdout, + theme_source: &impl ThemeSource, + display_state: Option<&mut DisplayState>, + ) -> Result<(), ConduitError> { match event { Event::LegacyPassThrough(content) => match content { LegacyPassThroughOutput::Stderr(content) => { @@ -74,9 +155,17 @@ impl ViewEnd { Event::StepStarted(_step_started) => {}, Event::StepFinished(_step_finished) => {}, Event::TextMessageStart(_text_message_start) => { + if let Some(display_state) = display_state { + *display_state = DisplayState::StreamingOutput; + } + queue!(stdout, theme_source.success_fg(), Print("> "), theme_source.reset(),)?; }, Event::TextMessageContent(text_message_content) => { + if let Some(display_state) = display_state { + *display_state = DisplayState::StreamingOutput; + } + stdout.write_all(&text_message_content.delta)?; stdout.flush()?; }, @@ -86,6 +175,10 @@ impl ViewEnd { }, Event::TextMessageChunk(_text_message_chunk) => {}, Event::ToolCallStart(tool_call_start) => { + if let Some(display_state) = display_state { + *display_state = DisplayState::StreamingOutput; + } + let ToolCallStart { tool_call_name, is_trusted, @@ -134,12 +227,8 @@ impl ViewEnd { execute!(stdout, style::Print(tool_call_args.delta))?; } }, - Event::ToolCallEnd(_tool_call_end) => { - // noop for now - }, - Event::ToolCallResult(_tool_call_result) => { - // noop for now (currently we don't show the tool call results to users) - }, + Event::ToolCallEnd(_tool_call_end) => {}, + Event::ToolCallResult(_tool_call_result) => {}, Event::StateSnapshot(_state_snapshot) => {}, Event::StateDelta(_state_delta) => {}, Event::MessagesSnapshot(_messages_snapshot) => {}, @@ -153,7 +242,17 @@ impl ViewEnd { Event::ReasoningMessageEnd(_reasoning_message_end) => {}, Event::ReasoningMessageChunk(_reasoning_message_chunk) => {}, Event::ReasoningEnd(_reasoning_end) => {}, - Event::MetaEvent(_meta_event) => {}, + Event::MetaEvent(MetaEvent { meta_type, payload }) => { + if meta_type.as_str() == "timing" { + if let serde_json::Value::String(s) = payload { + if s.as_str() == "prompt_user" { + if let Some(display_state) = display_state { + *display_state = DisplayState::Prompting; + } + } + } + } + }, Event::ToolCallRejection(tool_call_rejection) => { let ToolCallRejection { reason, name, .. } = tool_call_rejection; @@ -171,6 +270,124 @@ impl ViewEnd { )?; }, } + + Ok::<(), ConduitError>(()) + } + + if ui_managed_input { + let (incoming_events_tx, mut incoming_events_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (prompt_signal_tx, prompt_signal_rx) = std::sync::mpsc::channel::(); + + tokio::task::spawn_blocking(move || { + while let Ok(prompt_signal) = prompt_signal_rx.recv() { + let PromptSignal { + active_agent, + trust_all, + available_prompts, + history_path, + available_commands, + edit_mode, + history_hints_enabled, + is_in_tangent_mode, + usage_percentage, + } = prompt_signal; + + let mut rl = rl( + history_hints_enabled, + edit_mode, + history_path, + available_commands, + available_prompts, + ) + .expect("Failed to spawn readline"); + + let prompt = + generate_prompt(active_agent.as_deref(), trust_all, is_in_tangent_mode, usage_percentage); + + match rl.readline(&prompt) { + Ok(input) => { + _ = incoming_events_tx.send(IncomingEvent::Input(input)); + }, + Err(rustyline::error::ReadlineError::Interrupted) => { + _ = incoming_events_tx.send(IncomingEvent::Interrupt); + }, + Err(e) => panic!("Failed to spawn readline: {:?}", e), + }; + + drop(rl); + } + }); + + tokio::spawn(async move { + let mut display_state = DisplayState::default(); + let prompt_signal = PromptSignal::default(); + + loop { + let ctrl_c_handler: Pin< + Box> + Send + Sync + 'static>, + >; + + if matches!(display_state, DisplayState::Prompting) { + if let Err(e) = prompt_signal_tx.send(prompt_signal.clone()) { + error!("Error sending prompt signal: {:?}", e); + } + display_state = DisplayState::UserInsertingText; + + ctrl_c_handler = Box::pin(future::pending()); + } else if ui_managed_ctrl_c { + ctrl_c_handler = Box::pin(ctrl_c()); + } else { + ctrl_c_handler = Box::pin(future::pending()); + } + + tokio::select! { + _ = ctrl_c_handler => { + _ = self.sender.send(InputEvent::Interrupt).await; + }, + Some(incoming_event) = incoming_events_rx.recv() => { + match display_state { + DisplayState::UserInsertingText => { + match incoming_event { + IncomingEvent::Input(content) => { + if let Err(e) = self.sender.send(InputEvent::Text(content)).await { + error!("Error sending input event: {:?}", e); + } + display_state = DisplayState::StreamingOutput; + }, + IncomingEvent::Interrupt => { + display_state = DisplayState::default(); + _ = self.sender.send(InputEvent::Interrupt).await; + }, + } + }, + DisplayState::StreamingOutput if matches!(incoming_event, IncomingEvent::Interrupt)=> { + _ = self.sender.send(InputEvent::Interrupt).await; + }, + DisplayState::Hidden | DisplayState::StreamingOutput | DisplayState::Prompting => { + // We ignore everything that's not a sigint here + } + } + }, + session_event = self.receiver.recv() => { + if let Some(event) = session_event { + handle_session_event_legacy_mode(event, &mut stderr, &mut stdout, &theme_source, Some(&mut display_state))?; + } else { + break; + } + } + } + } + + Ok::<(), ConduitError>(()) + }); + } else { + tokio::spawn(async move { + while let Some(event) = self.receiver.recv().await { + handle_session_event_legacy_mode(event, &mut stderr, &mut stdout, &theme_source, None)?; + } + + Ok::<(), ConduitError>(()) + }); } Ok(()) @@ -184,7 +401,7 @@ pub struct DestinationStderr; #[derive(Clone, Debug)] pub struct DestinationStructuredOutput; -pub type InputReceiver = tokio::sync::mpsc::Receiver>; +pub type InputReceiver = tokio::sync::mpsc::Receiver; /// This compliments the [ViewEnd]. It can be thought of as the "other end" of a pipe. /// The control would own this. @@ -192,7 +409,7 @@ pub type InputReceiver = tokio::sync::mpsc::Receiver>; pub struct ControlEnd { pub current_event: Option, /// Used by the control to send state changes to the view - pub sender: std::sync::mpsc::Sender, + pub sender: tokio::sync::mpsc::UnboundedSender, /// Flag indicating whether structured events should be sent through the conduit. /// When true, the control end will send structured event data in addition to /// raw pass-through content, enabling richer communication between layers. @@ -381,15 +598,15 @@ pub fn get_legacy_conduits( ControlEnd, ControlEnd, ) { - let (state_tx, state_rx) = std::sync::mpsc::channel::(); - let (byte_tx, byte_rx) = tokio::sync::mpsc::channel::>(10); + let (state_tx, state_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (input_tx, input_rx) = tokio::sync::mpsc::channel::(10); ( ViewEnd { - sender: byte_tx, + sender: input_tx, receiver: state_rx, }, - byte_rx, + input_rx, ControlEnd { current_event: None, should_send_structured_event, diff --git a/crates/chat-cli-ui/src/legacy_ui_util.rs b/crates/chat-cli-ui/src/legacy_ui_util.rs index 93be999f18..9701acec38 100644 --- a/crates/chat-cli-ui/src/legacy_ui_util.rs +++ b/crates/chat-cli-ui/src/legacy_ui_util.rs @@ -1,14 +1,561 @@ +//! Everything in this module is here to preserve the old UI's look and feel when used on top of +//! the new event loop. +use std::borrow::Cow; +use std::path::PathBuf; + use crossterm::style::{ + Color, ResetColor, SetAttribute, SetForegroundColor, }; +use eyre::Result; +use rustyline::completion::{ + Completer, + FilenameCompleter, + extract_word, +}; +use rustyline::error::ReadlineError; +use rustyline::highlight::{ + CmdKind, + Highlighter, +}; +use rustyline::hint::Hinter as RustylineHinter; +use rustyline::history::{ + FileHistory, + SearchDirection, +}; +use rustyline::validate::{ + ValidationContext, + ValidationResult, + Validator, +}; +use rustyline::{ + Cmd, + Completer, + CompletionType, + Config, + Context, + EditMode, + Editor, + EventHandler, + Helper, + Hinter, + KeyCode, + KeyEvent, + Modifiers, +}; + +/// Complete commands that start with a slash +fn complete_command(commands: &[String], word: &str, start: usize) -> (usize, Vec) { + ( + start, + commands + .iter() + .filter(|p| p.starts_with(word)) + .map(|s| (*s).clone()) + .collect(), + ) +} + +/// A wrapper around FilenameCompleter that provides enhanced path detection +/// and completion capabilities for the chat interface. +#[derive(Default)] +pub struct PathCompleter { + /// The underlying filename completer from rustyline + filename_completer: FilenameCompleter, +} + +impl PathCompleter { + /// Attempts to complete a file path at the given position in the line + pub fn complete_path( + &self, + line: &str, + pos: usize, + os: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + // Use the filename completer to get path completions + match self.filename_completer.complete(line, pos, os) { + Ok((pos, completions)) => { + // Convert the filename completer's pairs to strings + let file_completions: Vec = completions.iter().map(|pair| pair.replacement.clone()).collect(); + + // Return the completions if we have any + Ok((pos, file_completions)) + }, + Err(err) => Err(err), + } + } +} + +pub struct ChatCompleter { + available_commands: Vec, + available_prompts: Vec, + path_completer: PathCompleter, +} + +impl ChatCompleter { + fn new(available_commands: Vec, available_prompts: Vec) -> Self { + Self { + available_commands, + available_prompts, + path_completer: Default::default(), + } + } +} + +impl Completer for ChatCompleter { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + let (start, word) = extract_word(line, pos, None, |c| c.is_whitespace()); + + // Handle command completion + if word.starts_with('/') { + return Ok(complete_command(&self.available_commands, word, start)); + } + + if line.starts_with('@') { + let search_word = line.strip_prefix('@').unwrap_or(""); + // Here we assume that the names given by the event loop is already namespaced + // appropriately (i.e. not namespaced if the prompt name is unique and namespaced with + // their respective server if it is) + let completions = self + .available_prompts + .iter() + .filter_map(|p| { + if p.contains(search_word) { + Some(format!("@{p}")) + } else { + None + } + }) + .collect::>(); + + if !completions.is_empty() { + return Ok((0, completions)); + } + } + + // Handle file path completion as fallback + if let Ok((pos, completions)) = self.path_completer.complete_path(line, pos, _ctx) { + if !completions.is_empty() { + return Ok((pos, completions)); + } + } + + // Default: no completions + Ok((start, Vec::new())) + } +} + +/// Custom hinter that provides shadowtext suggestions +pub struct ChatHinter { + /// Whether history-based hints are enabled + history_hints_enabled: bool, + history_path: PathBuf, + available_commands: Vec, +} + +impl ChatHinter { + /// Creates a new ChatHinter instance + pub fn new(history_hints_enabled: bool, history_path: PathBuf, available_commands: Vec) -> Self { + Self { + history_hints_enabled, + history_path, + available_commands, + } + } + + pub fn get_history_path(&self) -> PathBuf { + self.history_path.clone() + } + + /// Finds the best hint for the current input using rustyline's history + fn find_hint(&self, line: &str, ctx: &Context<'_>) -> Option { + // If line is empty, no hint + if line.is_empty() { + return None; + } + + // If line starts with a slash, try to find a command hint + if line.starts_with('/') { + return self + .available_commands + .iter() + .find(|cmd| cmd.starts_with(line)) + .map(|cmd| cmd[line.len()..].to_string()); + } + + // Try to find a hint from rustyline's history if history hints are enabled + if self.history_hints_enabled { + let history = ctx.history(); + let history_len = history.len(); + if history_len == 0 { + return None; + } + + if let Ok(Some(search_result)) = history.starts_with(line, history_len - 1, SearchDirection::Reverse) { + let entry = search_result.entry.to_string(); + if entry.len() > line.len() { + return Some(entry[line.len()..].to_string()); + } + } + } + + None + } +} + +impl RustylineHinter for ChatHinter { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option { + // Only provide hints when cursor is at the end of the line + if pos < line.len() { + return None; + } + + self.find_hint(line, ctx) + } +} + +/// Custom validator for multi-line input +pub struct MultiLineValidator; + +impl Validator for MultiLineValidator { + fn validate(&self, os: &mut ValidationContext<'_>) -> rustyline::Result { + let input = os.input(); + + // Check for code block markers + if input.contains("```") { + // Count the number of ``` occurrences + let triple_backtick_count = input.matches("```").count(); + + // If we have an odd number of ```, we're in an incomplete code block + if triple_backtick_count % 2 == 1 { + return Ok(ValidationResult::Incomplete); + } + } + + // Check for backslash continuation + if input.ends_with('\\') { + return Ok(ValidationResult::Incomplete); + } + + Ok(ValidationResult::Valid(None)) + } +} + +#[derive(Helper, Completer, Hinter)] +pub struct ChatHelper { + #[rustyline(Completer)] + completer: ChatCompleter, + #[rustyline(Hinter)] + hinter: ChatHinter, + validator: MultiLineValidator, +} + +impl ChatHelper { + pub fn get_history_path(&self) -> PathBuf { + self.hinter.get_history_path() + } +} + +impl Validator for ChatHelper { + fn validate(&self, os: &mut ValidationContext<'_>) -> rustyline::Result { + self.validator.validate(os) + } +} + +impl Highlighter for ChatHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Owned(format!("\x1b[38;5;240m{hint}\x1b[m")) + } + + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + Cow::Borrowed(line) + } + + fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool { + false + } + + fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> { + // Parse the plain text prompt to extract profile and warning information + // and apply colors using crossterm's ANSI escape codes + if let Some(components) = parse_prompt_components(prompt) { + let mut result = String::new(); + + // Add notifier part if present (info blue) + if let Some(notifier) = components.delegate_notifier { + let notifier = format!("[{}]\n", notifier); + result.push_str(&format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Blue), notifier)); + } + + // Add profile part if present (profile indicator cyan) + if let Some(profile) = components.profile { + let profile = &format!("[{}] ", profile); + result.push_str(&format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Cyan), profile)); + } + + // Add percentage part if present (colored by usage level) + if let Some(percentage) = components.usage_percentage { + let text = format!("{}% ", percentage as u32); + let colored_percentage = if percentage < 50.0 { + format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Green), text) + } else if percentage < 90.0 { + format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Yellow), text) + } else { + format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Red), text) + }; + result.push_str(&colored_percentage); + } + + // Add tangent indicator if present (tangent yellow) + if components.tangent_mode { + let text = format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Yellow), "↯ "); + result.push_str(&text); + } + + // Add warning symbol if present (error red) + if components.warning { + let text = format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Red), "!"); + result.push_str(&text); + } + + // Add the prompt symbol (prompt magenta) + let text = format!("\x1b[{}m{}\x1b[0m", color_to_ansi_code(Color::Magenta), "> "); + result.push_str(&text); + + Cow::Owned(result) + } else { + // If we can't parse the prompt, return it as-is + Cow::Borrowed(prompt) + } + } +} + +fn color_to_ansi_code(color: Color) -> u8 { + match color { + Color::Black => 30, + Color::DarkGrey => 90, + Color::Red => 31, + Color::DarkRed => 31, + Color::Green => 32, + Color::DarkGreen => 32, + Color::Yellow => 33, + Color::DarkYellow => 33, + Color::Blue => 34, + Color::DarkBlue => 34, + Color::Magenta => 35, + Color::DarkMagenta => 35, + Color::Cyan => 36, + Color::DarkCyan => 36, + Color::White => 37, + Color::Grey => 37, + Color::Rgb { r, g, b } => { + // For RGB colors, we'll use a simplified mapping to the closest basic color + // This is a fallback - in practice, most terminals support RGB + if r > 200 && g < 100 && b < 100 { + 31 + } + // Red-ish + else if r < 100 && g > 200 && b < 100 { + 32 + } + // Green-ish + else if r > 200 && g > 200 && b < 100 { + 33 + } + // Yellow-ish + else if r < 100 && g < 100 && b > 200 { + 34 + } + // Blue-ish + else if r > 200 && g < 100 && b > 200 { + 35 + } + // Magenta-ish + else if r < 100 && g > 200 && b > 200 { + 36 + } + // Cyan-ish + else if r > 150 && g > 150 && b > 150 { + 37 + } + // White-ish + else { + 30 + } // Black-ish + }, + Color::AnsiValue(val) => { + // Map ANSI 256 colors to basic 8 colors + match val { + 0..=7 => 30 + val, + 8..=15 => 90 + (val - 8), + _ => 37, // Default to white for other values + } + }, + Color::Reset => 37, // Default to white + } +} + +/// Components extracted from a prompt string +#[derive(Debug, PartialEq)] +pub struct PromptComponents { + pub delegate_notifier: Option, + pub profile: Option, + pub warning: bool, + pub tangent_mode: bool, + pub usage_percentage: Option, +} + +/// Parse prompt components from a plain text prompt +pub fn parse_prompt_components(prompt: &str) -> Option { + // Expected format: "[agent] 6% !> " or "> " or "!> " or "[agent] ↯ > " or "6% ↯ > " etc. + let mut delegate_notifier = None::; + let mut profile = None; + let mut warning = false; + let mut tangent_mode = false; + let mut usage_percentage = None; + let mut remaining = prompt.trim(); + + // Check for delegate notifier first + if let Some(start) = remaining.find('[') { + if let Some(end) = remaining.find(']') { + if start < end { + let content = &remaining[start + 1..end]; + // Only set profile if it's not "BACKGROUND TASK READY" or if it doesn't end with newline + if content == "BACKGROUND TASK READY" && remaining[end + 1..].starts_with('\n') { + delegate_notifier = Some(content.to_string()); + remaining = remaining[end + 1..].trim_start(); + } + } + } + } + + // Check for agent pattern [agent] first + if let Some(start) = remaining.find('[') { + if let Some(end) = remaining.find(']') { + if start < end { + let content = &remaining[start + 1..end]; + profile = Some(content.to_string()); + remaining = remaining[end + 1..].trim_start(); + } + } + } + + // Check for percentage pattern (e.g., "6% ") + if let Some(percent_pos) = remaining.find('%') { + let before_percent = &remaining[..percent_pos]; + if let Ok(percentage) = before_percent.trim().parse::() { + usage_percentage = Some(percentage); + if let Some(space_after_percent) = remaining[percent_pos..].find(' ') { + remaining = remaining[percent_pos + space_after_percent + 1..].trim_start(); + } + } + } + + // Check for tangent mode ↯ first + if let Some(after_tangent) = remaining.strip_prefix('↯') { + tangent_mode = true; + remaining = after_tangent.trim_start(); + } + + // Check for warning symbol ! (comes after tangent mode) + if remaining.starts_with('!') { + warning = true; + remaining = remaining[1..].trim_start(); + } + + // Should end with "> " for both normal and tangent mode + if remaining.trim_end() == ">" { + Some(PromptComponents { + delegate_notifier, + profile, + warning, + tangent_mode, + usage_percentage, + }) + } else { + None + } +} + +pub fn generate_prompt( + current_profile: Option<&str>, + warning: bool, + tangent_mode: bool, + usage_percentage: Option, +) -> String { + // Generate plain text prompt that will be colored by highlight_prompt + let warning_symbol = if warning { "!" } else { "" }; + let profile_part = current_profile.map(|p| format!("[{p}] ")).unwrap_or_default(); + + let percentage_part = usage_percentage.map(|p| format!("{:.0}% ", p)).unwrap_or_default(); + + if tangent_mode { + format!("{profile_part}{percentage_part}↯ {warning_symbol}> ") + } else { + format!("{profile_part}{percentage_part}{warning_symbol}> ") + } +} + +#[allow(clippy::too_many_arguments)] +pub fn rl( + history_hints_enabled: bool, + edit_mode: EditMode, + history_path: PathBuf, + available_commands: Vec, + available_prompts: Vec, +) -> eyre::Result> { + let config = Config::builder() + .history_ignore_space(true) + .completion_type(CompletionType::List) + .edit_mode(edit_mode) + .build(); + + let h = ChatHelper { + completer: ChatCompleter::new(available_commands.clone(), available_prompts), + hinter: ChatHinter::new(history_hints_enabled, history_path, available_commands), + validator: MultiLineValidator, + }; + + let mut rl = Editor::with_config(config)?; + rl.set_helper(Some(h)); + + if let Err(e) = rl.load_history(&rl.helper().unwrap().get_history_path()) { + if !matches!(e, ReadlineError::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound) { + eprintln!("Warning: Failed to load history: {}", e); + } + } + + // Add custom keybinding for Alt+Enter to insert a newline + rl.bind_sequence( + KeyEvent(KeyCode::Enter, Modifiers::ALT), + EventHandler::Simple(Cmd::Insert(1, "\n".to_string())), + ); + + // Add custom keybinding for Ctrl+j to insert a newline + rl.bind_sequence( + KeyEvent(KeyCode::Char('j'), Modifiers::CTRL), + EventHandler::Simple(Cmd::Insert(1, "\n".to_string())), + ); + + Ok(rl) +} /// This trait is purely here to facilitate a smooth transition from the old event loop to a new /// event loop. It is a way to achieve inversion of control to delegate the implementation of /// themes to the consumer of this crate. Without this, we would be running into a circular /// dependency. -pub trait ThemeSource { +pub trait ThemeSource: Send + Sync + 'static { fn error(&self, text: &str) -> String; fn info(&self, text: &str) -> String; fn emphasis(&self, text: &str) -> String; diff --git a/crates/chat-cli-ui/src/protocol.rs b/crates/chat-cli-ui/src/protocol.rs index df5d58ccbe..486c7cadf2 100644 --- a/crates/chat-cli-ui/src/protocol.rs +++ b/crates/chat-cli-ui/src/protocol.rs @@ -497,3 +497,8 @@ impl Event { ) } } + +pub enum InputEvent { + Text(String), + Interrupt, +} diff --git a/crates/chat-cli/src/cli/chat/managed_input.rs b/crates/chat-cli/src/cli/chat/managed_input.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/managed_input.rs @@ -0,0 +1 @@ + diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index e1eab6ddd6..91757ac3d9 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -4,7 +4,10 @@ use spinners::{ }; use crate::theme::StyledText; -use crate::util::ui::should_send_structured_message; +use crate::util::ui::{ + should_send_structured_message, + should_use_ui_managed_input, +}; pub mod cli; mod consts; pub mod context; @@ -21,6 +24,7 @@ mod prompt_parser; pub mod server_messenger; use crate::cli::chat::checkpoint::CHECKPOINT_MESSAGE_MAX_LENGTH; use crate::constants::ui_text; +mod managed_input; #[cfg(unix)] mod skim_integration; mod token_counter; @@ -54,6 +58,7 @@ use chat_cli_ui::conduit::{ }; use chat_cli_ui::protocol::{ Event, + InputEvent, MessageRole, TextMessageContent, TextMessageEnd, @@ -427,13 +432,18 @@ impl ChatArgs { .build(os, Box::new(std::io::stderr()), !self.no_interactive) .await?; let tool_config = tool_manager.load_tools(os, &mut stderr).await?; + let input_source = if should_use_ui_managed_input() { + None + } else { + Some(InputSource::new(os, prompt_request_sender, prompt_response_receiver)?) + }; ChatSession::new( os, &conversation_id, agents, input, - InputSource::new(os, prompt_request_sender, prompt_response_receiver)?, + input_source, self.resume, || terminal::window_size().map(|s| s.columns.into()).ok(), tool_manager, @@ -576,7 +586,7 @@ pub struct ChatSession { initial_input: Option, /// Whether we're starting a new conversation or continuing an old one. existing_conversation: bool, - input_source: InputSource, + input_source: Option, /// Width of the terminal, required for [ParseState]. terminal_width_provider: fn() -> Option, spinner: Option, @@ -605,6 +615,7 @@ pub struct ChatSession { inner: Option, ctrlc_rx: broadcast::Receiver<()>, wrap: Option, + managed_input: Option>, } impl ChatSession { @@ -614,7 +625,7 @@ impl ChatSession { conversation_id: &str, mut agents: Agents, mut input: Option, - input_source: InputSource, + input_source: Option, resume_conversation: bool, terminal_width_provider: fn() -> Option, tool_manager: ToolManager, @@ -627,17 +638,16 @@ impl ChatSession { // Only load prior conversation if we need to resume let mut existing_conversation = false; + let should_use_ui_managed_input = input_source.is_none(); let should_send_structured_msg = should_send_structured_message(os); - let (view_end, _byte_receiver, mut control_end_stderr, control_end_stdout) = + let (view_end, managed_input, mut control_end_stderr, control_end_stdout) = get_legacy_conduits(should_send_structured_msg); - tokio::task::spawn_blocking(move || { - let stderr = std::io::stderr(); - let stdout = std::io::stdout(); - if let Err(e) = view_end.into_legacy_mode(StyledText, stderr, stdout) { - error!("Conduit view end legacy mode exited: {:?}", e); - } - }); + let stderr = std::io::stderr(); + let stdout = std::io::stdout(); + if let Err(e) = view_end.into_legacy_mode(should_use_ui_managed_input, false, StyledText, stderr, stdout) { + error!("Conduit view end legacy mode exited: {:?}", e); + } let conversation = match resume_conversation { true => { @@ -739,6 +749,11 @@ impl ChatSession { inner: Some(ChatState::default()), ctrlc_rx, wrap, + managed_input: if should_use_ui_managed_input { + Some(managed_input) + } else { + None + }, }) } @@ -1936,19 +1951,33 @@ impl ChatSession { .filter(|name| *name != DUMMY_TOOL_NAME) .cloned() .collect::>(); - self.input_source - .put_skim_command_selector(os, Arc::new(context_manager.clone()), tool_names); + if let Some(input_source) = &mut self.input_source { + input_source.put_skim_command_selector(os, Arc::new(context_manager.clone()), tool_names); + } } execute!(self.stderr, StyledText::reset(), StyledText::reset_attributes())?; - let prompt = self.generate_tool_trust_prompt(os).await; - let user_input = match self.read_user_input(&prompt, false) { + let user_input = if self.managed_input.is_some() { + self.stderr.send(Event::MetaEvent(chat_cli_ui::protocol::MetaEvent { + meta_type: "timing".to_string(), + payload: serde_json::Value::String("prompt_user".to_string()), + }))?; + self.read_user_input_via_ui().await + } else { + let prompt = self.generate_tool_trust_prompt(os).await; + self.read_user_input(&prompt, false) + }; + let user_input = match user_input { Some(input) => input, None => return Ok(ChatState::Exit), }; // Check if there's a pending clipboard paste from Ctrl+V - let pasted_paths = self.input_source.take_clipboard_pastes(); + let pasted_paths = self + .input_source + .as_mut() + .map(|input_source| input_source.take_clipboard_pastes()) + .unwrap_or_default(); if !pasted_paths.is_empty() { // Check if the input contains image markers let image_marker_regex = regex::Regex::new(r"\[Image #\d+\]").unwrap(); @@ -1961,7 +1990,9 @@ impl ChatSession { .join(" "); // Reset the counter for next message - self.input_source.reset_paste_count(); + if let Some(input_source) = self.input_source.as_mut() { + input_source.reset_paste_count(); + } // Return HandleInput with all paths to automatically process the images return Ok(ChatState::HandleInput { input: paths_str }); @@ -1972,6 +2003,46 @@ impl ChatSession { Ok(ChatState::HandleInput { input: user_input }) } + async fn read_user_input_via_ui(&mut self) -> Option { + if let Some(managed_input) = &mut self.managed_input { + let mut has_hit_ctrl_c = false; + while let Some(input_event) = managed_input.recv().await { + match input_event { + InputEvent::Text(content) => { + return Some(content); + }, + InputEvent::Interrupt => { + if has_hit_ctrl_c { + return None; + } else { + has_hit_ctrl_c = true; + _ = execute!( + self.stderr, + style::Print(format!( + "\n(To exit the CLI, press Ctrl+C or Ctrl+D again or type {})\n\n", + "/quit".green() + )) + ); + + if self + .stderr + .send(Event::MetaEvent(chat_cli_ui::protocol::MetaEvent { + meta_type: "timing".to_string(), + payload: serde_json::Value::String("prompt_user".to_string()), + })) + .is_err() + { + return None; + } + } + }, + } + } + } + + None + } + async fn handle_input(&mut self, os: &mut Os, mut user_input: String) -> Result { queue!(self.stderr, style::Print('\n'))?; user_input = sanitize_unicode_tags(&user_input); @@ -3413,9 +3484,14 @@ impl ChatSession { /// Helper function to read user input with a prompt and Ctrl+C handling fn read_user_input(&mut self, prompt: &str, exit_on_single_ctrl_c: bool) -> Option { + // If this function is called at all, input_source should not be None + debug_assert!(self.input_source.is_some()); + let mut ctrl_c = false; + let input_source = self.input_source.as_mut()?; + loop { - match (self.input_source.read_line(Some(prompt)), ctrl_c) { + match (input_source.read_line(Some(prompt)), ctrl_c) { (Ok(Some(line)), _) => { if line.trim().is_empty() { continue; // Reprompt if the input is empty @@ -3887,11 +3963,11 @@ mod tests { "fake_conv_id", agents, None, - InputSource::new_mock(vec![ + Some(InputSource::new_mock(vec![ "create a new file".to_string(), "y".to_string(), "exit".to_string(), - ]), + ])), false, || Some(80), tool_manager, @@ -4015,7 +4091,7 @@ mod tests { "fake_conv_id", agents, None, - InputSource::new_mock(vec![ + Some(InputSource::new_mock(vec![ "/tools".to_string(), "/tools help".to_string(), "create a new file".to_string(), @@ -4032,7 +4108,7 @@ mod tests { "create a file".to_string(), // prompt again due to reset "n".to_string(), // cancel "exit".to_string(), - ]), + ])), false, || Some(80), tool_manager, @@ -4120,7 +4196,7 @@ mod tests { "fake_conv_id", agents, None, - InputSource::new_mock(vec![ + Some(InputSource::new_mock(vec![ "create 2 new files parallel".to_string(), "t".to_string(), "/tools reset".to_string(), @@ -4128,7 +4204,7 @@ mod tests { "y".to_string(), "y".to_string(), "exit".to_string(), - ]), + ])), false, || Some(80), tool_manager, @@ -4196,13 +4272,13 @@ mod tests { "fake_conv_id", agents, None, - InputSource::new_mock(vec![ + Some(InputSource::new_mock(vec![ "/tools trust-all".to_string(), "create a new file".to_string(), "/tools reset".to_string(), "create a new file".to_string(), "exit".to_string(), - ]), + ])), false, || Some(80), tool_manager, @@ -4252,7 +4328,11 @@ mod tests { "fake_conv_id", agents, None, - InputSource::new_mock(vec!["/subscribe".to_string(), "y".to_string(), "/quit".to_string()]), + Some(InputSource::new_mock(vec![ + "/subscribe".to_string(), + "y".to_string(), + "/quit".to_string(), + ])), false, || Some(80), tool_manager, @@ -4355,11 +4435,11 @@ mod tests { "fake_conv_id", agents, None, // No initial input - InputSource::new_mock(vec![ + Some(InputSource::new_mock(vec![ "read /test.txt".to_string(), "y".to_string(), // Accept tool execution "exit".to_string(), - ]), + ])), false, || Some(80), tool_manager, @@ -4489,7 +4569,10 @@ mod tests { "test_conv_id", agents, None, - InputSource::new_mock(vec!["read /sensitive.txt".to_string(), "exit".to_string()]), + Some(InputSource::new_mock(vec![ + "read /sensitive.txt".to_string(), + "exit".to_string(), + ])), false, || Some(80), tool_manager, diff --git a/crates/chat-cli/src/util/ui.rs b/crates/chat-cli/src/util/ui.rs index 0c7dda4e37..9196c53e63 100644 --- a/crates/chat-cli/src/util/ui.rs +++ b/crates/chat-cli/src/util/ui.rs @@ -6,10 +6,6 @@ use crossterm::style::{ Attribute, }; use eyre::Result; -use serde::{ - Deserialize, - Serialize, -}; use crate::cli::feed::Feed; use crate::constants::ui_text; @@ -158,17 +154,37 @@ fn print_with_bold(output: &mut impl Write, segments: &[(String, bool)]) -> Resu Ok(()) } -#[derive(Default, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum UiMode { - #[default] - Structured, - Passthrough, - New, -} - +/// This dictates the event loop's egress behavior. It controls what gets emitted to the UI from the +/// event loop. +/// There are three possible potent states: +/// - structured: This makes the event loop send structured messages where applicable (in addition +/// to logging ANSI bytes directly where it has not been instrumented) +/// - new: This spawns the new UI to be used on top of the current event loop (if we end up enabling +/// this). This would also require the event loop to emit structured events. +/// - unset: This is the default behavior where everything is unstructured (i.e. ANSI bytes straight +/// to stderr or stdout) +/// +/// The reason why this is a setting as opposed to managed input, which is controlled via an env +/// var, is because the choice of UI is a user concern. Whereas managed input is purely a +/// development concern. pub fn should_send_structured_message(os: &Os) -> bool { let ui_mode = os.database.settings.get_string(Setting::UiMode); - ui_mode.as_deref().is_some_and(|mode| mode == "structured") + ui_mode + .as_deref() + .is_some_and(|mode| mode == "structured" || mode == "new") +} + +/// NOTE: unless you are doing testing work for the new UI, you likely would not need to worry +/// about setting this environment variable. +/// This dictates the event loop's ingress behavior. It controls how the event loop receives input +/// from the user. +/// A normal input refers to the use of [crate::cli::chat::InputSource], which is owned by +/// the [crate::cli::chat::ChatSession]. It is not managed by the UI layer (nor is the UI even +/// aware of its existence). +/// Conversely, an "ui managed" input is one where stdin is managed by the UI layer. For the event +/// loop, this effectively means forgoing the ownership of [crate::cli::chat::InputSource] (it is +/// replaced by a None) and instead delegating the reading of user input to the UI layer. +pub fn should_use_ui_managed_input() -> bool { + std::env::var("Q_UI_MANAGED_INPUT").is_ok() }