diff --git a/Cargo.lock b/Cargo.lock index 3c95598e59..aee93a9cc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,9 +1719,9 @@ dependencies = [ [[package]] name = "console" -version = "0.15.10" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", "libc", @@ -3161,32 +3161,41 @@ dependencies = [ name = "fig_util" version = "1.8.0" dependencies = [ + "anyhow", "appkit-nsworkspace-bindings", + "async-trait", "bstr", "camino", "cfg-if", "clap", + "console", "core-foundation 0.10.0", + "crossterm", "dirs", "fig_os_shim", "fig_test", + "filedescriptor", "hex", "indoc", "insta", "libc", "macos-utils", + "nix 0.26.4", "nix 0.29.0", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "paste", + "portable-pty", "rand 0.9.0", "regex", "serde", "serde_json", "sha2", + "shlex", "strum 0.27.1", "sysinfo 0.32.1", + "termios 0.3.3", "thiserror 2.0.12", "time", "tokio", @@ -5069,6 +5078,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -5309,6 +5327,19 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -6857,6 +6888,7 @@ dependencies = [ "owo-colors 4.2.0", "parking_lot", "paste", + "portable-pty", "predicates", "rand 0.9.0", "regex", @@ -7912,7 +7944,7 @@ dependencies = [ "ioctl-rs", "libc", "serial-core", - "termios", + "termios 0.2.2", ] [[package]] @@ -8581,6 +8613,15 @@ dependencies = [ "libc", ] +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" diff --git a/crates/fig_util/Cargo.toml b/crates/fig_util/Cargo.toml index 255dd9c0d3..4f795c33be 100644 --- a/crates/fig_util/Cargo.toml +++ b/crates/fig_util/Cargo.toml @@ -14,15 +14,24 @@ workspace = true default = [] [dependencies] +anyhow.workspace = true +shlex.workspace = true +async-trait.workspace = true camino.workspace = true cfg-if.workspace = true clap.workspace = true +crossterm.workspace = true dirs.workspace = true fig_os_shim.workspace = true hex.workspace = true indoc.workspace = true libc.workspace = true paste = "1.0.11" +portable-pty.workspace = true +termios = "0.3" +nix = "0.26" +filedescriptor = "0.8.3" +console = "0.15.11" rand.workspace = true regex.workspace = true serde.workspace = true diff --git a/crates/fig_util/src/lib.rs b/crates/fig_util/src/lib.rs index d77d933b91..a049e23484 100644 --- a/crates/fig_util/src/lib.rs +++ b/crates/fig_util/src/lib.rs @@ -1,15 +1,15 @@ +pub mod consts; pub mod directories; +#[cfg(target_os = "macos")] +pub mod launchd_plist; pub mod manifest; mod open; pub mod process_info; -mod shell; +pub mod pty; +pub mod shell; pub mod system_info; pub mod terminal; -pub mod consts; -#[cfg(target_os = "macos")] -pub mod launchd_plist; - use std::cmp::Ordering; use std::path::{ Path, diff --git a/crates/figterm/src/pty/cmdbuilder.rs b/crates/fig_util/src/pty/cmdbuilder.rs similarity index 100% rename from crates/figterm/src/pty/cmdbuilder.rs rename to crates/fig_util/src/pty/cmdbuilder.rs diff --git a/crates/figterm/src/pty/mod.rs b/crates/fig_util/src/pty/mod.rs similarity index 100% rename from crates/figterm/src/pty/mod.rs rename to crates/fig_util/src/pty/mod.rs diff --git a/crates/figterm/src/pty/unix.rs b/crates/fig_util/src/pty/unix.rs similarity index 100% rename from crates/figterm/src/pty/unix.rs rename to crates/fig_util/src/pty/unix.rs diff --git a/crates/figterm/src/pty/win/mod.rs b/crates/fig_util/src/pty/win/mod.rs similarity index 100% rename from crates/figterm/src/pty/win/mod.rs rename to crates/fig_util/src/pty/win/mod.rs diff --git a/crates/figterm/src/pty/win/procthreadattr.rs b/crates/fig_util/src/pty/win/procthreadattr.rs similarity index 100% rename from crates/figterm/src/pty/win/procthreadattr.rs rename to crates/fig_util/src/pty/win/procthreadattr.rs diff --git a/crates/figterm/src/pty/win/pseudocon.rs b/crates/fig_util/src/pty/win/pseudocon.rs similarity index 100% rename from crates/figterm/src/pty/win/pseudocon.rs rename to crates/fig_util/src/pty/win/pseudocon.rs diff --git a/crates/fig_util/src/terminal.rs b/crates/fig_util/src/terminal.rs index e60f60b54a..9afda83f37 100644 --- a/crates/fig_util/src/terminal.rs +++ b/crates/fig_util/src/terminal.rs @@ -2,12 +2,17 @@ use std::borrow::Cow; use std::fmt; use std::sync::OnceLock; +use crossterm::event::{ + KeyCode, + KeyEvent, + KeyModifiers, +}; use fig_os_shim::Context; +use portable_pty::PtySize; use serde::{ Deserialize, Serialize, }; - /// Terminals that macOS supports pub const MACOS_TERMINALS: &[Terminal] = &[ Terminal::Alacritty, @@ -793,6 +798,58 @@ impl IntelliJVariant { } } +pub fn get_terminal_size() -> PtySize { + match crossterm::terminal::size() { + Ok((cols, rows)) => PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }, + Err(_) => { + // Fall back to default size + PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + } + }, + } +} + +pub fn key_event_to_bytes(key: KeyEvent) -> Vec { + match key.code { + KeyCode::Char(c) => { + // Handle Ctrl+key combinations + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Convert to control character (ASCII control chars are 1-26) + if c.is_ascii_lowercase() { + return vec![(c as u8) - b'a' + 1]; + } else if c.is_ascii_uppercase() { + return vec![(c as u8) - b'A' + 1]; + } + } + // Regular character + c.to_string().into_bytes() + }, + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![b'\x7f'], + KeyCode::Esc => vec![b'\x1b'], + KeyCode::Tab => vec![b'\t'], + KeyCode::Up => vec![b'\x1b', b'[', b'A'], + KeyCode::Down => vec![b'\x1b', b'[', b'B'], + KeyCode::Right => vec![b'\x1b', b'[', b'C'], + KeyCode::Left => vec![b'\x1b', b'[', b'D'], + KeyCode::Home => vec![b'\x1b', b'[', b'H'], + KeyCode::End => vec![b'\x1b', b'[', b'F'], + KeyCode::Delete => vec![b'\x1b', b'[', b'3', b'~'], + KeyCode::PageUp => vec![b'\x1b', b'[', b'5', b'~'], + KeyCode::PageDown => vec![b'\x1b', b'[', b'6', b'~'], + _ => vec![], // Ignore other keys for now + } +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/figterm/src/main.rs b/crates/figterm/src/main.rs index 40c9044d82..f39cfe3ced 100644 --- a/crates/figterm/src/main.rs +++ b/crates/figterm/src/main.rs @@ -9,7 +9,6 @@ pub mod interceptor; pub mod ipc; pub mod logger; mod message; -pub mod pty; pub mod term; pub mod update; @@ -79,6 +78,15 @@ use fig_util::process_info::{ Pid, PidExt, }; +#[cfg(unix)] +use fig_util::pty::unix::open_pty; +#[cfg(windows)] +use fig_util::pty::win::open_pty; +use fig_util::pty::{ + AsyncMasterPty, + AsyncMasterPtyExt, + CommandBuilder, +}; use fig_util::{ PRODUCT_NAME, PTY_BINARY_NAME, @@ -126,14 +134,6 @@ use crate::message::{ process_figterm_message, process_remote_message, }; -#[cfg(unix)] -use crate::pty::unix::open_pty; -#[cfg(windows)] -use crate::pty::win::open_pty; -use crate::pty::{ - AsyncMasterPtyExt, - CommandBuilder, -}; use crate::term::{ SystemTerminal, Terminal, @@ -239,7 +239,7 @@ async fn _should_install_remote_ssh_integration( remote_receiver: Receiver, remote_sender: Sender, term: &Term, - pty_master: &mut Box, + pty_master: &mut Box, key_interceptor: &mut KeyInterceptor, ) -> Option { use fig_proto::remote::clientbound; diff --git a/crates/figterm/src/message.rs b/crates/figterm/src/message.rs index 2ef00aba1b..8ef491b28f 100644 --- a/crates/figterm/src/message.rs +++ b/crates/figterm/src/message.rs @@ -35,6 +35,7 @@ use fig_proto::remote::{ hostbound, }; use fig_util::env_var::PROCESS_LAUNCHED_BY_Q; +use fig_util::pty::AsyncMasterPty; use flume::Sender; use tokio::process::Command; use tracing::{ @@ -47,7 +48,6 @@ use tracing::{ use crate::event_handler::EventHandler; use crate::history::HistorySender; use crate::interceptor::KeyInterceptor; -use crate::pty::AsyncMasterPty; use crate::{ EXPECTED_BUFFER, INSERT_ON_NEW_CMD, diff --git a/crates/q_cli/Cargo.toml b/crates/q_cli/Cargo.toml index 43ea84785f..136e77f924 100644 --- a/crates/q_cli/Cargo.toml +++ b/crates/q_cli/Cargo.toml @@ -62,6 +62,7 @@ indoc.workspace = true mimalloc.workspace = true owo-colors = "4.2.0" parking_lot.workspace = true +portable-pty.workspace = true rand.workspace = true regex.workspace = true rustyline = { version = "15.0.0", features = ["derive", "custom-bindings"] } diff --git a/crates/q_cli/src/cli/chat/hooks.rs b/crates/q_cli/src/cli/chat/hooks.rs index 5b42427228..4851537f51 100644 --- a/crates/q_cli/src/cli/chat/hooks.rs +++ b/crates/q_cli/src/cli/chat/hooks.rs @@ -1,12 +1,10 @@ use std::collections::HashMap; use std::io::Write; -use std::process::Stdio; use std::time::{ Duration, Instant, }; -use bstr::ByteSlice; use crossterm::style::{ Color, Stylize, @@ -35,7 +33,7 @@ use spinners::{ Spinners, }; -use super::util::truncate_safe; +use super::tools::execute_bash::ExecuteBash; const DEFAULT_TIMEOUT_MS: u64 = 30_000; const DEFAULT_MAX_OUTPUT_SIZE: usize = 1024 * 10; @@ -294,34 +292,20 @@ impl HookExecutor { async fn execute_inline_hook(&self, hook: &Hook) -> Result { let command = hook.command.as_ref().ok_or_else(|| eyre!("no command specified"))?; + let execute_bash = ExecuteBash { + command: command.clone(), + }; - let command_future = tokio::process::Command::new("bash") - .arg("-c") - .arg(command) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output(); + let command_future = execute_bash.execute_pty_without_input(hook.max_output_size, false); let timeout = Duration::from_millis(hook.timeout_ms); // Run with timeout match tokio::time::timeout(timeout, command_future).await { Ok(result) => { let result = result?; - if result.status.success() { - let stdout = result.stdout.to_str_lossy(); - let stdout = format!( - "{}{}", - truncate_safe(&stdout, hook.max_output_size), - if stdout.len() > hook.max_output_size { - " ... truncated" - } else { - "" - } - ); - Ok(stdout) - } else { - Err(eyre!("command returned non-zero exit code: {}", result.status)) + match result.exit_status { + 0 => Ok(result.stdout), + code => Err(eyre!("command returned non-zero exit code: {}", code)), } }, Err(_) => Err(eyre!("command timed out after {} ms", timeout.as_millis())), diff --git a/crates/q_cli/src/cli/chat/tools/execute_bash.rs b/crates/q_cli/src/cli/chat/tools/execute_bash.rs index ed6013d566..6aae0705d2 100644 --- a/crates/q_cli/src/cli/chat/tools/execute_bash.rs +++ b/crates/q_cli/src/cli/chat/tools/execute_bash.rs @@ -1,25 +1,36 @@ use std::collections::VecDeque; use std::io::Write; -use std::process::{ - ExitStatus, - Stdio, -}; use std::str::from_utf8; +use std::sync::Arc; +use std::time::Duration; use crossterm::queue; use crossterm::style::{ self, Color, }; +use dialoguer::console::strip_ansi_codes; use eyre::{ - Context as EyreContext, Result, + eyre, }; use fig_os_shim::Context; +#[cfg(unix)] +use fig_util::pty::unix::open_pty; +#[cfg(windows)] +use fig_util::pty::win::open_pty; +use fig_util::pty::{ + AsyncMasterPtyExt, + CommandBuilder, +}; +use fig_util::shell::Shell; +use fig_util::terminal::{ + get_terminal_size, + key_event_to_bytes, +}; +use portable_pty::PtySize; +use regex::Regex; use serde::Deserialize; -use tokio::io::AsyncBufReadExt; -use tokio::select; -use tracing::error; use super::super::util::truncate_safe; use super::{ @@ -93,12 +104,13 @@ impl ExecuteBash { false } - pub async fn invoke(&self, updates: impl Write) -> Result { - let output = run_command(&self.command, MAX_TOOL_RESPONSE_SIZE / 3, Some(updates)).await?; + // Note: _updates is unused because `impl Write` cannot be shared across threads, so we write to + // stdout directly. A type refactor is needed to support this. + pub async fn invoke(&self, _updates: impl Write) -> Result { + let output = self.execute_pty_with_input(MAX_TOOL_RESPONSE_SIZE / 3, true).await?; let result = serde_json::json!({ - "exit_status": output.exit_status.unwrap_or(0).to_string(), + "exit_status": output.exit_status.to_string(), "stdout": output.stdout, - "stderr": output.stderr, }); Ok(InvokeOutput { @@ -106,6 +118,191 @@ impl ExecuteBash { }) } + /// Run a bash command using a PTY. The user's cwd, env vars, and shell configurations will be + /// used. Records input from the user's terminal for text and key combinations. + /// + /// # Arguments + /// * `max_result_size` - max size of output streams, truncating if required + /// * `updates` - whether to push command output to stdout + /// # Returns + /// A [`CommandResult`] + pub async fn execute_pty_with_input(&self, max_result_size: usize, updates: bool) -> Result { + crossterm::terminal::enable_raw_mode()?; + let result = self._execute_pty(max_result_size, updates, true).await; + crossterm::terminal::disable_raw_mode()?; + + // Clean out any remaining events. + // Otherwise, the main terminal may behave strangely after returning. + while crossterm::event::poll(Duration::from_millis(0))? { + let _ = crossterm::event::read(); + } + + result + } + + /// Run a bash command using a PTY. The user's cwd, env vars, and shell configurations will be + /// used. Does not record any input. + /// + /// # Arguments + /// * `max_result_size` - max size of output streams, truncating if required + /// * `updates` - whether to push command output to stdout + /// # Returns + /// A [`CommandResult`] + pub async fn execute_pty_without_input(&self, max_result_size: usize, updates: bool) -> Result { + self._execute_pty(max_result_size, updates, false).await + } + + async fn _execute_pty(&self, max_result_size: usize, updates: bool, with_input: bool) -> Result { + const LINE_COUNT: usize = 1024; + + // Open a new pseudoterminal + let pty_pair = open_pty(&get_terminal_size()).map_err(|e| eyre!("Failed to start PTY: {}", e))?; + + // Create a command builder for the shell command + let shell = Shell::current_shell().map_or("bash", |s| s.as_str()); + let mut cmd_builder = CommandBuilder::new(shell); + cmd_builder.args(["-cli", &self.command]); + cmd_builder.cwd(std::env::current_dir()?); + + // Should work for most (all?) shells? Needs a bit more research. + // This is all but required because otherwise the stdout from the PTY gets cluttered + // with escape characters and shell integrations (e.g. current directory, current user, hostname). + // We can clean the escape chars but the shell integrations are much harder. Is there a better way? + // What happens if we don't use this: Q can get confused on what the output is actually saying. + // + // NOTE: This may disable certain interactive commands and display a warning for others + cmd_builder.env("TERM", "dumb"); + + let mut child = pty_pair + .slave + .spawn_command(cmd_builder) + .map_err(|e| eyre!("Failed to get slave PTY: {}", e))?; + let master = pty_pair + .master + .get_async_master_pty() + .map_err(|e| eyre!("Failed to get master PTY: {}", e))?; + let master = Arc::new(tokio::sync::Mutex::new(master)); + + // Set up a channel to coordinate shutdown + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); + + // Handle output from the command + let master_clone = Arc::clone(&master); + let mut stdout_lines: VecDeque = VecDeque::with_capacity(LINE_COUNT); + + let output_handle = tokio::spawn(async move { + let mut buffer = [0u8; LINE_COUNT]; + let mut stdout = std::io::stdout(); + + loop { + let mut master_guard = master_clone.lock().await; + + // Timeout to give other asyncs time to run since read will block until it is able to read. + // However, for most cases reading from PTY's stdout is more important than writing to its stdin. + match tokio::time::timeout(Duration::from_millis(20), master_guard.read(&mut buffer)).await { + Ok(Ok(0)) => break Ok(stdout_lines), // End of stream + Ok(Ok(n)) => { + if updates { + stdout.write_all(&buffer[..n])?; + stdout.flush()?; + } + + if let Ok(text) = from_utf8(&buffer) { + for subline in clean_pty_output(text).split_inclusive('\n') { + if stdout_lines.len() >= LINE_COUNT { + stdout_lines.pop_front(); + } + stdout_lines.push_back(strip_ansi_codes(subline).to_string().trim().to_string()); + } + } + }, + Ok(Err(e)) => { + break Err(eyre!("Failed reading from PTY: {}", e)); + }, + Err(_) => continue, + } + } + }); + + // Handle input from the user using crossterm + let master_clone = Arc::clone(&master); + let input_handle = if with_input { + tokio::spawn(async move { + loop { + tokio::select! { + // Check if the process is done + Some(_) = rx.recv() => break Ok(()), + + // Use a separate task to poll for events to avoid blocking + // Note: this reads one character a time basically. Which is fine for + // everything unless the user pastes a large amount of text. + // Could use an upgrade to avoid this (maybe read from stdin and events at the same time) + event = tokio::task::spawn_blocking(crossterm::event::read) => { + match event { + Ok(Ok(crossterm::event::Event::Key(key))) => { + // Convert the key event to bytes and send to the PTY + let bytes = key_event_to_bytes(key); + if !bytes.is_empty() { + if let Err(e) = master_clone.lock().await.write_all(&bytes).await { + break Err(eyre!("Failed writing to PTY: {}", e)); + } + } + } + Ok(Ok(crossterm::event::Event::Resize(cols, rows))) => { + // Handle terminal resize + let size = PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }; + let _ = master_clone.lock().await.resize(size); + } + Ok(Err(e)) => { + break Err(eyre!("Failed reading terminal event: {}", e)); + } + Err(e) => { + break Err(eyre!("Read terminal events async task error: {}", e)); + } + _ => {} // Ignore other events + } + } + } + } + }) + } else { + // Create a completed JoinHandle that returns Ok(()) + + tokio::spawn(async move { Ok(()) }) + }; + + // Wait for the output handler to complete + let stdout_lines = output_handle.await??; + + // Signal the input handler to stop + tx.send(()).await?; + + // Wait for the input handler to complete + let _ = input_handle.await?; + + // Wait for the child process to exit + let exit_status = child.wait()?; + + let stdout = stdout_lines.into_iter().collect::(); + Ok(CommandResult { + exit_status: exit_status.exit_code(), + stdout: format!( + "{}{}", + truncate_safe(&stdout, max_result_size), + if stdout.len() > max_result_size { + " ... truncated" + } else { + "" + } + ), + }) + } + pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { queue!(updates, style::Print("I will run the following shell command: "),)?; @@ -129,128 +326,21 @@ impl ExecuteBash { } } -pub struct CommandResult { - pub exit_status: Option, - /// Truncated stdout - pub stdout: String, - /// Truncated stderr - pub stderr: String, -} - -/// Run a bash command. -/// # Arguments -/// * `max_result_size` - max size of output streams, truncating if required -/// * `updates` - output stream to push informational messages about the progress -/// # Returns -/// A [`CommandResult`] -pub async fn run_command( - command: &str, - max_result_size: usize, - mut updates: Option, -) -> Result { - // We need to maintain a handle on stderr and stdout, but pipe it to the terminal as well - let mut child = tokio::process::Command::new("bash") - .arg("-c") - .arg(command) - .stdin(Stdio::inherit()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .wrap_err_with(|| format!("Unable to spawn command '{}'", command))?; - - let stdout_final: String; - let stderr_final: String; - let exit_status: ExitStatus; - - // Buffered output vs all-at-once - if let Some(u) = updates.as_mut() { - let stdout = child.stdout.take().unwrap(); - let stdout = tokio::io::BufReader::new(stdout); - let mut stdout = stdout.lines(); - - let stderr = child.stderr.take().unwrap(); - let stderr = tokio::io::BufReader::new(stderr); - let mut stderr = stderr.lines(); - - const LINE_COUNT: usize = 1024; - let mut stdout_buf = VecDeque::with_capacity(LINE_COUNT); - let mut stderr_buf = VecDeque::with_capacity(LINE_COUNT); - - let mut stdout_done = false; - let mut stderr_done = false; - exit_status = loop { - select! { - biased; - line = stdout.next_line(), if !stdout_done => match line { - Ok(Some(line)) => { - writeln!(u, "{line}")?; - if stdout_buf.len() >= LINE_COUNT { - stdout_buf.pop_front(); - } - stdout_buf.push_back(line); - }, - Ok(None) => stdout_done = true, - Err(err) => error!(%err, "Failed to read stdout of child process"), - }, - line = stderr.next_line(), if !stderr_done => match line { - Ok(Some(line)) => { - writeln!(u, "{line}")?; - if stderr_buf.len() >= LINE_COUNT { - stderr_buf.pop_front(); - } - stderr_buf.push_back(line); - }, - Ok(None) => stderr_done = true, - Err(err) => error!(%err, "Failed to read stderr of child process"), - }, - exit_status = child.wait() => { - break exit_status; - }, - }; - } - .wrap_err_with(|| format!("No exit status for '{}'", command))?; - - u.flush()?; +fn clean_pty_output(input: &str) -> String { + // Remove null characters + let without_nulls = input.replace('\0', ""); - stdout_final = stdout_buf.into_iter().collect::>().join("\n"); - stderr_final = stderr_buf.into_iter().collect::>().join("\n"); - } else { - // Take output all at once since we are not reporting anything in real time - // - // NOTE: If we don't split this logic, then any writes to stdout while calling - // this function concurrently may cause the piped child output to be ignored - - let output = child - .wait_with_output() - .await - .wrap_err_with(|| format!("No exit status for '{}'", command))?; + // Remove terminal control sequences + let re = Regex::new(r"\x1B\][^\x07]*\x07").unwrap(); + let cleaned = re.replace_all(&without_nulls, ""); - exit_status = output.status; - stdout_final = from_utf8(&output.stdout).unwrap_or_default().to_string(); - stderr_final = from_utf8(&output.stderr).unwrap_or_default().to_string(); - } + cleaned.to_string() +} - Ok(CommandResult { - exit_status: exit_status.code(), - stdout: format!( - "{}{}", - truncate_safe(&stdout_final, max_result_size), - if stdout_final.len() > max_result_size { - " ... truncated" - } else { - "" - } - ), - stderr: format!( - "{}{}", - truncate_safe(&stderr_final, max_result_size), - if stderr_final.len() > max_result_size { - " ... truncated" - } else { - "" - } - ), - }) +pub struct CommandResult { + pub exit_status: u32, + /// Truncated stdout + pub stdout: String, } #[cfg(test)]