use super::*;
pub(crate) fn role_glyph(class: Option<Class>) -> char {
match class.map(Class::role) {
Some(crate::model::Role::User) => '◂',
Some(crate::model::Role::Agent) => '▸',
Some(crate::model::Role::Harness) => '⚙',
None => '?',
}
}
pub(crate) fn render_label(h: &Hit) -> String {
let Some(class) = h.class else {
return "(no label)".to_string();
};
let err = if class == Class::AgentToolResult && h.is_error == Some(true) {
" [error]"
} else {
""
};
let nd = if h.delivery == Some(false) {
" [not delivered]"
} else {
""
};
let sv = match (h.survival, h.rewound_branch, h.replay_copy_of) {
(_, _, Some(line)) => format!(" [replay copy of L{line}]"),
(crate::model::Survival::Abandoned { .. }, true, _) => " [rewound]".to_string(),
(crate::model::Survival::Abandoned { .. }, false, _) => " [abandoned]".to_string(),
_ => String::new(),
};
let dk = match (class, h.denial_kind.as_deref()) {
(Class::AgentToolResult, Some(kind)) => format!(" [denied: {kind}]"),
_ => String::new(),
};
let nd = format!("{nd}{sv}{dk}");
let nd = nd.as_str();
match (class, h.pair) {
(Class::AgentToolUse | Class::AgentToolResult, Some(Pairing::Paired)) => {
return format!("agent.tool.use ▹ agent.tool.result{err}{nd}");
}
(Class::AgentToolUse, Some(Pairing::PendingNoResult)) => {
return format!("agent.tool.use (no result — pending){nd}");
}
(Class::AgentToolResult, Some(Pairing::OrphanResult)) => {
return format!("agent.tool.result (use not in scope){err}{nd}");
}
_ => {}
}
if !err.is_empty() {
return format!("{}{err}{nd}", class.path());
}
if class == Class::AgentThinkingNarration {
return format!("{} [narration summary]{nd}", class.path());
}
if class == Class::UserQueued {
let op = h.queue_operation.as_deref().unwrap_or("queued");
return match h.queue_reason.as_deref() {
Some(reason) => format!("{} [{op} · {reason}]{nd}", class.path()),
None => format!("{} [{op}]{nd}", class.path()),
};
}
if class == Class::ResumePlaceholder {
if let Some(paired) = h.resume_paired {
let state = if paired { "paired" } else { "unpaired" };
return format!("{} [{state}]{nd}", class.path());
}
}
if class == Class::ScheduleFire {
if let Some(when) = h.scheduled_at.as_deref() {
return format!("{} [scheduled fire {when}]{nd}", class.path());
}
}
if class == Class::CompactionSummary {
if let Some(dir) = h.compaction.as_ref().and_then(|c| c.direction.as_deref()) {
return format!("{} [summarize {dir}]{nd}", class.path());
}
}
if let Some((from, to)) = &h.direction {
return format!("{}{nd} {from} ⇨ {to}", class.path());
}
format!("{}{nd}", class.path())
}
pub(crate) fn noun<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
if n == 1 {
one
} else {
many
}
}
pub(crate) fn window_end(args: &SearchArgs) -> &'static str {
if args.max_count.is_some_and(|n| n < 0) {
"latest"
} else {
"earliest"
}
}
pub(crate) fn dropped_side(args: &SearchArgs) -> &'static str {
if args.max_count.is_some_and(|n| n < 0) {
"earlier"
} else {
"later"
}
}
pub(crate) fn id_prefix(id: &str, n: usize) -> &str {
id.char_indices().nth(n).map_or(id, |(i, _)| &id[..i])
}
pub(crate) fn header_tokens(exchanges: &[Exchange]) -> HashMap<&str, String> {
let ids: std::collections::BTreeSet<&str> =
exchanges.iter().map(|e| e.session_id.as_str()).collect();
let mut tok: HashMap<&str, String> = HashMap::new();
let mut by8: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for id in ids {
if crate::path::is_uuid(id) || crate::path::is_bare_subagent_hex(id) {
by8.entry(id_prefix(id, 8)).or_default().push(id);
} else {
tok.insert(id, id.to_string());
}
}
for (p8, group) in by8 {
if let [only] = group.as_slice() {
tok.insert(only, p8.to_string());
continue;
}
let mut by12: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for id in group {
by12.entry(id_prefix(id, 12)).or_default().push(id);
}
for (p12, g) in by12 {
if let [only] = g.as_slice() {
tok.insert(only, p12.to_string());
} else {
for id in g {
tok.insert(id, id.to_string());
}
}
}
}
tok
}
pub(crate) fn render_text(outcome: &SearchOutcome, args: &SearchArgs) {
crate::text::emit_scope_banner(outcome.scope_top, outcome.scope_sub);
if outcome.exchanges.is_empty() {
println!("no matching exchanges");
if outcome.skipped_lines > 0 {
println!("({})", crate::text::malformed_note(outcome.skipped_lines));
}
return;
}
print!(
"matches {} {} · {} {} · oldest first",
outcome.total_matched,
noun(outcome.total_matched, "exchange", "exchanges"),
outcome.total_sessions,
noun(outcome.total_sessions, "session", "sessions"),
);
if outcome.dropped_by_cap > 0 {
print!(
" · showing {} {}",
window_end(args),
outcome.exchanges.len()
);
}
if outcome.exchanges.iter().any(|ex| ex.started_utc.is_none()) {
print!(" · undated last");
}
println!();
let tok = header_tokens(&outcome.exchanges);
let mut image_hint_done = false;
for ex in &outcome.exchanges {
println!();
let t = &tok[ex.session_id.as_str()];
let turn_tag = match ex.turn_index {
Some(t) => format!("t{t}"),
None => abandoned_tag(ex),
};
if ex.is_subagent {
println!(
"{t}·{turn_tag} (parent {}) {}",
id_prefix(&ex.parent_session_id, 8),
format_local_compact(ex.started_utc.as_deref())
);
} else {
println!(
"{t}·{turn_tag} {}",
format_local_compact(ex.started_utc.as_deref())
);
}
if let Some(d) = &ex.draft_diff {
println!(" ↳ {}", d.text_line_for(ex.abandoned_kind));
}
for hit in &ex.hits {
print_record_line(role_glyph(hit.class), hit);
if ex.is_subagent && !hit.from_sidecar && hit.line > 0 {
println!(" ↳ csift show @{} --line {}", ex.session_id, hit.line);
}
if !image_hint_done {
if let Some(l) = image_hint_line(&ex.session_id, &hit.image_ids) {
println!("{l}");
image_hint_done = true;
}
}
}
for sib in &ex.siblings {
print_record_line('·', sib);
if !image_hint_done {
if let Some(l) = image_hint_line(&ex.session_id, &sib.image_ids) {
println!("{l}");
image_hint_done = true;
}
}
}
if ex.siblings_hidden > 0 && ex.turn_lines.0 > 0 {
println!(
" · (+{} more · csift show @{} --line {}..{})",
ex.siblings_hidden, ex.session_id, ex.turn_lines.0, ex.turn_lines.1
);
}
}
let cat = if args.labels.is_empty() {
"all".to_string()
} else {
args.labels.join(",")
};
println!();
let n = outcome.total_matched;
let ex_word = noun(n, "exchange", "exchanges");
let n_sessions = outcome.total_sessions;
let sess_word = noun(n_sessions, "session", "sessions");
print!("matched {n} {ex_word} · {n_sessions} {sess_word} · label={cat}");
if !args.labels_not.is_empty() {
print!(" · label-not={}", args.labels_not.join(","));
}
if outcome.dropped_by_cap > 0 {
print!(
" · {} {} dropped by --max-count",
outcome.dropped_by_cap,
dropped_side(args)
);
}
println!();
if merged_any_sidecar(&outcome.exchanges) {
println!("with elicitation sidecar");
}
emit_chain_disclosure(&outcome.chain);
if outcome.skipped_lines > 0 {
println!("({})", crate::text::malformed_note(outcome.skipped_lines));
}
if any_image_ids(&outcome.exchanges) {
println!(
"note: image annotations above are extractable, not decorative: \
csift image <target> --id <ID> --out DIR (bare number for a #N handle), then \
Read the decoded file."
);
}
if any_truncated_excerpt(&outcome.exchanges) {
emit_truncation_caution();
}
}
pub(crate) fn abandoned_tag(ex: &Exchange) -> String {
match ex.abandoned_kind {
Some(crate::model::Kind::Rewound { .. }) => {
"rewound turn (the conversation was rewound past it — outside turn numbering)"
.to_string()
}
Some(crate::model::Kind::Draft { .. }) => {
"draft (superseded — outside turn numbering)".to_string()
}
None => "abandoned (off the surviving conversation — outside turn numbering)".to_string(),
}
}
pub(crate) fn emit_chain_disclosure(c: &ChainCounts) {
if c.drafts > 0 {
println!(
"({} superseded draft(s) outside turn numbering — esc-edit resends; searchable \
as -t user.unsent, addressable via csift show --line/--uuid)",
c.drafts
);
}
if c.rewound_turns > 0 {
println!(
"({} rewound turn(s) outside turn numbering — answered, then rewound past; \
searchable as -t user.rewound)",
c.rewound_turns
);
}
if c.abandoned_records > 0 {
println!(
"({} record(s) off the surviving conversation — Claude Code's parentUuid chain \
no longer reaches them, so a bare -t user/agent/harness skips them; marked \
[abandoned]/[rewound] and always addressable)",
c.abandoned_records
);
}
if c.replay_copies > 0 {
println!(
"({} replay copy line(s) — a compaction re-anchor re-appended these records; the \
LAST copy is the survivor and the earlier line is marked)",
c.replay_copies
);
}
if let Some(line) = c.boundary_cut_line {
println!(
"(the surviving conversation is cut at the compaction boundary L{line}; csift \
keeps reading above it — Claude Code's own loader stops there)"
);
}
if let Some(src) = c.leaf_source.filter(|_| c.any()) {
println!("(chain leaf: {src})");
}
}
pub(crate) fn emit_truncation_caution() {
println!();
println!(
"note: matches above are TRUNCATED, match-centered FRAGMENTS — not summaries. A fragment \
can read very differently from the record's full intent, so do NOT draw conclusions from \
it alone."
);
println!(" whole records: re-run with --no-truncate");
println!(
" one record in full: csift show <@session|@agent-id> --line <N> (the L<n> shown on \
a row) or --uuid <U>"
);
}
pub(crate) fn print_record_line(marker: char, h: &Hit) {
let label = render_label(h);
let name = h
.tool_name
.as_deref()
.map(|n| format!(" {n}"))
.unwrap_or_default();
let images = image_suffix(&h.image_ids);
let locator = if h.from_sidecar {
"(elicitation sidecar)".to_string()
} else {
format!("L{}", h.line)
};
println!(
" {marker} {label}{name} {locator} {}{}",
h.excerpt, images
);
}
pub(crate) fn image_suffix(ids: &[String]) -> String {
if ids.is_empty() {
return String::new();
}
let noun = if ids.len() == 1 { "image" } else { "images" };
format!(" [{} {}: {}]", ids.len(), noun, ids.join(", "))
}
pub(crate) fn image_hint_line(session_id: &str, ids: &[String]) -> Option<String> {
let first = ids.first()?;
let input = first.strip_prefix('#').unwrap_or(first);
Some(format!(
" ↳ read the image(s): csift image @{session_id} --id {input} --out DIR (decodes to a file you can then Read - an image-bearing turn is evidence, not a blank)"
))
}
pub(crate) fn any_image_ids(exchanges: &[Exchange]) -> bool {
exchanges.iter().any(|ex| {
ex.hits
.iter()
.chain(ex.siblings.iter())
.any(|h| !h.image_ids.is_empty())
})
}