diff --git a/Cargo.lock b/Cargo.lock index 2bec540..c5c3800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,7 @@ dependencies = [ "tracing-error", "tracing-subscriber", "tui-textarea", + "uuid", ] [[package]] diff --git a/crates/coco-tui/Cargo.toml b/crates/coco-tui/Cargo.toml index cd6995f..e53ccfc 100644 --- a/crates/coco-tui/Cargo.toml +++ b/crates/coco-tui/Cargo.toml @@ -54,6 +54,7 @@ bon.workspace = true ctor.workspace = true lazy_static.workspace = true bitflags = "2.10.0" +uuid = { version = "1.11.0", features = ["v4"] } indoc.workspace = true time = { workspace = true, features = [ "macros", diff --git a/crates/coco-tui/src/components/chat.rs b/crates/coco-tui/src/components/chat.rs index 3ff1dd2..1fefdd6 100644 --- a/crates/coco-tui/src/components/chat.rs +++ b/crates/coco-tui/src/components/chat.rs @@ -33,6 +33,7 @@ use time::OffsetDateTime; use tokio::time::Instant; use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; +use uuid::Uuid; use super::{ Action, AnswerEvent, AskEvent, BotMessage, BotStreamKind, CacheInvalidation, Combo, @@ -488,17 +489,7 @@ impl Chat<'static> { tokio::task::spawn(task_combo_discover(cancel_token)); } - fn spawn_combo_execute(&mut self, name: String, args: Vec) { - // Generate a unique tool use id - let id = format!( - "toolu_{:016x}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - % (1u128 << 64) - ); - + fn spawn_combo_execute(&mut self, id: String, name: String, args: Vec) { // Create tool use for run_combo let tool_use = ToolUse { id, @@ -547,18 +538,24 @@ impl Chat<'static> { } } - fn combo_tool_event_to_combo_event(&self, event: &ComboToolEvent) -> ComboEvent { + fn combo_tool_event_to_combo_event(&self, id: &str, event: &ComboToolEvent) -> ComboEvent { match event { - ComboToolEvent::NotFound { name } => ComboEvent::NotFound { name: name.clone() }, + ComboToolEvent::NotFound { name } => ComboEvent::NotFound { + id: id.to_string(), + name: name.clone(), + }, ComboToolEvent::Executing { name, command_line } => ComboEvent::Executing { + id: id.to_string(), name: name.clone(), command_line: command_line.clone(), }, ComboToolEvent::Output { name, chunk } => ComboEvent::Output { + id: id.to_string(), name: name.clone(), chunk: chunk.clone(), }, ComboToolEvent::RecordStart { name, tool_use } => ComboEvent::RecordStart { + id: id.to_string(), name: name.clone(), tool_use: tool_use.clone(), }, @@ -567,6 +564,7 @@ impl Chat<'static> { tool_use_id, chunk, } => ComboEvent::RecordOutput { + id: id.to_string(), name: name.clone(), tool_use_id: tool_use_id.clone(), chunk: chunk.clone(), @@ -577,6 +575,7 @@ impl Chat<'static> { is_error, output, } => ComboEvent::RecordEnd { + id: id.to_string(), name: name.clone(), tool_use_id: tool_use_id.clone(), is_error: *is_error, @@ -587,6 +586,7 @@ impl Chat<'static> { prompt, thinking, } => ComboEvent::Prompt { + id: id.to_string(), name: name.clone(), prompt: prompt.clone(), thinking: thinking.clone(), @@ -597,6 +597,7 @@ impl Chat<'static> { kind, text, } => ComboEvent::PromptStream { + id: id.to_string(), name: name.clone(), index: *index, kind: match kind { @@ -611,6 +612,7 @@ impl Chat<'static> { thinking, offload, } => ComboEvent::ReplyToolUse { + id: id.to_string(), name: name.clone(), tool_use: tool_use.clone(), thinking: thinking.clone(), @@ -622,6 +624,7 @@ impl Chat<'static> { is_error, output, } => ComboEvent::ReplyToolResult { + id: id.to_string(), name: name.clone(), tool_use_id: tool_use_id.clone(), is_error: *is_error, @@ -635,11 +638,15 @@ impl Chat<'static> { starter, exit_code, } => ComboEvent::Executed { + id: id.to_string(), name: name.clone(), starter: starter.clone(), exit_code: *exit_code, }, - ComboToolEvent::Cancelled { name } => ComboEvent::Cancelled { name: name.clone() }, + ComboToolEvent::Cancelled { name } => ComboEvent::Cancelled { + id: Some(id.to_string()), + name: name.clone(), + }, } } @@ -1215,7 +1222,7 @@ impl Component for Chat<'static> { serde_json::from_value::(tool_use.input.clone()) { combo_tool_ids.push(tool_use.id.clone()); - Message::bot(Combo::new(&input.combo_name).into()) + Message::bot(Combo::new(&tool_use.id, &input.combo_name).into()) } else { Message::bot(Tool::new(tool_use.to_owned()).into()) } @@ -1308,7 +1315,7 @@ impl Component for Chat<'static> { global::trigger_schedule_session_save(); } Event::Answer(AnswerEvent::ComboToolEvent { id, event }) => { - let combo_event = self.combo_tool_event_to_combo_event(event); + let combo_event = self.combo_tool_event_to_combo_event(id, event); if !self.combo_tool_messages.contains(id) { self.pending_combo_tool_events .entry(id.clone()) @@ -1490,9 +1497,10 @@ impl Component for Chat<'static> { self.spawn_combo_discover(); } ComboAction::Execute { name, args } => { - let combo = Combo::new(name); + let id = format!("toolu_{}", Uuid::new_v4().as_simple()); + let combo = Combo::new(&id, name); self.messages.push(Message::user(combo.into())); - self.spawn_combo_execute(name.clone(), args.clone()); + self.spawn_combo_execute(id, name.clone(), args.clone()); debug!("Combo message pushed"); } }, @@ -1709,6 +1717,7 @@ async fn discover_combo_starters( if result.cancelled || cancel_token.is_cancelled() { tx.send( ComboEvent::Cancelled { + id: None, name: name.map(str::to_string), } .into(), diff --git a/crates/coco-tui/src/components/messages/combo.rs b/crates/coco-tui/src/components/messages/combo.rs index 9afe3e4..95a7184 100644 --- a/crates/coco-tui/src/components/messages/combo.rs +++ b/crates/coco-tui/src/components/messages/combo.rs @@ -58,6 +58,7 @@ enum ComboView { #[derive(Serialize, Deserialize)] struct Inner { + tool_use_id: String, name: String, #[serde(default)] command_line: String, @@ -74,6 +75,7 @@ struct Inner { impl Default for Inner { fn default() -> Self { Self { + tool_use_id: String::new(), name: String::new(), command_line: String::new(), is_error: false, @@ -114,9 +116,10 @@ fn default_preview_lines() -> StreamedLines { } impl Combo { - pub fn new(name: &str) -> Self { + pub fn new(tool_use_id: &str, name: &str) -> Self { Self { state: State::new(Inner { + tool_use_id: tool_use_id.to_string(), name: name.to_string(), starter_state: StarterState::Executing, ..Default::default() @@ -134,6 +137,10 @@ impl Combo { } } + fn matches_id(&self, id: &str) -> bool { + self.state.tool_use_id == id + } + fn has_collapsible_body(&self) -> bool { matches!(self.state.starter_state, StarterState::Finalized) && self.has_body_content() } @@ -303,14 +310,14 @@ impl Combo { fn on_combo_event(&mut self, event: &ComboEvent) { match event { - ComboEvent::NotFound { name } => { - if &self.state.name == name { + ComboEvent::NotFound { id, .. } => { + if self.matches_id(id) { self.state.write().starter_state = StarterState::NotFound } } - ComboEvent::Output { name, chunk } => self.on_ouput_event(name, chunk, None), - ComboEvent::RecordStart { name, tool_use } => { - if &self.state.name == name { + ComboEvent::Output { id, chunk, .. } => self.on_ouput_event(id, chunk, None), + ComboEvent::RecordStart { id, tool_use, .. } => { + if self.matches_id(id) { self.is_recording = true; self.combo_stream_suppressed = true; self.clear_combo_stream(); @@ -319,17 +326,19 @@ impl Combo { } } ComboEvent::RecordOutput { - name, + id, tool_use_id, chunk, - } => self.on_ouput_event(name, chunk, Some(tool_use_id.as_str())), + .. + } => self.on_ouput_event(id, chunk, Some(tool_use_id.as_str())), ComboEvent::RecordEnd { - name, + id, tool_use_id, is_error, output, + .. } => { - if &self.state.name == name { + if self.matches_id(id) { self.forward_result_to_child(tool_use_id, *is_error, output.clone()); self.has_child_output = false; self.is_recording = false; @@ -337,31 +346,33 @@ impl Combo { self.clear_combo_stream(); } } - ComboEvent::Prompt { name, prompt, .. } => { - if &self.state.name == name { + ComboEvent::Prompt { id, prompt, .. } => { + if self.matches_id(id) { self.messages.finalize_stream(); self.messages.reset_stream(); self.push_prompt(prompt); } } ComboEvent::PromptStream { - name, + id, index, kind, text, + .. } => { - if &self.state.name == name { + if self.matches_id(id) { self.messages .append_stream_text(*index, *kind, text.clone()); } } ComboEvent::ReplyToolUse { - name, + id, tool_use, thinking, offload, + .. } => { - if &self.state.name == name { + if self.matches_id(id) { self.messages.finalize_stream(); self.messages.reset_stream(); for block in thinking { @@ -375,17 +386,20 @@ impl Combo { } } ComboEvent::ReplyToolResult { - name, + id, tool_use_id, is_error, output, + .. } => { - if &self.state.name == name { + if self.matches_id(id) { self.forward_result_to_child(tool_use_id, *is_error, output.clone()); } } - ComboEvent::Executing { name, command_line } => { - if &self.state.name == name { + ComboEvent::Executing { + id, command_line, .. + } => { + if self.matches_id(id) { self.clear_child_focus(); self.has_child_output = false; self.is_recording = false; @@ -404,11 +418,12 @@ impl Combo { } } ComboEvent::Executed { - name, + id, starter, exit_code, + .. } => { - if &self.state.name == name { + if self.matches_id(id) { let mut state = self.state.write(); let error_message = match (&starter.combo, *exit_code) { (Err(err), _) => { @@ -435,12 +450,8 @@ impl Combo { self.has_child_output = false; } } - ComboEvent::Cancelled { name } => { - if name - .as_ref() - .map(|name| name == &self.state.name) - .unwrap_or(true) - { + ComboEvent::Cancelled { id, .. } => { + if id.as_ref().map(|id| self.matches_id(id)).unwrap_or(true) { self.clear_child_focus(); self.has_child_output = false; self.is_recording = false; @@ -463,8 +474,8 @@ impl Combo { } } - fn on_ouput_event(&mut self, name: &str, chunk: &OutputChunk, tool_use_id: Option<&str>) { - if self.state.name != name { + fn on_ouput_event(&mut self, id: &str, chunk: &OutputChunk, tool_use_id: Option<&str>) { + if !self.matches_id(id) { return; } if let Some(tool_use_id) = tool_use_id @@ -1046,6 +1057,9 @@ mod tests { use super::*; use code_combo::tools::{BASH_TOOL_NAME, BashInput}; + const TEST_ID: &str = "test_combo_id"; + const TEST_NAME: &str = "demo"; + fn test_key_z() -> KeyEvent { KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE) } @@ -1081,15 +1095,17 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_is_collapsed_by_default_and_toggles_with_z() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Prompt { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), prompt: "line1".to_string(), thinking: None, })); combo.handle_event(&Event::Combo(ComboEvent::Executed { - name: "demo".to_string(), - starter: make_starter("demo"), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), + starter: make_starter(TEST_NAME), exit_code: None, })); @@ -1104,13 +1120,15 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_is_visible_while_executing() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Executing { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), command_line: "demo".to_string(), })); combo.handle_event(&Event::Combo(ComboEvent::Output { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), chunk: code_combo::OutputChunk { timestamp: 0, stream: code_combo::StreamKind::Stdout, @@ -1125,15 +1143,17 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_persists_collapsed_state() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Prompt { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), prompt: "line1".to_string(), thinking: None, })); combo.handle_event(&Event::Combo(ComboEvent::Executed { - name: "demo".to_string(), - starter: make_starter("demo"), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), + starter: make_starter(TEST_NAME), exit_code: None, })); combo.handle_action(&Action::Blur); @@ -1147,10 +1167,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_marks_error_on_nonzero_exit() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Executed { - name: "demo".to_string(), - starter: make_starter("demo"), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), + starter: make_starter(TEST_NAME), exit_code: Some(1), })); @@ -1159,34 +1180,40 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_enters_and_exits_actionable_messages_with_enter_and_esc() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Executing { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), command_line: "demo".to_string(), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use: make_tool_use("combo_record_demo_0", "echo 1"), })); combo.handle_event(&Event::Combo(ComboEvent::RecordEnd { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_0".to_string(), is_error: false, output: Final::Message("ok".to_string()), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use: make_tool_use("combo_record_demo_1", "echo 2"), })); combo.handle_event(&Event::Combo(ComboEvent::RecordEnd { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_1".to_string(), is_error: false, output: Final::Message("ok".to_string()), })); combo.handle_event(&Event::Combo(ComboEvent::Executed { - name: "demo".to_string(), - starter: make_starter("demo"), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), + starter: make_starter(TEST_NAME), exit_code: None, })); @@ -1204,34 +1231,40 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_moves_actionable_messages_with_jk() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); combo.handle_event(&Event::Combo(ComboEvent::Executing { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), command_line: "demo".to_string(), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use: make_tool_use("combo_record_demo_0", "echo 1"), })); combo.handle_event(&Event::Combo(ComboEvent::RecordEnd { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_0".to_string(), is_error: false, output: Final::Message("ok".to_string()), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use: make_tool_use("combo_record_demo_1", "echo 2"), })); combo.handle_event(&Event::Combo(ComboEvent::RecordEnd { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_1".to_string(), is_error: false, output: Final::Message("ok".to_string()), })); combo.handle_event(&Event::Combo(ComboEvent::Executed { - name: "demo".to_string(), - starter: make_starter("demo"), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), + starter: make_starter(TEST_NAME), exit_code: None, })); @@ -1247,19 +1280,22 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_routes_output_to_child_component() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); let tool_use = make_tool_use("combo_record_demo_0", "echo 1"); combo.handle_event(&Event::Combo(ComboEvent::Executing { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), command_line: "demo".to_string(), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use, })); combo.handle_event(&Event::Combo(ComboEvent::RecordOutput { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_0".to_string(), chunk: code_combo::OutputChunk { timestamp: 0, @@ -1275,19 +1311,22 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn combo_suppresses_stream_until_new_output_after_record() { - let mut combo = Combo::new("demo"); + let mut combo = Combo::new(TEST_ID, TEST_NAME); let tool_use = make_tool_use("combo_record_demo_0", "echo 1"); combo.handle_event(&Event::Combo(ComboEvent::Executing { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), command_line: "demo".to_string(), })); combo.handle_event(&Event::Combo(ComboEvent::RecordStart { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use, })); combo.handle_event(&Event::Combo(ComboEvent::Output { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), chunk: code_combo::OutputChunk { timestamp: 0, stream: code_combo::StreamKind::Stdout, @@ -1300,7 +1339,8 @@ mod tests { assert!(combo.preview_lines.is_empty()); combo.handle_event(&Event::Combo(ComboEvent::RecordEnd { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), tool_use_id: "combo_record_demo_0".to_string(), is_error: false, output: Final::Message("ok".to_string()), @@ -1311,7 +1351,8 @@ mod tests { assert!(combo.preview_lines.is_empty()); combo.handle_event(&Event::Combo(ComboEvent::Output { - name: "demo".to_string(), + id: TEST_ID.to_string(), + name: TEST_NAME.to_string(), chunk: code_combo::OutputChunk { timestamp: 0, stream: code_combo::StreamKind::Stdout, diff --git a/crates/coco-tui/src/events.rs b/crates/coco-tui/src/events.rs index 58d89b0..2523fa8 100644 --- a/crates/coco-tui/src/events.rs +++ b/crates/coco-tui/src/events.rs @@ -88,34 +88,41 @@ pub enum ComboEvent { }, Executing { + id: String, name: String, command_line: String, }, RecordStart { + id: String, name: String, tool_use: ToolUse, }, Output { + id: String, name: String, chunk: OutputChunk, }, RecordOutput { + id: String, name: String, tool_use_id: String, chunk: OutputChunk, }, RecordEnd { + id: String, name: String, tool_use_id: String, is_error: bool, output: Final, }, Prompt { + id: String, name: String, prompt: String, thinking: Option, }, PromptStream { + id: String, name: String, index: usize, kind: BotStreamKind, @@ -123,6 +130,7 @@ pub enum ComboEvent { }, /// Reply tool use from prompt, with optional offload via bash ReplyToolUse { + id: String, name: String, tool_use: ToolUse, thinking: Vec, @@ -131,12 +139,14 @@ pub enum ComboEvent { }, /// Result of offload reply bash execution ReplyToolResult { + id: String, name: String, tool_use_id: String, is_error: bool, output: Final, }, Executed { + id: String, name: String, starter: Starter, exit_code: Option, @@ -147,9 +157,11 @@ pub enum ComboEvent { }, NotFound { + id: String, name: String, }, Cancelled { + id: Option, name: Option, }, }