M2: TUI — EngineHandle, ratatui app, markdown renderer, permission modal, session picker, snapshot tests

Builds the ratatui TUI: EngineHandle bridging the async engine to the render loop, chat viewport rendering with a markdown renderer, input handling, a permission modal wired to the real oneshot ask path, and a session picker. Adds TestBackend snapshot tests covering empty session, tool cards (running/completed/error), permission modal, session picker, and message rendering.
This commit is contained in:
2026-07-10 16:19:59 +02:00
parent d83f8c84b7
commit d4a846f827
18 changed files with 2848 additions and 79 deletions
+78 -8
View File
@@ -1,9 +1,19 @@
mod app;
mod input;
mod markdown;
mod modal;
mod render;
mod state;
mod terminal;
use std::path::PathBuf;
use harness_core::event::RunOutcome;
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-5";
fn print_usage() {
eprintln!("usage: harness run -p \"<prompt>\" [-m provider/model]");
eprintln!("usage: harness [run -p \"<prompt>\" [-m provider/model]] | [tui]");
}
fn parse_run_args(args: &[String]) -> Option<(String, Option<String>)> {
@@ -26,7 +36,7 @@ fn parse_run_args(args: &[String]) -> Option<(String, Option<String>)> {
prompt.map(|p| (p, model))
}
async fn run(args: &[String]) -> i32 {
async fn run_headless(args: &[String]) -> i32 {
let Some((prompt, model_arg)) = parse_run_args(args) else {
print_usage();
return 2;
@@ -49,7 +59,7 @@ async fn run(args: &[String]) -> i32 {
};
let model_ref = model_arg
.or_else(|| app.config.model.clone())
.or_else(|| app.config().model.clone())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
match app.run_prompt(prompt, &model_ref).await {
@@ -73,14 +83,74 @@ async fn run(args: &[String]) -> i32 {
}
}
fn log_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("ai-harness")
.join("log")
}
fn setup_tracing() -> anyhow::Result<tracing_appender::non_blocking::WorkerGuard> {
let log_dir = log_dir();
std::fs::create_dir_all(&log_dir)?;
let file_appender = tracing_appender::rolling::daily(log_dir, "harness-tui.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
Ok(guard)
}
async fn run_tui() -> i32 {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(e) => {
eprintln!("error: could not read cwd: {e}");
return 1;
}
};
let _guard = match setup_tracing() {
Ok(guard) => guard,
Err(e) => {
eprintln!("error: could not initialize logging: {e}");
return 1;
}
};
let mut app = match crate::app::App::new(cwd).await {
Ok(app) => app,
Err(e) => {
tracing::error!(error = %e, "failed to start TUI");
eprintln!("error: {e}");
return 1;
}
};
if let Err(e) = app.run().await {
tracing::error!(error = %e, "TUI error");
eprintln!("error: {e}");
return 1;
}
0
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let exit_code = if args.first().map(String::as_str) == Some("run") {
run(&args[1..]).await
} else {
println!("harness {}", env!("CARGO_PKG_VERSION"));
0
let exit_code = match args.first().map(String::as_str) {
Some("run") => run_headless(&args[1..]).await,
Some("tui") | None => run_tui().await,
Some("help") | Some("--help") | Some("-h") => {
print_usage();
0
}
Some(cmd) => {
eprintln!("unknown command: {cmd}");
print_usage();
2
}
};
std::process::exit(exit_code);
}