fix(tui): buffer output during turns — no more full-width bleeding

Architecture C from expert panel review: add run_turn_to<W: Write>
that writes all output (spinners, markdown, compaction logs) to a
custom writer instead of hardcoding stdout.

In the TUI path, we pass a Vec<u8> buffer. Zero bytes hit the real
terminal during a turn, so nothing bleeds past the conversation pane
boundary into the dashboard. After the turn, the buffer contents are
stripped of ANSI codes and pushed into the conversation pane where
wrap_line() constrains them to the pane width.

Key changes:
- run_turn() is now a thin wrapper: run_turn_to(input, stdout, true)
- run_turn_to<W: Write>(input, out, emit_output) is the real impl
- All println! → writeln!(out, ...), &mut stdout → out
- emit_output=false in TUI mode prevents runtime tool-stream output
- Add strip_ansi() helper for cleaning captured buffer text
- Remove Clear(ClearType::All) from redraw_after_turn (no debris to wipe)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-11 16:25:34 -05:00
parent e9f09d7fed
commit 58e095a0c0
2 changed files with 134 additions and 60 deletions

View File

@ -7179,12 +7179,24 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
update_dashboard(&dashboard_state, &cli);
app.set_status("Thinking...");
// Run the turn. Output goes to the alternate screen buffer (which
// ratatui owns) — it will be overwritten on next redraw.
// We capture the final assistant text from the session.
let result = cli.run_turn(&trimmed);
// Run the turn into a buffer instead of stdout.
// This is the KEY fix: no bytes hit the alternate screen during
// the turn, so nothing bleeds past the conversation pane boundary.
let mut buf: Vec<u8> = Vec::new();
let result = cli.run_turn_to(&trimmed, &mut buf, false);
// Read the last assistant message from the session for the conversation pane
// Feed the captured output into the conversation pane.
// The buffer may contain ANSI codes from TerminalRenderer —
// strip them before pushing.
let captured = String::from_utf8_lossy(&buf);
let plain = strip_ansi(&captured);
if !plain.is_empty() {
app.push_output(&plain, false);
}
// Also read the last assistant message from the session for
// the conversation pane (richer content than the spinner/status
// lines in the buffer).
{
let messages = &cli.runtime.session().messages;
if let Some(msg) = messages.last() {
@ -7277,6 +7289,35 @@ fn update_dashboard(state: &tui::SharedDashboardState, cli: &LiveCli) {
}
}
/// Strip ANSI escape sequences from a string. Used by the TUI path to
/// clean captured output before pushing it into the conversation pane.
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' && chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Consume the sequence: parameter bytes (0x30-0x3f),
// intermediate bytes (0x20-0x2f), final byte (0x40-0x7e)
while let Some(&b) = chars.peek() {
match b {
'\x20'..='\x2f' | '\x30'..='\x3f' => {
chars.next();
}
'\x40'..='\x7e' => {
chars.next();
break;
}
_ => break,
}
}
} else {
result.push(ch);
}
}
result
}
#[derive(Debug, Clone)]
struct SessionHandle {
id: String,
@ -7913,13 +7954,25 @@ impl LiveCli {
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
self.run_turn_to(input, &mut io::stdout(), true)
}
/// Core turn execution with a custom output writer.
/// In the plain REPL, `out` is `io::stdout()`.
/// In TUI mode, `out` is a `Vec<u8>` buffer so no bytes hit the
/// real terminal — the TUI reads the buffer afterward.
fn run_turn_to<W: io::Write>(
&mut self,
input: &str,
out: &mut W,
emit_output: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(emit_output)?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut stdout,
out,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
@ -7930,18 +7983,19 @@ impl LiveCli {
spinner.finish(
"✨ Done",
TerminalRenderer::new().color_theme(),
&mut stdout,
out,
)?;
let final_text = final_assistant_text(&summary);
if !final_text.is_empty() {
println!("{final_text}");
writeln!(out, "{final_text}")?;
}
println!();
writeln!(out)?;
if let Some(event) = summary.auto_compaction {
println!(
writeln!(
out,
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
)?;
}
self.persist_session()?;
Ok(())
@ -7951,34 +8005,14 @@ impl LiveCli {
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
out,
)?;
// ============================================================================
// Auto-compact retry on context window errors
// ============================================================================
// When the model API returns a context_window_blocked error (because the request
// exceeds the model's context window), we automatically:
// 1. Compact the session (remove old messages to free up space)
// 2. Retry the original request with the compacted session
// 3. Report results to the user
//
// This eliminates the need for users to manually run /compact when they
// hit context limits - the recovery happens automatically.
//
// Detection: We look for "context_window" or "Context window" in the error
// message, which covers error types like:
// - "context_window_blocked"
// - "Context window blocked"
// - "This model's maximum context length is X tokens..."
// ============================================================================
let error_str = error.to_string();
// Detect context window overflow. Some providers (e.g. OpenAI-compat backends)
// return 400 with "no parseable body" instead of a proper context_length_exceeded
// error when the request is too large to even parse — treat that as context overflow too.
// Also detect model-specific context error markers (e.g. llama.cpp returns
// "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size").
let is_context_window = error_str.contains("context_window")
|| error_str.contains("Context window")
|| error_str.contains("no parseable body")
@ -7988,44 +8022,33 @@ impl LiveCli {
.to_ascii_lowercase()
.contains("context size has been exceeded");
// Also treat "assistant stream produced no content" and reqwest decode failures
// as recoverable errors that may benefit from auto-compaction. Some backends (e.g.
// llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing
// reqwest to fail with "error decoding response body" — treat that as context overflow too.
let is_no_content = error_str.contains("assistant stream produced no content")
|| error_str.contains("Failed to parse input at pos")
|| error_str.contains("error decoding response body");
if is_context_window || is_no_content {
// If the error tells us the server's actual context window, adapt our
// auto-compaction threshold so future auto-compact-trigger checks are accurate.
if let Some(window) = extract_context_window_tokens_from_error(&error_str) {
// Set threshold at 70% of the reported window to leave headroom.
let threshold: u32 = (window as f64 * 0.7).round() as u32;
println!(
writeln!(
out,
" Server context window: {} tokens — setting auto-compaction threshold to {}",
window, threshold
);
)?;
runtime.set_auto_compaction_input_tokens_threshold(threshold);
}
// A single compaction pass may not free enough context space.
// Progressive retry: each round preserves fewer recent messages (4→2→1→0),
// trading conversation continuity for a smaller payload until it fits.
// Max 4 rounds before giving up and surfacing the error to the user.
let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds {
let preserve = preserve_schedule[round];
println!(
writeln!(
out,
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1,
max_compact_rounds,
preserve
);
// Run Trident pipeline then summary-based compaction
)?;
let result = runtime::trident::trident_compact_session(
runtime.session(),
CompactionConfig {
@ -8038,19 +8061,20 @@ impl LiveCli {
if removed == 0 && round > 0 {
// No more messages to compact — further rounds won't help
println!(" No further compaction possible.");
writeln!(out, " No further compaction possible.")?;
break;
}
if removed > 0 {
println!(
writeln!(
out,
"{}",
format_compact_report(
removed,
result.compacted_session.messages.len(),
false
)
);
)?;
}
// Without this, prepare_turn_runtime() reads from self.runtime.session()
@ -8060,7 +8084,7 @@ impl LiveCli {
// Build a new runtime with the compacted session and retry
let (mut new_runtime, hook_abort_monitor) =
self.prepare_turn_runtime(true)?;
self.prepare_turn_runtime(emit_output)?;
drop(hook_abort_monitor);
let mut rp = CliPermissionPrompter::new(self.permission_mode);
@ -8074,14 +8098,15 @@ impl LiveCli {
"✨ Done (after aggressive auto-compact)"
},
TerminalRenderer::new().color_theme(),
&mut stdout,
out,
)?;
println!();
writeln!(out)?;
if let Some(event) = summary.auto_compaction {
println!(
writeln!(
out,
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
)?;
}
self.persist_session()?;
return Ok(());

View File

@ -293,7 +293,12 @@ impl TuiApp {
if text.is_empty() {
return;
}
for raw_line in text.lines() {
// Strip any ANSI escape codes that may have leaked through from the
// runtime's stdout rendering. The conversation pane renders plain text
// with ratatui styles, so ANSI bytes would corrupt the layout and
// confuse wrap_line()'s character counting.
let clean = strip_ansi_escapes(text);
for raw_line in clean.lines() {
self.conversation.push(ConversationLine {
text: raw_line.to_string(),
color: if is_error { Color::Red } else { Color::White },
@ -303,6 +308,16 @@ impl TuiApp {
self.auto_scroll();
}
/// Force a full TUI clear + redraw. With Architecture C (buffered
/// output) this is no longer needed to clean up stdout debris, but
/// kept as a safety net for edge cases.
pub fn redraw_after_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.terminal.clear()?;
self.needs_redraw = true;
self.draw_screen()?;
Ok(())
}
pub fn set_slash_completions(&mut self, completions: Vec<String>) {
self.slash_completions = completions;
}
@ -895,3 +910,37 @@ fn kv<'a>(key: &str, val: &str, val_color: Color) -> Line<'a> {
Span::styled(val.to_string(), Style::default().fg(val_color)),
])
}
/// Strip ANSI escape sequences from a string.
///
/// The runtime's stdout rendering (`TerminalRenderer::markdown_to_ansi`)
/// produces ANSI-colored output for the full terminal width. When that text
/// leaks into the conversation pane (e.g. via error messages or raw captures)
/// the ANSI bytes corrupt ratatui's character-counting and word-wrapping.
/// This function removes them so the pane always works with plain text; styling
/// is handled by ratatui's `Style` system instead.
fn strip_ansi_escapes(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
// ESC sequence: ESC [ ... <final byte>
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
for next in chars.by_ref() {
// The final byte of a CSI sequence is 0x40..=0x7E
if next.is_ascii_alphabetic() || ('@'..='~').contains(&next) {
break;
}
}
} else {
// Bare ESC without '[' — just swallow it
}
} else {
output.push(ch);
}
}
output
}