use crate::util::{
clamp, continue_hint, count_messages, home_rel, parse_harness, resolve, resolve_found, short_id, split_harness_id,
usage, WindowArgs,
};
use anyhow::{bail, Context, Result};
use cv_core::ir::{truncate, Block, Harness, Message, MessageKind, Role, Session, SessionRef};
use cv_core::render::turn_label;
use cv_core::Adapter;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
const PIPE_GUARD_BYTES: usize = 200 * 1024;
const PIPE_GUARD_EDGE: usize = 20;
pub(crate) struct ShowView {
pub subagents: bool,
pub agent: Option<String>,
pub pre_compaction: Option<usize>,
pub tree: bool,
pub kind: Option<String>,
pub summaries: bool,
}
enum KindView {
Said { prompts: bool, answers: bool },
Compactions,
}
fn parse_kinds(spec: &str) -> Result<KindView> {
const SAID: [&str; 2] = ["prompt", "answer"];
const SEAMS: [&str; 2] = ["compaction_boundary", "compaction_summary"];
let kinds: Vec<&str> = spec.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
let accepted = "`--kind` takes `prompt,answer` (what the person said) or \
`compaction_boundary,compaction_summary` (the compaction seams)";
if let Some(k) = kinds.iter().find(|k| !SAID.contains(k) && !SEAMS.contains(k)) {
return usage(format!("no `--kind` view for {k:?} — {accepted}"));
}
let said = kinds.iter().filter(|k| SAID.contains(k)).count();
match (said, kinds.len() - said) {
(0, 0) => usage(format!("`--kind` is empty — {accepted}")),
(_, 0) => Ok(KindView::Said {
prompts: kinds.contains(&"prompt"),
answers: kinds.contains(&"answer"),
}),
(0, _) => Ok(KindView::Compactions),
_ => usage(format!("`--kind {spec}` mixes two views — {accepted}")),
}
}
pub(crate) fn cmd_show(
id: &str,
harness: Option<String>,
json: bool,
window: &WindowArgs,
view: ShowView,
) -> Result<()> {
let ShowView {
subagents,
agent,
pre_compaction,
tree,
kind,
summaries,
} = view;
let selector = window.window()?;
let kind = kind.as_deref().map(parse_kinds).transpose()?;
if summaries && !matches!(kind, Some(KindView::Compactions)) {
return usage("`--summaries` goes with `--kind compaction_boundary,compaction_summary`");
}
if let Some(kind) = kind {
let (r, adapter) = resolve(id, parse_harness(&harness)?)?;
return match kind {
KindView::Said { prompts, answers } => {
if window.max_bytes.is_some() {
return usage("`--max-bytes` cuts a transcript; `--kind prompt,answer` rows are printed whole");
}
let range = show_range(adapter.as_ref(), &r, window, pre_compaction)?;
crate::cmd::orchestrate::print_prompts(&r, adapter.as_ref(), range, prompts, answers, json)
}
KindView::Compactions => {
if selector.is_some() || window.max_bytes.is_some() || pre_compaction.is_some() {
return usage("the compaction view covers the whole session; drop the window flags");
}
crate::cmd::compaction::print_compactions(&r, summaries, json)
}
};
}
if tree && !json {
if selector.is_some() || window.max_bytes.is_some() {
return usage("`--tree` renders the whole session; drop the window flags (or add `--json` for the IR)");
}
let (r, adapter) = resolve(id, parse_harness(&harness)?)?;
return print_tree(&r, adapter.as_ref());
}
let (want, id) = split_harness_id(id, parse_harness(&harness)?);
if id.starts_with("agent-") && show_agent_fleetwide(id, json, window)? {
return Ok(());
}
let found = match cv_core::find_cheap(id, want) {
Ok(Some(hit)) => Ok(Some(hit)),
Ok(None) => {
if show_agent_fleetwide(id, json, window)? {
return Ok(());
}
cv_core::find(id, want)
}
Err(e) => Err(e),
};
let (r, adapter) = match found {
Ok(Some(hit)) => hit,
Ok(None) => return usage(format!("no session (and no sub-agent) matching {id:?}")),
Err(e) => return resolve_found(Err(e), id, want).map(|_| ()),
};
if let Some(agent_id) = &agent {
return show_one_subagent(&r, adapter.as_ref(), agent_id, json, window);
}
if subagents {
return show_subagents(&r, json);
}
let range = show_range(adapter.as_ref(), &r, window, pre_compaction)?;
if json {
return print_session_json(adapter.as_ref(), &r, range, window.max_bytes, false, None).map(|_| ());
}
let guard = selector.is_none() && pre_compaction.is_none() && window.max_bytes.is_none();
render_text(adapter.as_ref(), &r, range, window.max_bytes, guard)
}
fn show_range(
adapter: &dyn Adapter,
r: &SessionRef,
window: &WindowArgs,
pre_compaction: Option<usize>,
) -> Result<Option<(usize, Option<usize>)>> {
let Some(n) = pre_compaction else {
return window.bounds(|| count_messages(adapter, r));
};
let comps = cv_core::compaction::detect(r, false)?;
if comps.is_empty() {
bail!("{} never compacted — nothing pre-compaction to show", short_id(&r.id));
}
let idx = n.saturating_sub(1);
let (start, end) = cv_core::compaction::pre_compaction_span(&comps, idx).with_context(|| {
format!(
"{} compacted {} time(s); no compaction #{n} (use 1..={})",
short_id(&r.id),
comps.len(),
comps.len(),
)
})?;
eprintln!(
"✦ pre-compaction #{n} of {}: messages {start}..{end} (the span before boundary @msg {})",
comps.len(),
comps[idx].boundary_msg_idx,
);
Ok(Some((start, Some(end))))
}
fn print_session_json(
adapter: &dyn Adapter,
r: &SessionRef,
range: Option<(usize, Option<usize>)>,
max_bytes: Option<usize>,
as_document: bool,
redact: Option<&cv_core::redact::RedactOptions>,
) -> Result<cv_core::redact::RedactStats> {
let mut session = adapter.parse(r)?;
let mut stats = cv_core::redact::RedactStats::default();
let start = range.map(|(s, _)| s).unwrap_or(0);
if let Some(rg) = range {
let (s, e) = clamp(rg, session.messages.len());
session.messages = session.messages.drain(s..e).collect();
}
if let Some(budget) = max_bytes {
let mut bytes = 0usize;
let mut keep = 0usize;
for (i, m) in session.messages.iter().enumerate() {
let n = serde_json::to_vec(m)?.len();
if keep > 0 && bytes + n > budget {
break;
}
bytes += n;
keep = i + 1;
}
if keep < session.messages.len() {
eprintln!("{}", continue_hint(start + keep));
session.messages.truncate(keep);
}
}
if let Some(opts) = redact {
let (clean, st) = cv_core::redact::redact_with(&session, opts);
session = clean;
stats = st;
}
if as_document {
println!(
"{}",
serde_json::to_string_pretty(&cv_core::harness::opensession::document(&session))?
);
} else {
println!("{}", serde_json::to_string_pretty(&session)?);
}
Ok(stats)
}
fn render_text(
adapter: &dyn Adapter,
r: &SessionRef,
range: Option<(usize, Option<usize>)>,
max_bytes: Option<usize>,
guard: bool,
) -> Result<()> {
let stdout = std::io::stdout();
if guard && !stdout.is_terminal() {
let mut buf = Vec::new();
let probe = stream_session_render(
adapter,
r,
&mut buf,
show_header,
show_message,
RenderOpts {
range: None,
max_bytes: Some(PIPE_GUARD_BYTES),
},
)?;
let mut out = std::io::BufWriter::new(stdout.lock());
if probe.next.is_none() {
out.write_all(&buf)?;
out.flush()?;
return Ok(());
}
let total = count_messages(adapter, r)?;
if total <= 2 * PIPE_GUARD_EDGE {
stream_session_render(adapter, r, &mut out, show_header, show_message, RenderOpts::default())?;
out.flush()?;
return Ok(());
}
stream_session_render(
adapter,
r,
&mut out,
show_header,
show_message,
RenderOpts {
range: Some((0, Some(PIPE_GUARD_EDGE))),
max_bytes: None,
},
)?;
writeln!(
out,
"… {} messages omitted ({total} total; the full render exceeds {} KB on a pipe) — read them with \
--first N, --last N, --range A..B, or --around N; --max-bytes N caps output\n",
total - 2 * PIPE_GUARD_EDGE,
PIPE_GUARD_BYTES / 1024,
)?;
stream_session_render(
adapter,
r,
&mut out,
|_| String::new(),
show_message,
RenderOpts {
range: Some((total - PIPE_GUARD_EDGE, None)),
max_bytes: None,
},
)?;
out.flush()?;
return Ok(());
}
let mut out = std::io::BufWriter::new(stdout.lock());
let outcome = stream_session_render(
adapter,
r,
&mut out,
show_header,
show_message,
RenderOpts { range, max_bytes },
)?;
if let Some(next) = outcome.next {
writeln!(out, "{}", continue_hint(next))?;
}
out.flush()?;
Ok(())
}
fn show_agent_fleetwide(agent_id: &str, json: bool, window: &WindowArgs) -> Result<bool> {
let parents = cv_core::find_subagent_parents(agent_id);
let parents = if parents.len() > 1 && cv_core::one_agent_across(&parents, agent_id) {
eprintln!(
"✦ {} sessions hold a copy of this agent (adopted); reading the newest — `cv show <session> --agent {agent_id}` for another",
parents.len()
);
parents[..1].to_vec()
} else {
parents
};
match parents.as_slice() {
[] => Ok(false),
[parent] => {
let adapter = cv_core::harness::for_harness(parent.harness)
.with_context(|| format!("no adapter for {}", parent.harness))?;
eprintln!(
"✦ sub-agent of session {} ({})",
short_id(&parent.id),
parent.title.as_deref().unwrap_or("untitled"),
);
show_one_subagent(parent, adapter.as_ref(), agent_id, json, window)?;
Ok(true)
}
many => {
let mut lines: Vec<String> = many
.iter()
.map(|p| {
format!(
"{}:{} # cv show {} --agent {agent_id} ({})",
p.harness.as_str(),
p.id,
short_id(&p.id),
p.title.as_deref().unwrap_or("untitled")
)
})
.collect();
lines.sort();
usage(format!(
"ambiguous sub-agent id {agent_id:?} — {} sessions spawned a match:\n{}",
many.len(),
lines.join("\n")
))
}
}
}
fn show_subagents(r: &SessionRef, json: bool) -> Result<()> {
let subs = cv_core::subagent_tree_of(r);
if json {
let enriched: Vec<serde_json::Value> = subs
.iter()
.map(|s| {
let mut v = serde_json::to_value(s).unwrap_or(serde_json::Value::Null);
if let Some(obj) = v.as_object_mut() {
obj.insert("agent_id".into(), serde_json::Value::String(s.agent_id().to_string()));
if s.result_summary.is_none() {
if let Some(ret) = cv_core::harness::claude::subagent_return(&s.session.path) {
obj.insert("return".into(), serde_json::Value::String(ret));
}
}
}
v
})
.collect();
println!("{}", serde_json::to_string_pretty(&enriched)?);
return Ok(());
}
if subs.is_empty() {
println!("no sub-agents spawned by {}", short_id(&r.id));
return Ok(());
}
println!("# sub-agents of {}\n", short_id(&r.id));
for s in &subs {
let wf = s.workflow.as_deref().map(|w| format!(" ⟐{w}")).unwrap_or_default();
println!("• {}{wf}", subagent_line(s));
if s.result_summary.is_none() {
if let Some(ret) = cv_core::harness::claude::subagent_return(&s.session.path) {
println!(" ↩ {}", truncate(&cv_core::sanitize::sanitize_line(&ret), 400));
}
}
}
Ok(())
}
pub(crate) fn resolve_subagent(parent: &SessionRef, agent_id: &str) -> Result<cv_core::SubagentInfo> {
let subs = cv_core::subagent_tree_of(parent);
let matches: Vec<&cv_core::SubagentInfo> = subs
.iter()
.filter(|s| {
s.session.id == agent_id
|| s.agent_id() == agent_id
|| s.session.id.starts_with(agent_id)
|| s.agent_id().starts_with(agent_id)
})
.collect();
match matches.as_slice() {
[one] => Ok((*one).clone()),
[] => usage(format!(
"no sub-agent matching {agent_id:?} under {} ({} sub-agent(s); try `cv show {} --subagents`)",
short_id(&parent.id),
subs.len(),
short_id(&parent.id),
)),
many => {
let mut lines: Vec<String> = many
.iter()
.map(|s| format!("{}:{}", s.session.harness.as_str(), s.session.id))
.collect();
lines.sort();
usage(format!(
"ambiguous sub-agent id {agent_id:?} — {} candidates under {}:\n{}",
many.len(),
short_id(&parent.id),
lines.join("\n")
))
}
}
}
fn show_one_subagent(
parent: &SessionRef,
adapter: &dyn Adapter,
agent_id: &str,
json: bool,
window: &WindowArgs,
) -> Result<()> {
let sub = resolve_subagent(parent, agent_id)?;
let range = window.bounds(|| count_messages(adapter, &sub.session))?;
if json {
return print_session_json(adapter, &sub.session, range, window.max_bytes, false, None).map(|_| ());
}
if let Some(d) = &sub.description {
println!("# sub-agent: {}", truncate(d, 120));
}
let wf = sub
.workflow
.as_deref()
.map(|w| format!(" · workflow {w}"))
.unwrap_or_default();
let status = sub
.result_status
.as_deref()
.map(|st| format!(" · {st}"))
.unwrap_or_default();
println!(
"{} · {}{}{}\n",
sub.agent_type.as_deref().unwrap_or("agent"),
sub.agent_id(),
status,
wf,
);
render_text(adapter, &sub.session, range, window.max_bytes, false)
}
pub(crate) fn cmd_export(
id: &str,
format: &str,
harness: Option<String>,
window: &WindowArgs,
redact: bool,
) -> Result<()> {
use cv_core::redact::{redact_message, redact_text, RedactOptions, RedactStats};
let want = parse_harness(&harness)?;
let (r, adapter) = resolve(id, want)?;
let range = window.bounds(|| count_messages(adapter.as_ref(), &r))?;
let opts = RedactOptions::default();
let stats = std::cell::RefCell::new(RedactStats::default());
match format {
"md" | "markdown" => {
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
let outcome = stream_session_render(
adapter.as_ref(),
&r,
&mut out,
|h| {
let head = md_header(h);
if redact {
redact_text(&head)
} else {
head
}
},
|m| {
if !redact {
return md_message(m);
}
let mut m = m.clone();
redact_message(&mut m, &opts, &mut stats.borrow_mut());
md_message(&m)
},
RenderOpts {
range,
max_bytes: window.max_bytes,
},
)?;
if let Some(next) = outcome.next {
writeln!(out, "{}", continue_hint(next))?;
}
out.flush()?;
}
"json" => {
let st = print_session_json(
adapter.as_ref(),
&r,
range,
window.max_bytes,
true,
redact.then_some(&opts),
)?;
*stats.borrow_mut() = st;
}
"html" => {
return usage(
"`cv export --format html` was removed in 0.15.0 — `cv share <id> [--out FILE]` writes the \
self-contained, redacted HTML artifact (`--no-redact` to keep secrets)",
)
}
other => bail!("unknown format {other:?} (use md or json; `cv share` for html)"),
}
if redact {
eprintln!("{}", redaction_summary(&stats.borrow()));
}
Ok(())
}
pub(crate) fn redaction_summary(st: &cv_core::redact::RedactStats) -> String {
format!(
"✦ redacted {} item(s): {} api_key, {} private_key, {} jwt, {} email, {} blob, {} assignment",
st.total(),
st.api_keys,
st.private_keys,
st.jwts,
st.emails,
st.blobs,
st.assignments,
)
}
fn print_tree(r: &SessionRef, adapter: &dyn Adapter) -> Result<()> {
let session = adapter.parse(r)?;
println!("# {}", session.label());
println!("{} · {} · {} msg", session.harness, session.id, session.messages.len());
println!();
let has_threading = session.messages.iter().any(|m| m.parent_id.is_some());
if has_threading {
render_tree_dag(&session);
} else {
for (i, m) in session.messages.iter().enumerate() {
println!("{:>4}. {}", i + 1, tree_line(m));
}
}
render_subagent_forest(r);
Ok(())
}
fn render_subagent_forest(r: &SessionRef) {
use std::collections::BTreeMap;
let subs = cv_core::subagent_tree_of(r);
if subs.is_empty() {
return;
}
let direct: Vec<&cv_core::SubagentInfo> = subs.iter().filter(|s| s.workflow.is_none()).collect();
let mut by_wf: BTreeMap<&str, Vec<&cv_core::SubagentInfo>> = BTreeMap::new();
for s in subs.iter().filter(|s| s.workflow.is_some()) {
by_wf.entry(s.workflow.as_deref().unwrap_or("")).or_default().push(s);
}
println!();
println!(
"## sub-agents ({} direct, {} workflow agent(s) across {} workflow(s))",
direct.len(),
subs.len() - direct.len(),
by_wf.len()
);
if !direct.is_empty() {
println!();
for s in &direct {
println!("• {}", subagent_line(s));
}
}
for (wf, agents) in &by_wf {
println!("\n ⟐ workflow {wf} ({} agent(s))", agents.len());
for s in agents {
println!(" • {}", subagent_line(s));
}
}
}
fn subagent_line(s: &cv_core::SubagentInfo) -> String {
let id = short_id(s.agent_id());
let kind = s.agent_type.as_deref().unwrap_or("agent");
let status = s
.result_status
.as_deref()
.map(|st| format!(" [{st}]"))
.unwrap_or_default();
let blurb = s
.result_summary
.as_deref()
.or(s.description.as_deref())
.map(|t| truncate(&cv_core::sanitize::sanitize_line(t), 88))
.unwrap_or_default();
format!("{id} {kind}{status} {} msg {blurb}", s.session.message_count)
}
fn render_tree_dag(session: &Session) {
use std::collections::HashMap;
let mut by_id: HashMap<&str, usize> = HashMap::new();
for (i, m) in session.messages.iter().enumerate() {
if let Some(id) = &m.id {
by_id.insert(id.as_str(), i);
}
}
let mut children: HashMap<Option<usize>, Vec<usize>> = HashMap::new();
for (i, m) in session.messages.iter().enumerate() {
let parent = m
.parent_id
.as_deref()
.and_then(|p| by_id.get(p).copied())
.filter(|&p| p != i);
children.entry(parent).or_default().push(i);
}
fn walk(
node: Option<usize>,
depth: usize,
session: &Session,
children: &std::collections::HashMap<Option<usize>, Vec<usize>>,
) {
if let Some(kids) = children.get(&node) {
for &c in kids {
let indent = " ".repeat(depth);
println!("{indent}• {}", tree_line(&session.messages[c]));
walk(Some(c), depth + 1, session, children);
}
}
}
walk(None, 0, session, &children);
}
fn tree_line(m: &Message) -> String {
let role = turn_label(m);
let mut tags = Vec::new();
let has_tool_use = m.content.iter().any(|b| matches!(b, Block::ToolUse { .. }));
let has_tool_result = m.content.iter().any(|b| matches!(b, Block::ToolResult { .. }));
if has_tool_use {
tags.push("🔧 tool".to_string());
for b in &m.content {
if let Block::ToolUse { name, .. } = b {
let n = name.to_ascii_lowercase();
if n.contains("task") || n.contains("agent") || n.contains("dispatch") || n.contains("spawn") {
tags.push(format!("↳ sub-agent ({name})"));
}
}
}
}
if has_tool_result {
tags.push("↩ result".to_string());
}
if m.kind == MessageKind::SubagentSpawn && !tags.iter().any(|t| t.starts_with("↳ sub-agent")) {
tags.push("↳ sub-agent".to_string());
}
let preview = m.text().map(|t| truncate(&t, 80)).unwrap_or_else(|| {
if has_tool_use {
m.content
.iter()
.find_map(|b| match b {
Block::ToolUse { name, .. } => Some(format!("[{name}]")),
_ => None,
})
.unwrap_or_default()
} else {
String::new()
}
});
let tagstr = if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join(", "))
};
format!("{role}{tagstr} {preview}")
}
pub(crate) struct HeaderInfo {
harness: Harness,
id: String,
cwd: Option<PathBuf>,
label: String,
model: Option<String>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct RenderOpts {
pub range: Option<(usize, Option<usize>)>,
pub max_bytes: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct RenderOutcome {
pub rendered: usize,
pub next: Option<usize>,
}
pub(crate) fn stream_session_render<W: std::io::Write>(
adapter: &dyn Adapter,
r: &SessionRef,
out: &mut W,
header: impl Fn(&HeaderInfo) -> String,
render_msg: impl Fn(&Message) -> String,
opts: RenderOpts,
) -> Result<RenderOutcome> {
use cv_core::{Flow, MessageSink, ParseOptions};
const HOLDBACK: usize = 24;
struct Sink<'w, W, H, R> {
out: &'w mut W,
harness: Harness,
id: String,
cwd: Option<PathBuf>,
title: Option<String>,
model: Option<String>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
first_user: Option<String>,
header: H,
render_msg: R,
buf: Vec<String>,
printed: bool,
result: std::io::Result<()>,
resolver: cv_core::Resolver,
idx: usize,
start: usize,
end: Option<usize>,
max_bytes: Option<usize>,
bytes: usize,
rendered: usize,
next: Option<usize>,
}
impl<W: std::io::Write, H: Fn(&HeaderInfo) -> String, R: Fn(&Message) -> String> Sink<'_, W, H, R> {
fn write(&mut self, s: &str) {
if self.result.is_ok() {
self.result = self.out.write_all(s.as_bytes());
}
}
fn flush_header(&mut self) {
if self.printed {
return;
}
let info = HeaderInfo {
harness: self.harness,
id: self.id.clone(),
cwd: self.cwd.clone(),
label: cv_core::label_from(self.title.as_deref(), self.first_user.as_deref()),
model: self.model.clone(),
created_at: self.created_at,
};
let head = (self.header)(&info);
self.bytes += head.len();
self.write(&head);
let buffered = std::mem::take(&mut self.buf);
for s in buffered {
self.write(&s);
}
self.printed = true;
}
}
impl<W: std::io::Write, H: Fn(&HeaderInfo) -> String, R: Fn(&Message) -> String> MessageSink for Sink<'_, W, H, R> {
fn meta(&mut self, s: &Session) {
self.title = s.title.clone();
if self.model.is_none() {
self.model = s.model.clone();
}
if self.cwd.is_none() {
self.cwd = s.cwd.clone();
}
if self.created_at.is_none() {
self.created_at = s.created_at;
}
}
fn message(&mut self, m: Message) -> Flow {
if self.result.is_err() || self.next.is_some() {
return Flow::Stop;
}
let idx = self.idx;
self.idx += 1;
if let Some(end) = self.end {
if idx >= end {
return Flow::Stop;
}
}
if self.model.is_none() {
if let Some(md) = &m.model {
self.model = Some(md.clone());
}
}
if idx < self.start {
return Flow::Continue;
}
let mut m = m;
m.materialize(&self.resolver);
if self.first_user.is_none() && m.role == Role::User {
if let Some(t) = m.text() {
if !t.trim().is_empty() {
self.first_user = Some(t);
}
}
}
let rendered = (self.render_msg)(&m);
drop(m);
if let Some(budget) = self.max_bytes {
if self.rendered > 0 && self.bytes + rendered.len() > budget {
self.next = Some(idx);
return Flow::Stop;
}
}
self.bytes += rendered.len();
self.rendered += 1;
if self.printed {
self.write(&rendered);
} else {
self.buf.push(rendered);
let title_known = self.title.is_some() || self.first_user.is_some();
if (self.model.is_some() && title_known) || self.buf.len() >= HOLDBACK {
self.flush_header();
}
}
if self.result.is_err() {
Flow::Stop
} else {
Flow::Continue
}
}
}
let start = opts.range.map(|(s, _)| s).unwrap_or(0);
let end = opts.range.and_then(|(_, e)| e);
let mut sink = Sink {
out,
harness: r.harness,
id: r.id.clone(),
cwd: r.cwd.clone(),
title: r.title.clone(),
model: None,
created_at: r.created_at,
first_user: None,
header,
render_msg,
buf: Vec::new(),
printed: false,
result: Ok(()),
resolver: cv_core::Resolver::new(Some(r.path.clone())),
idx: 0,
start,
end,
max_bytes: opts.max_bytes,
bytes: 0,
rendered: 0,
next: None,
};
let parse_opts = if opts.range.is_some() {
ParseOptions::lazy()
} else {
ParseOptions::bulk()
};
cv_core::stream::stream_counted(adapter, r, &parse_opts, &mut sink)?;
sink.flush_header(); sink.result?;
Ok(RenderOutcome {
rendered: sink.rendered,
next: sink.next,
})
}
pub(crate) fn show_header(h: &HeaderInfo) -> String {
format!(
"# {}\n{} · {} · {}{}\n\n",
h.label,
h.harness,
h.id,
h.cwd.as_deref().map(home_rel).unwrap_or_else(|| "?".into()),
h.model.as_ref().map(|m| format!(" · {m}")).unwrap_or_default(),
)
}
pub(crate) fn show_message(m: &Message) -> String {
let mut s = format!("── {} ──\n", turn_label(m));
for b in &m.content {
match b {
Block::Text { text } => {
s.push_str(text);
s.push('\n');
}
Block::Thinking { text, .. } => s.push_str(&format!("[thinking] {}\n", truncate(text, 200))),
Block::ToolUse { id, name, input, .. } => s.push_str(&format!(
"[tool_use {name} {id}] {}\n",
truncate(&input.to_string(), 200)
)),
Block::ToolResult {
tool_use_id,
content,
is_error,
details,
..
} => {
s.push_str(&format!(
"[tool_result{} {tool_use_id}] {}\n",
if *is_error { " error" } else { "" },
truncate(content, 200)
));
if let Some(p) = persisted_path(details.as_ref()) {
s.push_str(&format!(
" ↳ full output on disk: {p} (cv cat <session> {tool_use_id})\n"
));
}
}
Block::File { path, source, .. } => s.push_str(&format!(
"[file: {}]\n",
path.as_deref().or(source.as_deref()).unwrap_or("?")
)),
Block::Image { .. } => s.push_str("[image]\n"),
}
}
s.push('\n');
s
}
fn persisted_path(details: Option<&serde_json::Value>) -> Option<&str> {
details?.pointer("/persistedOutput/path")?.as_str()
}
fn md_header(h: &HeaderInfo) -> String {
cv_core::render::md_header(
&h.label,
h.harness,
&h.id,
h.cwd.as_deref(),
h.model.as_deref(),
h.created_at,
)
}
fn md_message(m: &Message) -> String {
let mut out = String::new();
cv_core::render::md_message(m, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_home(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("cv-view-render-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn render(r: &SessionRef, range: Option<(usize, Option<usize>)>) -> String {
render_with(r, RenderOpts { range, max_bytes: None }).0
}
fn render_with(r: &SessionRef, opts: RenderOpts) -> (String, RenderOutcome) {
let adapter = cv_core::harness::for_harness(r.harness).unwrap();
let mut out = Vec::new();
let outcome = stream_session_render(adapter.as_ref(), r, &mut out, show_header, show_message, opts).unwrap();
(String::from_utf8(out).unwrap(), outcome)
}
fn assert_window_matches_full_parse(r: &SessionRef, range: (usize, Option<usize>)) -> String {
let adapter = cv_core::harness::for_harness(r.harness).unwrap();
let mut all = cv_core::CollectSink::default();
adapter.stream(r, &cv_core::ParseOptions::bulk(), &mut all).unwrap();
let end = range.1.unwrap_or(usize::MAX).min(all.messages.len());
let want: String = all.messages[range.0..end].iter().map(show_message).collect();
let windowed = render(r, Some(range));
let body_at = windowed.find("── ").unwrap_or(windowed.len());
assert_eq!(
&windowed[body_at..],
want,
"range {range:?} body must equal the full parse's slice"
);
windowed[..body_at].to_string()
}
fn claude_fixture(home: &std::path::Path) -> SessionRef {
let path = home.join("s.jsonl");
let big = "long \"quoted\" content\n".repeat(400);
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"{{"type":"ai-title","aiTitle":"render window"}}"#).unwrap();
writeln!(
f,
r#"{{"type":"user","uuid":"u0","cwd":"/w","message":{{"role":"user","content":"q one"}}}}"#
)
.unwrap();
writeln!(
f,
"{}",
serde_json::json!({"type":"assistant","uuid":"a1","message":{"role":"assistant",
"model":"claude-test","content":[{"type":"text","text":"a one"}]}})
)
.unwrap();
writeln!(
f,
"{}",
serde_json::json!({"type":"user","uuid":"u2","message":{"role":"user","content":big}})
)
.unwrap();
for i in 0..8 {
writeln!(
f,
"{}",
serde_json::json!({"type":"user","uuid":format!("u{}", 3+i),
"message":{"role":"user","content":format!("follow-up {i}")}})
)
.unwrap();
}
drop(f);
SessionRef {
id: "render-window".into(),
harness: Harness::Claude,
path,
cwd: Some("/w".into()),
title: Some("render window".into()),
created_at: None,
updated_at: None,
message_count: 11,
}
}
#[test]
fn windowed_render_is_byte_identical_to_the_full_parse() {
let home = temp_home("claude");
let r = claude_fixture(&home);
let full_header = {
let full = render(&r, None);
full[..full.find("── ").unwrap()].to_string()
};
for range in [(3usize, Some(6usize)), (2, Some(11)), (5, None), (1, Some(2))] {
let header = assert_window_matches_full_parse(&r, range);
assert_eq!(header, full_header, "range {range:?} header");
assert!(header.contains("claude-test"), "header model expected in {range:?}");
}
std::fs::remove_dir_all(&home).ok();
}
#[test]
fn byte_budget_cuts_and_reports_the_next_index() {
let home = temp_home("budget");
let r = claude_fixture(&home);
let (out, oc) = render_with(
&r,
RenderOpts {
range: None,
max_bytes: Some(1024),
},
);
assert_eq!(oc.rendered, 2, "{out}");
assert_eq!(oc.next, Some(2), "{out}");
assert!(
out.contains("q one") && out.contains("a one") && !out.contains("follow-up"),
"{out}"
);
let (out, oc) = render_with(
&r,
RenderOpts {
range: Some((2, None)),
max_bytes: Some(1024),
},
);
assert_eq!(oc.rendered, 1, "{out}");
assert_eq!(oc.next, Some(3), "{out}");
assert!(out.contains("long \"quoted\" content"), "{out}");
let (_, oc) = render_with(
&r,
RenderOpts {
range: Some((3, None)),
max_bytes: Some(1 << 20),
},
);
assert_eq!(oc.rendered, 8);
assert_eq!(oc.next, None);
std::fs::remove_dir_all(&home).ok();
}
#[test]
fn show_export_and_share_agree_on_labels_and_block_text() {
let mut sys = Message::new(Role::System);
sys.kind = MessageKind::InjectedContext;
sys.harness_extra_mut(Harness::Claude)
.insert("attachment_type".into(), serde_json::json!("hook_success"));
sys.content.push(Block::Text {
text: "reminder: run the gate".into(),
});
let mut summary = Message::new(Role::User);
summary.kind = MessageKind::CompactionSummary;
summary.content.push(Block::Text {
text: "earlier we fixed the parser".into(),
});
let mut reply = Message::new(Role::Assistant);
reply.content.push(Block::Text {
text: "on it, editing ir.rs".into(),
});
for (m, label) in [
(&sys, "system · injected_context/hook_success"),
(&summary, "user · compaction_summary"),
(&reply, "assistant"),
] {
assert_eq!(turn_label(m), label);
let text = m.text().unwrap();
let shown = show_message(m);
assert!(
shown.starts_with(&format!("── {label} ──\n")) && shown.contains(&text),
"{shown}"
);
let md = md_message(m);
assert!(md.starts_with(&format!("## {label}\n\n")) && md.contains(&text), "{md}");
let mut html = String::new();
cv_core::html::share_message(m, 0, &mut html);
assert!(
html.contains(&format!("<span class=\"role\">{label}</span>")) && html.contains(&text),
"{html}"
);
}
let mut legacy = Message::new(Role::System);
legacy.kind = MessageKind::Error;
legacy.extra.insert("subtype".into(), serde_json::json!("api_error"));
assert_eq!(turn_label(&legacy), "system · error");
}
#[test]
fn codex_windowed_render_is_byte_identical_to_the_full_parse() {
let home = temp_home("codex");
let path = home.join("rollout-r.jsonl");
let big = "giant output\n".repeat(500);
let mut f = std::fs::File::create(&path).unwrap();
for l in [
r#"{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"rollout-r","cwd":"/work"}}"#.to_string(),
r#"{"timestamp":"2026-01-01T00:00:01Z","type":"turn_context","payload":{"cwd":"/work","model":"gpt-test"}}"#.to_string(),
r#"{"timestamp":"2026-01-01T00:00:02Z","type":"event_msg","payload":{"type":"user_message","message":"hello there"}}"#.to_string(),
r#"{"timestamp":"2026-01-01T00:00:03Z","type":"event_msg","payload":{"type":"agent_message","message":"working"}}"#.to_string(),
r#"{"timestamp":"2026-01-01T00:00:04Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{}","call_id":"c1"}}"#.to_string(),
serde_json::json!({"timestamp":"2026-01-01T00:00:05Z","type":"response_item",
"payload":{"type":"function_call_output","call_id":"c1","output":big}}).to_string(),
r#"{"timestamp":"2026-01-01T00:00:06Z","type":"event_msg","payload":{"type":"agent_message","message":"all done"}}"#.to_string(),
] {
writeln!(f, "{l}").unwrap();
}
drop(f);
let r = SessionRef {
id: "rollout-r".into(),
harness: Harness::Codex,
path: path.clone(),
cwd: Some("/work".into()),
title: Some("discovery title".into()),
created_at: None,
updated_at: None,
message_count: 3,
};
for range in [(2usize, Some(4usize)), (1, None), (3, Some(5))] {
let header = assert_window_matches_full_parse(&r, range);
assert!(header.contains("gpt-test"), "header model expected in {range:?}");
assert!(!header.contains("discovery title"), "parsed title wins in {range:?}");
}
std::fs::remove_dir_all(&home).ok();
}
}