use crate::cli::OutputFormat;
use chrono::TimeZone;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::BTreeSet;
use std::time::Duration;
const WRAP_WIDTH: usize = 80;
const DORMANCY_THRESHOLD_MS: i64 = 7 * 24 * 60 * 60 * 1000;
const CLUSTER_GAP_MS: i64 = 4 * 60 * 60 * 1000;
#[allow(clippy::too_many_arguments)]
pub async fn run(
topic: Vec<String>,
limit: usize,
since: Option<String>,
no_llm: bool,
llm_model: Option<String>,
output: OutputFormat,
embed_model: String,
embed_cache_dir: Option<String>,
timeline: bool,
) -> anyhow::Result<()> {
let query = topic.join(" ");
let query = query.trim().to_string();
let since_ms = match since {
Some(ref s) => crate::util::parse_time_filter(s)?,
None => None,
};
let cwd = std::env::current_dir()?;
let ws = crate::workspace::get_or_create_workspace_paths(&cwd)?;
if query.is_empty() {
return run_themes(&ws, since_ms, output).await;
}
let spinner = if let Some(target) = crate::narrative::spinner_draw_target(output) {
let pb = ProgressBar::new_spinner();
pb.set_draw_target(target);
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg:.dim}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_message("pulling on the thread...");
Some(pb)
} else {
None
};
let embedder = crate::embed::load_embedder(
&embed_model,
embed_cache_dir.as_deref().map(std::path::PathBuf::from),
false,
)
.await?;
let framed = crate::storage::frame_query_for_command(
&query,
crate::storage::QueryIntent::Trace,
);
let mut hits = crate::storage::query_capsules_cross_workspace(
&framed,
embedder,
&ws,
10,
limit,
)
.await;
if let Some(since_ms) = since_ms {
hits.retain(|h| h.ts_ms >= since_ms);
}
if let Some(ref spinner) = spinner {
spinner.finish_and_clear();
}
if hits.is_empty() {
println!("unlost: no moments found for this topic in any workspace.");
return Ok(());
}
hits.sort_by_key(|h| h.ts_ms);
let mut view = ThreadView::from_hits(&hits, &ws);
let thread_hit_ids: BTreeSet<String> = hits.iter().map(|h| h.id.clone()).collect();
view.enrich_spatial_context(&ws, &thread_hit_ids).await;
let llm_spinner = if !no_llm {
if let Some(target) = crate::narrative::spinner_draw_target(output) {
let pb = ProgressBar::new_spinner();
pb.set_draw_target(target);
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg:.dim}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_message("reading between the lines...");
Some(pb)
} else {
None
}
} else {
None
};
let (narrative, narrative_warning) = if no_llm {
(None, None)
} else {
match crate::narrative::llm_thread_narrative(llm_model.as_deref(), &query, &view).await {
Ok(n) => (Some(n), None),
Err(e) => {
tracing::debug!(error = %e, "thread narrative unavailable");
(
None,
Some(
"No LLM configured; showing extracted notes only. Configure with `unlost config llm ollama --model <model>`."
.to_string(),
),
)
}
}
};
if let Some(ref sp) = llm_spinner {
sp.finish_and_clear();
}
let rendered = if timeline {
render_timeline(&query, narrative.as_deref(), narrative_warning.as_deref(), &view, output)
} else {
render_trail(&query, narrative.as_deref(), narrative_warning.as_deref(), &view, output)
};
print!("{rendered}");
Ok(())
}
pub struct Cluster {
pub notes: Vec<DisplayNote>,
pub earliest_ts: i64,
pub latest_ts: i64,
pub provenance: String,
pub source_links: Vec<String>,
pub session_context: Option<String>,
pub nearby_topics: Vec<String>,
}
pub struct DisplayNote {
pub decision: String,
pub rationale: Option<String>,
pub failure_mode: Option<String>,
pub symbols: Vec<String>,
pub echoes: usize,
pub ts_ms: i64,
}
pub struct LongGap {
pub from_ts: i64,
pub to_ts: i64,
pub days: i64,
}
pub struct ThreadView {
pub clusters: Vec<Cluster>,
pub long_gaps: Vec<LongGap>,
pub total_moments: usize,
pub total_notes: usize,
pub earliest_ts: i64,
pub latest_ts: i64,
pub span_days: i64,
pub project_count: usize,
}
impl ThreadView {
pub fn from_hits(hits: &[crate::CapsuleHit], ws: &crate::WorkspacePaths) -> Self {
let project_ids: BTreeSet<&str> = hits
.iter()
.filter_map(|h| h.origin_workspace_id.as_deref())
.collect();
let project_count = project_ids.len().max(1);
let raw_clusters = cluster_hits(hits);
let mut clusters: Vec<Cluster> = Vec::new();
let mut total_notes = 0usize;
for raw in &raw_clusters {
let folded = fold_similar(raw);
total_notes += folded.len();
let prov = cluster_provenance(raw, ws);
let earliest = raw.first().unwrap().ts_ms;
let latest = raw.last().unwrap().ts_ms;
let mut source_links: Vec<String> = raw
.iter()
.filter_map(|h| h.meta.source_pointer.as_deref())
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
source_links.truncate(3);
clusters.push(Cluster {
notes: folded,
earliest_ts: earliest,
latest_ts: latest,
provenance: prov,
source_links,
session_context: None,
nearby_topics: Vec::new(),
});
}
let mut long_gaps: Vec<LongGap> = Vec::new();
for i in 1..clusters.len() {
let from_ts = clusters[i - 1].latest_ts;
let to_ts = clusters[i].earliest_ts;
let gap = to_ts - from_ts;
if gap >= DORMANCY_THRESHOLD_MS {
let days = (gap / (24 * 60 * 60 * 1000)).max(1);
long_gaps.push(LongGap { from_ts, to_ts, days });
}
}
let earliest_ts = hits.first().unwrap().ts_ms;
let latest_ts = hits.last().unwrap().ts_ms;
let span_days = ((latest_ts - earliest_ts) / (24 * 60 * 60 * 1000)).max(0);
ThreadView {
clusters,
long_gaps,
total_moments: hits.len(),
total_notes,
earliest_ts,
latest_ts,
span_days,
project_count,
}
}
pub async fn enrich_spatial_context(
&mut self,
ws: &crate::WorkspacePaths,
thread_hit_ids: &BTreeSet<String>,
) {
const NEIGHBOR_WINDOW_MS: i64 = 30 * 60 * 1000;
for cluster in &mut self.clusters {
if let Ok(db) = lancedb::connect(ws.db_dir.to_string_lossy().as_ref())
.execute()
.await
{
if let Ok(checkpoints) =
crate::storage_checkpoint::get_checkpoints_in_range(
&db,
&ws.id,
cluster.earliest_ts,
cluster.latest_ts,
)
.await
{
if let Some(cp) = checkpoints.first() {
cluster.session_context = extract_session_topic(&cp.narrative);
}
}
}
let since = cluster.earliest_ts - NEIGHBOR_WINDOW_MS;
let until = cluster.latest_ts + NEIGHBOR_WINDOW_MS;
if let Ok(nearby) = crate::storage::scan_capsules_lancedb(
ws,
20,
None,
None,
None,
Some(since),
Some(until),
)
.await
{
let mut topics: Vec<String> = Vec::new();
let mut seen = BTreeSet::new();
for hit in &nearby {
if thread_hit_ids.contains(&hit.id) {
continue;
}
if matches!(hit.meta.source.as_str(), "git" | "changelog" | "init") {
continue;
}
let text = hit.capsule.decision.trim();
if text.is_empty() || text.len() < 20 {
continue;
}
if is_placeholder_decision(text) {
continue;
}
let lower = text.to_ascii_lowercase();
if lower.starts_with("no action")
|| lower.starts_with("no actionable")
|| lower.starts_with("no specific")
|| lower.starts_with("user simply")
|| lower.starts_with("user rejects")
|| lower.starts_with("user disputes")
|| lower.starts_with("user is unsure")
|| lower.starts_with("acknowledge")
|| lower.contains("conversation ended")
|| lower.contains("conversation effectively")
|| lower.contains("no further request")
|| lower.contains("end interaction")
|| lower.contains("close interaction")
|| lower.contains("awaiting context")
|| lower.starts_with("none;")
|| lower.starts_with("none.")
{
continue;
}
if !text.contains(' ') && text.len() < 30 {
continue;
}
let short = truncate(text, 60);
let key = short.to_ascii_lowercase();
if seen.contains(&key) {
continue;
}
let dominated = cluster
.notes
.iter()
.any(|n| similar_notes_loose(&short, &n.decision));
if dominated {
continue;
}
seen.insert(key);
topics.push(short);
if topics.len() >= 3 {
break;
}
}
cluster.nearby_topics = topics;
}
}
}
}
fn extract_session_topic(narrative: &str) -> Option<String> {
let narrative = narrative.trim();
if narrative.is_empty() {
return None;
}
let marker = "WHAT WAS WORKED ON";
if let Some(start) = narrative.find(marker) {
let after = &narrative[start + marker.len()..];
let content: String = after
.lines()
.skip_while(|l| l.trim().is_empty() || l.trim() == marker)
.take(2)
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if !content.is_empty() {
return Some(truncate(&content, 120));
}
}
for line in narrative.lines() {
let l = line.trim();
if !l.is_empty() && !l.chars().all(|c| c == '─' || c == '-' || c == '=') {
return Some(truncate(l, 120));
}
}
None
}
fn cluster_hits<'a>(hits: &'a [crate::CapsuleHit]) -> Vec<Vec<&'a crate::CapsuleHit>> {
let mut clusters: Vec<Vec<&crate::CapsuleHit>> = Vec::new();
let mut current: Vec<&crate::CapsuleHit> = Vec::new();
for hit in hits {
if current.is_empty() {
current.push(hit);
continue;
}
let prev_ts = current.last().unwrap().ts_ms;
if hit.ts_ms - prev_ts <= CLUSTER_GAP_MS {
current.push(hit);
} else {
clusters.push(std::mem::take(&mut current));
current.push(hit);
}
}
if !current.is_empty() {
clusters.push(current);
}
clusters
}
fn fold_similar(hits: &[&crate::CapsuleHit]) -> Vec<DisplayNote> {
let mut notes: Vec<DisplayNote> = Vec::new();
for hit in hits {
let text = note_text(hit);
if is_placeholder_decision(&text) {
continue;
}
if let Some(existing) = notes.iter_mut().find(|n| similar_notes(&text, &n.decision)) {
existing.echoes += 1;
} else {
let cap = &hit.capsule;
let rationale = if cap.rationale.trim().is_empty() {
None
} else {
Some(truncate(
&humanize_rationale(&first_sentence(cap.rationale.trim())),
80,
))
};
let failure_mode = match cap.failure_mode {
crate::types::FailureMode::None => None,
crate::types::FailureMode::Drift => Some("drift".into()),
crate::types::FailureMode::Rediscovery => Some("rediscovery".into()),
crate::types::FailureMode::DecisionConflict => Some("decision conflict".into()),
crate::types::FailureMode::RetrySpiral => Some("retry spiral".into()),
crate::types::FailureMode::FalseProgress => Some("false progress".into()),
crate::types::FailureMode::UnboundedHorizon => Some("unbounded horizon".into()),
};
let symbols: Vec<String> = cap
.symbols
.iter()
.filter(|s| s.contains('/') || s.contains('.'))
.take(2)
.cloned()
.collect();
notes.push(DisplayNote {
decision: text,
rationale,
failure_mode,
symbols,
echoes: 0,
ts_ms: hit.ts_ms,
});
}
}
notes
}
fn cluster_provenance(hits: &[&crate::CapsuleHit], ws: &crate::WorkspacePaths) -> String {
let mut labels: BTreeSet<String> = BTreeSet::new();
for hit in hits {
if let Some(label) = provenance_label(hit, ws) {
labels.insert(label);
}
}
if labels.len() == 1 {
labels.into_iter().next().unwrap()
} else if labels.is_empty() {
workspace_label_from_root(&ws.root).unwrap_or_default()
} else {
let ws_name = workspace_label_from_root(&ws.root).unwrap_or_default();
let sources: Vec<&String> = labels.iter().collect();
if sources.iter().all(|s| s.starts_with(&ws_name)) {
ws_name
} else {
labels.into_iter().collect::<Vec<_>>().join(", ")
}
}
}
fn render_trail(
topic: &str,
narrative: Option<&str>,
narrative_warning: Option<&str>,
view: &ThreadView,
output: OutputFormat,
) -> String {
let mut out = String::new();
render_header(&mut out, topic, view, output);
render_narrative_block(&mut out, narrative, narrative_warning, output);
let anchors = pick_thread_anchors(view);
let anchors_total = view.clusters.len();
let anchors_shown = anchors.len();
for (i, idx) in anchors.iter().enumerate() {
let cluster = &view.clusters[*idx];
let role = anchor_role(i, anchors_shown, *idx, anchors_total);
let when_phrase = relative_when(cluster.earliest_ts, view.latest_ts);
out.push('\n');
render_anchor_header(&mut out, role, &when_phrase, cluster, output);
render_spatial_context_compact(&mut out, cluster, output);
let notes_to_show = pick_representative_notes(&cluster.notes, 2);
for note in ¬es_to_show {
render_note_anchor(&mut out, note, output);
}
render_source_links_inline(&mut out, cluster, output);
}
let hidden_clusters = anchors_total.saturating_sub(anchors_shown);
if hidden_clusters > 0 {
out.push('\n');
let hint = format!(
" {} more cluster{} in `unlost thread \"{}\" --timeline`",
hidden_clusters,
if hidden_clusters == 1 { "" } else { "s" },
topic,
);
if is_ansi(output) {
out.push_str(&format!("\x1b[2;3m{}\x1b[0m\n", hint));
} else {
out.push_str(&format!("{}\n", hint));
}
}
out.push('\n');
out
}
fn pick_thread_anchors(view: &ThreadView) -> Vec<usize> {
let n = view.clusters.len();
if n == 0 {
return Vec::new();
}
if n <= 3 {
return (0..n).rev().collect(); }
let newest = n - 1;
let origin = 0;
let mut best_pivot: Option<(usize, i64, usize)> = None;
for i in 1..n - 1 {
let gap = view.clusters[i].earliest_ts - view.clusters[i - 1].latest_ts;
let notes = view.clusters[i].notes.len();
let score = (gap, notes);
if best_pivot.map(|(_, g, n2)| (gap, notes) > (g, n2)).unwrap_or(true) {
best_pivot = Some((i, score.0, score.1));
}
}
let mut picks = vec![newest, origin];
if let Some((idx, _, _)) = best_pivot {
picks.push(idx);
}
picks.sort_by(|a, b| b.cmp(a)); picks.dedup();
picks
}
fn anchor_role(position: usize, total_shown: usize, cluster_idx: usize, total_clusters: usize) -> &'static str {
let is_newest = cluster_idx == total_clusters - 1;
let is_origin = cluster_idx == 0;
if total_shown == 1 {
""
} else if is_newest {
"where it landed"
} else if is_origin {
"where it started"
} else {
let _ = position;
"the turn"
}
}
fn relative_when(ts_ms: i64, latest_ts: i64) -> String {
let days = ((latest_ts - ts_ms) / (24 * 60 * 60 * 1000)).max(0);
if days == 0 {
"today".to_string()
} else if days == 1 {
"yesterday".to_string()
} else if days < 7 {
format!("{} days ago", days)
} else if days < 30 {
let weeks = (days / 7).max(1);
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
} else if days < 90 {
let weeks = days / 7;
format!("{} weeks ago", weeks)
} else {
let months = (days / 30).max(3);
format!("{} months ago", months)
}
}
fn render_anchor_header(
out: &mut String,
role: &str,
when_phrase: &str,
cluster: &Cluster,
output: OutputFormat,
) {
let date_str = fmt_day_label(cluster.earliest_ts);
if is_ansi(output) {
if !role.is_empty() {
out.push_str(&format!("\x1b[36m{}\x1b[0m ", role));
}
out.push_str(&format!("\x1b[1;97m{}\x1b[0m", date_str));
if !when_phrase.is_empty() {
out.push_str(&format!(" \x1b[2m({})\x1b[0m", when_phrase));
}
out.push('\n');
if !cluster.provenance.is_empty() {
push_wrapped_ansi(
out,
" \x1b[2;36m",
"\x1b[0m",
" \x1b[2;36m",
"\x1b[0m",
&cluster.provenance,
WRAP_WIDTH - 2,
);
}
} else {
if !role.is_empty() {
out.push_str(&format!("{} ", role));
}
out.push_str(&date_str);
if !when_phrase.is_empty() {
out.push_str(&format!(" ({})", when_phrase));
}
out.push('\n');
if !cluster.provenance.is_empty() {
push_wrapped_plain(
out,
" ",
" ",
&cluster.provenance,
WRAP_WIDTH - 2,
);
}
}
out.push('\n');
}
fn render_spatial_context_compact(out: &mut String, cluster: &Cluster, output: OutputFormat) {
if let Some(ref ctx) = cluster.session_context {
let line = format!("inside: {}", truncate(ctx, 72));
if is_ansi(output) {
push_wrapped_ansi(
out,
" \x1b[2;3m",
"\x1b[0m",
" \x1b[2;3m",
"\x1b[0m",
&line,
WRAP_WIDTH - 4,
);
} else {
push_wrapped_plain(out, " ", " ", &line, WRAP_WIDTH - 4);
}
out.push('\n');
}
if !cluster.nearby_topics.is_empty() {
let joined = cluster
.nearby_topics
.iter()
.map(|t| truncate(t, 40))
.collect::<Vec<_>>()
.join(", ");
let line = format!("alongside: {}", truncate(&joined, 100));
if is_ansi(output) {
push_wrapped_ansi(
out,
" \x1b[2;3m",
"\x1b[0m",
" \x1b[2;3m",
"\x1b[0m",
&line,
WRAP_WIDTH - 4,
);
} else {
push_wrapped_plain(out, " ", " ", &line, WRAP_WIDTH - 4);
}
out.push('\n');
}
}
fn pick_representative_notes(notes: &[DisplayNote], max: usize) -> Vec<&DisplayNote> {
let mut scored: Vec<(usize, &DisplayNote)> = notes
.iter()
.map(|n| {
let mut score = n.decision.len();
if n.failure_mode.is_some() {
score += 1000;
}
score += n.echoes * 20;
(score, n)
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.into_iter().take(max).map(|(_, n)| n).collect()
}
fn render_note_anchor(out: &mut String, note: &DisplayNote, output: OutputFormat) {
let text = truncate(¬e.decision, 200);
let echo_suffix = if note.echoes > 0 {
if note.echoes == 1 {
" (+1)".to_string()
} else {
format!(" (+{})", note.echoes)
}
} else {
String::new()
};
let fm_prefix = if note.failure_mode.is_some() { "▲ " } else { "" };
let body = format!("{fm_prefix}{text}{echo_suffix}");
if is_ansi(output) {
push_wrapped_ansi(
out,
" \x1b[36m│\x1b[0m \x1b[36m",
"\x1b[0m",
" \x1b[36m│\x1b[0m \x1b[36m",
"\x1b[0m",
&body,
WRAP_WIDTH - 4,
);
} else {
push_wrapped_plain(out, " │ ", " │ ", &body, WRAP_WIDTH - 4);
}
out.push('\n');
}
fn render_source_links_inline(out: &mut String, cluster: &Cluster, output: OutputFormat) {
if cluster.source_links.is_empty() {
return;
}
let link = &cluster.source_links[0];
if is_ansi(output) {
out.push_str(&format!(" \x1b[2;36m→ {}\x1b[0m\n", link));
} else {
out.push_str(&format!(" → {}\n", link));
}
}
fn render_timeline(
topic: &str,
narrative: Option<&str>,
narrative_warning: Option<&str>,
view: &ThreadView,
output: OutputFormat,
) -> String {
let mut out = String::new();
render_header(&mut out, topic, view, output);
render_narrative_block(&mut out, narrative, narrative_warning, output);
let cluster_count = view.clusters.len();
for (ci, cluster) in view.clusters.iter().rev().enumerate() {
if ci > 0 {
let newer_cluster = &view.clusters[cluster_count - ci];
let gap_ms = newer_cluster.earliest_ts - cluster.latest_ts;
if gap_ms >= DORMANCY_THRESHOLD_MS {
let label = gap_label(gap_ms);
if is_ansi(output) {
out.push_str(&format!("\n\x1b[2m ~ {}\x1b[0m\n", label));
} else {
out.push_str(&format!("\n ~ {}\n", label));
}
}
}
let date_str = fmt_day_label(cluster.earliest_ts);
out.push('\n');
if is_ansi(output) {
out.push_str(&format!("\x1b[1;97m{}\x1b[0m", date_str));
out.push('\n');
if !cluster.provenance.is_empty() {
push_wrapped_ansi(
&mut out,
" \x1b[2;36m",
"\x1b[0m",
" \x1b[2;36m",
"\x1b[0m",
&cluster.provenance,
WRAP_WIDTH - 2,
);
}
} else {
out.push_str(&date_str);
out.push('\n');
if !cluster.provenance.is_empty() {
push_wrapped_plain(
&mut out,
" ",
" ",
&cluster.provenance,
WRAP_WIDTH - 2,
);
}
}
out.push('\n');
render_source_links(&mut out, cluster, output);
render_spatial_context(&mut out, cluster, output);
for (ni, note) in cluster.notes.iter().rev().enumerate() {
render_note(&mut out, note, ni + 1, output);
}
}
out.push('\n');
out
}
fn render_header(out: &mut String, topic: &str, view: &ThreadView, output: OutputFormat) {
let topic = topic.trim();
if is_ansi(output) {
out.push_str(&format!("\x1b[1m{}\x1b[0m\n", topic));
} else {
out.push_str(topic);
out.push('\n');
}
let project_label = if view.project_count > 1 {
format!(" across {} projects", view.project_count)
} else {
String::new()
};
let earliest = fmt_range_date(view.earliest_ts);
let latest = fmt_range_date(view.latest_ts);
let meta = format!(
"{} moment{} · {} note{}{} · {} back to {}{}",
view.total_moments,
if view.total_moments == 1 { "" } else { "s" },
view.total_notes,
if view.total_notes == 1 { "" } else { "s" },
project_label,
latest,
earliest,
span_suffix(view.span_days),
);
if is_ansi(output) {
out.push_str(&format!("\x1b[2m{}\x1b[0m\n", meta));
} else {
out.push_str(&meta);
out.push('\n');
}
}
fn render_narrative_block(
out: &mut String,
narrative: Option<&str>,
warning: Option<&str>,
output: OutputFormat,
) {
if let Some(n) = narrative {
out.push('\n');
let rendered = crate::narrative::render_narrative(output, n);
for line in rendered.lines() {
out.push_str(line);
out.push('\n');
}
} else if let Some(w) = warning {
out.push('\n');
if is_ansi(output) {
push_wrapped_ansi(out, "\x1b[2m", "\x1b[0m", "\x1b[2m", "\x1b[0m", w, WRAP_WIDTH);
} else {
push_wrapped_plain(out, "", "", w, WRAP_WIDTH);
}
out.push('\n');
}
}
fn render_spatial_context(out: &mut String, cluster: &Cluster, output: OutputFormat) {
if let Some(ref ctx) = cluster.session_context {
if is_ansi(output) {
push_wrapped_ansi(
out,
" \x1b[2;3mpart of: ",
"\x1b[0m\n",
" \x1b[2;3m",
"\x1b[0m\n",
ctx,
WRAP_WIDTH - 10,
);
} else {
push_wrapped_plain(out, " part of: ", " ", ctx, WRAP_WIDTH - 10);
out.push('\n');
}
}
if !cluster.nearby_topics.is_empty() {
let label = if cluster.nearby_topics.len() == 1 {
" also discussing: "
} else {
" also discussing: "
};
for (i, topic) in cluster.nearby_topics.iter().enumerate() {
let prefix = if i == 0 { label } else { " " };
if is_ansi(output) {
out.push_str(&format!("\x1b[2m{}{}\x1b[0m\n", prefix, truncate(topic, 58)));
} else {
out.push_str(&format!("{}{}\n", prefix, truncate(topic, 58)));
}
}
}
}
fn render_source_links(out: &mut String, cluster: &Cluster, output: OutputFormat) {
if cluster.source_links.is_empty() {
return;
}
for link in &cluster.source_links {
if is_ansi(output) {
out.push_str(&format!(" \x1b[2;36m{}\x1b[0m\n", link));
} else {
out.push_str(&format!(" {}\n", link));
}
}
}
fn render_note(out: &mut String, note: &DisplayNote, index: usize, output: OutputFormat) {
if is_ansi(output) {
push_wrapped_ansi(
out,
&format!("\n \x1b[2m{:>2}\x1b[0m \x1b[36m", index),
"\x1b[0m",
" \x1b[36m",
"\x1b[0m",
¬e.decision,
WRAP_WIDTH - 6,
);
} else {
push_wrapped_plain(
out,
&format!("\n {:>2} ", index),
" ",
¬e.decision,
WRAP_WIDTH - 6,
);
}
if let Some(ref rationale) = note.rationale {
if is_ansi(output) {
push_wrapped_ansi(
out,
"\n \x1b[2m",
"\x1b[0m",
" \x1b[2m",
"\x1b[0m",
rationale,
WRAP_WIDTH - 6,
);
} else {
push_wrapped_plain(out, "\n ", " ", rationale, WRAP_WIDTH - 6);
}
}
if let Some(ref fm) = note.failure_mode {
if is_ansi(output) {
out.push_str(&format!("\n \x1b[33m▲ {}\x1b[0m", fm));
} else {
out.push_str(&format!("\n ▲ {}", fm));
}
}
if !note.symbols.is_empty() {
let sym_str = note.symbols.join(" ");
if is_ansi(output) {
out.push_str(&format!("\n \x1b[2;32m{}\x1b[0m", sym_str));
} else {
out.push_str(&format!("\n {}", sym_str));
}
}
if note.echoes > 0 {
let line = if note.echoes == 1 {
"same idea appears once more".to_string()
} else {
format!("same idea appears {} more times", note.echoes)
};
if is_ansi(output) {
out.push_str(&format!("\n \x1b[2;3m{}\x1b[0m", line));
} else {
out.push_str(&format!("\n {}", line));
}
}
out.push('\n');
}
fn is_ansi(output: OutputFormat) -> bool {
output == OutputFormat::Ansi && std::env::var_os("NO_COLOR").is_none()
}
impl ThreadView {
pub fn to_llm_context(&self) -> String {
let mut ctx = String::new();
ctx.push_str(&format!(
"Thread: {} moments, {} notes, {} clusters, spanning {} days\n\n",
self.total_moments,
self.total_notes,
self.clusters.len(),
self.span_days,
));
for (ci, cluster) in self.clusters.iter().enumerate() {
let date = fmt_range_date(cluster.earliest_ts);
let gap_ctx = if ci > 0 {
let prev = &self.clusters[ci - 1];
let gap_days =
(cluster.earliest_ts - prev.latest_ts) / (24 * 60 * 60 * 1000);
if gap_days >= 90 {
format!(" (returned after {} months)", (gap_days / 30).max(3))
} else if gap_days >= 30 {
format!(" (returned after {} weeks)", (gap_days / 7).max(4))
} else if gap_days >= 7 {
format!(" (gap: {} days)", gap_days)
} else {
String::new()
}
} else {
String::new()
};
ctx.push_str(&format!(
"Cluster {} · {}{} · {}\n",
ci + 1,
date,
gap_ctx,
cluster.provenance,
));
if let Some(ref session_ctx) = cluster.session_context {
ctx.push_str(&format!(" session_topic: {}\n", session_ctx));
}
if !cluster.nearby_topics.is_empty() {
ctx.push_str(&format!(
" also_discussing: {}\n",
cluster.nearby_topics.join("; "),
));
}
for note in &cluster.notes {
ctx.push_str(&format!(" decision: {}\n", note.decision));
if let Some(ref r) = note.rationale {
ctx.push_str(&format!(" rationale: {}\n", r));
}
if let Some(ref fm) = note.failure_mode {
ctx.push_str(&format!(" failure_mode: {}\n", fm));
}
if !note.symbols.is_empty() {
ctx.push_str(&format!(" symbols: {}\n", note.symbols.join(", ")));
}
if note.echoes > 0 {
ctx.push_str(&format!(" echoes: {} similar notes folded\n", note.echoes));
}
}
ctx.push('\n');
}
if !self.long_gaps.is_empty() {
ctx.push_str("Long gaps:\n");
for g in &self.long_gaps {
ctx.push_str(&format!(
" {} → {} ({} days)\n",
fmt_range_date(g.from_ts),
fmt_range_date(g.to_ts),
g.days,
));
}
ctx.push('\n');
}
ctx
}
}
fn provenance_label(hit: &crate::CapsuleHit, ws: &crate::WorkspacePaths) -> Option<String> {
let workspace = hit
.origin_workspace_id
.as_deref()
.and_then(crate::workspace::workspace_label_by_id)
.or_else(|| workspace_label_from_root(&ws.root))?;
let source = hit
.meta
.source_pointer
.as_deref()
.and_then(crate::workspace::resolve_source_label)
.or_else(|| fallback_source_label(hit));
match source {
Some(source) if !source.trim().is_empty() => Some(format!("{workspace} · {source}")),
_ => Some(workspace),
}
}
fn workspace_label_from_root(root: &std::path::Path) -> Option<String> {
root.file_name()
.and_then(|n| n.to_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn fallback_source_label(hit: &crate::CapsuleHit) -> Option<String> {
let source = hit.meta.source.trim();
let path = hit.meta.request_path.trim();
if source.is_empty() {
return None;
}
if source == "changelog" {
return if path.is_empty() {
Some("CHANGELOG".into())
} else {
Some(format!("CHANGELOG {path}"))
};
}
if source == "git" {
return if path.is_empty() {
Some("git".into())
} else {
Some(format!("git {path}"))
};
}
if source == "record" {
if path.contains("opencode") || path.contains("companion") {
return Some("OpenCode".into());
}
if path.contains("claude") {
return Some("Claude".into());
}
if path.contains("copilot") {
return Some("Copilot".into());
}
if path.contains("chat/completions") {
return Some("chat".into());
}
return None; }
if source == "init" {
return Some("init".into());
}
None
}
fn note_text(hit: &crate::CapsuleHit) -> String {
let decision = hit.capsule.decision.trim();
let text = if decision.is_empty() {
hit.capsule.intent.trim().to_string()
} else {
decision.to_string()
};
humanize_note_text(&text)
}
fn is_placeholder_decision(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
let placeholders = [
"proceed",
"defer",
"pause",
"continue",
"no_action_required",
"accepted/implemented",
"awaiting_commit_instruction",
"awaiting_user_instruction",
"proceed_with_planned_action",
"awaiting context to determine next action",
"no specific task",
];
for p in placeholders {
if lower == p || lower.starts_with(p) {
return true;
}
}
if !lower.contains(' ') && lower.contains('_') {
return true;
}
false
}
fn span_suffix(days: i64) -> String {
if days >= 90 {
format!(" · {}-month arc", (days / 30).max(3))
} else if days >= 30 {
format!(" · {}-week arc", (days / 7).max(4))
} else if days >= 7 {
format!(" · {}-day arc", days)
} else {
String::new()
}
}
fn gap_label(gap_ms: i64) -> String {
let days = (gap_ms / (24 * 60 * 60 * 1000)).max(1);
if days >= 90 {
format!("{} months earlier · long return", (days / 30).max(3))
} else if days >= 30 {
format!("{} weeks earlier · shelved for a while", (days / 7).max(4))
} else {
format!("{} days earlier", days)
}
}
fn fmt_range_date(ts_ms: i64) -> String {
chrono::Utc
.timestamp_millis_opt(ts_ms)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| ts_ms.to_string())
}
fn fmt_day_label(ts_ms: i64) -> String {
chrono::Utc
.timestamp_millis_opt(ts_ms)
.single()
.map(|dt| dt.format("%b %d").to_string())
.unwrap_or_else(|| ts_ms.to_string())
}
fn push_wrapped_plain(
out: &mut String,
first_prefix: &str,
cont_prefix: &str,
text: &str,
width: usize,
) {
let lines = wrap_words(text, width);
for (i, line) in lines.iter().enumerate() {
if i == 0 {
out.push_str(first_prefix);
} else {
out.push('\n');
out.push_str(cont_prefix);
}
out.push_str(line);
}
}
fn push_wrapped_ansi(
out: &mut String,
first_prefix: &str,
first_suffix: &str,
cont_prefix: &str,
cont_suffix: &str,
text: &str,
width: usize,
) {
let lines = wrap_words(text, width);
for (i, line) in lines.iter().enumerate() {
if i == 0 {
out.push_str(first_prefix);
out.push_str(line);
out.push_str(first_suffix);
} else {
out.push('\n');
out.push_str(cont_prefix);
out.push_str(line);
out.push_str(cont_suffix);
}
}
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_len = 0usize;
for word in text.split_whitespace() {
let word_len = word.chars().count();
if current.is_empty() {
current.push_str(word);
current_len = word_len;
} else if current_len + 1 + word_len <= width {
current.push(' ');
current.push_str(word);
current_len += 1 + word_len;
} else {
lines.push(current);
current = word.to_string();
current_len = word_len;
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn similar_notes_loose(a: &str, b: &str) -> bool {
let a_tokens = note_tokens(a);
let b_tokens = note_tokens(b);
if a_tokens.is_empty() || b_tokens.is_empty() {
return false;
}
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
let smaller = (a_tokens.len().min(b_tokens.len())) as f32;
(intersection / smaller) >= 0.3
}
fn similar_notes(a: &str, b: &str) -> bool {
let a_tokens = note_tokens(a);
let b_tokens = note_tokens(b);
if a_tokens.is_empty() || b_tokens.is_empty() {
return false;
}
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
let union = a_tokens.union(&b_tokens).count() as f32;
(intersection / union) >= 0.48
}
fn note_tokens(s: &str) -> BTreeSet<String> {
s.split(|c: char| !c.is_alphanumeric())
.map(str::to_ascii_lowercase)
.filter(|t| t.len() >= 4)
.collect()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let end = s
.char_indices()
.nth(max.saturating_sub(1))
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}…", &s[..end])
}
}
fn first_sentence(s: &str) -> String {
let bytes = s.as_bytes();
for i in 0..bytes.len() {
if bytes[i] == b'.' {
let after = i + 1;
if after >= bytes.len() || bytes[after] == b' ' || bytes[after] == b'\n' {
let candidate = s[..=i].trim();
if !candidate.is_empty() {
return candidate.to_string();
}
}
}
}
s.to_string()
}
fn humanize_rationale(s: &str) -> String {
let s = s.trim();
let replacements = [
("The user wants ", "Wanted "),
("The user wanted ", "Wanted "),
("The user needs ", "Needed "),
("The user needed ", "Needed "),
("The user expects ", "Expected "),
("The user expected ", "Expected "),
("The user expresses ", "Expressed "),
("The user expressed ", "Expressed "),
("The user is assessing ", "Assessing "),
("The user was assessing ", "Assessing "),
("The user agrees ", "Agreed "),
("The user agreed ", "Agreed "),
("The user highlighted ", "Flagged "),
("The user asked ", "Asked "),
("The user is interested in ", "Wanted to understand "),
("The user was interested in ", "Wanted to understand "),
("The user seeks to understand ", "Wanted to understand "),
("The user sought to understand ", "Wanted to understand "),
("User initiated ", "Started "),
("User initiates ", "Started "),
("User seeks to understand ", "Wanted to understand "),
("User seeks ", "Wanted "),
("User is interested in ", "Wanted to understand "),
("User was interested in ", "Wanted to understand "),
("User wants ", "Wanted "),
("User wanted ", "Wanted "),
("User needs ", "Needed "),
("User needed ", "Needed "),
("User expects ", "Expected "),
("User expected ", "Expected "),
("User expresses ", "Expressed "),
("User expressed ", "Expressed "),
("User agrees ", "Agreed "),
("User agreed ", "Agreed "),
("User highlighted ", "Flagged "),
("User asked ", "Asked "),
("The conversation focuses on understanding how ", "Wanted to understand how "),
("The conversation focused on understanding how ", "Wanted to understand how "),
("The conversation focuses on ", "Looked at "),
("The conversation focused on ", "Looked at "),
];
for (from, to) in replacements {
if let Some(rest) = s.strip_prefix(from) {
return polish_rationale(format!("{to}{rest}"));
}
}
polish_rationale(s.to_string())
}
fn polish_rationale(s: String) -> String {
s.replace(" but wants ", " but wanted ")
.replace("user expects", "expected")
.replace("User expects", "Expected")
.replace(" and seeks clarity ", " and needed clarity ")
.replace(" seeks clarity ", " needed clarity ")
.replace("indicating a need for", "pointing toward")
.replace("their knowledge or project", "the design")
.replace("Wanted to understand analyzing ", "Wanted better ways to analyze ")
.replace("Wanted to understand expanding ", "Wanted to expand ")
.replace("Needed code-level", "Needed a code-level")
}
fn humanize_note_text(s: &str) -> String {
let s = s.trim();
let replacements = [
("User requests ", "Requested "),
("User requested ", "Requested "),
("User initiates ", "Started "),
("User initiated ", "Started "),
("The user requests ", "Requested "),
("The user requested ", "Requested "),
];
for (from, to) in replacements {
if let Some(rest) = s.strip_prefix(from) {
return format!("{to}{rest}");
}
}
s.to_string()
}
const THEMES_DEFAULT_SINCE_MS: i64 = 180 * 24 * 60 * 60 * 1000; const THEMES_SCAN_LIMIT: usize = 1500;
struct Theme {
tokens: BTreeSet<String>,
members: Vec<ThemeMember>,
span_days: i64,
latest_ts: i64,
earliest_ts: i64,
}
struct ThemeMember {
decision: String,
ts_ms: i64,
project: Option<String>,
}
async fn run_themes(
ws: &crate::WorkspacePaths,
since_ms: Option<i64>,
output: OutputFormat,
) -> anyhow::Result<()> {
let spinner = if let Some(target) = crate::narrative::spinner_draw_target(output) {
let pb = ProgressBar::new_spinner();
pb.set_draw_target(target);
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg:.dim}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_message("looking for what's been on your mind...");
Some(pb)
} else {
None
};
let since = since_ms.unwrap_or_else(|| crate::workspace::now_ms() - THEMES_DEFAULT_SINCE_MS);
let hits = match crate::storage::scan_capsules_lancedb(
ws,
THEMES_SCAN_LIMIT,
None,
None,
None,
Some(since),
None,
)
.await
{
Ok(h) => h,
Err(e) => {
if let Some(sp) = spinner {
sp.finish_and_clear();
}
eprintln!("unlost: could not scan capsules: {e}");
return Ok(());
}
};
if let Some(sp) = spinner {
sp.finish_and_clear();
}
let themes = compute_themes(&hits);
let rendered = render_themes(&themes, since, output);
print!("{rendered}");
Ok(())
}
fn compute_themes(hits: &[crate::CapsuleHit]) -> Vec<Theme> {
let mut themes: Vec<Theme> = Vec::new();
let mut total_processed = 0usize;
for hit in hits {
let text = hit.capsule.decision.trim();
if text.is_empty() || is_placeholder_decision(text) {
continue;
}
let lower = text.to_ascii_lowercase();
if lower.starts_with("no action")
|| lower.starts_with("no actionable")
|| lower.starts_with("acknowledge")
|| lower.starts_with("acknowledged")
|| lower.starts_with("user acknowledg")
|| lower.starts_with("agent acknowledg")
|| lower.contains("conversation ended")
|| lower.contains("end interaction")
|| lower.contains("close interaction")
|| lower.contains("awaiting context")
|| lower.starts_with("none;")
|| lower.starts_with("none.")
|| lower.starts_with("user disputes")
|| lower.starts_with("user rejects")
|| lower.starts_with("user is unsure")
|| lower.starts_with("user simply")
{
continue;
}
if matches!(hit.meta.source.as_str(), "git" | "changelog" | "init") {
continue;
}
let tokens = theme_tokens(text);
if tokens.len() < 2 {
continue;
}
total_processed += 1;
let project = hit
.origin_workspace_id
.as_deref()
.and_then(crate::workspace::workspace_label_by_id);
let member = ThemeMember {
decision: text.to_string(),
ts_ms: hit.ts_ms,
project,
};
let mut best: Option<(usize, f32)> = None;
for (i, theme) in themes.iter().enumerate() {
let inter = tokens.intersection(&theme.tokens).count() as f32;
let smaller = (tokens.len().min(theme.tokens.len())) as f32;
let overlap = inter / smaller;
if overlap >= 0.45 {
if best.map(|(_, s)| overlap > s).unwrap_or(true) {
best = Some((i, overlap));
}
}
}
if let Some((idx, _)) = best {
for t in &tokens {
themes[idx].tokens.insert(t.clone());
}
themes[idx].earliest_ts = themes[idx].earliest_ts.min(member.ts_ms);
themes[idx].latest_ts = themes[idx].latest_ts.max(member.ts_ms);
themes[idx].members.push(member);
} else {
themes.push(Theme {
tokens,
earliest_ts: member.ts_ms,
latest_ts: member.ts_ms,
span_days: 0,
members: vec![member],
});
}
}
let _ = total_processed;
themes.retain(|t| {
if t.members.len() < 3 {
return false;
}
let day = |ts: i64| ts / (24 * 60 * 60 * 1000);
let distinct_days: BTreeSet<i64> = t.members.iter().map(|m| day(m.ts_ms)).collect();
distinct_days.len() >= 2
});
for t in &mut themes {
t.span_days = ((t.latest_ts - t.earliest_ts) / (24 * 60 * 60 * 1000)).max(0);
}
let now = crate::workspace::now_ms();
themes.sort_by(|a, b| {
let sa = theme_score(a, now);
let sb = theme_score(b, now);
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
});
themes.truncate(10);
themes
}
fn theme_score(t: &Theme, now_ms: i64) -> f32 {
let recurrence = (t.members.len() as f32).ln_1p();
let span = ((t.span_days as f32 + 1.0).ln() * 1.0).min(5.0);
let age_days = ((now_ms - t.latest_ts).max(0) / (24 * 60 * 60 * 1000)) as f32;
let recency = (-age_days / 90.0).exp(); let projects: BTreeSet<&str> = t
.members
.iter()
.filter_map(|m| m.project.as_deref())
.collect();
let project_bonus = if projects.len() > 1 { 1.0 } else { 0.0 };
recurrence * 1.5 + span * 1.2 + recency * 0.8 + project_bonus
}
fn theme_tokens(s: &str) -> BTreeSet<String> {
const STOPWORDS: &[&str] = &[
"this", "that", "with", "from", "into", "have", "will", "their", "them",
"they", "would", "could", "should", "about", "which", "where", "when",
"what", "while", "than", "then", "been", "being", "more", "most",
"some", "such", "only", "also", "very", "much", "many", "make", "made",
"over", "under", "after", "before", "between", "through", "during",
"without", "within", "across", "against", "among", "around",
"user", "users", "thing", "things", "stuff", "item", "items",
"want", "wants", "wanted", "wanting",
"need", "needs", "needed", "needing",
"consider", "considering", "considered",
"propose", "proposed", "proposing", "proposal",
"explore", "exploring", "explored", "exploration",
"investigate", "investigating", "investigated", "investigation",
"review", "reviewing", "reviewed",
"implement", "implementing", "implemented", "implementation",
"adopt", "adopting", "adopted",
"discuss", "discussing", "discussed",
"clarify", "clarifying", "clarified", "clarification",
"assess", "assessing", "assessed", "assessment",
"request", "requesting", "requested",
"suggest", "suggesting", "suggested", "suggestion",
"complete", "completed", "completing", "completion",
"focus", "focusing", "focused",
"ensure", "ensuring", "ensured",
"address", "addressing", "addressed",
"section", "sections", "header", "headers", "label", "labels",
"output", "outputs", "input", "inputs", "value", "values",
"field", "fields", "option", "options", "default", "defaults",
"format", "formats", "structure", "structures",
"approach", "approaches", "decision", "decisions",
"agent", "agents", "system", "systems",
"feature", "features", "behavior", "behaviors",
"support", "supported", "supporting",
"current", "currently", "existing",
"general", "generic", "specific", "particular",
"additional", "different", "various", "multiple",
"irrelevant", "relevant", "useful", "important",
"closest", "nearest", "similar",
"snapshot", "snapshots", "document", "documents", "documented",
"incorporate", "incorporated", "incorporating",
"artifact", "artifacts", "recorded", "recording",
"acknowledge", "acknowledged", "acknowledging", "acknowledgment",
"affirm", "affirmed", "affirming", "affirmation", "affirmative",
"confirm", "confirmed", "confirming", "confirmation",
"prior", "previous", "previously", "earlier",
"requirement", "requirements", "request", "requests",
"expectation", "expectations", "available", "placeholder",
"outdated", "diagnostic", "diagnostics",
"detected", "detect", "detecting", "detection",
"metadata", "data", "info", "information",
"update", "updated", "updating", "change", "changes",
"next", "prepare", "preparation", "prepared", "preparing",
"inclusion", "filtering", "include", "included", "exclude", "excluded",
"codebase", "directory", "directories",
"consistently", "accordingly", "automatically", "explicitly",
"context", "contexts", "source", "sources",
"improvement", "improvements", "improve", "improving", "improved",
"statement", "statements", "subscription", "subscriptions",
"event", "events", "logging", "capturing", "capture",
"feasibility", "configuration", "configurations",
"provided", "providing", "provides",
"commit", "commits", "committed", "committing",
"dependencie", "dependencies", "dependency", "bump", "bumped",
];
s.split(|c: char| !c.is_alphanumeric())
.filter_map(|t| {
let t = t.trim().to_ascii_lowercase();
if t.len() < 4 || t.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if STOPWORDS.contains(&t.as_str()) {
return None;
}
let stem = if t.len() > 5 && t.ends_with('s') && !t.ends_with("ss") {
t[..t.len() - 1].to_string()
} else {
t
};
Some(stem)
})
.collect()
}
fn theme_label(theme: &Theme) -> String {
let mut freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut original_case: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &theme.members {
for raw_word in m.decision.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
let raw = raw_word.trim();
if raw.is_empty() {
continue;
}
let lower = raw.to_ascii_lowercase();
let toks = theme_tokens(raw);
if toks.is_empty() {
continue;
}
let stem = toks.iter().next().unwrap().clone();
*freq.entry(stem.clone()).or_default() += 1;
let cur = original_case.entry(stem).or_insert_with(|| raw.to_string());
if score_case(raw) > score_case(cur) {
*cur = raw.to_string();
}
let _ = lower;
}
}
let n = theme.members.len();
let mut scored: Vec<(String, f32)> = freq
.into_iter()
.filter(|(_, c)| *c >= 2)
.map(|(t, c)| {
let coverage = c as f32 / n as f32;
let length_bonus = (t.len() as f32).min(12.0) * 0.08;
let original = original_case.get(&t).cloned().unwrap_or_else(|| t.clone());
let tech_bonus = score_case(&original) as f32 * 0.5;
(t, coverage + length_bonus + tech_bonus)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let picked: Vec<String> = scored
.into_iter()
.take(3)
.map(|(t, _)| original_case.get(&t).cloned().unwrap_or(t))
.collect();
if picked.is_empty() {
theme
.members
.first()
.map(|m| truncate(&m.decision, 48))
.unwrap_or_default()
} else {
picked.join(" · ")
}
}
fn score_case(s: &str) -> u8 {
let has_underscore = s.contains('_');
let has_dash = s.contains('-');
let has_upper = s.chars().any(|c| c.is_ascii_uppercase());
let has_lower = s.chars().any(|c| c.is_ascii_lowercase());
let mixed_case = has_upper && has_lower;
let starts_upper = s.chars().next().map(|c| c.is_ascii_uppercase()).unwrap_or(false);
let mut score: u8 = 0;
if has_underscore || has_dash {
score += 3;
}
if mixed_case && !starts_upper {
score += 3;
} else if starts_upper && has_lower {
score += 2;
}
score
}
fn render_themes(themes: &[Theme], since_ms: i64, output: OutputFormat) -> String {
let mut out = String::new();
let now = crate::workspace::now_ms();
let window_phrase = window_phrase_from_since(since_ms, now);
if is_ansi(output) {
out.push_str("\x1b[1mwhat's been on your mind\x1b[0m\n");
out.push_str(&format!(
"\x1b[2m{} theme{} surfaced from the last {}\x1b[0m\n",
themes.len(),
if themes.len() == 1 { "" } else { "s" },
window_phrase,
));
} else {
out.push_str("what's been on your mind\n");
out.push_str(&format!(
"{} theme{} surfaced from the last {}\n",
themes.len(),
if themes.len() == 1 { "" } else { "s" },
window_phrase,
));
}
if themes.is_empty() {
out.push('\n');
if is_ansi(output) {
out.push_str("\x1b[2m Nothing recurring yet. Try a longer window: `unlost thread --since 1y`\x1b[0m\n");
} else {
out.push_str(" Nothing recurring yet. Try a longer window: `unlost thread --since 1y`\n");
}
return out;
}
let now = crate::workspace::now_ms();
for (i, theme) in themes.iter().enumerate() {
out.push('\n');
let label = theme_label(theme);
let last_when = relative_when_from_now(theme.latest_ts, now);
let projects: BTreeSet<&str> = theme
.members
.iter()
.filter_map(|m| m.project.as_deref())
.collect();
let project_suffix = if projects.is_empty() {
String::new()
} else if projects.len() == 1 {
format!(" · {}", projects.iter().next().unwrap())
} else {
format!(" · {} projects", projects.len())
};
if is_ansi(output) {
out.push_str(&format!(
" \x1b[2m{:>2}.\x1b[0m \x1b[1;36m{}\x1b[0m\n",
i + 1,
label,
));
out.push_str(&format!(
" \x1b[2m{} appearance{}{} · last seen {}\x1b[0m\n",
theme.members.len(),
if theme.members.len() == 1 { "" } else { "s" },
project_suffix,
last_when,
));
} else {
out.push_str(&format!(" {:>2}. {}\n", i + 1, label));
out.push_str(&format!(
" {} appearance{}{} · last seen {}\n",
theme.members.len(),
if theme.members.len() == 1 { "" } else { "s" },
project_suffix,
last_when,
));
}
if is_ansi(output) {
out.push_str(&format!(
" \x1b[2;36m→ unlost thread \"{}\"\x1b[0m\n",
label,
));
} else {
out.push_str(&format!(" → unlost thread \"{}\"\n", label));
}
}
out.push('\n');
if is_ansi(output) {
out.push_str("\x1b[2;3m Pick one and dive in, or widen the window: `unlost thread --since 1y`\x1b[0m\n");
} else {
out.push_str(" Pick one and dive in, or widen the window: `unlost thread --since 1y`\n");
}
out
}
fn window_phrase_from_since(since_ms: i64, now_ms: i64) -> String {
let days = ((now_ms - since_ms) / (24 * 60 * 60 * 1000)).max(1);
if days < 14 {
format!("{} days", days)
} else if days < 60 {
let weeks = (days / 7).max(2);
format!("{} weeks", weeks)
} else if days < 730 {
let months = (days as f32 / 30.0).round().max(2.0) as i64;
format!("{} months", months)
} else {
let years = (days as f32 / 365.0).round().max(2.0) as i64;
format!("{} years", years)
}
}
fn relative_when_from_now(ts_ms: i64, now_ms: i64) -> String {
let days = ((now_ms - ts_ms) / (24 * 60 * 60 * 1000)).max(0);
if days == 0 {
"today".to_string()
} else if days == 1 {
"yesterday".to_string()
} else if days < 7 {
format!("{} days ago", days)
} else if days < 30 {
let weeks = (days / 7).max(1);
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
} else if days < 90 {
let weeks = days / 7;
format!("{} weeks ago", weeks)
} else {
let months = (days / 30).max(3);
format!("{} months ago", months)
}
}