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);
|
app.push_user_input(&input);
|
||||||
update_dashboard(&dashboard_state, &cli);
|
update_dashboard(&dashboard_state, &cli);
|
||||||
app.set_status("Thinking...");
|
app.set_status("Thinking...");
|
||||||
|
app.draw_screen()?;
|
||||||
|
|
||||||
// ── Turn execution ──
|
app.suspend()?;
|
||||||
// Exit the alternate screen before running the turn so that
|
let mut stdout = std::io::stdout().lock();
|
||||||
// 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()?;
|
|
||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = cli.run_turn_to(&trimmed, &mut buf, false);
|
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;
|
let messages = &cli.runtime.session().messages;
|
||||||
if let Some(msg) = messages.last() {
|
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 {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => app.set_status("Done"),
|
||||||
app.set_status("Done");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.push_system_message(&format!("Error: {e}"));
|
app.push_system_message(&format!("Error: {e}"));
|
||||||
app.set_status("");
|
app.set_status("");
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,19 @@ impl TuiApp {
|
||||||
Ok(app)
|
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>> {
|
pub fn suspend(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let _ = self.terminal.show_cursor();
|
let _ = self.terminal.show_cursor();
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
|
crossterm::execute!(
|
||||||
let _ = io::stdout().flush();
|
io::stdout(),
|
||||||
|
crossterm::terminal::LeaveAlternateScreen,
|
||||||
|
crossterm::terminal::Clear(ClearType::All),
|
||||||
|
crossterm::cursor::MoveTo(0, 0),
|
||||||
|
crossterm::cursor::Show
|
||||||
|
)?;
|
||||||
|
io::stdout().flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,7 +329,7 @@ impl TuiApp {
|
||||||
// Rendering — pre-borrow pattern
|
// 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.
|
// Pre-borrow component references before the draw closure.
|
||||||
// Each component.render(&self) reads its own state — no cloning needed.
|
// Each component.render(&self) reads its own state — no cloning needed.
|
||||||
let conversation = &self.conversation;
|
let conversation = &self.conversation;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
//! Safe stdout/stderr capture for TUI turns.
|
//! Safe stdout/stderr capture for TUI turns.
|
||||||
//!
|
//!
|
||||||
//! Replaces the unsafe `libc::dup/dup2` hack. The `gag` crate redirects
|
//! Replaces the unsafe `libc::dup/dup2` hack. The `gag` crate redirects
|
||||||
//! file descriptors to an in-process pipe, which we read after the turn
|
//! file descriptors to an in-process pipe for the duration of the closure.
|
||||||
//! completes. Captured output can then be rendered inside the TUI instead
|
//! Captured output can then be rendered inside the TUI instead of reaching
|
||||||
//! of bleeding onto the terminal.
|
//! the terminal directly.
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
use gag::BufferRedirect;
|
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.
|
/// Returns `(f_result, captured_stdout, captured_stderr)`. If capture
|
||||||
/// Any errors during capture setup are logged to the original stderr and
|
/// setup fails we fall back to running `f` uncaptured.
|
||||||
/// ignored — the closure still runs without capture in that case.
|
|
||||||
pub fn capture_output<F, T>(f: F) -> (T, String, String)
|
pub fn capture_output<F, T>(f: F) -> (T, String, String)
|
||||||
where
|
where
|
||||||
F: FnOnce() -> T,
|
F: FnOnce() -> T,
|
||||||
|
|
@ -30,14 +28,8 @@ where
|
||||||
|
|
||||||
let result = f();
|
let result = f();
|
||||||
|
|
||||||
let stdout_str = match read_redirect(stdout_gag) {
|
let stdout_str = read_redirect(stdout_gag).unwrap_or_default();
|
||||||
Ok(s) => s,
|
let stderr_str = read_redirect(stderr_gag).unwrap_or_default();
|
||||||
Err(_) => String::new(),
|
|
||||||
};
|
|
||||||
let stderr_str = match read_redirect(stderr_gag) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(result, stdout_str, stderr_str)
|
(result, stdout_str, stderr_str)
|
||||||
}
|
}
|
||||||
|
|
@ -52,23 +44,31 @@ fn read_redirect(mut redirect: BufferRedirect) -> Result<String, std::io::Error>
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_capture_runs_closure() {
|
fn test_capture_basic() {
|
||||||
let (result, _stdout, _stderr) = capture_output(|| 42);
|
#[allow(clippy::let_and_return)]
|
||||||
assert_eq!(result, 42);
|
let captured = capture_output(|| {
|
||||||
|
println!("out");
|
||||||
|
eprintln!("err");
|
||||||
|
42
|
||||||
|
});
|
||||||
|
assert_eq!(captured.0, 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_capture_no_output_is_safe() {
|
fn test_capture_empty_output() {
|
||||||
let (result, stdout, stderr) = capture_output(|| 7);
|
#[allow(clippy::let_and_return)]
|
||||||
assert_eq!(result, 7);
|
let (result, stdout, stderr) = capture_output(|| 1);
|
||||||
// Strings are returned and are valid UTF-8
|
assert_eq!(result, 1);
|
||||||
assert!(stdout.is_empty() || stdout.chars().count() >= 0);
|
assert_eq!(stdout, String::new());
|
||||||
assert!(stderr.is_empty() || stderr.chars().count() >= 0);
|
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