fix(tui): leave alternate screen during turns — zero output bleeding

The TUI is not active during tool execution. The flow becomes:
  1. leave_for_turn()  — exit alt screen, enable raw mode
  2. cli.run_turn_to() — stdout/stderr goes to normal terminal
  3. wait_to_return()  — 'Press any key to return to TUI...'
  4. reenter_after_turn() — re-enter alt screen, redraw

This guarantees zero output bleeding. Replaces suspend/resume approach.
This commit is contained in:
TheArchitectit 2026-06-12 14:30:06 -05:00
parent 4af649b442
commit 25610f2a06
2 changed files with 66 additions and 19 deletions

View File

@ -7295,26 +7295,19 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
app.set_status("Thinking..."); app.set_status("Thinking...");
// ── Turn execution ── // ── Turn execution ──
// Capture stdout/stderr safely with `gag` so child processes // Exit the alternate screen before running the turn so that
// can't bleed output onto the TUI. Then render any captured // any stdout/stderr from tools goes to the normal terminal
// tool output inside the conversation pane. // instead of corrupting the TUI frame. After the turn the
let (result, captured_stdout, captured_stderr) = // user presses a key to return to the TUI, which is then
crate::tui::capture::capture_output(|| { // fully redrawn.
let mut buf: Vec<u8> = Vec::new(); app.leave_for_turn()?;
cli.run_turn_to(&trimmed, &mut buf, false)
});
// Render captured stdout/stderr from tools (only if meaningful) let mut buf: Vec<u8> = Vec::new();
if !captured_stdout.is_empty() { let result = cli.run_turn_to(&trimmed, &mut buf, false);
app.push_output("```tool-output\n", false);
app.push_output(&captured_stdout, false); // Wait for user acknowledgment before redrawing the TUI.
app.push_output("\n```\n", false); app.wait_to_return()?;
} app.reenter_after_turn()?;
if !captured_stderr.is_empty() {
app.push_output("```tool-error\n", false);
app.push_output(&captured_stderr, true);
app.push_output("\n```\n", false);
}
// Read the last assistant message from the session for // Read the last assistant message from the session for
// the conversation pane. // the conversation pane.

View File

@ -108,6 +108,60 @@ impl TuiApp {
Ok(()) Ok(())
} }
/// Leave the TUI before a turn: exit alt screen, disable raw mode,
/// leave cursor visible, and clear. Stdout during the turn now goes
/// to the normal terminal instead of corrupting the TUI frame.
pub fn leave_for_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
disable_raw_mode()?;
crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::terminal::Clear(ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::cursor::Show
)?;
io::stdout().flush()?;
Ok(())
}
/// Wait for one keypress before re-entering the TUI.
/// Gives the user a chance to read any tool output that printed
/// to the terminal during the turn.
pub fn wait_to_return(&mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
crossterm::execute!(
io::stdout(),
crossterm::style::Print("\nPress any key to return to TUI..."),
crossterm::cursor::MoveTo(0, 0)
)?;
io::stdout().flush()?;
// Block until a key is pressed
loop {
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(_) = event::read()? {
break;
}
}
}
Ok(())
}
/// Re-enter the TUI after a turn: alt screen, raw mode, clear, redraw.
pub fn reenter_after_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::terminal::Clear(ClearType::All),
crossterm::cursor::Hide
)?;
io::stdout().flush()?;
enable_raw_mode()?;
self.terminal.clear()?;
Ok(())
}
/// Restore terminal on exit. /// Restore terminal on exit.
pub fn restore_terminal(&mut self) -> Result<(), Box<dyn std::error::Error>> { pub fn restore_terminal(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor(); let _ = self.terminal.show_cursor();