use ansi_to_tui::IntoText;
use crossterm::style::Color;
use crate::cli::Cli;
use crate::config::Config;
use crate::session::Session;
use crate::ui::colors::c_tool;
use crate::ui::events::sanitize_output;
use crate::ui::renderer::Renderer;
use crate::ui::tool_display::{
chamber_widths, close_tool_chamber_passive, fit_banner_header, format_tool_banner_value,
};
use super::RunCtx;
use super::tool_result::handle_tool_result;
fn simulate_tool_call(ctx: &mut RunCtx<'_>, id: &str, name: &str, args: serde_json::Value) {
ctx.tool_calls_buf.push(crate::session::ToolCallEntry {
id: id.to_string(),
name: name.to_string(),
args: args.clone(),
state: crate::session::ToolCallState::Interrupted,
});
*ctx.tool_calls_this_run = ctx.tool_calls_this_run.saturating_add(1);
close_tool_chamber_passive(
ctx.renderer,
ctx.last_tool_name,
ctx.tool_chamber_open,
ctx.chamber_top_start,
ctx.chamber_top_end,
)
.expect("close passive");
*ctx.last_tool_name = Some(name.to_string());
*ctx.last_tool_call_id = Some(id.to_string());
*ctx.chamber_top_start = Some(ctx.renderer.buffer_len());
ctx.renderer.write_line("", Color::White).expect("spacer");
let upper = name.to_ascii_uppercase();
let raw_value = format_tool_banner_value(name, &args);
let raw_value = sanitize_output(&raw_value).into_string();
let (frame_w, _) = chamber_widths(ctx.renderer);
let header = fit_banner_header(&upper, &raw_value, frame_w);
ctx.renderer.write_line(&header, c_tool()).expect("header");
*ctx.chamber_top_end = Some(ctx.renderer.buffer_len());
*ctx.tool_chamber_open = true;
}
#[derive(Debug)]
struct Chamber {
top_idx: usize,
bottom_idx: usize,
body_rows: usize,
name: String,
#[allow(dead_code)]
first_body: Option<String>,
}
fn collect_chambers(renderer: &Renderer) -> Vec<Chamber> {
let lines: Vec<String> = renderer
.buffer_lines()
.into_iter()
.map(|s| s.to_string())
.collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with('\u{256d}') {
let name = extract_banner_name(&lines[i]);
let top_idx = i;
let mut j = i + 1;
while j < lines.len() && !lines[j].starts_with('\u{2570}') {
j += 1;
}
if j < lines.len() {
let body_rows = j.saturating_sub(top_idx).saturating_sub(1);
let first_body = lines.get(top_idx + 1).cloned();
out.push(Chamber {
top_idx,
bottom_idx: j,
body_rows,
name,
first_body,
});
i = j + 1;
} else {
out.push(Chamber {
top_idx,
bottom_idx: usize::MAX,
body_rows: 0,
name,
first_body: None,
});
break;
}
} else {
i += 1;
}
}
out
}
fn extract_banner_name(top_line: &str) -> String {
top_line
.split('\u{2500}') .map(|s| s.trim())
.find(|s| !s.is_empty() && !s.starts_with('\u{256d}'))
.unwrap_or("?")
.to_string()
}
fn fresh_scaffold() -> (Cli, Config, Session, Renderer) {
use clap::Parser;
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let session = Session::new("test-provider", "test-model", 200_000);
let renderer = Renderer::new().expect("renderer");
(cli, cfg, session, renderer)
}
#[allow(clippy::too_many_arguments)]
fn make_ctx<'a>(
renderer: &'a mut Renderer,
session: &'a mut Session,
state: &'a mut State,
cli: &'a Cli,
cfg: &'a Config,
) -> RunCtx<'a> {
RunCtx {
renderer,
session,
response_buf: &mut state.response_buf,
response_start_line: &mut state.response_start_line,
reasoning_buf: &mut state.reasoning_buf,
reasoning_start_line: &mut state.reasoning_start_line,
agent_line_started: &mut state.agent_line_started,
last_tool_name: &mut state.last_tool_name,
last_tool_call_id: &mut state.last_tool_call_id,
tool_chamber_open: &mut state.tool_chamber_open,
chamber_top_start: &mut state.chamber_top_start,
chamber_top_end: &mut state.chamber_top_end,
tool_calls_buf: &mut state.tool_calls_buf,
tool_calls_this_run: &mut state.tool_calls_this_run,
last_collapsed: &mut state.last_collapsed,
last_thinking: &mut state.last_thinking,
expand_target: &mut state.expand_target,
expansion_anchor: &mut state.expansion_anchor,
last_user_prompt: &mut state.last_user_prompt,
cli,
cfg,
active_plan: &mut state.active_plan,
}
}
struct State {
response_buf: String,
response_start_line: Option<usize>,
reasoning_buf: String,
reasoning_start_line: Option<usize>,
agent_line_started: bool,
last_tool_name: Option<String>,
last_tool_call_id: Option<String>,
tool_chamber_open: bool,
chamber_top_start: Option<usize>,
chamber_top_end: Option<usize>,
tool_calls_buf: Vec<crate::session::ToolCallEntry>,
tool_calls_this_run: u32,
last_collapsed: Option<crate::ui::tool_display::CollapsedToolResult>,
last_thinking: Option<String>,
expand_target: crate::ui::state::ExpandTarget,
expansion_anchor: Option<(usize, usize, u64)>,
last_user_prompt: String,
active_plan: Option<crate::agent::plan::runtime::ActivePlan>,
}
impl State {
fn new() -> Self {
Self {
response_buf: String::new(),
response_start_line: None,
reasoning_buf: String::new(),
reasoning_start_line: None,
agent_line_started: false,
last_tool_name: None,
last_tool_call_id: None,
tool_chamber_open: false,
chamber_top_start: None,
chamber_top_end: None,
tool_calls_buf: Vec::new(),
tool_calls_this_run: 0,
last_collapsed: None,
last_thinking: None,
expand_target: crate::ui::state::ExpandTarget::None,
expansion_anchor: None,
last_user_prompt: String::new(),
active_plan: None,
}
}
}
async fn drive(n: usize, result_order: Vec<usize>) -> Vec<Chamber> {
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let mut state = State::new();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
for i in 0..n {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.txt")}),
);
}
for i in result_order {
let id = format!("call-{i}");
let body = format!("file {i} body line one\nfile {i} body line two");
handle_tool_result(&mut ctx, id, body)
.await
.expect("handle_tool_result");
}
drop(ctx);
collect_chambers(&renderer)
}
fn assert_all_chambers_have_body(chambers: &[Chamber], expected_count: usize) {
assert_eq!(
chambers.len(),
expected_count,
"expected {expected_count} chambers, got {}: {chambers:#?}",
chambers.len()
);
let mut empties = Vec::new();
for (i, c) in chambers.iter().enumerate() {
if c.bottom_idx == usize::MAX {
empties.push(format!(
"chamber {i} ({}): UNCLOSED — TOP at line {} has no matching BOTTOM",
c.name, c.top_idx
));
} else if c.body_rows == 0 {
empties.push(format!(
"chamber {i} ({}): TOP+BOTTOM only — no body rows between lines {} and {}",
c.name, c.top_idx, c.bottom_idx
));
}
}
if !empties.is_empty() {
panic!(
"dirge-5h5 reproduced: {}/{} chambers have no body. Details:\n {}",
empties.len(),
chambers.len(),
empties.join("\n ")
);
}
}
#[tokio::test]
async fn dirge_5h5_repro_seven_parallel_dispatch_order() {
let chambers = drive(7, (0..7).collect()).await;
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_seven_parallel_reverse_order() {
let chambers = drive(7, (0..7).rev().collect()).await;
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_seven_parallel_scrambled_order() {
let order = vec![3, 0, 6, 1, 5, 2, 4];
let chambers = drive(7, order).await;
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_two_parallel_reverse_order() {
let chambers = drive(2, vec![0, 1]).await;
assert_all_chambers_have_body(&chambers, 2);
}
#[tokio::test]
async fn dirge_5h5_repro_seven_parallel_match_arrives_last() {
let chambers = drive(7, vec![0, 1, 2, 3, 4, 5, 6]).await;
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_interleaved_baseline() {
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let mut state = State::new();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
for i in 0..7 {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.txt")}),
);
let body = format!("file {i} body line one\nfile {i} body line two");
handle_tool_result(&mut ctx, id.clone(), body)
.await
.expect("handle_tool_result");
}
drop(ctx);
let chambers = collect_chambers(&renderer);
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_add_chat_during_burst() {
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let mut state = State::new();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
let _pre = ctx.renderer.add_chat("subagent-pre");
let buffer_len_after_pre = ctx.renderer.buffer_len();
assert_eq!(
buffer_len_after_pre, 0,
"add_chat should not touch active buffer (pre)"
);
for i in 0..7 {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.txt")}),
);
if i == 3 {
let len_before = ctx.renderer.buffer_len();
let _mid = ctx.renderer.add_chat("subagent-mid");
let len_after = ctx.renderer.buffer_len();
assert_eq!(
len_before, len_after,
"add_chat mid-burst must not mutate active buffer length"
);
}
}
for i in 0..7 {
let id = format!("call-{i}");
let body = format!("file {i} body line one\nfile {i} body line two");
handle_tool_result(&mut ctx, id, body)
.await
.expect("handle_tool_result");
}
let _post = ctx.renderer.add_chat("subagent-post");
drop(ctx);
let chambers = collect_chambers(&renderer);
assert_all_chambers_have_body(&chambers, 7);
}
#[tokio::test]
async fn dirge_5h5_repro_buffer_integrity_after_burst() {
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let mut state = State::new();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
for i in 0..7 {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.txt")}),
);
}
for i in 0..7 {
let id = format!("call-{i}");
let body = format!("file {i} body line one\nfile {i} body line two");
handle_tool_result(&mut ctx, id, body)
.await
.expect("handle_tool_result");
}
drop(ctx);
let chambers = collect_chambers(&renderer);
assert_eq!(
chambers.len(),
7,
"expected 7 chambers, got {}",
chambers.len()
);
for (i, c) in chambers.iter().enumerate() {
assert!(
c.body_rows >= 1,
"chamber {i} ({}) lost body rows: {:?}",
c.name,
c
);
}
}
#[tokio::test]
async fn dirge_5h5_repro_subagent_writes_between_tool_results() {
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let subagent_idx = renderer.add_chat("task: simulated subagent");
assert_eq!(subagent_idx, 1, "subagent chat should be at idx 1");
let mut state = State::new();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
for i in 0..7 {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.txt")}),
);
}
use crate::ui::colors::c_agent;
use crate::ui::theme;
for i in 0..7 {
let id = format!("call-{i}");
let body = format!("file {i} body line one\nfile {i} body line two");
handle_tool_result(&mut ctx, id, body)
.await
.expect("handle_tool_result");
let _ = ctx.renderer.write_line_to_chat(
subagent_idx,
"<you> simulated subagent prompt",
theme::user(),
);
let _ = ctx
.renderer
.write_line_to_chat(subagent_idx, "(subagent running…)", theme::dim());
let _ = ctx.renderer.write_line_to_chat(
subagent_idx,
"<dirge> simulated subagent result",
c_agent(),
);
}
drop(ctx);
let chambers = collect_chambers(&renderer);
assert_all_chambers_have_body(&chambers, 7);
}
#[test]
fn chamber_row_parses_to_single_line_under_into_text() {
let inputs = [
"1: hello world",
" fn main() {",
" let x = 42;",
"",
" │ already-quoted │",
"1: line with spaces and tabs\t.",
"let foo = bar; // comment",
"// comment",
" return Ok(())",
"}",
];
for body in inputs {
let row = crate::ui::box_render::row(crate::ui::box_render::BoxStyle::Rounded, body, 80);
let parsed = row.as_str().into_text().expect("parse");
assert_eq!(
parsed.lines.len(),
1,
"chamber-row for {body:?} parsed to {} lines (paint_line only renders the first!): row={:?} parsed={:#?}",
parsed.lines.len(),
row,
parsed,
);
}
}
#[test]
fn chamber_row_with_bg_parses_to_single_line() {
let inputs = ["+ added line", "- removed line", " context line"];
for body in inputs {
let row = crate::ui::box_render::row_with_bg(
crate::ui::box_render::BoxStyle::Rounded,
body,
80,
22,
);
let parsed = row.as_str().into_text().expect("parse");
assert_eq!(
parsed.lines.len(),
1,
"chamber_row_with_bg for {body:?} parsed to {} lines: row={:?} parsed={:#?}",
parsed.lines.len(),
row,
parsed,
);
}
}
#[test]
fn chamber_top_banner_parses_to_single_line() {
for value in [
"/tmp/file0.txt",
"/tmp/very/long/path/with/many/segments/file.txt",
"(no args)",
"",
] {
let header = fit_banner_header("READ", value, 80);
let parsed = header.as_str().into_text().expect("parse");
assert_eq!(
parsed.lines.len(),
1,
"banner for value={value:?} parsed to {} lines: header={:?} parsed={:#?}",
parsed.lines.len(),
header,
parsed,
);
}
}
#[test]
fn empty_spacer_into_text_behaviour() {
let parsed = "".into_text().expect("parse");
assert!(
parsed.lines.len() <= 1,
"empty string parsed to {} lines",
parsed.lines.len()
);
}
#[tokio::test]
async fn dirge_5h5_repro_full_issue_shape() {
use crate::ui::theme;
let (cli, cfg, mut session, mut renderer) = fresh_scaffold();
let mut state = State::new();
let subagent_idxs: Vec<usize> = (0..4)
.map(|i| {
let idx = renderer.add_chat(format!("subagent-{i}"));
let _ = renderer.write_line_to_chat(
idx,
&format!("<you> subagent task {i}"),
theme::user(),
);
idx
})
.collect();
let mut ctx = make_ctx(&mut renderer, &mut session, &mut state, &cli, &cfg);
for i in 0..7 {
let id = format!("call-{i}");
simulate_tool_call(
&mut ctx,
&id,
"read",
serde_json::json!({"path": format!("/tmp/file{i}.rs")}),
);
}
let result_order = [3, 0, 6, 1, 5, 2, 4];
for (step, &i) in result_order.iter().enumerate() {
let id = format!("call-{i}");
let body = format!(
"1: // file {i}\n2: fn main() {{\n3: println!(\"hello from {i}\");\n4: }}\n5: "
);
handle_tool_result(&mut ctx, id, body)
.await
.expect("handle_tool_result");
let sub_idx = subagent_idxs[step % subagent_idxs.len()];
let _ = ctx.renderer.write_line_to_chat(
sub_idx,
&format!("<dirge> subagent step {step}"),
c_agent(),
);
}
drop(ctx);
let chambers = collect_chambers(&renderer);
assert_all_chambers_have_body(&chambers, 7);
for (i, c) in chambers.iter().enumerate() {
let first = c.first_body.as_deref().unwrap_or("");
assert!(
first.contains("1:") || first.contains("//") || first.starts_with('\u{2502}'),
"chamber {i} ({}) first body row doesn't look like read output: {first:?}",
c.name
);
}
}
use crate::ui::colors::c_agent;
#[test]
fn realistic_read_body_lines_parse_one_each() {
let body = "\
1: use std::fs;
2:
3: fn main() {
4: let s = fs::read_to_string(\"x\").unwrap();
5: println!(\"{}\", s);
6: }
7: ";
let sanitized = sanitize_output(body).into_string();
let lines: Vec<&str> = sanitized.lines().collect();
assert_eq!(
lines.len(),
7,
"expected 7 lines after sanitize_output, got {}: {:?}",
lines.len(),
lines
);
for line in &lines {
let row = crate::ui::box_render::row(crate::ui::box_render::BoxStyle::Rounded, line, 80);
let parsed = row.as_str().into_text().expect("parse");
assert_eq!(
parsed.lines.len(),
1,
"row {row:?} parsed to {} lines",
parsed.lines.len()
);
}
}