use anstyle::{Reset, Style as AnsiStyle};
use std::borrow::Cow;
use std::io::{self, Write as _};
use std::time::Duration;
use vtcode_commons::color_policy::color_output_enabled;
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_ui::tui::ui::theme;
use crate::agent::runloop::unified::state::FirstCallComposition;
pub(crate) struct ExitData<'a> {
pub app_name: &'static str,
pub version: &'static str,
pub model: &'a str,
pub provider: &'a str,
pub trust_label: &'a str,
pub reasoning: &'a str,
pub session_duration: Duration,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub cached_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_hit_rate_percent: Option<f64>,
pub code_additions: u64,
pub code_deletions: u64,
pub final_response: Option<&'a str>,
pub resume_identifier: Option<&'a str>,
pub budget_limit: Option<(f64, f64)>,
pub total_cost_usd: Option<f64>,
pub end_reason_label: &'static str,
pub first_call_composition: Option<FirstCallComposition>,
pub session_end_reason: vtcode_core::hooks::SessionEndReason,
}
pub(crate) fn print_exit_summary(data: ExitData<'_>) {
crate::agent::runloop::unified::session_setup::mark_exit_postamble_armed();
vtcode_ui::tui::panic_hook::finish_deferred_raw_mode_restore();
vtcode_ui::tui::panic_hook::ensure_raw_mode_disabled();
if matches!(data.session_end_reason, vtcode_core::hooks::SessionEndReason::Completed)
&& let Some(response) = data.final_response.filter(|response| !response.trim().is_empty())
{
render_final_response(response);
}
write_postamble(&build_exit_postamble(&data, color_output_enabled()));
}
fn build_exit_postamble(data: &ExitData<'_>, color_enabled: bool) -> String {
let styles = PostambleStyles::resolve(color_enabled);
let mut out = String::with_capacity(640);
out.push_str("\r\n");
if matches!(
data.session_end_reason,
vtcode_core::hooks::SessionEndReason::Exit | vtcode_core::hooks::SessionEndReason::Cancelled
) {
push_row(&mut out, styles.notice("Interrupted — session exited. Transcript saved; resume to continue."));
}
out.push_str("\r\n");
push_row(&mut out, styles.banner(&format!("> {} ({})", data.app_name, data.version)));
let trust = build_trust_label(data.trust_label);
if !trust.is_empty() {
push_row(&mut out, styles.muted(&trust));
}
let model_line = build_model_line(&styles, data);
if !model_line.is_empty() {
push_row(&mut out, model_line);
}
push_row(&mut out, styles.muted(&build_stats_line(data)));
if let Some((max_budget_usd, actual_cost_usd)) = data.budget_limit {
push_row(&mut out, styles.muted(&format!("Budget at ${actual_cost_usd:.2} / ${max_budget_usd:.2}")));
}
if let Some(session_id) = data.resume_identifier {
push_row(
&mut out,
format!("{} {}", styles.muted("Resume:"), styles.accent(&format!("vtcode --resume {session_id}")),),
);
}
out.push_str("\r\n");
out
}
struct PostambleStyles {
banner: AnsiStyle,
notice: AnsiStyle,
muted: AnsiStyle,
accent: AnsiStyle,
color_enabled: bool,
}
impl PostambleStyles {
fn resolve(color_enabled: bool) -> Self {
let styles = theme::active_styles();
Self {
banner: theme::banner_style(),
notice: styles.info,
muted: styles.tool_detail,
accent: styles.primary,
color_enabled,
}
}
fn paint(&self, style: &AnsiStyle, text: &str) -> String {
if self.color_enabled {
format!("{style}{text}{Reset}")
} else {
text.to_string()
}
}
fn banner(&self, text: &str) -> String {
self.paint(&self.banner, text)
}
fn notice(&self, text: &str) -> String {
self.paint(&self.notice, text)
}
fn muted(&self, text: &str) -> String {
self.paint(&self.muted, text)
}
fn accent(&self, text: &str) -> String {
self.paint(&self.accent, text)
}
}
fn push_row(out: &mut String, content: String) {
out.push('\r');
out.push_str(&content);
out.push('\n');
}
fn write_postamble(postamble: &str) {
let mut stdout = io::stdout().lock();
let payload = format!("{}{}", viewport_bottom_anchor(), postamble);
if let Err(error) = stdout.write_all(payload.as_bytes()).and_then(|()| stdout.flush()) {
tracing::warn!(%error, "failed to write the exit postamble");
}
vtcode_ui::tui::panic_hook::drain_pending_terminal_input();
}
fn viewport_bottom_anchor() -> String {
match crossterm::terminal::size() {
Ok((_, rows)) => anchor_for_rows(rows),
_ => "\r\n".to_string(),
}
}
fn anchor_for_rows(rows: u16) -> String {
if rows == 0 {
return "\r\n".to_string();
}
format!("\x1b[r\x1b[{rows};1H")
}
fn render_final_response(response: &str) {
let mut renderer = AnsiRenderer::stdout();
if let Err(error) = renderer.line(MessageStyle::Response, response) {
tracing::warn!(%error, "failed to render final response during session exit");
}
}
fn build_stats_line(data: &ExitData<'_>) -> String {
let mut stats = Vec::new();
stats.push(format!("Session {}", format_duration(data.session_duration)));
if data.prompt_tokens > 0 || data.completion_tokens > 0 {
stats
.push(format!("{} in / {} out", format_number(data.prompt_tokens), format_number(data.completion_tokens),));
}
if data.cached_tokens > 0 {
let mut cache_stat = format!("Cache {} read", format_number(data.cached_tokens));
if let Some(hit_rate) = data.cache_hit_rate_percent {
cache_stat.push_str(&format!(" ({hit_rate:.1}% hit rate)"));
}
if data.cache_creation_tokens > 0 {
cache_stat.push_str(&format!(", {} creation", format_number(data.cache_creation_tokens)));
}
stats.push(cache_stat);
}
if data.code_additions > 0 || data.code_deletions > 0 {
stats.push(format!("Code +{} / -{}", data.code_additions, data.code_deletions));
}
if let Some(cost) = data.total_cost_usd {
stats.push(format!("Cost ${cost:.2}"));
}
if !data.end_reason_label.is_empty() {
stats.push(format!("End {}", data.end_reason_label));
}
if let Some(overhead) = data.first_call_composition {
let fixed = overhead.fixed_overhead_tokens();
if fixed > 0 {
stats.push(format!(
"First-call overhead {} (system {} + tools {})",
format_number(fixed as u64),
format_number(overhead.system_prompt_tokens as u64),
format_number(overhead.tool_schema_tokens as u64),
));
}
}
stats.join(" | ")
}
fn build_model_line(styles: &PostambleStyles, data: &ExitData<'_>) -> String {
let model = data.model.trim();
let provider = data.provider.trim();
let reasoning = data.reasoning.trim();
let show_model = !model.is_empty();
let show_provider = !provider.is_empty();
let show_reasoning = !reasoning.is_empty();
let mut line = match (show_model, show_provider) {
(true, true) => {
format!("{} {} {}", styles.muted("Model:"), styles.accent(model), styles.muted(&format!("via {provider}")),)
}
(true, false) => format!("{} {}", styles.muted("Model:"), styles.accent(model)),
(false, true) => format!("{} {provider}", styles.muted("Provider:")),
(false, false) => String::new(),
};
if show_reasoning {
let suffix = format!(" · {reasoning}");
if line.is_empty() {
line = styles.muted(&format!("Reasoning:{suffix}"));
} else {
line.push_str(&styles.muted(&suffix));
}
}
line
}
fn build_trust_label(trust_label: &str) -> Cow<'static, str> {
let t = trust_label.trim().to_ascii_lowercase().replace('_', " ");
if t.contains("full auto") {
Cow::Borrowed("Full-auto trust")
} else if t.contains("tools policy") {
Cow::Borrowed("Safe tools")
} else if t.is_empty() || t == "unknown" {
Cow::Borrowed("")
} else {
Cow::Owned(format!("Trust: {t}"))
}
}
fn format_duration(d: Duration) -> String {
let s = d.as_secs();
let h = s / 3600;
let m = (s % 3600) / 60;
let sec = s % 60;
if h > 0 {
format!("{h}h {m}m {sec}s")
} else if m > 0 {
format!("{m}m {sec}s")
} else {
format!("{sec}s")
}
}
fn format_number(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}m", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trust_line_full_auto() {
assert_eq!(build_trust_label("full auto").as_ref(), "Full-auto trust");
assert_eq!(build_trust_label("full_auto").as_ref(), "Full-auto trust");
}
#[test]
fn trust_line_safe_tools() {
assert_eq!(build_trust_label("tools policy").as_ref(), "Safe tools");
assert_eq!(build_trust_label("tools_policy").as_ref(), "Safe tools");
}
#[test]
fn trust_line_empty_or_unknown() {
assert_eq!(build_trust_label("").as_ref(), "");
assert_eq!(build_trust_label("unknown").as_ref(), "");
}
#[test]
fn trust_line_fallback() {
assert_eq!(build_trust_label("some_other").as_ref(), "Trust: some other");
}
#[test]
fn formats_duration() {
assert_eq!(format_duration(Duration::from_secs(55)), "55s");
assert_eq!(format_duration(Duration::from_secs(95)), "1m 35s");
assert_eq!(format_duration(Duration::from_secs(3670)), "1h 1m 10s");
}
#[test]
fn formats_numbers() {
assert_eq!(format_number(999), "999");
assert_eq!(format_number(12_345), "12.3k");
assert_eq!(format_number(8_900_000), "8.9m");
}
fn stats_test_data(
session_duration: Duration,
prompt_tokens: u64,
completion_tokens: u64,
cached_tokens: u64,
cache_creation_tokens: u64,
cache_hit_rate_percent: Option<f64>,
code_additions: u64,
code_deletions: u64,
) -> ExitData<'static> {
ExitData {
app_name: "VT Code",
version: "0.0.0",
model: "",
provider: "",
trust_label: "",
reasoning: "",
session_duration,
prompt_tokens,
completion_tokens,
cached_tokens,
cache_creation_tokens,
cache_hit_rate_percent,
code_additions,
code_deletions,
final_response: None,
resume_identifier: None,
budget_limit: None,
total_cost_usd: None,
end_reason_label: "",
first_call_composition: None,
session_end_reason: vtcode_core::hooks::SessionEndReason::Completed,
}
}
#[test]
fn stats_line_with_zero_input_omits_token_and_cache_segments() {
let data = stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0);
let line = build_stats_line(&data);
assert_eq!(line, "Session 30s");
}
#[test]
fn stats_line_includes_first_call_overhead_when_present() {
let data = ExitData {
first_call_composition: Some(FirstCallComposition {
system_prompt_tokens: 1_100,
tool_schema_tokens: 2_100,
message_history_tokens: 40,
on_wire_tools: 4,
}),
..stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0)
};
let line = build_stats_line(&data);
assert!(
line.contains("First-call overhead 3.2k (system 1.1k + tools 2.1k)"),
"stats line missing first-call overhead: {line}"
);
}
#[test]
fn stats_line_omits_zero_first_call_overhead() {
let data = ExitData {
first_call_composition: Some(FirstCallComposition {
system_prompt_tokens: 0,
tool_schema_tokens: 0,
message_history_tokens: 0,
on_wire_tools: 0,
}),
..stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0)
};
let line = build_stats_line(&data);
assert_eq!(line, "Session 30s");
}
#[test]
fn stats_line_includes_cost_when_pricing_is_known() {
let data = ExitData {
total_cost_usd: Some(0.42),
end_reason_label: "cancelled",
..stats_test_data(Duration::from_secs(30), 1_000, 100, 0, 0, None, 0, 0)
};
let line = build_stats_line(&data);
assert!(line.contains("Cost $0.42"), "missing cost: {line}");
assert!(line.contains("End cancelled"), "missing end reason: {line}");
}
#[test]
fn stats_line_omits_cost_when_pricing_is_unknown() {
let data = stats_test_data(Duration::from_secs(30), 1_000, 100, 0, 0, None, 0, 0);
let line = build_stats_line(&data);
assert!(!line.contains("Cost"), "unknown pricing must not show cost: {line}");
}
#[test]
fn stats_line_with_cache_includes_hit_rate_and_creation_tokens() {
let data = stats_test_data(Duration::from_secs(95), 1_000, 200, 800, 50, Some(80.0), 10, 2);
let line = build_stats_line(&data);
assert_eq!(
line,
"Session 1m 35s | 1.0k in / 200 out | Cache 800 read (80.0% hit rate), 50 creation | Code +10 / -2"
);
}
#[test]
fn stats_line_with_cache_but_no_creation_omits_creation_segment() {
let data = stats_test_data(Duration::from_secs(10), 500, 100, 400, 0, Some(80.0), 0, 0);
let line = build_stats_line(&data);
assert_eq!(line, "Session 10s | 500 in / 100 out | Cache 400 read (80.0% hit rate)");
}
fn colored_postamble(data: &ExitData<'_>) -> String {
build_exit_postamble(data, true)
}
#[test]
fn final_response_is_rendered_before_exit_metrics() {
let data = ExitData {
final_response: Some("final response"),
session_end_reason: vtcode_core::hooks::SessionEndReason::Completed,
..stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0)
};
let postamble = colored_postamble(&data);
assert!(postamble.contains("> VT Code (0.0.0)"), "title row missing: {postamble:?}");
assert!(postamble.contains("Session 30s"), "stats row missing: {postamble:?}");
assert!(
!postamble.contains("Interrupted — session exited"),
"completed exits must not carry the interrupt notice: {postamble:?}"
);
}
#[test]
fn interrupt_postamble_keeps_full_summary_block() {
let data = ExitData {
final_response: Some("long transcript body that must not leak"),
session_end_reason: vtcode_core::hooks::SessionEndReason::Exit,
resume_identifier: Some("session-test-1234"),
..stats_test_data(Duration::from_secs(95), 1_000, 200, 800, 50, Some(80.0), 10, 2)
};
let postamble = colored_postamble(&data);
let rows: Vec<&str> = postamble.split('\n').collect();
let notice_row = rows
.iter()
.position(|row| row.contains("Interrupted — session exited"))
.expect("interrupt notice row");
let title_row = rows.iter().position(|row| row.contains("> VT Code")).expect("title row");
let stats_row = rows.iter().position(|row| row.contains("Session 1m 35s")).expect("stats row");
let resume_row = rows
.iter()
.position(|row| row.contains("vtcode --resume session-test-1234"))
.expect("resume id row");
assert!(notice_row < title_row, "notice must precede the block: {postamble:?}");
assert!(title_row < stats_row, "block order broken: {postamble:?}");
assert!(stats_row < resume_row, "resume id must follow the stats row: {postamble:?}");
assert!(
!postamble.contains("long transcript body"),
"interrupt exits must suppress the final response dump: {postamble:?}"
);
}
#[test]
fn postamble_rows_are_carriage_prefixed_and_open_on_a_fresh_row() {
let data = ExitData {
session_end_reason: vtcode_core::hooks::SessionEndReason::Exit,
..stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0)
};
let postamble = colored_postamble(&data);
assert!(postamble.starts_with("\r\n"), "postamble must open on a fresh row: {postamble:?}");
for row in postamble.split('\n').filter(|row| !row.is_empty()) {
assert!(row.starts_with('\r'), "every postamble row must return the carriage: {row:?}");
}
}
#[test]
fn postamble_avoids_faint_and_unvalidated_palette_styling() {
let data = ExitData {
model: "gpt-5.6-sol",
provider: "openai",
reasoning: "medium",
trust_label: "tools policy",
resume_identifier: Some("session-x"),
..stats_test_data(Duration::from_secs(95), 1_000, 200, 800, 50, Some(80.0), 10, 2)
};
let postamble = colored_postamble(&data);
assert!(
!postamble.contains("\x1b[2m") && !postamble.contains("\x1b[2;"),
"faint styling must not ship: {postamble:?}"
);
assert!(!postamble.contains("38;5;"), "hand-picked 256-palette colors must not ship: {postamble:?}");
let opens = postamble.matches("\x1b[").count();
assert!(
opens >= 4 && postamble.matches(&*Reset.to_string()).count() >= 4,
"styled spans must be closed: {postamble:?}"
);
}
#[test]
fn postamble_colors_meet_the_theme_minimum_contrast() {
let styles = PostambleStyles::resolve(true);
let minimum = theme::get_minimum_contrast();
for (name, style) in [
("banner", &styles.banner),
("notice", &styles.notice),
("muted", &styles.muted),
("accent", &styles.accent),
] {
let ratio = theme::style_contrast_ratio(style)
.unwrap_or_else(|| panic!("{name} style must carry an RGB foreground"));
assert!(ratio >= minimum, "{name} contrast {ratio:.2} below {minimum:.1}");
}
}
#[test]
fn postamble_emits_the_active_theme_tokens() {
let styles = PostambleStyles::resolve(true);
let data = ExitData {
model: "gpt-5.6-sol",
provider: "openai",
resume_identifier: Some("session-x"),
..stats_test_data(Duration::from_secs(30), 1_000, 100, 0, 0, None, 0, 0)
};
let postamble = colored_postamble(&data);
assert!(
postamble.contains(&styles.banner.to_string()),
"title row must use the theme banner style: {postamble:?}"
);
assert!(
postamble.contains(&styles.accent.to_string()),
"model and resume command must use the theme accent: {postamble:?}"
);
assert!(
postamble.contains(&styles.muted.to_string()),
"metadata rows must use the theme muted token: {postamble:?}"
);
}
#[test]
fn postamble_is_plain_text_when_color_is_disabled() {
let data = ExitData {
model: "gpt-5.6-sol",
provider: "openai",
reasoning: "medium",
session_end_reason: vtcode_core::hooks::SessionEndReason::Exit,
resume_identifier: Some("session-x"),
..stats_test_data(Duration::from_secs(30), 1_000, 100, 0, 0, None, 0, 0)
};
let postamble = build_exit_postamble(&data, false);
assert!(!postamble.contains('\x1b'), "plain output must carry no escapes: {postamble:?}");
assert!(postamble.contains("Model: gpt-5.6-sol via openai"), "model row text: {postamble:?}");
assert!(postamble.contains("Resume: vtcode --resume session-x"), "resume row text: {postamble:?}");
assert!(
postamble.contains("Interrupted — session exited"),
"interrupt notice must survive without color: {postamble:?}"
);
}
#[test]
fn viewport_anchor_resets_scroll_region_and_targets_the_last_row() {
let anchor = anchor_for_rows(40);
assert_eq!(anchor, "\x1b[r\x1b[40;1H");
let anchor = anchor_for_rows(1);
assert_eq!(anchor, "\x1b[r\x1b[1;1H");
assert!(anchor_for_rows(0).starts_with("\r\n"), "unknown geometry must not emit a bad CUP");
}
#[test]
fn exit_suppresses_final_response_dump() {
for reason in [
vtcode_core::hooks::SessionEndReason::Exit,
vtcode_core::hooks::SessionEndReason::Cancelled,
vtcode_core::hooks::SessionEndReason::Error,
] {
let data = ExitData {
final_response: Some("long transcript body that must not leak"),
session_end_reason: reason,
..stats_test_data(Duration::from_secs(30), 0, 0, 0, 0, None, 0, 0)
};
let postamble = colored_postamble(&data);
assert!(
!postamble.contains("long transcript body"),
"exit reason {reason:?} must suppress final response dump: {postamble:?}"
);
assert!(postamble.contains("Session 30s"), "exit metrics must still render for {reason:?}: {postamble:?}");
}
}
}