diff --git a/Cargo.lock b/Cargo.lock index c83be2250..56ddce02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1187,6 +1187,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -1268,6 +1274,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chat-cli-ui" +version = "1.18.0" +dependencies = [ + "chrono", + "crossterm", + "eyre", + "futures", + "nix 0.29.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "ratatui", + "security-framework 3.3.0", + "serde", + "serde_json", + "skim", + "thiserror 2.0.14", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "windows 0.61.3", + "winreg", +] + [[package]] name = "chat_cli" version = "1.18.0" @@ -1296,6 +1329,7 @@ dependencies = [ "bytes", "camino", "cfg-if", + "chat-cli-ui", "chrono", "clap", "clap_complete", @@ -1380,7 +1414,7 @@ dependencies = [ "tracing-subscriber", "tracing-test", "typed-path", - "unicode-width 0.2.1", + "unicode-width 0.2.0", "url", "uuid", "walkdir", @@ -1487,7 +1521,7 @@ dependencies = [ "strsim", "terminal_size", "unicase", - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -1620,6 +1654,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 = "compact_str" version = "0.9.0" @@ -1644,7 +1692,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.1", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -3423,7 +3471,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width 0.2.1", + "unicode-width 0.2.0", "web-time", ] @@ -3454,6 +3502,19 @@ dependencies = [ "similar", ] +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling 0.20.11", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "inventory" version = "0.3.20" @@ -5289,6 +5350,27 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.9.1", + "cassowary", + "compact_str 0.8.1", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "10.7.0" @@ -5761,7 +5843,7 @@ dependencies = [ "radix_trie", "rustyline-derive", "unicode-segmentation", - "unicode-width 0.2.1", + "unicode-width 0.2.0", "utf8parse", "windows-sys 0.59.0", ] @@ -6175,7 +6257,7 @@ dependencies = [ "time", "timer", "tuikit", - "unicode-width 0.2.1", + "unicode-width 0.2.0", "vte 0.15.0", "which 7.0.3", ] @@ -6309,6 +6391,9 @@ name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] [[package]] name = "strum" @@ -6555,7 +6640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -6692,7 +6777,7 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", @@ -7193,6 +7278,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" @@ -7201,9 +7297,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode_categories" diff --git a/Cargo.toml b/Cargo.toml index cc1834742..5add67b3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/amzn-codewhisperer-client", "crates/amzn-codewhisperer-streaming-client", "crates/amzn-consolas-client", "crates/amzn-qdeveloper-streaming-client", "crates/amzn-toolkit-telemetry-client", "crates/aws-toolkit-telemetry-definitions", "crates/chat-cli", "crates/semantic-search-client"] +members = ["crates/amzn-codewhisperer-client", "crates/amzn-codewhisperer-streaming-client", "crates/amzn-consolas-client", "crates/amzn-qdeveloper-streaming-client", "crates/amzn-toolkit-telemetry-client", "crates/aws-toolkit-telemetry-definitions", "crates/chat-cli", "crates/semantic-search-client", "crates/chat-cli-ui"] default-members = ["crates/chat-cli"] [workspace.package] @@ -132,6 +132,7 @@ schemars = "1.0.4" jsonschema = "0.30.0" zip = "2.2.0" rmcp = { version = "0.8.0", features = ["client", "transport-sse-client-reqwest", "reqwest", "transport-streamable-http-client-reqwest", "transport-child-process", "tower", "auth"] } +chat-cli-ui = { path = "crates/chat-cli-ui" } [workspace.lints.rust] future_incompatible = "warn" diff --git a/crates/chat-cli-ui/Cargo.toml b/crates/chat-cli-ui/Cargo.toml new file mode 100644 index 000000000..1449199a1 --- /dev/null +++ b/crates/chat-cli-ui/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "chat-cli-ui" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +publish.workspace = true +version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +tracing.workspace = true +tracing-appender.workspace = true +tracing-subscriber.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +crossterm.workspace = true +tokio.workspace = true +eyre.workspace = true +tokio-util.workspace = true +futures.workspace = true +ratatui = "0.29.0" + +[target.'cfg(unix)'.dependencies] +nix.workspace = true +skim.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2.workspace = true +objc2-app-kit.workspace = true +objc2-foundation.workspace = true +security-framework.workspace = true + +[target.'cfg(windows)'.dependencies] +windows.workspace = true +winreg.workspace = true diff --git a/crates/chat-cli-ui/src/conduit.rs b/crates/chat-cli-ui/src/conduit.rs new file mode 100644 index 000000000..7445e6589 --- /dev/null +++ b/crates/chat-cli-ui/src/conduit.rs @@ -0,0 +1,433 @@ +use std::io::Write as _; +use std::marker::PhantomData; + +use crossterm::style::{ + self, + Print, + Stylize, +}; +use crossterm::{ + execute, + queue, +}; + +use crate::legacy_ui_util::ThemeSource; +use crate::protocol::{ + Event, + LegacyPassThroughOutput, + ToolCallRejection, + ToolCallStart, +}; + +const TOOL_BULLET: &str = " ● "; +const CONTINUATION_LINE: &str = " ⋮ "; + +#[derive(thiserror::Error, Debug)] +pub enum ConduitError { + #[error(transparent)] + Send(#[from] Box>), + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + #[error("No event set")] + NullState, + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// The view would own this struct. +/// [ViewEnd] serves two purposes +/// - To deliver user inputs to the control layer from the view layer +/// - 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>, + /// To receive messages from control about state changes + pub receiver: std::sync::mpsc::Receiver, +} + +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] + pub fn into_legacy_mode( + self, + theme_source: impl ThemeSource, + mut stderr: std::io::Stderr, + mut stdout: std::io::Stdout, + ) -> Result<(), ConduitError> { + while let Ok(event) = self.receiver.recv() { + match event { + Event::LegacyPassThrough(content) => match content { + LegacyPassThroughOutput::Stderr(content) => { + stderr.write_all(&content)?; + stderr.flush()?; + }, + LegacyPassThroughOutput::Stdout(content) => { + stdout.write_all(&content)?; + stdout.flush()?; + }, + }, + Event::RunStarted(_run_started) => {}, + Event::RunFinished(_run_finished) => {}, + Event::RunError(_run_error) => {}, + Event::StepStarted(_step_started) => {}, + Event::StepFinished(_step_finished) => {}, + Event::TextMessageStart(_text_message_start) => { + queue!(stdout, theme_source.success_fg(), Print("> "), theme_source.reset(),)?; + }, + Event::TextMessageContent(text_message_content) => { + stdout.write_all(&text_message_content.delta)?; + stdout.flush()?; + }, + Event::TextMessageEnd(_text_message_end) => { + queue!(stderr, theme_source.reset(), theme_source.reset_attributes())?; + execute!(stdout, style::Print("\n"))?; + }, + Event::TextMessageChunk(_text_message_chunk) => {}, + Event::ToolCallStart(tool_call_start) => { + let ToolCallStart { + tool_call_name, + is_trusted, + mcp_server_name, + .. + } = tool_call_start; + + queue!( + stdout, + theme_source.emphasis_fg(), + Print(format!( + "🛠️ Using tool: {}{}", + tool_call_name, + if is_trusted { + " (trusted)".dark_green() + } else { + "".reset() + } + )), + theme_source.reset(), + )?; + + if let Some(server_name) = mcp_server_name { + queue!( + stdout, + theme_source.reset(), + Print(" from mcp server "), + theme_source.emphasis_fg(), + Print(&server_name), + theme_source.reset(), + )?; + } + + execute!( + stdout, + Print("\n"), + Print(CONTINUATION_LINE), + Print("\n"), + Print(TOOL_BULLET) + )?; + }, + Event::ToolCallArgs(tool_call_args) => { + if let serde_json::Value::String(content) = tool_call_args.delta { + execute!(stdout, style::Print(content))?; + } else { + 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::StateSnapshot(_state_snapshot) => {}, + Event::StateDelta(_state_delta) => {}, + Event::MessagesSnapshot(_messages_snapshot) => {}, + Event::Raw(_raw) => {}, + Event::Custom(_custom) => {}, + Event::ActivitySnapshotEvent(_activity_snapshot_event) => {}, + Event::ActivityDeltaEvent(_activity_delta_event) => {}, + Event::ReasoningStart(_reasoning_start) => {}, + Event::ReasoningMessageStart(_reasoning_message_start) => {}, + Event::ReasoningMessageContent(_reasoning_message_content) => {}, + Event::ReasoningMessageEnd(_reasoning_message_end) => {}, + Event::ReasoningMessageChunk(_reasoning_message_chunk) => {}, + Event::ReasoningEnd(_reasoning_end) => {}, + Event::MetaEvent(_meta_event) => {}, + Event::ToolCallRejection(tool_call_rejection) => { + let ToolCallRejection { reason, name, .. } = tool_call_rejection; + + execute!( + stderr, + theme_source.error_fg(), + Print("Command "), + theme_source.warning_fg(), + Print(name), + theme_source.error_fg(), + Print(" is rejected because it matches one or more rules on the denied list:"), + Print(reason), + Print("\n"), + theme_source.reset(), + )?; + }, + } + } + + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct DestinationStdout; +#[derive(Clone, Debug)] +pub struct DestinationStderr; +#[derive(Clone, Debug)] +pub struct DestinationStructuredOutput; + +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. +#[derive(Debug)] +pub struct ControlEnd { + pub current_event: Option, + /// Used by the control to send state changes to the view + pub sender: std::sync::mpsc::Sender, + /// 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. + pub should_send_structured_event: bool, + /// Phantom data to specify the destination type for pass-through operations. + /// This allows the type system to track whether this ControlEnd is configured + /// for stdout or stderr output without runtime overhead. + pass_through_destination: PhantomData, +} + +impl Clone for ControlEnd { + fn clone(&self) -> Self { + Self { + current_event: self.current_event.clone(), + sender: self.sender.clone(), + should_send_structured_event: self.should_send_structured_event, + pass_through_destination: PhantomData, + } + } +} + +impl ControlEnd { + /// Primes the [ControlEnd] with the state passed in + /// This api is intended to serve as an interim solution to bridge the gap between the current + /// code base, which heavily relies on crossterm apis to print directly to the terminal and the + /// refactor where the message passing paradigm is the norm + pub fn prime(&mut self, event: Event) { + self.current_event.replace(event); + } + + /// Sends an event to the view layer through the conduit + pub fn send(&self, event: Event) -> Result<(), ConduitError> { + Ok(self.sender.send(event).map_err(Box::new)?) + } +} + +impl ControlEnd { + pub fn as_stdout(&self) -> ControlEnd { + ControlEnd { + current_event: self.current_event.clone(), + should_send_structured_event: self.should_send_structured_event, + sender: self.sender.clone(), + pass_through_destination: PhantomData, + } + } +} + +impl ControlEnd { + pub fn as_stderr(&self) -> ControlEnd { + ControlEnd { + current_event: self.current_event.clone(), + should_send_structured_event: self.should_send_structured_event, + sender: self.sender.clone(), + pass_through_destination: PhantomData, + } + } +} + +impl std::io::Write for ControlEnd { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.current_event.is_none() { + self.current_event + .replace(Event::LegacyPassThrough(LegacyPassThroughOutput::Stderr( + Default::default(), + ))); + } + + let current_event = self + .current_event + .as_mut() + .ok_or(std::io::Error::other("No event set"))?; + + current_event + .insert_content(buf) + .map_err(|_e| std::io::Error::other("Error inserting content"))?; + + // By default stderr is unbuffered (the content is flushed immediately) + self.flush()?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if let Some(current_state) = self.current_event.take() { + self.sender.send(current_state).map_err(std::io::Error::other) + } else { + Ok(()) + } + } +} + +impl std::io::Write for ControlEnd { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // By default stdout is line buffered, so we'll delimit the incoming buffer with new line + // and flush accordingly. + let mut start = 0_usize; + let mut end = 0_usize; + while end < buf.len() { + let Some(byte) = buf.get(end) else { + break; + }; + + if byte == &10 || byte == &13 { + if self.current_event.is_none() { + self.current_event + .replace(Event::LegacyPassThrough(LegacyPassThroughOutput::Stderr( + Default::default(), + ))); + } + + let current_event = self + .current_event + .as_mut() + .ok_or(std::io::Error::other("No event set"))?; + + current_event + .insert_content(&buf[start..=end]) + .map_err(std::io::Error::other)?; + + self.flush()?; + + start = end + 1; + } + + end += 1; + } + + if start < end { + if self.current_event.is_none() { + self.current_event + .replace(Event::LegacyPassThrough(LegacyPassThroughOutput::Stderr( + Default::default(), + ))); + } + + let current_event = self + .current_event + .as_mut() + .ok_or(std::io::Error::other("No event set"))?; + + current_event + .insert_content(&buf[start..end]) + .map_err(std::io::Error::other)?; + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if let Some(current_state) = self.current_event.take() { + self.sender.send(current_state).map_err(std::io::Error::other) + } else { + Ok(()) + } + } +} + +/// Creates a set of legacy conduits for communication between view and control layers. +/// +/// This function establishes the communication channels needed for the legacy mode operation, +/// where the view layer and control layer can exchange events and byte data. +/// +/// # Parameters +/// +/// - `should_send_structured_event`: Flag indicating whether structured events should be sent +/// through the conduit +/// +/// # Returns +/// +/// A tuple containing: +/// - `ViewEnd`: The view-side endpoint for sending input and receiving state changes +/// - `InputReceiver`: A receiver for raw byte input from the view +/// - `ControlEnd`: Control endpoint configured for stderr output +/// - `ControlEnd`: Control endpoint configured for stdout output +/// +/// # Example +/// +/// ```rust +/// let (view_end, input_receiver, stderr_control, stdout_control) = get_legacy_conduits(true); +/// ``` +pub fn get_legacy_conduits( + should_send_structured_event: bool, +) -> ( + ViewEnd, + InputReceiver, + ControlEnd, + ControlEnd, +) { + let (state_tx, state_rx) = std::sync::mpsc::channel::(); + let (byte_tx, byte_rx) = tokio::sync::mpsc::channel::>(10); + + ( + ViewEnd { + sender: byte_tx, + receiver: state_rx, + }, + byte_rx, + ControlEnd { + current_event: None, + should_send_structured_event, + sender: state_tx.clone(), + pass_through_destination: PhantomData, + }, + ControlEnd { + current_event: None, + should_send_structured_event, + sender: state_tx, + pass_through_destination: PhantomData, + }, + ) +} + +pub trait InterimEvent { + type Error: std::error::Error; + fn insert_content(&mut self, content: &[u8]) -> Result<(), Self::Error>; +} + +// It seems silly to implement a trait we have defined in the crate for a type we have also defined +// in the same crate. But the plan is to move the Event type definition out of this crate (or use a +// an external crate once AGUI has a rust crate) +impl InterimEvent for Event { + type Error = ConduitError; + + fn insert_content(&mut self, content: &[u8]) -> Result<(), ConduitError> { + debug_assert!(self.is_compatible_with_legacy_event_loop()); + + match self { + Self::LegacyPassThrough(buf) => match buf { + LegacyPassThroughOutput::Stdout(buf) | LegacyPassThroughOutput::Stderr(buf) => { + buf.extend_from_slice(content); + }, + }, + _ => unreachable!(), + } + + Ok(()) + } +} diff --git a/crates/chat-cli-ui/src/input_bar.rs b/crates/chat-cli-ui/src/input_bar.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/crates/chat-cli-ui/src/input_bar.rs @@ -0,0 +1 @@ + diff --git a/crates/chat-cli-ui/src/legacy_ui_util.rs b/crates/chat-cli-ui/src/legacy_ui_util.rs new file mode 100644 index 000000000..93be999f1 --- /dev/null +++ b/crates/chat-cli-ui/src/legacy_ui_util.rs @@ -0,0 +1,35 @@ +use crossterm::style::{ + ResetColor, + SetAttribute, + SetForegroundColor, +}; + +/// 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 { + fn error(&self, text: &str) -> String; + fn info(&self, text: &str) -> String; + fn emphasis(&self, text: &str) -> String; + fn command(&self, text: &str) -> String; + fn prompt(&self, text: &str) -> String; + fn profile(&self, text: &str) -> String; + fn tangent(&self, text: &str) -> String; + fn usage_low(&self, text: &str) -> String; + fn usage_medium(&self, text: &str) -> String; + fn usage_high(&self, text: &str) -> String; + fn brand(&self, text: &str) -> String; + fn primary(&self, text: &str) -> String; + fn secondary(&self, text: &str) -> String; + fn success(&self, text: &str) -> String; + fn error_fg(&self) -> SetForegroundColor; + fn warning_fg(&self) -> SetForegroundColor; + fn success_fg(&self) -> SetForegroundColor; + fn info_fg(&self) -> SetForegroundColor; + fn brand_fg(&self) -> SetForegroundColor; + fn secondary_fg(&self) -> SetForegroundColor; + fn emphasis_fg(&self) -> SetForegroundColor; + fn reset(&self) -> ResetColor; + fn reset_attributes(&self) -> SetAttribute; +} diff --git a/crates/chat-cli-ui/src/lib.rs b/crates/chat-cli-ui/src/lib.rs new file mode 100644 index 000000000..b86217160 --- /dev/null +++ b/crates/chat-cli-ui/src/lib.rs @@ -0,0 +1,5 @@ +pub mod conduit; +pub mod input_bar; +pub mod legacy_ui_util; +pub mod protocol; +pub mod ui; diff --git a/crates/chat-cli-ui/src/protocol.rs b/crates/chat-cli-ui/src/protocol.rs new file mode 100644 index 000000000..df5d58ccb --- /dev/null +++ b/crates/chat-cli-ui/src/protocol.rs @@ -0,0 +1,499 @@ +//! This is largely based on https://docs.ag-ui.com/concepts/events +//! They do not have a rust SDK so for now we are handrolling these types + +use serde::{ + Deserialize, + Serialize, +}; +use serde_json::Value; + +/// Role of a message sender +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + Developer, + System, + Assistant, + User, + Tool, +} + +/// Base properties shared by all events +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BaseEvent { + #[serde(rename = "type")] + pub event_type: String, + #[serde(default, with = "chrono::serde::ts_seconds_option")] + pub timestamp: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_event: Option, +} + +// ============================================================================ +// Lifecycle Events +// ============================================================================ + +/// Signals the start of an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunStarted { + pub thread_id: String, + pub run_id: String, + // Extended fields (draft) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Signals the successful completion of an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunFinished { + pub thread_id: String, + pub run_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + // Extended fields (draft) + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome: Option, // "success" or "interrupt" + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt: Option, +} + +/// Signals an error during an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunError { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, +} + +/// Signals the start of a step within an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StepStarted { + pub step_name: String, +} + +/// Signals the completion of a step within an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StepFinished { + pub step_name: String, +} + +// ============================================================================ +// Text Message Events +// ============================================================================ + +/// Signals the start of a text message +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextMessageStart { + pub message_id: String, + pub role: MessageRole, +} + +/// Represents a chunk of content in a streaming text message +#[derive(Debug, Clone, Serialize, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextMessageContent { + pub message_id: String, + pub delta: Vec, +} + +/// Signals the end of a text message +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextMessageEnd { + pub message_id: String, +} + +/// A self-contained text message event that combines start, content, and end +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextMessageChunk { + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delta: Option, +} + +// ============================================================================ +// Tool Call Events +// ============================================================================ + +/// Signals the start of a tool call +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallStart { + pub tool_call_id: String, + pub tool_call_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_message_id: Option, + // bespoke fields + pub mcp_server_name: Option, + pub is_trusted: bool, +} + +/// Represents a chunk of argument data for a tool call +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallArgs { + pub tool_call_id: String, + pub delta: Value, +} + +/// Signals the end of a tool call +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallEnd { + pub tool_call_id: String, +} + +/// Provides the result of a tool call execution +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResult { + pub message_id: String, + pub tool_call_id: String, + pub content: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +/// Signifies a rejection to a tool call +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallRejection { + pub tool_call_id: String, + pub name: String, + pub reason: String, +} + +// ============================================================================ +// State Management Events +// ============================================================================ + +/// Provides a complete snapshot of an agent's state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSnapshot { + pub snapshot: Value, +} + +/// Provides a partial update to an agent's state using JSON Patch +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateDelta { + pub delta: Vec, // Array of JSON Patch operations (RFC 6902) +} + +/// Message object for MessagesSnapshot +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub id: String, + pub role: MessageRole, + pub content: String, + #[serde(default, with = "chrono::serde::ts_seconds_option")] + pub timestamp: Option>, +} + +/// Provides a snapshot of all messages in a conversation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessagesSnapshot { + pub messages: Vec, +} + +// ============================================================================ +// Special Events +// ============================================================================ + +/// Used to pass through events from external systems +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Raw { + pub event: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Used for application-specific custom events +#[derive(Debug, Clone, Serialize, Default, Deserialize)] +pub struct Custom { + pub name: String, + pub value: Value, +} + +/// Legacy pass-through output for compatibility with older event systems. +/// +/// This enum represents different types of output that can be passed through +/// from legacy systems that haven't been fully migrated to the new event protocol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LegacyPassThroughOutput { + /// Standard output stream data + Stdout(Vec), + /// Standard error stream data + Stderr(Vec), +} + +impl Default for LegacyPassThroughOutput { + fn default() -> Self { + Self::Stderr(Default::default()) + } +} + +// ============================================================================ +// Draft Events - Activity Events +// ============================================================================ + +/// Provides the complete activity state at a point in time (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivitySnapshotEvent { + pub message_id: String, + pub activity_type: String, // e.g., "PLAN", "SEARCH", "SCRAPE" + pub content: Value, +} + +/// Provides incremental updates to the activity state using JSON Patch operations (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityDeltaEvent { + pub message_id: String, + pub activity_type: String, // e.g., "PLAN", "SEARCH", "SCRAPE" + pub patch: Vec, // JSON Patch operations (RFC 6902) +} + +// ============================================================================ +// Draft Events - Reasoning Events +// ============================================================================ + +/// Marks the start of reasoning (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningStart { + pub message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_content: Option, +} + +/// Signals the start of a reasoning message (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningMessageStart { + pub message_id: String, + pub role: MessageRole, +} + +/// Represents a chunk of content in a streaming reasoning message (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningMessageContent { + pub message_id: String, + pub delta: String, +} + +/// Signals the end of a reasoning message (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningMessageEnd { + pub message_id: String, +} + +/// A convenience event to auto start/close reasoning messages (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningMessageChunk { + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delta: Option, +} + +/// Marks the end of reasoning (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningEnd { + pub message_id: String, +} + +// ============================================================================ +// Draft Events - Meta Events +// ============================================================================ + +/// A side-band annotation event that can occur anywhere in the stream (DRAFT) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetaEvent { + pub meta_type: String, // e.g., "thumbs_up", "tag" + pub payload: Value, +} + +// ============================================================================ +// Main Event Enum +// ============================================================================ + +/// Main event enum that encompasses all event types in the Agent UI Protocol +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Event { + // Lifecycle Events + RunStarted(RunStarted), + RunFinished(RunFinished), + RunError(RunError), + StepStarted(StepStarted), + StepFinished(StepFinished), + + // Text Message Events + TextMessageStart(TextMessageStart), + TextMessageContent(TextMessageContent), + TextMessageEnd(TextMessageEnd), + TextMessageChunk(TextMessageChunk), + + // Tool Call Events + ToolCallStart(ToolCallStart), + ToolCallArgs(ToolCallArgs), + ToolCallEnd(ToolCallEnd), + ToolCallResult(ToolCallResult), + // bespoke variant + ToolCallRejection(ToolCallRejection), + + // State Management Events + StateSnapshot(StateSnapshot), + StateDelta(StateDelta), + MessagesSnapshot(MessagesSnapshot), + + // Special Events + Raw(Raw), + Custom(Custom), + // bespoke variant + LegacyPassThrough(LegacyPassThroughOutput), + + // Draft Events - Activity Events + ActivitySnapshotEvent(ActivitySnapshotEvent), + ActivityDeltaEvent(ActivityDeltaEvent), + + // Draft Events - Reasoning Events + ReasoningStart(ReasoningStart), + ReasoningMessageStart(ReasoningMessageStart), + ReasoningMessageContent(ReasoningMessageContent), + ReasoningMessageEnd(ReasoningMessageEnd), + ReasoningMessageChunk(ReasoningMessageChunk), + ReasoningEnd(ReasoningEnd), + + // Draft Events - Meta Events + MetaEvent(MetaEvent), +} + +impl Event { + /// Get the event type string for this event + pub fn event_type(&self) -> &'static str { + match self { + // Lifecycle Events + Event::RunStarted(_) => "runStarted", + Event::RunFinished(_) => "runFinished", + Event::RunError(_) => "runError", + Event::StepStarted(_) => "stepStarted", + Event::StepFinished(_) => "stepFinished", + + // Text Message Events + Event::TextMessageStart(_) => "textMessageStart", + Event::TextMessageContent(_) => "textMessageContent", + Event::TextMessageEnd(_) => "textMessageEnd", + Event::TextMessageChunk(_) => "textMessageChunk", + + // Tool Call Events + Event::ToolCallStart(_) => "toolCallStart", + Event::ToolCallArgs(_) => "toolCallArgs", + Event::ToolCallEnd(_) => "toolCallEnd", + Event::ToolCallResult(_) => "toolCallResult", + Event::ToolCallRejection(_) => "toolCallRejection", + + // State Management Events + Event::StateSnapshot(_) => "stateSnapshot", + Event::StateDelta(_) => "stateDelta", + Event::MessagesSnapshot(_) => "messagesSnapshot", + + // Special Events + Event::Raw(_) => "raw", + Event::Custom(_) => "custom", + Event::LegacyPassThrough(_) => "legacyPassThrough", + + // Draft Events - Activity Events + Event::ActivitySnapshotEvent(_) => "activitySnapshotEvent", + Event::ActivityDeltaEvent(_) => "activityDeltaEvent", + + // Draft Events - Reasoning Events + Event::ReasoningStart(_) => "reasoningStart", + Event::ReasoningMessageStart(_) => "reasoningMessageStart", + Event::ReasoningMessageContent(_) => "reasoningMessageContent", + Event::ReasoningMessageEnd(_) => "reasoningMessageEnd", + Event::ReasoningMessageChunk(_) => "reasoningMessageChunk", + Event::ReasoningEnd(_) => "reasoningEnd", + + // Draft Events - Meta Events + Event::MetaEvent(_) => "metaEvent", + } + } + + pub fn is_compatible_with_legacy_event_loop(&self) -> bool { + matches!(self, Event::LegacyPassThrough(_)) + } + + /// Check if this is a lifecycle event + pub fn is_lifecycle_event(&self) -> bool { + matches!( + self, + Event::RunStarted(_) + | Event::RunFinished(_) + | Event::RunError(_) + | Event::StepStarted(_) + | Event::StepFinished(_) + ) + } + + /// Check if this is a text message event + pub fn is_text_message_event(&self) -> bool { + matches!( + self, + Event::TextMessageStart(_) + | Event::TextMessageContent(_) + | Event::TextMessageEnd(_) + | Event::TextMessageChunk(_) + ) + } + + /// Check if this is a tool call event + pub fn is_tool_call_event(&self) -> bool { + matches!( + self, + Event::ToolCallStart(_) | Event::ToolCallArgs(_) | Event::ToolCallEnd(_) | Event::ToolCallResult(_) + ) + } + + /// Check if this is a state management event + pub fn is_state_management_event(&self) -> bool { + matches!( + self, + Event::StateSnapshot(_) | Event::StateDelta(_) | Event::MessagesSnapshot(_) + ) + } + + /// Check if this is a draft event (experimental/unstable) + pub fn is_draft_event(&self) -> bool { + matches!( + self, + Event::ActivitySnapshotEvent(_) + | Event::ActivityDeltaEvent(_) + | Event::ReasoningStart(_) + | Event::ReasoningMessageStart(_) + | Event::ReasoningMessageContent(_) + | Event::ReasoningMessageEnd(_) + | Event::ReasoningMessageChunk(_) + | Event::ReasoningEnd(_) + | Event::MetaEvent(_) + ) + } +} diff --git a/crates/chat-cli-ui/src/ui/action.rs b/crates/chat-cli-ui/src/ui/action.rs new file mode 100644 index 000000000..7bf9b372e --- /dev/null +++ b/crates/chat-cli-ui/src/ui/action.rs @@ -0,0 +1,7 @@ +#![allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Quit, + Tick, + Noop, +} diff --git a/crates/chat-cli-ui/src/ui/component.rs b/crates/chat-cli-ui/src/ui/component.rs new file mode 100644 index 000000000..e69de29bb diff --git a/crates/chat-cli-ui/src/ui/components/app.rs b/crates/chat-cli-ui/src/ui/components/app.rs new file mode 100644 index 000000000..36ca72088 --- /dev/null +++ b/crates/chat-cli-ui/src/ui/components/app.rs @@ -0,0 +1,46 @@ +use eyre::Result; +use tokio::sync::mpsc::unbounded_channel; +use tracing::error; + +use crate::ui::action::Action; +use crate::ui::tui::Tui; + +pub struct App { + pub should_quit: bool, +} + +impl App { + pub async fn run(&mut self) -> Result<()> { + let (_render_tx, mut render_rx) = unbounded_channel::<()>(); + let (action_tx, mut _action_rx) = unbounded_channel::(); + + let mut tui = Tui::new(4.0, 60.0)?; + // TODO: make a defer routine that restores the terminal on exit + tui.enter()?; + + let mut event_receiver = tui.event_rx.take().expect("Missing event receiver"); + + // Render Task + tokio::spawn(async move { + while render_rx.recv().await.is_some() { + // TODO: render here + tui.terminal.draw(|_f| {})?; + } + + Ok::<(), Box>(()) + }); + + // Event monitoring task + tokio::spawn(async move { + while let Some(_event) = event_receiver.recv().await { + // TODO: derive action from the main component + let action = Action::Tick; + if let Err(e) = action_tx.send(action) { + error!("Error sending action: {:?}", e); + } + } + }); + + Ok(()) + } +} diff --git a/crates/chat-cli-ui/src/ui/components/mod.rs b/crates/chat-cli-ui/src/ui/components/mod.rs new file mode 100644 index 000000000..dbf13902c --- /dev/null +++ b/crates/chat-cli-ui/src/ui/components/mod.rs @@ -0,0 +1,45 @@ +#![allow(dead_code)] +use crossterm::event::{ + Event, + KeyEvent, + MouseEvent, +}; +use eyre::Result; +use ratatui::Frame; +use ratatui::layout::Rect; +use tokio::sync::mpsc::UnboundedSender; + +use super::action::Action; + +mod app; + +pub trait Component { + #[allow(unused_variables)] + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + Ok(()) + } + fn init(&mut self) -> Result<()> { + Ok(()) + } + fn handle_events(&mut self, event: Option) -> Result> { + let r = match event { + Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, + _ => None, + }; + Ok(r) + } + #[allow(unused_variables)] + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn update(&mut self, action: Action) -> Result> { + Ok(None) + } + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>; +} diff --git a/crates/chat-cli-ui/src/ui/mod.rs b/crates/chat-cli-ui/src/ui/mod.rs new file mode 100644 index 000000000..ca4a4dfee --- /dev/null +++ b/crates/chat-cli-ui/src/ui/mod.rs @@ -0,0 +1,3 @@ +mod action; +mod components; +mod tui; diff --git a/crates/chat-cli-ui/src/ui/tui.rs b/crates/chat-cli-ui/src/ui/tui.rs new file mode 100644 index 000000000..b9c42b942 --- /dev/null +++ b/crates/chat-cli-ui/src/ui/tui.rs @@ -0,0 +1,221 @@ +#![allow(dead_code)] +use std::io::{ + Stderr, + stderr, +}; +use std::ops::{ + Deref, + DerefMut, +}; + +use crossterm::cursor; +use crossterm::event::{ + Event as CrosstermEvent, + KeyEvent, + KeyEventKind, + MouseEvent, +}; +use crossterm::terminal::{ + EnterAlternateScreen, + LeaveAlternateScreen, +}; +use eyre::Result; +use futures::{ + FutureExt, + StreamExt, +}; +use ratatui::backend::CrosstermBackend as Backend; +use tokio::sync::mpsc::{ + UnboundedReceiver, + UnboundedSender, + unbounded_channel, +}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::error; + +#[derive(Clone, Debug)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: Option>, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, +} + +impl Tui { + pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + let terminal = ratatui::Terminal::new(Backend::new(stderr()))?; + let (event_tx, event_rx) = unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + + Ok(Self { + terminal, + task, + cancellation_token, + event_rx: Some(event_rx), + event_tx, + frame_rate, + tick_rate, + }) + } + + fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let cancellation_token_clone = self.cancellation_token.clone(); + let event_tx_clone = self.event_tx.clone(); + + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + if let Err(e) = event_tx_clone.send(Event::Init) { + error!("Error sending event: {:?}", e); + } + + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + + tokio::select! { + _ = cancellation_token_clone.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + if let Err(e) = event_tx_clone.send(Event::Key(key)) { + error!("Error sending event: {:?}", e); + } + } + }, + CrosstermEvent::Mouse(mouse) => { + if let Err(e) = event_tx_clone.send(Event::Mouse(mouse)) { + error!("Error sending event: {:?}", e); + } + }, + CrosstermEvent::Resize(x, y) => { + if let Err(e) = event_tx_clone.send(Event::Resize(x, y)) { + error!("Error sending event: {:?}", e); + } + }, + CrosstermEvent::FocusLost => { + if let Err(e) = event_tx_clone.send(Event::FocusLost) { + error!("Error sending event: {:?}", e); + } + }, + CrosstermEvent::FocusGained => { + if let Err(e) = event_tx_clone.send(Event::FocusGained) { + error!("Error sending event: {:?}", e); + } + }, + CrosstermEvent::Paste(s) => { + if let Err(e) = event_tx_clone.send(Event::Paste(s)) { + error!("Error sending event: {:?}", e); + } + }, + } + } + Some(Err(_)) => { + if let Err(e) = event_tx_clone.send(Event::Error) { + error!("Error sending event: {:?}", e); + } + } + None => {}, + } + }, + _ = tick_delay => { + if let Err(e) = event_tx_clone.send(Event::Tick) { + error!("Error sending event: {:?}", e); + } + }, + _ = render_delay => { + if let Err(e) = event_tx_clone.send(Event::Render) { + error!("Error sending event: {:?}", e); + } + }, + } + } + }); + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; + self.start(); + + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.cancel(); + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + + Ok(()) + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/crates/chat-cli/Cargo.toml b/crates/chat-cli/Cargo.toml index 51648f35c..26524e0b1 100644 --- a/crates/chat-cli/Cargo.toml +++ b/crates/chat-cli/Cargo.toml @@ -119,6 +119,7 @@ schemars.workspace = true jsonschema.workspace = true zip.workspace = true rmcp.workspace = true +chat-cli-ui.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true diff --git a/crates/chat-cli/src/cli/agent/mod.rs b/crates/chat-cli/src/cli/agent/mod.rs index 037257478..f6ca84b70 100644 --- a/crates/chat-cli/src/cli/agent/mod.rs +++ b/crates/chat-cli/src/cli/agent/mod.rs @@ -92,7 +92,11 @@ pub enum AgentConfigError { #[error("File URI not found: {uri} (resolved to {path})")] FileUriNotFound { uri: String, path: PathBuf }, #[error("Failed to read file URI: {uri} (resolved to {path}): {error}")] - FileUriReadError { uri: String, path: PathBuf, error: std::io::Error }, + FileUriReadError { + uri: String, + path: PathBuf, + error: std::io::Error, + }, #[error("Invalid file URI format: {uri}")] InvalidFileUri { uri: String }, } @@ -314,26 +318,24 @@ impl Agent { Ok(content) => Ok(Some(content)), Err(file_uri::FileUriError::InvalidUri { uri }) => { Err(AgentConfigError::InvalidFileUri { uri }) - } - Err(file_uri::FileUriError::FileNotFound { path }) => { - Err(AgentConfigError::FileUriNotFound { - uri: prompt_str.clone(), - path - }) - } + }, + Err(file_uri::FileUriError::FileNotFound { path }) => Err(AgentConfigError::FileUriNotFound { + uri: prompt_str.clone(), + path, + }), Err(file_uri::FileUriError::ReadError { path, source }) => { Err(AgentConfigError::FileUriReadError { uri: prompt_str.clone(), path, - error: source + error: source, }) - } + }, } } else { // Return the prompt as-is for backward compatibility Ok(Some(prompt_str.clone())) } - } + }, } } @@ -990,8 +992,9 @@ fn validate_agent_name(name: &str) -> eyre::Result<()> { #[cfg(test)] mod tests { - use serde_json::json; use std::fs; + + use serde_json::json; use tempfile::TempDir; use super::*; diff --git a/crates/chat-cli/src/cli/chat/checkpoint.rs b/crates/chat-cli/src/cli/chat/checkpoint.rs index cfe76635c..f32be5280 100644 --- a/crates/chat-cli/src/cli/chat/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/checkpoint.rs @@ -27,8 +27,8 @@ use serde::{ }; use tracing::debug; -use crate::cli::ConversationState; use super::util::truncate_safe; +use crate::cli::ConversationState; use crate::cli::chat::conversation::HistoryEntry; use crate::os::Os; diff --git a/crates/chat-cli/src/cli/chat/custom_spinner.rs b/crates/chat-cli/src/cli/chat/custom_spinner.rs new file mode 100644 index 000000000..71e533e26 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/custom_spinner.rs @@ -0,0 +1,67 @@ +use crossterm::{ + cursor, + execute, + terminal, +}; +use indicatif::{ + ProgressBar, + ProgressStyle, +}; +use tokio_util::sync::CancellationToken; + +use crate::theme::StyledText; + +const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + +pub struct Spinners { + cancellation_token: CancellationToken, +} + +impl Spinners { + pub fn new(message: String) -> Self { + // Hide the cursor when starting the spinner + let _ = execute!(std::io::stderr(), cursor::Hide); + + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .tick_chars(SPINNER_CHARS) + .template("{spinner:.green} {msg}") + .unwrap(), + ); + pb.set_message(message); + let token = CancellationToken::new(); + let token_clone = token.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = token_clone.cancelled() => { + break; + }, + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { + pb.tick(); + } + } + } + + Ok::<(), Box>(()) + }); + + Self { + cancellation_token: token, + } + } +} + +impl Drop for Spinners { + fn drop(&mut self) { + self.cancellation_token.cancel(); + let _ = execute!( + std::io::stderr(), + terminal::Clear(terminal::ClearType::CurrentLine), + cursor::MoveToColumn(0), + StyledText::reset_attributes(), + cursor::Show + ); + } +} diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 6a8038529..08fa13015 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1,8 +1,10 @@ use crate::theme::StyledText; +use crate::util::ui::should_send_structured_message; pub mod cli; mod consts; pub mod context; mod conversation; +mod custom_spinner; mod input_source; mod message; mod parse; @@ -39,6 +41,22 @@ use std::time::{ }; use amzn_codewhisperer_client::types::SubscriptionStatus; +use chat_cli_ui::conduit::{ + ConduitError, + ControlEnd, + DestinationStderr, + DestinationStdout, + get_legacy_conduits, +}; +use chat_cli_ui::protocol::{ + Event, + MessageRole, + TextMessageContent, + TextMessageEnd, + TextMessageStart, + ToolCallRejection, + ToolCallStart, +}; use clap::{ Args, CommandFactory, @@ -65,6 +83,7 @@ use crossterm::{ style, terminal, }; +use custom_spinner::Spinners; use eyre::{ Report, Result, @@ -89,10 +108,6 @@ use parser::{ }; use regex::Regex; use rmcp::model::PromptMessage; -use spinners::{ - Spinner, - Spinners, -}; use thiserror::Error; use time::OffsetDateTime; use token_counter::TokenCounter; @@ -250,7 +265,6 @@ impl ChatArgs { } } - let stdout = std::io::stdout(); let mut stderr = std::io::stderr(); let args: Vec = std::env::args().collect(); @@ -413,8 +427,6 @@ impl ChatArgs { ChatSession::new( os, - stdout, - stderr, &conversation_id, agents, input, @@ -491,6 +503,8 @@ pub enum ChatError { CompactHistoryFailure, #[error("Failed to swap to agent: {0}")] AgentSwapError(eyre::Report), + #[error(transparent)] + Conduit(#[from] ConduitError), } impl ChatError { @@ -508,6 +522,7 @@ impl ChatError { ChatError::NonInteractiveToolApproval => None, ChatError::CompactHistoryFailure => None, ChatError::AgentSwapError(_) => None, + ChatError::Conduit(_) => None, } } } @@ -527,6 +542,7 @@ impl ReasonCode for ChatError { ChatError::NonInteractiveToolApproval => "NonInteractiveToolApproval".to_string(), ChatError::CompactHistoryFailure => "CompactHistoryFailure".to_string(), ChatError::AgentSwapError(_) => "AgentSwapError".to_string(), + ChatError::Conduit(_) => "ConduitError".to_string(), } } } @@ -551,16 +567,16 @@ impl From for ChatError { pub struct ChatSession { /// For output read by humans and machine - pub stdout: std::io::Stdout, + pub stdout: ControlEnd, /// For display output, only read by humans - pub stderr: std::io::Stderr, + pub stderr: ControlEnd, initial_input: Option, /// Whether we're starting a new conversation or continuing an old one. existing_conversation: bool, input_source: InputSource, /// Width of the terminal, required for [ParseState]. terminal_width_provider: fn() -> Option, - spinner: Option, + spinner: Option, /// [ConversationState]. conversation: ConversationState, /// Tool uses requested by the model that are actively being handled. @@ -592,8 +608,6 @@ impl ChatSession { #[allow(clippy::too_many_arguments)] pub async fn new( os: &mut Os, - stdout: std::io::Stdout, - mut stderr: std::io::Stderr, conversation_id: &str, mut agents: Agents, mut input: Option, @@ -609,6 +623,19 @@ impl ChatSession { ) -> Result { // Only load prior conversation if we need to resume let mut existing_conversation = false; + + let should_send_structured_msg = should_send_structured_message(os); + let (view_end, _byte_receiver, 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 conversation = match resume_conversation { true => { let previous_conversation = std::env::current_dir() @@ -626,7 +653,7 @@ impl ChatSession { if let Some(profile) = cs.current_profile() { if agents.switch(profile).is_err() { execute!( - stderr, + &mut control_end_stderr, StyledText::error_fg(), style::Print("Error"), StyledText::reset(), @@ -689,8 +716,8 @@ impl ChatSession { }); Ok(Self { - stdout, - stderr, + stdout: control_end_stdout, + stderr: control_end_stderr, initial_input: input, existing_conversation, input_source, @@ -802,11 +829,6 @@ impl ChatSession { if self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - )?; } let (context, report, display_err_message) = match err { @@ -1096,10 +1118,6 @@ impl ChatSession { impl Drop for ChatSession { fn drop(&mut self) { - if let Some(spinner) = &mut self.spinner { - spinner.stop(); - } - execute!( self.stderr, cursor::MoveToColumn(0), @@ -1397,8 +1415,7 @@ impl ChatSession { .await?; if self.interactive { - execute!(self.stderr, cursor::Hide, style::Print("\n"))?; - self.spinner = Some(Spinner::new(Spinners::Dots, "Creating summary...".to_string())); + self.spinner = Some(Spinners::new("Creating summary...".to_string())); } let mut response = match self @@ -1414,12 +1431,6 @@ impl ChatSession { Err(err) => { if self.interactive { self.spinner.take(); - execute!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - StyledText::reset_attributes() - )?; } // If the request fails due to context window overflow, then we'll see if it's @@ -1517,12 +1528,6 @@ impl ChatSession { if self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - cursor::Show - )?; } self.conversation @@ -1693,10 +1698,10 @@ impl ChatSession { if self.interactive { execute!(self.stderr, cursor::Hide, style::Print("\n"))?; - self.spinner = Some(Spinner::new( - Spinners::Dots, - format!("Generating agent config for '{}'...", agent_name), - )); + self.spinner = Some(Spinners::new(format!( + "Generating agent config for '{}'...", + agent_name + ))); } let mut response = match self @@ -1712,12 +1717,6 @@ impl ChatSession { Err(err) => { if self.interactive { self.spinner.take(); - execute!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - StyledText::reset_attributes() - )?; } return Err(err); }, @@ -1764,12 +1763,6 @@ impl ChatSession { if self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - cursor::Show - )?; } // Parse and validate the initial generated config let initial_agent_config = match serde_json::from_str::(&agent_config_json) { @@ -2140,7 +2133,7 @@ impl ChatSession { queue!(self.stderr, cursor::Hide)?; if self.interactive { - self.spinner = Some(Spinner::new(Spinners::Dots, "Thinking...".to_owned())); + self.spinner = Some(Spinners::new("Thinking...".to_owned())); } Ok(ChatState::HandleResponseStream(conv_state)) @@ -2194,18 +2187,27 @@ impl ChatSession { acc }); - execute!( - self.stderr, - StyledText::error_fg(), - style::Print("Command "), - StyledText::warning_fg(), - style::Print(&tool.name), - StyledText::error_fg(), - style::Print(" is rejected because it matches one or more rules on the denied list:"), - style::Print(formatted_set), - style::Print("\n"), - StyledText::reset(), - )?; + if self.stderr.should_send_structured_event { + execute!( + self.stderr, + StyledText::error_fg(), + style::Print("Command "), + StyledText::warning_fg(), + style::Print(&tool.name), + StyledText::error_fg(), + style::Print(" is rejected because it matches one or more rules on the denied list:"), + style::Print(formatted_set), + style::Print("\n"), + StyledText::reset(), + )?; + } else { + let event = ToolCallRejection { + tool_call_id: tool.id.clone(), + name: tool.name.clone(), + reason: formatted_set, + }; + self.stderr.send(Event::ToolCallRejection(event))?; + } return Ok(ChatState::HandleInput { input: format!( @@ -2226,7 +2228,10 @@ impl ChatSession { // TODO: Control flow is hacky here because of borrow rules let _ = tool; + self.print_tool_description(os, i, allowed).await?; + self.stdout.flush()?; + let tool = &mut self.tool_uses[i]; if allowed { @@ -2278,15 +2283,9 @@ impl ChatSession { ) .await; - if self.spinner.is_some() { - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - cursor::Show - )?; + if let Some(spinner) = self.spinner.take() { + drop(spinner); } - execute!(self.stdout, style::Print("\n"))?; // Handle checkpoint after tool execution - store tag for later display let checkpoint_tag: Option = { @@ -2561,7 +2560,7 @@ impl ChatSession { execute!(self.stderr, cursor::Hide)?; execute!(self.stderr, style::Print("\n"), StyledText::reset_attributes())?; if self.interactive { - self.spinner = Some(Spinner::new(Spinners::Dots, "Thinking...".to_string())); + self.spinner = Some(Spinners::new("Thinking...".to_string())); } self.send_chat_telemetry(os, TelemetryResult::Succeeded, None, None, None, false) @@ -2617,19 +2616,13 @@ impl ChatSession { if self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - StyledText::reset(), - cursor::MoveToColumn(0), - cursor::Show, - terminal::Clear(terminal::ClearType::CurrentLine), - )?; } loop { match rx.recv().await { Some(Ok(msg_event)) => { trace!("Consumed: {:?}", msg_event); + match msg_event { parser::ResponseEvent::ToolUseStart { name } => { // We need to flush the buffer here, otherwise text will not be @@ -2638,27 +2631,33 @@ impl ChatSession { tool_name_being_recvd = Some(name); }, parser::ResponseEvent::AssistantText(text) => { - // Add Q response prefix before the first assistant text. - if !response_prefix_printed && !text.trim().is_empty() { - queue!( - self.stdout, - StyledText::success_fg(), - style::Print("> "), - StyledText::reset(), - )?; - response_prefix_printed = true; + if self.stdout.should_send_structured_event { + if !response_prefix_printed && !text.trim().is_empty() { + let msg_start = TextMessageStart { + message_id: request_id.clone().unwrap_or_default(), + role: MessageRole::Assistant, + }; + + self.stdout.send(Event::TextMessageStart(msg_start))?; + response_prefix_printed = true; + } + } else { + // Add Q response prefix before the first assistant text. + if !response_prefix_printed && !text.trim().is_empty() { + queue!( + self.stdout, + StyledText::success_fg(), + style::Print("> "), + StyledText::reset(), + )?; + response_prefix_printed = true; + } } buf.push_str(&text); }, parser::ResponseEvent::ToolUse(tool_use) => { if self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - cursor::Show - )?; } tool_uses.push(tool_use); tool_name_being_recvd = None; @@ -2708,7 +2707,7 @@ impl ChatSession { ); execute!(self.stderr, cursor::Hide)?; - self.spinner = Some(Spinner::new(Spinners::Dots, "Dividing up the work...".to_string())); + self.spinner = Some(Spinners::new("Dividing up the work...".to_string())); // For stream timeouts, we'll tell the model to try and split its response into // smaller chunks. @@ -2847,28 +2846,48 @@ impl ChatSession { if tool_name_being_recvd.is_none() && !buf.is_empty() && self.spinner.is_some() { drop(self.spinner.take()); - queue!( - self.stderr, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - cursor::Show - )?; } + info!("## control end: buf: {:?}", buf); + + let mut temp_buf = Vec::::new(); + // Print the response for normal cases loop { let input = Partial::new(&buf[offset..]); - match interpret_markdown(input, &mut self.stdout, &mut state) { - Ok(parsed) => { - offset += parsed.offset_from(&input); - self.stdout.flush()?; - state.newline = state.set_newline; - state.set_newline = false; - }, - Err(err) => match err.into_inner() { - Some(err) => return Err(ChatError::Custom(err.to_string().into())), - None => break, // Data was incomplete - }, + if self.stdout.should_send_structured_event { + match interpret_markdown(input, &mut temp_buf, &mut state) { + Ok(parsed) => { + offset += parsed.offset_from(&input); + temp_buf.flush()?; + + let text_msg_content = TextMessageContent { + message_id: request_id.clone().unwrap_or_default(), + delta: std::mem::take(&mut temp_buf), + }; + self.stdout.send(Event::TextMessageContent(text_msg_content))?; + + state.newline = state.set_newline; + state.set_newline = false; + }, + Err(err) => match err.into_inner() { + Some(err) => return Err(ChatError::Custom(err.to_string().into())), + None => break, // Data was incomplete + }, + } + } else { + match interpret_markdown(input, &mut self.stdout, &mut state) { + Ok(parsed) => { + offset += parsed.offset_from(&input); + self.stdout.flush()?; + state.newline = state.set_newline; + state.set_newline = false; + }, + Err(err) => match err.into_inner() { + Some(err) => return Err(ChatError::Custom(err.to_string().into())), + None => break, // Data was incomplete + }, + } } // TODO: We should buffer output based on how much we have to parse, not as a constant @@ -2880,7 +2899,7 @@ impl ChatSession { if tool_name_being_recvd.is_some() { queue!(self.stderr, cursor::Hide)?; if self.interactive { - self.spinner = Some(Spinner::new(Spinners::Dots, "Thinking...".to_string())); + self.spinner = Some(Spinners::new("Thinking...".to_string())); } } @@ -2895,8 +2914,14 @@ impl ChatSession { play_notification_bell(tool_uses.is_empty()); } - queue!(self.stderr, StyledText::reset(), StyledText::reset_attributes())?; - execute!(self.stdout, style::Print("\n"))?; + if self.stderr.should_send_structured_event { + self.stderr.send(Event::TextMessageEnd(TextMessageEnd { + message_id: request_id.clone().unwrap_or_default(), + }))?; + } else { + queue!(self.stderr, StyledText::reset(), StyledText::reset_attributes())?; + execute!(self.stdout, style::Print("\n"))?; + } for (i, citation) in &state.citations { queue!( @@ -3197,7 +3222,7 @@ impl ChatSession { } if self.interactive { - self.spinner = Some(Spinner::new(Spinners::Dots, "Thinking...".to_owned())); + self.spinner = Some(Spinners::new("Thinking...".to_owned())); } Ok(ChatState::HandleResponseStream( @@ -3234,34 +3259,49 @@ impl ChatSession { async fn print_tool_description(&mut self, os: &Os, tool_index: usize, trusted: bool) -> Result<(), ChatError> { let tool_use = &self.tool_uses[tool_index]; - queue!( - self.stdout, - StyledText::emphasis_fg(), - style::Print(format!( - "🛠️ Using tool: {}{}", - tool_use.tool.display_name(), - if trusted { " (trusted)".dark_green() } else { "".reset() } - )), - StyledText::reset(), - )?; - if let Tool::Custom(ref tool) = tool_use.tool { + if self.stderr.should_send_structured_event { + let tool_call_start = ToolCallStart { + tool_call_id: tool_use.id.clone(), + tool_call_name: tool_use.name.clone(), + mcp_server_name: if let Tool::Custom(ref tool) = tool_use.tool { + Some(tool.server_name.clone()) + } else { + None + }, + is_trusted: trusted, + parent_message_id: None, + }; + self.stdout.send(Event::ToolCallStart(tool_call_start))?; + } else { queue!( self.stdout, - StyledText::reset(), - style::Print(" from mcp server "), StyledText::emphasis_fg(), - style::Print(&tool.server_name), + style::Print(format!( + "🛠️ Using tool: {}{}", + tool_use.tool.display_name(), + if trusted { " (trusted)".dark_green() } else { "".reset() } + )), StyledText::reset(), )?; - } + if let Tool::Custom(ref tool) = tool_use.tool { + queue!( + self.stdout, + StyledText::reset(), + style::Print(" from mcp server "), + StyledText::emphasis_fg(), + style::Print(&tool.server_name), + StyledText::reset(), + )?; + } - execute!( - self.stdout, - style::Print("\n"), - style::Print(CONTINUATION_LINE), - style::Print("\n"), - style::Print(TOOL_BULLET) - )?; + execute!( + self.stdout, + style::Print("\n"), + style::Print(CONTINUATION_LINE), + style::Print("\n"), + style::Print(TOOL_BULLET) + )?; + } tool_use .tool @@ -3607,7 +3647,7 @@ async fn get_subscription_status(os: &mut Os) -> Result Result { return with_spinner(output, "Checking subscription status...", || async { get_subscription_status(os).await @@ -3615,24 +3655,26 @@ async fn get_subscription_status_with_spinner( .await; } -pub async fn with_spinner(output: &mut impl std::io::Write, spinner_text: &str, f: F) -> Result +pub async fn with_spinner( + output: &mut S, + spinner_text: &str, + f: F, +) -> Result where F: FnOnce() -> Fut, Fut: std::future::Future>, { queue!(output, cursor::Hide,).ok(); - let spinner = Some(Spinner::new(Spinners::Dots, spinner_text.to_owned())); + let spinner = Spinners::new(spinner_text.to_owned()); let result = f().await; - if let Some(mut s) = spinner { - s.stop(); - let _ = queue!( - output, - terminal::Clear(terminal::ClearType::CurrentLine), - cursor::MoveToColumn(0), - ); - } + drop(spinner); + let _ = queue!( + output, + terminal::Clear(terminal::ClearType::CurrentLine), + cursor::MoveToColumn(0), + ); result } @@ -3656,6 +3698,31 @@ fn does_input_reference_file(input: &str) -> Option { None } +// Helper method to save the agent config to file +async fn save_agent_config(os: &mut Os, config: &Agent, agent_name: &str, is_global: bool) -> Result<(), ChatError> { + let config_dir = if is_global { + directories::chat_global_agent_path(os) + .map_err(|e| ChatError::Custom(format!("Could not find global agent directory: {}", e).into()))? + } else { + directories::chat_local_agent_dir(os) + .map_err(|e| ChatError::Custom(format!("Could not find local agent directory: {}", e).into()))? + }; + + tokio::fs::create_dir_all(&config_dir) + .await + .map_err(|e| ChatError::Custom(format!("Failed to create config directory: {}", e).into()))?; + + let config_file = config_dir.join(format!("{}.json", agent_name)); + let config_json = serde_json::to_string_pretty(config) + .map_err(|e| ChatError::Custom(format!("Failed to serialize agent config: {}", e).into()))?; + + tokio::fs::write(&config_file, config_json) + .await + .map_err(|e| ChatError::Custom(format!("Failed to write agent config file: {}", e).into()))?; + + Ok(()) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -3718,8 +3785,6 @@ mod tests { .expect("Tools failed to load"); ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, @@ -3848,8 +3913,6 @@ mod tests { .expect("Tools failed to load"); ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, @@ -3955,8 +4018,6 @@ mod tests { .expect("Tools failed to load"); ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, @@ -4033,8 +4094,6 @@ mod tests { .expect("Tools failed to load"); ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, @@ -4091,8 +4150,6 @@ mod tests { .expect("Tools failed to load"); ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, @@ -4196,8 +4253,6 @@ mod tests { // Test that PreToolUse hook runs ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "fake_conv_id", agents, None, // No initial input @@ -4332,8 +4387,6 @@ mod tests { // Run chat session - hook should block tool execution let result = ChatSession::new( &mut os, - std::io::stdout(), - std::io::stderr(), "test_conv_id", agents, None, @@ -4376,28 +4429,3 @@ mod tests { } } } - -// Helper method to save the agent config to file -async fn save_agent_config(os: &mut Os, config: &Agent, agent_name: &str, is_global: bool) -> Result<(), ChatError> { - let config_dir = if is_global { - directories::chat_global_agent_path(os) - .map_err(|e| ChatError::Custom(format!("Could not find global agent directory: {}", e).into()))? - } else { - directories::chat_local_agent_dir(os) - .map_err(|e| ChatError::Custom(format!("Could not find local agent directory: {}", e).into()))? - }; - - tokio::fs::create_dir_all(&config_dir) - .await - .map_err(|e| ChatError::Custom(format!("Failed to create config directory: {}", e).into()))?; - - let config_file = config_dir.join(format!("{}.json", agent_name)); - let config_json = serde_json::to_string_pretty(config) - .map_err(|e| ChatError::Custom(format!("Failed to serialize agent config: {}", e).into()))?; - - tokio::fs::write(&config_file, config_json) - .await - .map_err(|e| ChatError::Custom(format!("Failed to write agent config file: {}", e).into()))?; - - Ok(()) -} diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index a176bd39c..36fa167d5 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -21,6 +21,14 @@ use std::path::{ PathBuf, }; +use chat_cli_ui::conduit::{ + ControlEnd, + DestinationStdout, +}; +use chat_cli_ui::protocol::{ + Event, + ToolCallArgs, +}; use crossterm::queue; use crossterm::style::{ self, @@ -159,20 +167,51 @@ impl Tool { } /// Queues up a tool's intention in a human readable format - pub async fn queue_description(&self, os: &Os, output: &mut impl Write) -> Result<()> { - match self { - Tool::FsRead(fs_read) => fs_read.queue_description(os, output).await, - Tool::FsWrite(fs_write) => fs_write.queue_description(os, output), - Tool::ExecuteCommand(execute_command) => execute_command.queue_description(output), - Tool::UseAws(use_aws) => use_aws.queue_description(output), - Tool::Custom(custom_tool) => custom_tool.queue_description(output), - Tool::GhIssue(gh_issue) => gh_issue.queue_description(output), - Tool::Introspect(_) => Introspect::queue_description(output), - Tool::Knowledge(knowledge) => knowledge.queue_description(os, output).await, - Tool::Thinking(thinking) => thinking.queue_description(output), - Tool::Todo(_) => Ok(()), - Tool::Delegate(delegate) => delegate.queue_description(output), - } + pub async fn queue_description(&self, os: &Os, output: &mut ControlEnd) -> Result<()> { + if output.should_send_structured_event { + let mut buf = Vec::::new(); + + match self { + Tool::FsRead(fs_read) => fs_read.queue_description(os, &mut buf).await, + Tool::FsWrite(fs_write) => fs_write.queue_description(os, &mut buf), + Tool::ExecuteCommand(execute_command) => execute_command.queue_description(&mut buf), + Tool::UseAws(use_aws) => use_aws.queue_description(&mut buf), + Tool::Custom(custom_tool) => custom_tool.queue_description(&mut buf), + Tool::GhIssue(gh_issue) => gh_issue.queue_description(&mut buf), + Tool::Introspect(_) => Introspect::queue_description(&mut buf), + Tool::Knowledge(knowledge) => knowledge.queue_description(os, &mut buf).await, + Tool::Thinking(thinking) => thinking.queue_description(&mut buf), + Tool::Todo(_) => Ok(()), + Tool::Delegate(delegate) => delegate.queue_description(&mut buf), + }?; + + let tool_call_args = ToolCallArgs { + // We'll ignore this for now + tool_call_id: Default::default(), + delta: { + let sanitized = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf)); + serde_json::Value::String(sanitized) + }, + }; + + output.send(Event::ToolCallArgs(tool_call_args))?; + } else { + match self { + Tool::FsRead(fs_read) => fs_read.queue_description(os, output).await, + Tool::FsWrite(fs_write) => fs_write.queue_description(os, output), + Tool::ExecuteCommand(execute_command) => execute_command.queue_description(output), + Tool::UseAws(use_aws) => use_aws.queue_description(output), + Tool::Custom(custom_tool) => custom_tool.queue_description(output), + Tool::GhIssue(gh_issue) => gh_issue.queue_description(output), + Tool::Introspect(_) => Introspect::queue_description(output), + Tool::Knowledge(knowledge) => knowledge.queue_description(os, output).await, + Tool::Thinking(thinking) => thinking.queue_description(output), + Tool::Todo(_) => Ok(()), + Tool::Delegate(delegate) => delegate.queue_description(output), + }?; + }; + + Ok(()) } /// Validates the tool with the arguments supplied diff --git a/crates/chat-cli/src/database/settings.rs b/crates/chat-cli/src/database/settings.rs index e83c5d4b9..51f2cd073 100644 --- a/crates/chat-cli/src/database/settings.rs +++ b/crates/chat-cli/src/database/settings.rs @@ -88,6 +88,8 @@ pub enum Setting { EnabledCheckpoint, #[strum(message = "Enable the delegate tool for subagent management (boolean)")] EnabledDelegate, + #[strum(message = "Specify UI variant to use (string)")] + UiMode, } impl AsRef for Setting { @@ -129,6 +131,7 @@ impl AsRef for Setting { Self::EnabledCheckpoint => "chat.enableCheckpoint", Self::EnabledContextUsageIndicator => "chat.enableContextUsageIndicator", Self::EnabledDelegate => "chat.enableDelegate", + Self::UiMode => "chat.uiMode", } } } @@ -178,6 +181,7 @@ impl TryFrom<&str> for Setting { "chat.enableTodoList" => Ok(Self::EnabledTodoList), "chat.enableCheckpoint" => Ok(Self::EnabledCheckpoint), "chat.enableContextUsageIndicator" => Ok(Self::EnabledContextUsageIndicator), + "chat.uiMode" => Ok(Self::UiMode), _ => Err(DatabaseError::InvalidSetting(value.to_string())), } } diff --git a/crates/chat-cli/src/mcp_client/client.rs b/crates/chat-cli/src/mcp_client/client.rs index 6598fc71d..1225c3e35 100644 --- a/crates/chat-cli/src/mcp_client/client.rs +++ b/crates/chat-cli/src/mcp_client/client.rs @@ -458,7 +458,8 @@ impl McpClientService { .. } = &self.config; - let http_service_builder = HttpServiceBuilder::new(url, os, url, *timeout, scopes, headers, oauth, messenger); + let http_service_builder = + HttpServiceBuilder::new(url, os, url, *timeout, scopes, headers, oauth, messenger); let (service, auth_client_wrapper) = http_service_builder.try_build(&self).await?; diff --git a/crates/chat-cli/src/mcp_client/oauth_util.rs b/crates/chat-cli/src/mcp_client/oauth_util.rs index 5a2199def..a6485521d 100644 --- a/crates/chat-cli/src/mcp_client/oauth_util.rs +++ b/crates/chat-cli/src/mcp_client/oauth_util.rs @@ -201,6 +201,7 @@ pub struct HttpServiceBuilder<'a> { } impl<'a> HttpServiceBuilder<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( server_name: &'a str, os: &'a Os, @@ -527,7 +528,7 @@ async fn get_auth_manager_impl( .and_then(|cfg| cfg.redirect_uri.as_ref()) .and_then(|uri| { // Parse port from redirect_uri like "127.0.0.1:7778" or ":7778" - uri.split(':').last().and_then(|p| p.parse::().ok()) + uri.split(':').next_back().and_then(|p| p.parse::().ok()) }) .unwrap_or(0); // Port 0 = OS assigns random available port diff --git a/crates/chat-cli/src/theme/crossterm_ext.rs b/crates/chat-cli/src/theme/crossterm_ext.rs index c21b6d317..69e8c4b17 100644 --- a/crates/chat-cli/src/theme/crossterm_ext.rs +++ b/crates/chat-cli/src/theme/crossterm_ext.rs @@ -1,4 +1,5 @@ //! Crossterm extensions for the theme system +use chat_cli_ui::legacy_ui_util::ThemeSource; use crossterm::style::{ Attribute, Color, @@ -166,6 +167,100 @@ impl StyledText { } } +impl ThemeSource for StyledText { + fn error(&self, text: &str) -> String { + StyledText::error(text) + } + + fn info(&self, text: &str) -> String { + StyledText::info(text) + } + + fn emphasis(&self, text: &str) -> String { + StyledText::emphasis(text) + } + + fn command(&self, text: &str) -> String { + StyledText::command(text) + } + + fn prompt(&self, text: &str) -> String { + StyledText::prompt(text) + } + + fn profile(&self, text: &str) -> String { + StyledText::profile(text) + } + + fn tangent(&self, text: &str) -> String { + StyledText::tangent(text) + } + + fn usage_low(&self, text: &str) -> String { + StyledText::usage_low(text) + } + + fn usage_medium(&self, text: &str) -> String { + StyledText::usage_medium(text) + } + + fn usage_high(&self, text: &str) -> String { + StyledText::usage_high(text) + } + + fn brand(&self, text: &str) -> String { + StyledText::brand(text) + } + + fn primary(&self, text: &str) -> String { + StyledText::primary(text) + } + + fn secondary(&self, text: &str) -> String { + StyledText::secondary(text) + } + + fn success(&self, text: &str) -> String { + StyledText::success(text) + } + + fn error_fg(&self) -> SetForegroundColor { + StyledText::error_fg() + } + + fn warning_fg(&self) -> SetForegroundColor { + StyledText::warning_fg() + } + + fn success_fg(&self) -> SetForegroundColor { + StyledText::success_fg() + } + + fn info_fg(&self) -> SetForegroundColor { + StyledText::info_fg() + } + + fn brand_fg(&self) -> SetForegroundColor { + StyledText::brand_fg() + } + + fn secondary_fg(&self) -> SetForegroundColor { + StyledText::secondary_fg() + } + + fn emphasis_fg(&self) -> SetForegroundColor { + StyledText::emphasis_fg() + } + + fn reset(&self) -> ResetColor { + StyledText::reset() + } + + fn reset_attributes(&self) -> SetAttribute { + StyledText::reset_attributes() + } +} + /// Convert a crossterm Color to ANSI color code fn color_to_ansi_code(color: Color) -> u8 { match color { diff --git a/crates/chat-cli/src/util/file_uri.rs b/crates/chat-cli/src/util/file_uri.rs index 303d0aa3c..37b402cd5 100644 --- a/crates/chat-cli/src/util/file_uri.rs +++ b/crates/chat-cli/src/util/file_uri.rs @@ -1,5 +1,8 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{ + Path, + PathBuf, +}; use eyre::Result; use thiserror::Error; @@ -18,7 +21,8 @@ pub enum FileUriError { /// /// # Arguments /// * `uri` - The file:// URI to resolve -/// * `base_path` - Base path for resolving relative URIs (typically the agent config file directory) +/// * `base_path` - Base path for resolving relative URIs (typically the agent config file +/// directory) /// /// # Returns /// The content of the file as a String @@ -56,19 +60,20 @@ pub fn resolve_file_uri(uri: &str, base_path: &Path) -> Result Resu } Ok(()) } + +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UiMode { + #[default] + Structured, + Passthrough, + New, +} + +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") +}