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:
parent
e9f09d7fed
commit
58e095a0c0
|
|
@ -7179,12 +7179,24 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
update_dashboard(&dashboard_state, &cli);
|
update_dashboard(&dashboard_state, &cli);
|
||||||
app.set_status("Thinking...");
|
app.set_status("Thinking...");
|
||||||
|
|
||||||
// Run the turn. Output goes to the alternate screen buffer (which
|
// Run the turn into a buffer instead of stdout.
|
||||||
// ratatui owns) — it will be overwritten on next redraw.
|
// This is the KEY fix: no bytes hit the alternate screen during
|
||||||
// We capture the final assistant text from the session.
|
// the turn, so nothing bleeds past the conversation pane boundary.
|
||||||
let result = cli.run_turn(&trimmed);
|
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;
|
let messages = &cli.runtime.session().messages;
|
||||||
if let Some(msg) = messages.last() {
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct SessionHandle {
|
struct SessionHandle {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -7913,13 +7954,25 @@ impl LiveCli {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
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 spinner = Spinner::new();
|
||||||
let mut stdout = io::stdout();
|
|
||||||
spinner.tick(
|
spinner.tick(
|
||||||
"🦀 Thinking...",
|
"🦀 Thinking...",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
&mut stdout,
|
out,
|
||||||
)?;
|
)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
|
|
@ -7930,18 +7983,19 @@ impl LiveCli {
|
||||||
spinner.finish(
|
spinner.finish(
|
||||||
"✨ Done",
|
"✨ Done",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
&mut stdout,
|
out,
|
||||||
)?;
|
)?;
|
||||||
let final_text = final_assistant_text(&summary);
|
let final_text = final_assistant_text(&summary);
|
||||||
if !final_text.is_empty() {
|
if !final_text.is_empty() {
|
||||||
println!("{final_text}");
|
writeln!(out, "{final_text}")?;
|
||||||
}
|
}
|
||||||
println!();
|
writeln!(out)?;
|
||||||
if let Some(event) = summary.auto_compaction {
|
if let Some(event) = summary.auto_compaction {
|
||||||
println!(
|
writeln!(
|
||||||
|
out,
|
||||||
"{}",
|
"{}",
|
||||||
format_auto_compaction_notice(event.removed_message_count)
|
format_auto_compaction_notice(event.removed_message_count)
|
||||||
);
|
)?;
|
||||||
}
|
}
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -7951,34 +8005,14 @@ impl LiveCli {
|
||||||
spinner.fail(
|
spinner.fail(
|
||||||
"❌ Request failed",
|
"❌ Request failed",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
&mut stdout,
|
out,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Auto-compact retry on context window errors
|
// 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();
|
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")
|
let is_context_window = error_str.contains("context_window")
|
||||||
|| error_str.contains("Context window")
|
|| error_str.contains("Context window")
|
||||||
|| error_str.contains("no parseable body")
|
|| error_str.contains("no parseable body")
|
||||||
|
|
@ -7988,44 +8022,33 @@ impl LiveCli {
|
||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
.contains("context size has been exceeded");
|
.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")
|
let is_no_content = error_str.contains("assistant stream produced no content")
|
||||||
|| error_str.contains("Failed to parse input at pos")
|
|| error_str.contains("Failed to parse input at pos")
|
||||||
|| error_str.contains("error decoding response body");
|
|| error_str.contains("error decoding response body");
|
||||||
|
|
||||||
if is_context_window || is_no_content {
|
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) {
|
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;
|
let threshold: u32 = (window as f64 * 0.7).round() as u32;
|
||||||
println!(
|
writeln!(
|
||||||
|
out,
|
||||||
" Server context window: {} tokens — setting auto-compaction threshold to {}",
|
" Server context window: {} tokens — setting auto-compaction threshold to {}",
|
||||||
window, threshold
|
window, threshold
|
||||||
);
|
)?;
|
||||||
runtime.set_auto_compaction_input_tokens_threshold(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 max_compact_rounds = 4;
|
||||||
let preserve_schedule = [4, 2, 1, 0];
|
let preserve_schedule = [4, 2, 1, 0];
|
||||||
|
|
||||||
for round in 0..max_compact_rounds {
|
for round in 0..max_compact_rounds {
|
||||||
let preserve = preserve_schedule[round];
|
let preserve = preserve_schedule[round];
|
||||||
println!(
|
writeln!(
|
||||||
|
out,
|
||||||
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
||||||
round + 1,
|
round + 1,
|
||||||
max_compact_rounds,
|
max_compact_rounds,
|
||||||
preserve
|
preserve
|
||||||
);
|
)?;
|
||||||
|
|
||||||
// Run Trident pipeline then summary-based compaction
|
|
||||||
let result = runtime::trident::trident_compact_session(
|
let result = runtime::trident::trident_compact_session(
|
||||||
runtime.session(),
|
runtime.session(),
|
||||||
CompactionConfig {
|
CompactionConfig {
|
||||||
|
|
@ -8038,19 +8061,20 @@ impl LiveCli {
|
||||||
|
|
||||||
if removed == 0 && round > 0 {
|
if removed == 0 && round > 0 {
|
||||||
// No more messages to compact — further rounds won't help
|
// No more messages to compact — further rounds won't help
|
||||||
println!(" No further compaction possible.");
|
writeln!(out, " No further compaction possible.")?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
println!(
|
writeln!(
|
||||||
|
out,
|
||||||
"{}",
|
"{}",
|
||||||
format_compact_report(
|
format_compact_report(
|
||||||
removed,
|
removed,
|
||||||
result.compacted_session.messages.len(),
|
result.compacted_session.messages.len(),
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
);
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without this, prepare_turn_runtime() reads from self.runtime.session()
|
// 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
|
// Build a new runtime with the compacted session and retry
|
||||||
let (mut new_runtime, hook_abort_monitor) =
|
let (mut new_runtime, hook_abort_monitor) =
|
||||||
self.prepare_turn_runtime(true)?;
|
self.prepare_turn_runtime(emit_output)?;
|
||||||
drop(hook_abort_monitor);
|
drop(hook_abort_monitor);
|
||||||
|
|
||||||
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
||||||
|
|
@ -8074,14 +8098,15 @@ impl LiveCli {
|
||||||
"✨ Done (after aggressive auto-compact)"
|
"✨ Done (after aggressive auto-compact)"
|
||||||
},
|
},
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
&mut stdout,
|
out,
|
||||||
)?;
|
)?;
|
||||||
println!();
|
writeln!(out)?;
|
||||||
if let Some(event) = summary.auto_compaction {
|
if let Some(event) = summary.auto_compaction {
|
||||||
println!(
|
writeln!(
|
||||||
|
out,
|
||||||
"{}",
|
"{}",
|
||||||
format_auto_compaction_notice(event.removed_message_count)
|
format_auto_compaction_notice(event.removed_message_count)
|
||||||
);
|
)?;
|
||||||
}
|
}
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,12 @@ impl TuiApp {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
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 {
|
self.conversation.push(ConversationLine {
|
||||||
text: raw_line.to_string(),
|
text: raw_line.to_string(),
|
||||||
color: if is_error { Color::Red } else { Color::White },
|
color: if is_error { Color::Red } else { Color::White },
|
||||||
|
|
@ -303,6 +308,16 @@ impl TuiApp {
|
||||||
self.auto_scroll();
|
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>) {
|
pub fn set_slash_completions(&mut self, completions: Vec<String>) {
|
||||||
self.slash_completions = completions;
|
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)),
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue