git_ui: Strip ANSI escape codes from git command output (#53444)

Luca Zani created

When git commands (push, pull, hooks...) produce output containing ANSI
escape sequences for colors or formatting, Zed was displaying them as
raw escape codes in the output buffer, making the output hard to read.
This simply escapes ANSI from the git output

### Before and After

  <table>
    <tr>
      <th align="center">Before</th>
<th align="center">After</th>
    </tr>
    <tr>
  <td>
<img width="882" height="862" alt="Screenshot 2026-04-08 at 21 13 07"
src="https://github.com/user-attachments/assets/58731e80-d864-47ca-8983-d0e86e924843"
/>
</td>
      <td>
<img width="882" height="862" alt="Screenshot 2026-04-08 at 21 15 14"
src="https://github.com/user-attachments/assets/7649200a-2d82-4442-88da-e231304911a8"
/>
</td>
    </tr>         
  </table>        

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Related to #43817. This PR only addresses the escaping of the ANSI
codes; colors and other stuff are not handled

Release Notes:

- Fixed ANSI escape codes being displayed as raw text in git command
output

Change summary

Cargo.lock                     |  1 
crates/git_ui/Cargo.toml       |  1 
crates/git_ui/src/git_panel.rs | 54 +++++++++++++++++++++++++++++++++++
3 files changed, 55 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -7266,6 +7266,7 @@ name = "git_ui"
 version = "0.1.0"
 dependencies = [
  "agent_settings",
+ "alacritty_terminal",
  "anyhow",
  "askpass",
  "buffer_diff",

crates/git_ui/Cargo.toml 🔗

@@ -17,6 +17,7 @@ test-support = ["multi_buffer/test-support", "remote_connection/test-support"]
 
 [dependencies]
 agent_settings.workspace = true
+alacritty_terminal.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 buffer_diff.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -11,6 +11,7 @@ use crate::{
     repository_selector::RepositorySelector,
 };
 use agent_settings::AgentSettings;
+use alacritty_terminal::vte::ansi;
 use anyhow::Context as _;
 use askpass::AskPassDelegate;
 use collections::{BTreeMap, HashMap, HashSet};
@@ -6407,7 +6408,13 @@ fn open_output(
     cx: &mut Context<Workspace>,
 ) {
     let operation = operation.into();
-    let buffer = cx.new(|cx| Buffer::local(output, cx));
+
+    let mut handler = GitOutputHandler::default();
+    let mut processor = ansi::Processor::<ansi::StdSyncHandler>::default();
+    processor.advance(&mut handler, output.as_bytes());
+    let plain_text = handler.output;
+
+    let buffer = cx.new(|cx| Buffer::local(plain_text.as_str(), cx));
     buffer.update(cx, |buffer, cx| {
         buffer.set_capability(language::Capability::ReadOnly, cx);
     });
@@ -6423,6 +6430,32 @@ fn open_output(
     workspace.add_item_to_center(Box::new(editor), window, cx);
 }
 
+#[derive(Default)]
+struct GitOutputHandler {
+    output: String,
+    line_start: usize,
+}
+
+impl ansi::Handler for GitOutputHandler {
+    fn input(&mut self, c: char) {
+        self.output.push(c);
+    }
+
+    fn linefeed(&mut self) {
+        self.output.push('\n');
+        self.line_start = self.output.len();
+    }
+
+    fn carriage_return(&mut self) {
+        self.output.truncate(self.line_start);
+    }
+
+    fn put_tab(&mut self, count: u16) {
+        self.output
+            .extend(std::iter::repeat_n('\t', count as usize));
+    }
+}
+
 pub(crate) fn show_error_toast(
     workspace: Entity<Workspace>,
     action: impl Into<SharedString>,
@@ -7863,6 +7896,25 @@ mod tests {
         assert_eq!(message, Some("Update tracked".to_string()));
     }
 
+    #[test]
+    fn test_git_output_handler_strips_ansi_codes() {
+        use alacritty_terminal::vte::ansi;
+
+        let cases = [
+            ("no escape codes here\n", "no escape codes here\n"),
+            ("\x1b[31mhello\x1b[0m", "hello"),
+            ("\x1b[1;32mfoo\x1b[0m bar", "foo bar"),
+            ("progress 10%\rprogress 100%\n", "progress 100%\n"),
+        ];
+
+        for (input, expected) in cases {
+            let mut handler = GitOutputHandler::default();
+            let mut processor = ansi::Processor::<ansi::StdSyncHandler>::default();
+            processor.advance(&mut handler, input.as_bytes());
+            assert_eq!(handler.output, expected);
+        }
+    }
+
     #[gpui::test]
     async fn test_dispatch_context_with_focus_states(cx: &mut TestAppContext) {
         init_test(cx);