use std::io::BufRead;
use std::path::PathBuf;
use anyhow::{Context, Result};
use vtcode_commons::sanitizer::redact_secrets;
use vtcode_core::core::threads::ThreadEventRecord;
use vtcode_core::exec::events::VersionedThreadEvent;
use vtcode_core::utils::ansi::MessageStyle;
use vtcode_core::utils::file_utils::write_file_with_context_sync;
use super::{SlashCommandContext, SlashCommandControl};
use crate::agent::runloop::slash_commands::{LogFormat, LogScope};
const MAX_EVENTS_IN_MODAL: usize = 2000;
const MAX_FIELD_DISPLAY_LEN: usize = 200;
pub(crate) async fn handle_show_log_viewer(
ctx: SlashCommandContext<'_>,
format: LogFormat,
scope: LogScope,
save: bool,
) -> Result<SlashCommandControl> {
if !ctx.renderer.supports_inline_ui() {
ctx.renderer.line(MessageStyle::Error, "Log viewer requires inline UI mode.")?;
return Ok(SlashCommandControl::Continue);
}
let entries = collect_log_entries(ctx.thread_handle, &ctx.config.workspace, scope)?;
if entries.is_empty() {
ctx.renderer.line(MessageStyle::Info, "No events recorded yet.")?;
return Ok(SlashCommandControl::Continue);
}
let formatted = match format {
LogFormat::Text => format_entries_text(&entries),
LogFormat::Json => format_entries_json(&entries),
};
if save {
let path = save_log_file(&ctx.config.workspace, &formatted, format)?;
ctx.renderer
.line(MessageStyle::Info, &format!("Session log saved to: {}", path.display()))?;
return Ok(SlashCommandControl::Continue);
}
let display_lines = if entries.len() > MAX_EVENTS_IN_MODAL {
let truncated = &entries[..MAX_EVENTS_IN_MODAL];
match format {
LogFormat::Text => {
let mut lines = format_entries_text(truncated);
lines.push_str(&format!(
"\n... ({} of {} events shown, use --save to export full log)",
MAX_EVENTS_IN_MODAL,
entries.len()
));
lines
}
LogFormat::Json => {
let mut lines = format_entries_json(truncated);
lines.push_str(&format!(
"\n// ... ({} of {} events shown, use --save to export full log)",
MAX_EVENTS_IN_MODAL,
entries.len()
));
lines
}
}
} else {
match format {
LogFormat::Text => format_entries_text(&entries),
LogFormat::Json => format_entries_json(&entries),
}
};
ctx.handle
.show_modal(format!("Session Log — {} events", entries.len()), vec![display_lines], None);
Ok(SlashCommandControl::Continue)
}
struct LogEntry {
seq: usize,
event_type: &'static str,
detail: String,
}
fn collect_log_entries(
thread_handle: &vtcode_core::core::threads::ThreadRuntimeHandle,
workspace: &std::path::Path,
scope: LogScope,
) -> Result<Vec<LogEntry>> {
match scope {
LogScope::Thread => read_from_memory(thread_handle),
LogScope::All => collect_memory_and_disk(thread_handle, workspace),
}
}
fn read_from_memory(thread_handle: &vtcode_core::core::threads::ThreadRuntimeHandle) -> Result<Vec<LogEntry>> {
let records: Vec<ThreadEventRecord> = thread_handle.replay_recent();
let mut entries = Vec::with_capacity(records.len());
for record in records {
let (event_type, detail) = describe_event(&record.event);
entries.push(LogEntry { seq: record.sequence as usize, event_type, detail });
}
Ok(entries)
}
fn collect_memory_and_disk(
thread_handle: &vtcode_core::core::threads::ThreadRuntimeHandle,
workspace: &std::path::Path,
) -> Result<Vec<LogEntry>> {
let mut all_entries = read_from_memory(thread_handle)?;
let sessions_root = workspace.join(".vtcode/sessions");
let Ok(read_dir) = std::fs::read_dir(&sessions_root) else {
return Ok(all_entries);
};
for entry in read_dir.filter_map(|entry| entry.ok()) {
let path = entry.path().join("events.jsonl");
if !path.exists() {
continue;
}
if let Ok(disk_entries) = read_events_from_file(path) {
all_entries.extend(disk_entries);
}
}
all_entries.sort_by_key(|e| e.seq);
all_entries.dedup_by_key(|e| e.seq);
Ok(all_entries)
}
fn read_events_from_file(path: PathBuf) -> Result<Vec<LogEntry>> {
let file = std::fs::File::open(&path).with_context(|| format!("Failed to open events log: {}", path.display()))?;
let reader = std::io::BufReader::new(file);
let mut entries = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let Ok(versioned) = serde_json::from_str::<VersionedThreadEvent>(&line) else {
continue;
};
let event = versioned.into_event();
let (event_type, detail) = describe_event(&event);
entries.push(LogEntry { seq: idx, event_type, detail });
}
Ok(entries)
}
fn item_type_label(details: &vtcode_core::exec::events::ThreadItemDetails) -> &'static str {
use vtcode_core::exec::events::ThreadItemDetails;
match details {
ThreadItemDetails::AgentMessage(_) => "AgentMessage",
ThreadItemDetails::Plan(_) => "Plan",
ThreadItemDetails::Reasoning(_) => "Reasoning",
ThreadItemDetails::CommandExecution(_) => "CommandExecution",
ThreadItemDetails::ToolInvocation(_) => "ToolInvocation",
ThreadItemDetails::ToolOutput(_) => "ToolOutput",
ThreadItemDetails::FileChange(_) => "FileChange",
ThreadItemDetails::McpToolCall(_) => "McpToolCall",
ThreadItemDetails::WebSearch(_) => "WebSearch",
ThreadItemDetails::Harness(_) => "Harness",
ThreadItemDetails::Error(_) => "Error",
}
}
fn describe_event(event: &vtcode_core::exec::events::ThreadEvent) -> (&'static str, String) {
match event {
vtcode_core::exec::events::ThreadEvent::ThreadStarted(e) => {
("thread.started", format!("thread_id={}", e.thread_id))
}
vtcode_core::exec::events::ThreadEvent::ThreadCompleted(e) => (
"thread.completed",
format!("subtype={:?}, outcome={}, num_turns={}", e.subtype, e.outcome_code, e.num_turns),
),
vtcode_core::exec::events::ThreadEvent::ThreadCompactBoundary(e) => {
("thread.compact_boundary", format!("trigger={:?}", e.trigger))
}
vtcode_core::exec::events::ThreadEvent::TurnStarted(_) => ("turn.started", String::new()),
vtcode_core::exec::events::ThreadEvent::TurnCompleted(_) => ("turn.completed", String::new()),
vtcode_core::exec::events::ThreadEvent::TurnFailed(e) => {
("turn.failed", truncate(&e.message, MAX_FIELD_DISPLAY_LEN).to_string())
}
vtcode_core::exec::events::ThreadEvent::ItemStarted(e) => {
("item.started", format!("type={}", item_type_label(&e.item.details)))
}
vtcode_core::exec::events::ThreadEvent::ItemUpdated(e) => {
("item.updated", format!("type={}", item_type_label(&e.item.details)))
}
vtcode_core::exec::events::ThreadEvent::ItemCompleted(e) => {
("item.completed", format!("type={}", item_type_label(&e.item.details)))
}
vtcode_core::exec::events::ThreadEvent::PermissionRequested(e) => {
("permission.requested", format!("tool={}", truncate(&e.tool_name, MAX_FIELD_DISPLAY_LEN)))
}
vtcode_core::exec::events::ThreadEvent::PermissionResolved(e) => (
"permission.resolved",
format!(
"tool={}, decision={:?}, wait_ms={}",
truncate(&e.tool_name, MAX_FIELD_DISPLAY_LEN),
e.decision,
e.wait_ms
),
),
vtcode_core::exec::events::ThreadEvent::Interjected(e) => {
("interjected", format!("source={:?}, redirect={:?}", e.source, e.redirect_kind))
}
vtcode_core::exec::events::ThreadEvent::PlanDelta(e) => {
("plan.delta", format!("thread_id={}, turn_id={}, item_id={}", e.thread_id, e.turn_id, e.item_id))
}
vtcode_core::exec::events::ThreadEvent::PlanApprovalRequested(e) => {
("plan.approval.requested", format!("thread_id={}, turn_id={}", e.thread_id, e.turn_id))
}
vtcode_core::exec::events::ThreadEvent::PlanApprovalResolved(e) => (
"plan.approval.resolved",
format!(
"thread_id={}, turn_id={}, decision={:?}, automatic={}",
e.thread_id, e.turn_id, e.decision, e.automatic
),
),
vtcode_core::exec::events::ThreadEvent::Error(e) => {
("error", truncate(&e.message, MAX_FIELD_DISPLAY_LEN).to_string())
}
vtcode_core::exec::events::ThreadEvent::Unknown => ("unknown", String::new()),
}
}
fn format_entries_text(entries: &[LogEntry]) -> String {
entries
.iter()
.map(|e| {
let detail = if e.detail.is_empty() {
String::new()
} else {
format!(" — {}", redact_secrets(e.detail.clone()))
};
format!("[#{:04}] {}{}", e.seq, e.event_type, detail)
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_entries_json(entries: &[LogEntry]) -> String {
let lines: Vec<String> = entries
.iter()
.map(|e| {
serde_json::json!({
"seq": e.seq,
"type": e.event_type,
"detail": e.detail,
})
.to_string()
})
.collect();
lines.join("\n")
}
fn save_log_file(workspace: &std::path::Path, content: &str, format: LogFormat) -> Result<PathBuf> {
use chrono::Local;
let ts = Local::now().format("%Y%m%d_%H%M%S");
let ext = match format {
LogFormat::Text => "txt",
LogFormat::Json => "json",
};
let name = format!("vtcode-session-log-{}.{}", ts, ext);
let path = workspace.join(name);
write_file_with_context_sync(&path, content, "session log")?;
Ok(path)
}
fn truncate(s: &str, max: usize) -> &str {
let len = s.chars().count();
if len <= max {
s
} else {
s.split_at(s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len())).0
}
}