diff --git a/Cargo.lock b/Cargo.lock index a67db7a6f1..c8e51095f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image 0.25.5", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -2188,6 +2208,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2713,6 +2743,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.0.7", + "windows-link 0.2.1", +] + [[package]] name = "getopts" version = "0.2.24" @@ -3108,6 +3148,7 @@ name = "goose-tui" version = "1.14.0" dependencies = [ "anyhow", + "arboard", "axum 0.8.1", "chrono", "clap", @@ -4737,13 +4778,49 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4752,12 +4829,24 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.9.0", "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -8589,6 +8678,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.2.0" @@ -8624,7 +8719,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -8643,7 +8738,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -8734,7 +8829,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -9005,6 +9100,23 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.0.7", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.5.0" diff --git a/crates/goose-tui/Cargo.toml b/crates/goose-tui/Cargo.toml index 7a8f8c7b37..05a18ddb51 100644 --- a/crates/goose-tui/Cargo.toml +++ b/crates/goose-tui/Cargo.toml @@ -40,6 +40,7 @@ syntect = "5.2" chrono = { version = "0.4", features = ["serde"] } termimad = "0.34.0" unicode-width = "0.2.0" +arboard = "3.6.1" [dev-dependencies] tokio = { version = "1.43", features = ["full"] } diff --git a/crates/goose-tui/src/components/popups/help.rs b/crates/goose-tui/src/components/popups/help.rs index 788c2beee6..7c9e439a42 100644 --- a/crates/goose-tui/src/components/popups/help.rs +++ b/crates/goose-tui/src/components/popups/help.rs @@ -49,6 +49,7 @@ impl Component for HelpPopup { Line::from(" Esc Switch to Normal mode / Close popups"), Line::from(" i Switch to Editing mode"), Line::from(" j / k Scroll / Navigate"), + Line::from(" Ctrl+S Toggle copy mode (disable mouse capture)"), Line::from(" Ctrl+T Toggle Todo List"), Line::from(" Ctrl+C Interrupt / Clear / Quit"), Line::from(""), @@ -57,6 +58,7 @@ impl Component for HelpPopup { Style::default().add_modifier(Modifier::UNDERLINED), )), Line::from(" /help Show this help"), + Line::from(" /copy Toggle copy mode"), Line::from(" /config Open configuration"), Line::from(" /session Open session picker"), Line::from(" /theme Change theme (e.g. /theme light)"), diff --git a/crates/goose-tui/src/components/popups/message.rs b/crates/goose-tui/src/components/popups/message.rs index 2960901287..7e39852d55 100644 --- a/crates/goose-tui/src/components/popups/message.rs +++ b/crates/goose-tui/src/components/popups/message.rs @@ -21,6 +21,8 @@ pub struct MessagePopup { last_scroll_time: Option, content_height: u16, viewport_height: u16, + cached_message_idx: Option, + cached_plain_text: String, } impl Default for MessagePopup { @@ -36,6 +38,8 @@ impl MessagePopup { last_scroll_time: None, content_height: 0, viewport_height: 0, + cached_message_idx: None, + cached_plain_text: String::new(), } } @@ -47,6 +51,55 @@ impl MessagePopup { } } + fn content_as_plain_text(message: &goose::conversation::message::Message) -> String { + let mut lines = Vec::new(); + + for content in &message.content { + match content { + MessageContent::Text(t) => { + lines.push(t.text.clone()); + } + MessageContent::ToolRequest(req) => { + lines.push("Tool Request:".to_string()); + if let Ok(call) = &req.tool_call { + lines.push(format!("Name: {}", call.name)); + if let Some(args) = &call.arguments { + if let Ok(json_str) = serde_json::to_string_pretty(args) { + lines.push(json_str); + } + } + } + } + MessageContent::ToolResponse(resp) => { + lines.push("Tool Output:".to_string()); + if let Ok(contents) = &resp.tool_result { + for content in contents { + if let rmcp::model::Content { + raw: rmcp::model::RawContent::Text(text_content), + .. + } = content + { + let text = &text_content.text; + let display_string = if let Ok(v) = + serde_json::from_str::(text) + { + serde_json::to_string_pretty(&v) + .unwrap_or_else(|_| text.to_string()) + } else { + text.to_string() + }; + lines.push(display_string); + } + } + } + } + _ => {} + } + lines.push(String::new()); + } + lines.join("\n") + } + fn render_content( &self, message: &goose::conversation::message::Message, @@ -113,6 +166,14 @@ impl MessagePopup { } lines } + + fn copy_to_clipboard(&self) -> Result<(), String> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("Clipboard error: {e}"))?; + clipboard + .set_text(&self.cached_plain_text) + .map_err(|e| format!("Clipboard error: {e}")) + } } impl Component for MessagePopup { @@ -120,6 +181,12 @@ impl Component for MessagePopup { match event { Event::Input(key) => match key.code { KeyCode::Esc | KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), + KeyCode::Char('c') => { + return match self.copy_to_clipboard() { + Ok(()) => Ok(Some(Action::ShowFlash("Copied to clipboard".to_string()))), + Err(e) => Ok(Some(Action::ShowFlash(e))), + }; + } KeyCode::Char('j') | KeyCode::Down => { self.scroll = self.scroll.saturating_add(1).min(self.max_scroll()); self.last_scroll_time = Some(Instant::now()); @@ -157,18 +224,23 @@ impl Component for MessagePopup { None => return, }; + if self.cached_message_idx != Some(msg_idx) { + self.cached_message_idx = Some(msg_idx); + self.cached_plain_text = Self::content_as_plain_text(message); + self.scroll = 0; + } + let area = centered_rect(80, 80, area); f.render_widget(Clear, area); let lines = self.render_content(message); let block = Block::default() - .title("Message Details (Esc to Close)") + .title("Message Details (Esc to close, c to copy)") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().bg(state.config.theme.base.background)); - // Calculate height with wrapping estimation let inner_width = area.width.saturating_sub(2).max(1); let mut wrapped_height = 0; for line in &lines {