feat(tui): component-based TuiApp, slash dispatch, markdown AST
- Convert tui.rs -> tui/ directory module, old god-struct in legacy.rs - New component architecture: Component/Overlay traits, ConversationPane, InputBar, Dashboard, StatusBar, CommandPaletteOverlay, AgentViewOverlay - Component-based TuiApp in app.rs with draw_screen pre-borrow pattern - SlashCommandDispatcher extracted from main.rs - MarkdownAst + shared parser (normalize_nested_fences, render_diff, find_stream_safe_boundary, strip_ansi) - EventBus with crossbeam-channel - add capture.rs for future stdout gating - Reuse in workspace: 59 TUI tests pass
This commit is contained in:
parent
25610f2a06
commit
6e787c2b0c
|
|
@ -7293,24 +7293,15 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
|||
app.push_user_input(&input);
|
||||
update_dashboard(&dashboard_state, &cli);
|
||||
app.set_status("Thinking...");
|
||||
app.draw_screen()?;
|
||||
|
||||
// ── Turn execution ──
|
||||
// Exit the alternate screen before running the turn so that
|
||||
// any stdout/stderr from tools goes to the normal terminal
|
||||
// instead of corrupting the TUI frame. After the turn the
|
||||
// user presses a key to return to the TUI, which is then
|
||||
// fully redrawn.
|
||||
app.leave_for_turn()?;
|
||||
|
||||
app.suspend()?;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = cli.run_turn_to(&trimmed, &mut buf, false);
|
||||
drop(stdout);
|
||||
app.resume()?;
|
||||
|
||||
// Wait for user acknowledgment before redrawing the TUI.
|
||||
app.wait_to_return()?;
|
||||
app.reenter_after_turn()?;
|
||||
|
||||
// Read the last assistant message from the session for
|
||||
// the conversation pane.
|
||||
{
|
||||
let messages = &cli.runtime.session().messages;
|
||||
if let Some(msg) = messages.last() {
|
||||
|
|
@ -7325,9 +7316,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
app.set_status("Done");
|
||||
}
|
||||
Ok(()) => app.set_status("Done"),
|
||||
Err(e) => {
|
||||
app.push_system_message(&format!("Error: {e}"));
|
||||
app.set_status("");
|
||||
|
|
|
|||
|
|
@ -89,12 +89,19 @@ impl TuiApp {
|
|||
Ok(app)
|
||||
}
|
||||
|
||||
/// Suspend TUI for a blocking stdout operation.
|
||||
/// Suspend TUI so a blocking turn can use the terminal directly.
|
||||
/// Leaves the alternate screen and disables raw mode.
|
||||
pub fn suspend(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.terminal.show_cursor();
|
||||
disable_raw_mode()?;
|
||||
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
|
||||
let _ = io::stdout().flush();
|
||||
crossterm::execute!(
|
||||
io::stdout(),
|
||||
crossterm::terminal::LeaveAlternateScreen,
|
||||
crossterm::terminal::Clear(ClearType::All),
|
||||
crossterm::cursor::MoveTo(0, 0),
|
||||
crossterm::cursor::Show
|
||||
)?;
|
||||
io::stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +329,7 @@ impl TuiApp {
|
|||
// Rendering — pre-borrow pattern
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
fn draw_screen(&mut self) -> io::Result<()> {
|
||||
pub fn draw_screen(&mut self) -> io::Result<()> {
|
||||
// Pre-borrow component references before the draw closure.
|
||||
// Each component.render(&self) reads its own state — no cloning needed.
|
||||
let conversation = &self.conversation;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
//! Safe stdout/stderr capture for TUI turns.
|
||||
//!
|
||||
//! Replaces the unsafe `libc::dup/dup2` hack. The `gag` crate redirects
|
||||
//! file descriptors to an in-process pipe, which we read after the turn
|
||||
//! completes. Captured output can then be rendered inside the TUI instead
|
||||
//! of bleeding onto the terminal.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
//! file descriptors to an in-process pipe for the duration of the closure.
|
||||
//! Captured output can then be rendered inside the TUI instead of reaching
|
||||
//! the terminal directly.
|
||||
|
||||
use std::io::Read;
|
||||
use gag::BufferRedirect;
|
||||
|
||||
/// Captures both stdout and stderr during a closure.
|
||||
/// Runs `f` with stdout and stderr redirected into memory.
|
||||
///
|
||||
/// Returns the captured stdout and stderr bytes, plus the closure result.
|
||||
/// Any errors during capture setup are logged to the original stderr and
|
||||
/// ignored — the closure still runs without capture in that case.
|
||||
/// Returns `(f_result, captured_stdout, captured_stderr)`. If capture
|
||||
/// setup fails we fall back to running `f` uncaptured.
|
||||
pub fn capture_output<F, T>(f: F) -> (T, String, String)
|
||||
where
|
||||
F: FnOnce() -> T,
|
||||
|
|
@ -30,14 +28,8 @@ where
|
|||
|
||||
let result = f();
|
||||
|
||||
let stdout_str = match read_redirect(stdout_gag) {
|
||||
Ok(s) => s,
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let stderr_str = match read_redirect(stderr_gag) {
|
||||
Ok(s) => s,
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let stdout_str = read_redirect(stdout_gag).unwrap_or_default();
|
||||
let stderr_str = read_redirect(stderr_gag).unwrap_or_default();
|
||||
|
||||
(result, stdout_str, stderr_str)
|
||||
}
|
||||
|
|
@ -52,23 +44,31 @@ fn read_redirect(mut redirect: BufferRedirect) -> Result<String, std::io::Error>
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: Rust's test harness itself captures stdout/stderr, so println!()
|
||||
// inside a unit test does not write to fd 1/2. These tests verify the
|
||||
// capture utility runs and returns sensible values. Real fd capture is
|
||||
// exercised when the TUI runs outside the test harness.
|
||||
|
||||
#[test]
|
||||
fn test_capture_runs_closure() {
|
||||
let (result, _stdout, _stderr) = capture_output(|| 42);
|
||||
assert_eq!(result, 42);
|
||||
fn test_capture_basic() {
|
||||
#[allow(clippy::let_and_return)]
|
||||
let captured = capture_output(|| {
|
||||
println!("out");
|
||||
eprintln!("err");
|
||||
42
|
||||
});
|
||||
assert_eq!(captured.0, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capture_no_output_is_safe() {
|
||||
let (result, stdout, stderr) = capture_output(|| 7);
|
||||
assert_eq!(result, 7);
|
||||
// Strings are returned and are valid UTF-8
|
||||
assert!(stdout.is_empty() || stdout.chars().count() >= 0);
|
||||
assert!(stderr.is_empty() || stderr.chars().count() >= 0);
|
||||
fn test_capture_empty_output() {
|
||||
#[allow(clippy::let_and_return)]
|
||||
let (result, stdout, stderr) = capture_output(|| 1);
|
||||
assert_eq!(result, 1);
|
||||
assert_eq!(stdout, String::new());
|
||||
assert_eq!(stderr, String::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capture_panic_safety() {
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
capture_output(|| panic!("boom"))
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue