use chrono::Datelike;
use compact_str::CompactString;
use crossterm::style::Color;
use crate::cli::Cli;
use crate::config::Config;
use crate::context::ContextFiles;
use crate::session::{MessageRole, Session};
use crate::ui::markdown;
use crate::ui::renderer::Renderer;
use crate::ui::theme;
pub fn session_preview(session: &crate::session::Session, max_chars: usize) -> String {
if session.messages.is_empty() && session.compactions.is_empty() {
return String::new();
}
let raw = compaction_active_task(session)
.or_else(|| first_user_message(session))
.or_else(|| last_message_content(session))
.unwrap_or_default();
truncate_oneline(&raw, max_chars)
}
fn compaction_active_task(session: &crate::session::Session) -> Option<String> {
let summary = session.compactions.last()?.summary.as_str();
for heading in ["## Active Task", "## Goal"] {
if let Some(start) = summary.find(heading) {
let rest = &summary[start + heading.len()..];
for line in rest.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if t.starts_with('#') {
break;
}
return Some(t.to_string());
}
}
}
None
}
fn first_user_message(session: &crate::session::Session) -> Option<String> {
session
.messages
.iter()
.find(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.to_string())
.filter(|s| !s.is_empty())
}
fn last_message_content(session: &crate::session::Session) -> Option<String> {
session
.messages
.last()
.map(|m| m.content.to_string())
.filter(|s| !s.is_empty())
}
fn truncate_oneline(s: &str, max_chars: usize) -> String {
let mut collapsed = String::with_capacity(s.len());
let mut prev_space = false;
for c in s.chars() {
let mapped = if c == '\n' || c == '\r' || c == '\t' {
' '
} else {
c
};
if mapped == ' ' {
if prev_space {
continue;
}
prev_space = true;
} else {
prev_space = false;
}
collapsed.push(mapped);
}
let trimmed = collapsed.trim();
if trimmed.chars().count() <= max_chars {
trimmed.to_string()
} else {
let prefix: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
format!("{prefix}…")
}
}
pub fn format_time(rfc3339: &str) -> CompactString {
let dt = chrono::DateTime::parse_from_rfc3339(rfc3339).ok();
let dt = match dt {
Some(dt) => dt,
None => return CompactString::new(rfc3339),
};
let local = dt.with_timezone(&chrono::Local);
let now = chrono::Local::now();
if local.date_naive() == now.date_naive() {
CompactString::new(local.format("%H:%M").to_string())
} else if local.year() == now.year() {
CompactString::new(local.format("%b %d %H:%M").to_string())
} else {
CompactString::new(local.format("%Y-%m-%d %H:%M").to_string())
}
}
pub(crate) fn finalization_nudge_body(content: &str) -> Option<&str> {
use crate::agent::agent_loop::{critic::CRITIC_TAG, run::TODO_NUDGE_TAG, verifier::VERIFY_TAG};
let trimmed = content.trim_start();
[CRITIC_TAG, VERIFY_TAG, TODO_NUDGE_TAG]
.into_iter()
.find_map(|tag| trimmed.strip_prefix(tag).map(str::trim_start))
}
pub fn render_session(
renderer: &mut Renderer,
session: &Session,
cli: &Cli,
cfg: &Config,
context: &ContextFiles,
) -> anyhow::Result<()> {
renderer.clear_content()?;
let provider = cli.resolve_provider(cfg);
let config_model = cfg
.resolve_role(crate::config::ConfigRole::Default)
.and_then(|(_, e)| e.model);
let model = if cli.model.is_none() && config_model.is_none() {
compact_str::CompactString::new(crate::provider::default_model_for_alias(
&provider,
&cfg.providers_map(),
))
} else {
cli.resolve_model(cfg)
};
renderer.write_line("", Color::Reset)?;
renderer.write_line("", Color::Reset)?;
render_banner(renderer, &provider, &model)?;
if context.agents.is_some() {
renderer.write_line("░ loaded AGENTS.md", theme::dim())?;
renderer.write_line("", Color::Reset)?;
}
if !session.compactions.is_empty() {
renderer.write_line(
&format!(
"░ compacted {} times (saved ~{} tokens)",
session.compactions.len(),
session
.compactions
.last()
.map(|c| c.token_savings)
.unwrap_or(0),
),
theme::dim(),
)?;
renderer.write_line("", Color::Reset)?;
}
let total = session.messages.len();
for (idx, msg) in session.messages.iter().enumerate() {
let nudge_body = if msg.role == MessageRole::User {
finalization_nudge_body(&msg.content)
} else {
None
};
let (handle, line_color) = if nudge_body.is_some() {
("<critic> ", theme::critic())
} else {
match msg.role {
MessageRole::User => ("<you> ", theme::user()),
MessageRole::Assistant => ("<dirge> ", theme::agent()),
MessageRole::System => ("<sys> ", theme::system()),
}
};
let body: &str = nudge_body.unwrap_or(&msg.content);
let cont_indent = " ".repeat(handle.chars().count());
if msg.role == MessageRole::Assistant {
if !msg.content.is_empty() {
let max_width = renderer
.content_width()
.saturating_sub(handle.chars().count() + 1);
let mut styled = markdown::markdown_to_styled(&msg.content, max_width, line_color);
for (i, entry) in styled.iter_mut().enumerate() {
if i == 0 {
entry.text = CompactString::from(format!("{} {}", handle, entry.text));
} else {
entry.text = CompactString::from(format!("{}{}", cont_indent, entry.text));
}
}
for entry in styled {
renderer.write_line(&entry.text, entry.color)?;
}
}
render_tool_calls_replay(
renderer,
&msg.tool_calls,
cfg.resolve_tool_result_max_chars(),
cfg.resolve_tool_result_max_lines(),
)?;
} else {
for (i, line) in body.lines().enumerate() {
let prefix = if i == 0 {
handle.to_string()
} else {
cont_indent.clone()
};
renderer.write_line(&format!("{} {}", prefix, line), line_color)?;
}
}
if idx + 1 < total {
renderer.write_line("·", theme::divider())?;
} else {
renderer.write_line("", Color::Reset)?;
}
}
Ok(())
}
pub(crate) fn render_tool_calls_replay(
renderer: &mut Renderer,
tool_calls: &[crate::session::ToolCallEntry],
max_chars: usize,
max_lines: usize,
) -> anyhow::Result<()> {
use crate::session::ToolCallState;
use crate::ui::tool_display::{
chamber_widths, fit_banner_header, format_tool_banner_value, render_tool_output,
};
for tc in tool_calls {
let banner_value = format_tool_banner_value(&tc.name, &tc.args);
let (frame_w, _) = chamber_widths(renderer);
let header = fit_banner_header(&tc.name.to_ascii_uppercase(), &banner_value, frame_w);
renderer.write_line("", Color::Reset)?;
renderer.write_line(&header, theme::tool())?;
let body = match &tc.state {
ToolCallState::Completed { result } => result.clone(),
ToolCallState::Interrupted => "[Tool execution was interrupted]".to_string(),
ToolCallState::Failed { error } => format!("[Tool error: {error}]"),
};
render_tool_output(
renderer,
&tc.name,
&banner_value,
&body,
max_chars,
max_lines,
)?;
}
Ok(())
}
const DIRGE_BLOCK_ART: &[&str] = &[
"██████╗ ██╗██████╗ ██████╗ ███████╗",
"██╔══██╗██║██╔══██╗██╔════╝ ██╔════╝",
"██║ ██║██║██████╔╝██║ ███╗█████╗ ",
"██║ ██║██║██╔══██╗██║ ██║██╔══╝ ",
"██████╔╝██║██║ ██║╚██████╔╝███████╗",
"╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
];
fn render_banner(renderer: &mut Renderer, provider: &str, model: &str) -> anyhow::Result<()> {
let label = theme::current().label;
let version = env!("CARGO_PKG_VERSION");
let term_w = renderer.line_width().max(20);
if term_w < 50 {
renderer.write_line(
&format!("╭─ DIRGE · {} · v{} ", label, version),
theme::banner_primary(),
)?;
renderer.write_line(
&format!("│ provider: {} · model: {}", provider, model),
theme::banner_secondary(),
)?;
renderer.write_line("╰─", theme::banner_secondary())?;
renderer.write_line("", Color::Reset)?;
return Ok(());
}
let frame_w = term_w.min(78);
let inner_w = frame_w.saturating_sub(2);
let top_label = format!(" DIRGE · {} ", label);
let top_label_len = top_label.chars().count();
let top_filler = inner_w.saturating_sub(top_label_len + 2);
let top_left = "─".repeat(2);
let top_right = "─".repeat(top_filler);
let top_border = format!("╭{}{}{}╮", top_left, top_label, top_right);
let bot_label = format!(" v{} · {} · {} ", version, provider, model);
let bot_label_len = bot_label.chars().count();
let bot_left = "─".repeat(inner_w.saturating_sub(bot_label_len + 2));
let bot_right = "─".repeat(2);
let bot_border = format!("╰{}{}{}╯", bot_left, bot_label, bot_right);
renderer.write_line(&top_border, theme::banner_secondary())?;
renderer.write_line(
&format!("│{}│", " ".repeat(inner_w)),
theme::banner_secondary(),
)?;
for art_line in DIRGE_BLOCK_ART {
let art_len = art_line.chars().count();
let total_pad = inner_w.saturating_sub(art_len);
let left = total_pad / 2;
let right = total_pad - left;
let line = format!("│{}{}{}│", " ".repeat(left), art_line, " ".repeat(right),);
renderer.write_line(&line, theme::banner_primary())?;
}
renderer.write_line(
&format!("│{}│", " ".repeat(inner_w)),
theme::banner_secondary(),
)?;
renderer.write_line(&bot_border, theme::banner_secondary())?;
renderer.write_line("", Color::Reset)?;
Ok(())
}
pub fn sanitize_output(text: &str) -> CompactString {
use crate::ui::ansi::{StripPolicy, strip_escapes};
let stripped = strip_orphan_mouse_reports(text);
CompactString::from(strip_escapes(&stripped, StripPolicy::KEEP_BOTH))
}
fn strip_orphan_mouse_reports(text: &str) -> String {
let bytes: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '[' && i + 1 < bytes.len() && bytes[i + 1] == '<' {
let mut j = i + 2;
let mut saw_digit_or_semi = false;
while j < bytes.len() {
let c = bytes[j];
if c.is_ascii_digit() || c == ';' {
saw_digit_or_semi = true;
j += 1;
} else if (c == 'M' || c == 'm') && saw_digit_or_semi {
i = j + 1;
break;
} else {
out.push(bytes[i]);
i += 1;
break;
}
}
if j >= bytes.len() {
out.push(bytes[i]);
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{MessageRole, Session};
#[test]
fn finalization_nudge_body_recognizes_all_tags() {
use crate::agent::agent_loop::{
critic::CRITIC_TAG, run::TODO_NUDGE_TAG, verifier::VERIFY_TAG,
};
assert_eq!(
finalization_nudge_body(&format!("{CRITIC_TAG} not done yet")),
Some("not done yet")
);
assert_eq!(
finalization_nudge_body(&format!("{VERIFY_TAG} run the tests")),
Some("run the tests")
);
assert_eq!(
finalization_nudge_body(&format!(
"{TODO_NUDGE_TAG} You still have 6 unfinished todos"
)),
Some("You still have 6 unfinished todos")
);
assert_eq!(finalization_nudge_body("fix the parser bug"), None);
assert_eq!(finalization_nudge_body("[note] just a note"), None);
}
fn make_session() -> Session {
Session::new("openrouter", "gpt-5", 200_000)
}
#[test]
fn session_preview_empty_session_is_empty() {
let s = make_session();
assert_eq!(session_preview(&s, 60), "");
}
#[test]
fn session_preview_uses_first_user_message_when_no_compaction() {
let mut s = make_session();
s.add_message(
MessageRole::User,
"Implement session_search exclusion for current session",
);
s.add_message(MessageRole::Assistant, "ok done");
let p = session_preview(&s, 60);
assert!(
p.starts_with("Implement session_search"),
"preview should lead with the user prompt: {p:?}"
);
assert!(!p.contains("ok done"));
}
#[test]
fn session_preview_truncates_long_prompts_with_ellipsis() {
let mut s = make_session();
let long = "x".repeat(200);
s.add_message(MessageRole::User, &long);
let p = session_preview(&s, 30);
assert_eq!(p.chars().count(), 30, "preview must respect max_chars");
assert!(p.ends_with('…'));
}
#[test]
fn session_preview_collapses_newlines_to_single_line() {
let mut s = make_session();
s.add_message(MessageRole::User, "first line\n\nsecond line\nthird\ttab");
let p = session_preview(&s, 80);
assert!(!p.contains('\n'), "preview must be single-line: {p:?}");
assert!(!p.contains('\t'));
assert!(!p.contains(" "), "double-space remained: {p:?}");
assert!(p.contains("first line second line third tab"));
}
#[test]
fn session_preview_skips_system_messages_for_first_user() {
let mut s = make_session();
s.add_message(MessageRole::System, "system bootstrap");
s.add_message(MessageRole::User, "user intent");
s.add_message(MessageRole::Assistant, "reply");
let p = session_preview(&s, 60);
assert!(p.contains("user intent"));
assert!(!p.contains("system bootstrap"));
assert!(!p.contains("reply"));
}
#[test]
fn session_preview_falls_back_to_last_when_no_user_message() {
let mut s = make_session();
s.add_message(MessageRole::Assistant, "spontaneous greeting");
s.add_message(MessageRole::Assistant, "follow-up reply");
let p = session_preview(&s, 60);
assert!(
p.contains("follow-up reply"),
"fallback should be last message: {p:?}"
);
}
#[test]
fn session_preview_prefers_compaction_active_task() {
let mut s = make_session();
s.add_message(MessageRole::User, "old prompt that got compacted");
s.compactions.push(crate::session::Compaction {
summary: compact_str::CompactString::new(
"## Active Task\nWire session_search current-session exclusion\n\n## Goal\nFix the bug",
),
first_kept_index: 1,
summarized_count: 1,
token_savings: 100,
created_at: compact_str::CompactString::new("2026-05-28T00:00:00Z"),
});
let p = session_preview(&s, 80);
assert!(
p.contains("Wire session_search current-session exclusion"),
"preview must use compaction Active Task: {p:?}"
);
assert!(
!p.contains("old prompt"),
"compaction overrides first-user-message: {p:?}"
);
}
#[test]
fn session_preview_falls_back_to_goal_when_active_task_missing() {
let mut s = make_session();
s.add_message(MessageRole::User, "u");
s.compactions.push(crate::session::Compaction {
summary: compact_str::CompactString::new(
"## Goal\nrefactor the curator into umbrella skills\n\n## Completed\n- nothing",
),
first_kept_index: 1,
summarized_count: 1,
token_savings: 50,
created_at: compact_str::CompactString::new("2026-05-28T00:00:00Z"),
});
let p = session_preview(&s, 80);
assert!(
p.contains("refactor the curator into umbrella skills"),
"Goal section should be used when Active Task absent: {p:?}"
);
}
#[test]
fn session_preview_ignores_empty_active_task_section() {
let mut s = make_session();
s.add_message(MessageRole::User, "the user's actual intent");
s.compactions.push(crate::session::Compaction {
summary: compact_str::CompactString::new("## Active Task\n\n## Goal\n\n## Notes\n"),
first_kept_index: 1,
summarized_count: 1,
token_savings: 0,
created_at: compact_str::CompactString::new("2026-05-28T00:00:00Z"),
});
let p = session_preview(&s, 80);
assert!(
p.contains("the user's actual intent"),
"should fall through past empty sections to user message: {p:?}"
);
}
#[test]
fn replays_tool_calls_as_chambers() {
use crate::session::{ToolCallEntry, ToolCallState};
let mut renderer = Renderer::new().unwrap();
renderer.set_chat_rect_for_test(ratatui::layout::Rect::new(0, 1, 100, 24));
let calls = vec![ToolCallEntry {
id: "call_1".into(),
name: "bash".into(),
args: serde_json::json!({ "command": "ls -la" }),
state: ToolCallState::Completed {
result: "total 4\ndrwxr-xr-x src".into(),
},
}];
render_tool_calls_replay(&mut renderer, &calls, 10_000, 100).unwrap();
let text = renderer.buffer_lines().join("\n");
assert!(text.contains("BASH"), "missing tool header: {text}");
assert!(text.contains("ls -la"), "missing banner value: {text}");
assert!(text.contains("total 4"), "missing tool output: {text}");
}
#[test]
fn replays_failed_tool_call_with_error_body() {
use crate::session::{ToolCallEntry, ToolCallState};
let mut renderer = Renderer::new().unwrap();
renderer.set_chat_rect_for_test(ratatui::layout::Rect::new(0, 1, 100, 24));
let calls = vec![ToolCallEntry {
id: "call_1".into(),
name: "read".into(),
args: serde_json::json!({ "path": "/nope.txt" }),
state: ToolCallState::Failed {
error: "file not found".into(),
},
}];
render_tool_calls_replay(&mut renderer, &calls, 10_000, 100).unwrap();
let text = renderer.buffer_lines().join("\n");
assert!(text.contains("READ"), "missing tool header: {text}");
assert!(
text.contains("file not found"),
"missing error body: {text}"
);
}
}