Show task summary in its terminal after it stops running (#10615)

Kirill Bulatov created

Based on https://github.com/alacritty/alacritty/issues/7795

Unknown error code commands (now includes the interrupted ones):

![image](https://github.com/zed-industries/zed/assets/2690773/801868bc-081c-453c-a353-233d4397bda9)

Successful command:

![image](https://github.com/zed-industries/zed/assets/2690773/874377c7-c967-4a6f-8a89-ec7bf398a8b3)

Unsuccessful command:

![image](https://github.com/zed-industries/zed/assets/2690773/6c99dc5d-d324-41e9-a71b-5d0bf705de27)

The "design", including wordings and special characters, is not final,
suggestions are welcome.
The main idea was to somehow distinguish the appended lines without
occupying extra vertical space.

Release Notes:

- Added task summary output into corresponding terminal tabs

Change summary

crates/project/src/terminals.rs |   8 ++
crates/terminal/src/terminal.rs | 105 +++++++++++++++++++++++++++++++---
2 files changed, 102 insertions(+), 11 deletions(-)

Detailed changes

crates/project/src/terminals.rs 🔗

@@ -55,6 +55,14 @@ impl Project {
                     id: spawn_task.id,
                     full_label: spawn_task.full_label,
                     label: spawn_task.label,
+                    command_label: spawn_task.args.iter().fold(
+                        spawn_task.command.clone(),
+                        |mut command_label, new_arg| {
+                            command_label.push(' ');
+                            command_label.push_str(new_arg);
+                            command_label
+                        },
+                    ),
                     status: TaskStatus::Running,
                     completion_rx,
                 }),

crates/terminal/src/terminal.rs 🔗

@@ -598,6 +598,7 @@ pub struct TaskState {
     pub id: TaskId,
     pub full_label: String,
     pub label: String,
+    pub command_label: String,
     pub status: TaskStatus,
     pub completion_rx: Receiver<()>,
 }
@@ -657,13 +658,7 @@ impl Terminal {
             AlacTermEvent::Bell => {
                 cx.emit(Event::Bell);
             }
-            AlacTermEvent::Exit => match &mut self.task {
-                Some(task) => {
-                    task.status.register_terminal_exit();
-                    self.completion_tx.try_send(()).ok();
-                }
-                None => cx.emit(Event::CloseTerminal),
-            },
+            AlacTermEvent::Exit => self.register_task_finished(None, cx),
             AlacTermEvent::MouseCursorDirty => {
                 //NOOP, Handled in render
             }
@@ -679,10 +674,7 @@ impl Terminal {
                     .push_back(InternalEvent::ColorRequest(*idx, fun_ptr.clone()));
             }
             AlacTermEvent::ChildExit(error_code) => {
-                if let Some(task) = &mut self.task {
-                    task.status.register_task_exit(*error_code);
-                    self.completion_tx.try_send(()).ok();
-                }
+                self.register_task_finished(Some(*error_code), cx);
             }
         }
     }
@@ -1425,6 +1417,97 @@ impl Terminal {
         }
         Task::ready(())
     }
+
+    fn register_task_finished(
+        &mut self,
+        error_code: Option<i32>,
+        cx: &mut ModelContext<'_, Terminal>,
+    ) {
+        self.completion_tx.try_send(()).ok();
+        let task = match &mut self.task {
+            Some(task) => task,
+            None => {
+                if error_code.is_none() {
+                    cx.emit(Event::CloseTerminal);
+                }
+                return;
+            }
+        };
+        if task.status != TaskStatus::Running {
+            return;
+        }
+        match error_code {
+            Some(error_code) => {
+                task.status.register_task_exit(error_code);
+            }
+            None => {
+                task.status.register_terminal_exit();
+            }
+        };
+
+        let (task_line, command_line) = task_summary(task, error_code);
+        // SAFETY: the invocation happens on non `TaskStatus::Running` tasks, once,
+        // after either `AlacTermEvent::Exit` or `AlacTermEvent::ChildExit` events that are spawned
+        // when Zed task finishes and no more output is made.
+        // After the task summary is output once, no more text is appended to the terminal.
+        unsafe { append_text_to_term(&mut self.term.lock(), &[&task_line, &command_line]) };
+    }
+}
+
+const TASK_DELIMITER: &str = "⏵ ";
+fn task_summary(task: &TaskState, error_code: Option<i32>) -> (String, String) {
+    let escaped_full_label = task.full_label.replace("\r\n", "\r").replace('\n', "\r");
+    let task_line = match error_code {
+        Some(0) => {
+            format!("{TASK_DELIMITER}Task `{escaped_full_label}` finished successfully")
+        }
+        Some(error_code) => {
+            format!("{TASK_DELIMITER}Task `{escaped_full_label}` finished with non-zero error code: {error_code}")
+        }
+        None => {
+            format!("{TASK_DELIMITER}Task `{escaped_full_label}` finished")
+        }
+    };
+    let escaped_command_label = task.command_label.replace("\r\n", "\r").replace('\n', "\r");
+    let command_line = format!("{TASK_DELIMITER}Command: '{escaped_command_label}'");
+    (task_line, command_line)
+}
+
+/// Appends a stringified task summary to the terminal, after its output.
+///
+/// SAFETY: This function should only be called after terminal's PTY is no longer alive.
+/// New text being added to the terminal here, uses "less public" APIs,
+/// which are not maintaining the entire terminal state intact.
+///
+///
+/// The library
+///
+/// * does not increment inner grid cursor's _lines_ on `input` calls
+/// (but displaying the lines correctly and incrementing cursor's columns)
+///
+/// * ignores `\n` and \r` character input, requiring the `newline` call instead
+///
+/// * does not alter grid state after `newline` call
+/// so its `bottommost_line` is always the the same additions, and
+/// the cursor's `point` is not updated to the new line and column values
+///
+/// * ??? there could be more consequences, and any further "proper" streaming from the PTY might bug and/or panic.
+/// Still, concequent `append_text_to_term` invocations are possible and display the contents correctly.
+///
+/// Despite the quirks, this is the simplest approach to appending text to the terminal: its alternative, `grid_mut` manipulations,
+/// do not properly set the scrolling state and display odd text after appending; also those manipulations are more tedious and error-prone.
+/// The function achieves proper display and scrolling capabilities, at a cost of grid state not properly synchronized.
+/// This is enough for printing moderately-sized texts like task summaries, but might break or perform poorly for larger texts.
+unsafe fn append_text_to_term(term: &mut Term<ZedListener>, text_lines: &[&str]) {
+    term.newline();
+    term.grid_mut().cursor.point.column = Column(0);
+    for line in text_lines {
+        for c in line.chars() {
+            term.input(c);
+        }
+        term.newline();
+        term.grid_mut().cursor.point.column = Column(0);
+    }
 }
 
 impl Drop for Terminal {