use super::super::*;
use a3s_tui::components::{
divider_line_with, Badge, DetailPanel, DetailRow, Paragraph, Progress, Timeline, TimelineItem,
TimelineRow,
};
const MEMORY_PANEL_SESSION_LIMIT: usize = 1_000;
fn mem_type_style(t: &str) -> (&'static str, Color) {
match t {
"semantic" => ("sem", TN_CYAN),
"procedural" => ("proc", TN_GREEN),
"working" => ("work", TN_GRAY),
_ => ("epis", TN_YELLOW), }
}
fn tier_style(tier: MemoryTier) -> Color {
match tier {
MemoryTier::Short => TN_GREEN,
MemoryTier::Mid => TN_CYAN,
MemoryTier::Long => TN_YELLOW,
}
}
fn forget_mark(signal: ForgetSignal) -> &'static str {
match signal {
ForgetSignal::Keep => " ",
ForgetSignal::Cooling => "~",
ForgetSignal::Candidate => "!",
ForgetSignal::Protected => "*",
}
}
fn importance_bar(importance: f32, color: Color) -> String {
Progress::new()
.value(f64::from(importance.clamp(0.0, 1.0)) + 1e-7)
.width(5)
.filled_char('▰')
.empty_char('▱')
.filled_color(color)
.empty_color(TN_GRAY)
.show_percentage(false)
.view()
}
fn memory_line(rendered: &str, width: usize) -> String {
pad_to(&truncate(rendered, width), width)
}
fn memory_detail_metadata_lines(
e: &MemEntry,
detail: &MemDetail,
graph: &MemoryGraph,
now: chrono::DateTime<chrono::Utc>,
width: usize,
) -> Vec<String> {
if width == 0 {
return Vec::new();
}
let (_, color) = mem_type_style(&e.memory_type);
let ty = if e.memory_type.is_empty() {
"memory"
} else {
&e.memory_type
};
let header = format!(
"{} {} {}",
Badge::new(ty).color(color).view(),
importance_bar(e.importance, color),
Style::new().fg(TN_GRAY).render(&format!(
"importance {:.2} · {}",
e.importance,
rel_time(e.timestamp, now)
))
);
let mut lines = vec![a3s_tui::style::fit_visible(&header, width)];
let mut panel = DetailPanel::without_title()
.show_separator(false)
.indent(0)
.label_width(15)
.label_color(TN_GRAY)
.value_color(TN_GRAY)
.muted_color(TN_GRAY)
.unlimited_rows();
if !e.tags.is_empty() {
panel = panel.row(DetailRow::pair("tags", e.tags.join(", ")).color(TN_CYAN));
}
if let Some(facet) = graph.by_memory.get(&e.id) {
let lifecycle = lifecycle_labels(facet);
if !lifecycle.is_empty() {
let color = if facet.conflicts { TN_RED } else { TN_GREEN };
panel = panel.row(DetailRow::pair("lifecycle", lifecycle.join(" · ")).color(color));
}
}
if let Some(event) = graph.event_for_memory(&e.id) {
panel = panel
.pair(
"event",
format!(
"{} · {} · {} · {} entities",
event.label,
event.source,
event.timestamp.format("%Y-%m-%d %H:%M"),
event.entity_ids.len()
),
)
.pair(
"event retention",
format!(
"{} · {:.2} · {}",
event.tier.label(),
event.retention_score,
event.forget.label()
),
);
}
if let Some(facet) = graph.by_memory.get(&e.id) {
panel = panel.row(
DetailRow::pair(
"tier",
format!(
"{} · retention {:.2} · {}",
facet.tier.label(),
facet.retention_score,
facet.forget.label()
),
)
.color(tier_style(facet.tier)),
);
let entities = graph.entity_labels(&facet.entity_ids, 8);
if !entities.is_empty() {
panel = panel.row(DetailRow::pair("entities", entities.join(", ")).color(TN_CYAN));
}
let aliases = graph.alias_labels(&facet.entity_ids, 6);
if !aliases.is_empty() {
panel = panel.pair("aliases", aliases.join(", "));
}
let relations = graph.relation_labels(&facet.relation_ids, 6);
if !relations.is_empty() {
panel = panel.pair("relations", relations.join(" · "));
}
}
let created = e.timestamp.format("%Y-%m-%d %H:%M").to_string();
let accessed = match detail.last_accessed {
Some(la) => format!(" · last {}", la.format("%Y-%m-%d %H:%M")),
None => String::new(),
};
panel = panel.pair(
"created",
format!("{created} · {}× accessed{accessed}", detail.access_count),
);
for (k, v) in &detail.metadata {
panel = panel.pair(k.clone(), v.clone());
}
lines.extend(
panel
.view(width.min(u16::MAX as usize) as u16, panel.rows().len())
.lines()
.map(str::to_string),
);
lines
}
impl App {
pub(crate) fn load_memory_panel(&self, dir: std::path::PathBuf) -> Cmd<Msg> {
let memory = self.session.memory().cloned();
cmd::cmd(
move || async move { Msg::MemoryLoaded(load_memory_panel_data(dir, memory).await) },
)
}
pub(crate) fn memory_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
if key.code == KeyCode::Esc {
self.memory = None;
return None;
}
if matches!(key.code, KeyCode::Char('c')) {
if let Some(cmd) = self.memory_open_ctx_source() {
return Some(cmd);
}
return None;
}
if matches!(key.code, KeyCode::Char('f')) {
return self.memory_forget_candidate();
}
let session_memory = self.session.memory().cloned();
let body = (self.height as usize).saturating_sub(3);
let m = self.memory.as_mut()?;
let last = m.entries.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
m.sel = m.sel.saturating_sub(1);
m.refresh_detail();
}
KeyCode::Down | KeyCode::Char('j') => {
m.sel = (m.sel + 1).min(last);
m.refresh_detail();
}
KeyCode::Char('g') => {
m.sel = 0;
m.refresh_detail();
}
KeyCode::Char('G') => {
m.sel = last;
m.refresh_detail();
}
KeyCode::PageUp => {
m.detail_scroll = m
.detail_scroll
.saturating_sub(body.saturating_sub(1).max(1));
}
KeyCode::PageDown => m.detail_scroll += body.saturating_sub(1).max(1),
KeyCode::Char('r') => {
let dir = m.dir.clone();
m.note = "refreshing graph…".to_string();
return Some(memory_panel_load_cmd(dir, session_memory));
}
_ => {}
}
None
}
fn memory_open_ctx_source(&mut self) -> Option<Cmd<Msg>> {
let m = self.memory.as_mut()?;
let Some(event_id) = m.detail.metadata.get("ctx_event_id").cloned() else {
m.note = "this memory has no ctx source (press c only on source=ctx)".to_string();
return None;
};
if !self.ctx_ready {
if let Some(m) = self.memory.as_mut() {
m.note = "ctx is not installed — can't open the source session".to_string();
}
return None;
}
Some(cmd::cmd(move || async move {
let out = tokio::process::Command::new("ctx")
.args(["show", "event", &event_id, "--window", "8"])
.output()
.await;
Msg::CtxMemorySource(match out {
Ok(o) if o.status.success() => {
Ok((event_id, String::from_utf8_lossy(&o.stdout).into_owned()))
}
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).into_owned()),
Err(e) => Err(e.to_string()),
})
}))
}
fn memory_forget_candidate(&mut self) -> Option<Cmd<Msg>> {
let session_memory = self.session.memory().cloned();
let m = self.memory.as_mut()?;
let entry = m.entries.get(m.sel).cloned()?;
let Some(facet) = m.graph.by_memory.get(&entry.id) else {
m.note = "graph data is still loading".to_string();
return None;
};
if !facet.forget.is_candidate() {
m.note = format!(
"{} memory is {} · only forget candidates can be removed here",
facet.tier.label(),
facet.forget.label()
);
return None;
}
let id = entry.id.clone();
let dir = m.dir.clone();
let loaded_from_session = m.loaded_from_session;
m.note = format!("forgetting candidate {id}…");
Some(cmd::cmd(move || async move {
let id_for_msg = id.clone();
let result = async {
delete_memory_item(&dir, session_memory.clone(), loaded_from_session, &id).await?;
let data = load_memory_panel_data(dir, session_memory).await;
Ok((id_for_msg, data))
}
.await;
Msg::MemoryForgotten(result)
}))
}
pub(crate) fn render_memory(&self, m: &MemPanel) -> String {
let width = self.width as usize;
let h = self.height as usize;
let now = chrono::Utc::now();
let header = format!(
" memory graph · {} events · {} entities · {} aliases · {} relations · LLM {} · merged {} · conflicts {} · S/M/L {}/{}/{} · {} forget candidates {}",
m.graph.stats.events,
m.graph.stats.entities,
m.graph.stats.aliases,
m.graph.stats.relations,
m.graph.stats.llm_extracted,
m.graph.stats.consolidated,
m.graph.stats.conflicts,
m.graph.stats.short,
m.graph.stats.mid,
m.graph.stats.long,
m.graph.stats.forget_candidates,
Style::new().fg(TN_GRAY).render(&m.note),
);
let mut out = vec![
memory_line(&header, width),
memory_line(
÷r_line_with(width.min(u16::MAX as usize) as u16, "─", TN_GRAY),
width,
),
];
let body = h.saturating_sub(3);
if m.entries.is_empty() {
out.push(memory_line(
&Style::new().fg(TN_GRAY).render(
" no memories yet — the agent records them as you work (success/failure patterns, facts)",
),
width,
));
while out.len() + 1 < h {
out.push(String::new());
}
out.push(memory_line(
&Style::new().fg(TN_GRAY).render(" Esc close"),
width,
));
out.truncate(h);
return out.join("\n");
}
let tw = (width / 3).clamp(26, 52);
let sep = Style::new().fg(TN_GRAY).render(" │ ");
let left_lines = memory_timeline_lines(&m.entries, &m.graph, m.sel, now, tw, body);
let right_lines = self.memory_detail_lines(m, now, width.saturating_sub(tw + 4));
for i in 0..body {
let left = left_lines.get(i).cloned().unwrap_or_else(|| " ".repeat(tw));
let right = right_lines
.get(m.detail_scroll + i)
.cloned()
.unwrap_or_default();
out.push(format!("{left}{sep}{right}"));
}
let hint = " ↑↓/jk select · g/G top/bottom · PgUp/PgDn scroll · c ctx source · f forget candidate · r refresh · Esc close";
while out.len() + 1 < h {
out.push(String::new());
}
out.push(memory_line(&Style::new().fg(TN_GRAY).render(hint), width));
out.truncate(h);
out.join("\n")
}
fn memory_detail_lines(
&self,
m: &MemPanel,
now: chrono::DateTime<chrono::Utc>,
w: usize,
) -> Vec<String> {
let mut lines = Vec::new();
let Some(e) = m.entries.get(m.sel) else {
return lines;
};
lines.extend(memory_detail_metadata_lines(e, &m.detail, &m.graph, now, w));
lines.push(divider_line_with(w.min(48) as u16, "─", TN_GRAY));
let content = if m.detail.content.is_empty() {
e.content_lower.as_str()
} else {
m.detail.content.as_str()
};
lines.extend(memory_detail_content_rows(content, w));
lines
.into_iter()
.map(|line| memory_line(&line, w))
.collect()
}
}
fn memory_detail_content_rows(content: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for raw in content.lines() {
if raw.is_empty() {
lines.push(String::new());
} else {
lines.extend(Paragraph::new(raw).width(width.max(8)).color(TN_FG).lines());
}
}
lines
}
fn memory_timeline_lines(
entries: &[MemEntry],
graph: &MemoryGraph,
selected: usize,
now: chrono::DateTime<chrono::Utc>,
width: usize,
height: usize,
) -> Vec<String> {
if width == 0 || height == 0 || entries.is_empty() {
return Vec::new();
}
let rows = timeline_rows(entries, now)
.into_iter()
.filter_map(|row| match row {
TlRow::Day(label) => Some(TimelineRow::section(label)),
TlRow::Node(idx) => {
let entry = entries.get(idx)?;
let (badge, color) = mem_type_style(&entry.memory_type);
let facet = graph.by_memory.get(&entry.id);
let tier = facet.map(|f| f.tier).unwrap_or(MemoryTier::Short);
let forget = facet.map(|f| f.forget).unwrap_or(ForgetSignal::Keep);
let preview = entry.content_lower.lines().next().unwrap_or("");
Some(TimelineRow::item(
TimelineItem::new(
rel_time(entry.timestamp, now),
format!("{}{} {badge}", tier.badge(), forget_mark(forget)),
preview,
)
.color(color),
))
}
})
.collect::<Vec<_>>();
Timeline::new()
.rows(rows)
.selected_item(selected)
.margin(1)
.marker("●")
.time_width(4)
.badge_width(7)
.fill_height(true)
.selected_fg(Color::Black)
.section_color(TN_GRAY)
.time_color(TN_GRAY)
.preview_color(TN_FG)
.view(width.min(u16::MAX as usize) as u16, height)
.lines()
.map(str::to_string)
.collect()
}
fn lifecycle_labels(facet: &MemoryGraphFacet) -> Vec<&'static str> {
let mut labels = Vec::new();
if facet.llm_extracted {
labels.push("llm extracted");
}
if facet.consolidated {
labels.push("consolidated");
}
if facet.conflicts {
labels.push("conflict");
}
labels
}
fn memory_panel_load_cmd(
dir: std::path::PathBuf,
memory: Option<std::sync::Arc<a3s_code_core::memory::AgentMemory>>,
) -> Cmd<Msg> {
cmd::cmd(move || async move { Msg::MemoryLoaded(load_memory_panel_data(dir, memory).await) })
}
async fn load_memory_panel_data(
dir: std::path::PathBuf,
memory: Option<std::sync::Arc<a3s_code_core::memory::AgentMemory>>,
) -> MemPanelData {
let file_dir = dir.clone();
let file_data = tokio::task::spawn_blocking(move || memutil::load_panel_data(&file_dir))
.await
.unwrap_or_default();
if !file_data.entries.is_empty() {
return file_data;
}
let Some(memory) = memory else {
return file_data;
};
match memory.get_recent(MEMORY_PANEL_SESSION_LIMIT).await {
Ok(items) if !items.is_empty() => memutil::panel_data_from_memory_items(items),
Ok(_) => file_data,
Err(_) => file_data,
}
}
async fn delete_memory_item(
dir: &std::path::Path,
memory: Option<std::sync::Arc<a3s_code_core::memory::AgentMemory>>,
prefer_session: bool,
id: &str,
) -> Result<(), String> {
if prefer_session {
let Some(memory) = memory else {
return Err("session memory is unavailable".to_string());
};
let store = memory.store().clone();
return a3s_memory::MemoryStore::delete(store.as_ref(), id)
.await
.map_err(|e| e.to_string());
}
let store = a3s_memory::FileMemoryStore::new(dir)
.await
.map_err(|e| e.to_string())?;
a3s_memory::MemoryStore::delete(&store, id)
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ts(raw: &str) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::parse_from_rfc3339(raw)
.unwrap()
.with_timezone(&chrono::Utc)
}
fn test_entry(id: &str, content: &str, ty: &str, raw_time: &str) -> MemEntry {
MemEntry {
id: id.to_string(),
content_lower: content.to_string(),
tags: Vec::new(),
importance: 0.7,
timestamp: test_ts(raw_time),
memory_type: ty.to_string(),
}
}
#[test]
fn memory_timeline_lines_use_shared_component_and_fit_width() {
let now = test_ts("2026-06-30T12:00:00Z");
let entries = vec![
test_entry(
"a",
"remember terminal layout",
"semantic",
"2026-06-30T11:58:00Z",
),
test_entry(
"b",
"fix narrow tui overflow",
"procedural",
"2026-06-30T11:45:00Z",
),
];
let mut graph = MemoryGraph::default();
graph.by_memory.insert(
"b".to_string(),
MemoryGraphFacet {
tier: MemoryTier::Long,
forget: ForgetSignal::Candidate,
..MemoryGraphFacet::default()
},
);
let lines = memory_timeline_lines(&entries, &graph, 1, now, 40, 4);
let plain = lines
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(lines.len(), 4);
assert!(plain.contains("Today"), "{plain}");
assert!(plain.contains("sem"), "{plain}");
assert!(plain.contains("fix narrow tui"), "{plain}");
assert!(plain.contains("L! proc"), "{plain}");
assert!(lines.iter().any(|line| line.contains("\x1b[30;")));
assert!(
lines
.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 40),
"{plain}"
);
}
#[test]
fn memory_detail_metadata_lines_use_shared_badge_and_detail_panel() {
let now = test_ts("2026-06-30T12:00:00Z");
let mut entry = test_entry(
"m1",
"remember terminal layout",
"semantic",
"2026-06-30T11:58:00Z",
);
entry.tags = vec!["tui".into(), "layout".into()];
let mut detail = MemDetail {
access_count: 3,
last_accessed: Some(test_ts("2026-06-30T11:59:00Z")),
..MemDetail::default()
};
detail
.metadata
.insert("ctx_event_id".into(), "event-1234567890".into());
let mut graph = MemoryGraph::default();
graph.by_memory.insert(
"m1".to_string(),
MemoryGraphFacet {
tier: MemoryTier::Long,
forget: ForgetSignal::Protected,
retention_score: 0.92,
llm_extracted: true,
consolidated: true,
..MemoryGraphFacet::default()
},
);
let lines = memory_detail_metadata_lines(&entry, &detail, &graph, now, 42);
let plain = lines
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n");
assert!(plain.contains("[semantic]"), "{plain}");
assert!(plain.contains("▰▰▰▰▱ importance"), "{plain}");
assert!(plain.contains("tags"), "{plain}");
assert!(plain.contains("lifecycle"), "{plain}");
assert!(plain.contains("long-term"), "{plain}");
assert!(plain.contains("ctx_event_id"), "{plain}");
assert!(
lines
.iter()
.any(|line| line.contains(&format!("\x1b[1;{}m[semantic]", TN_CYAN.fg_ansi()))),
"{lines:?}"
);
assert!(lines.iter().any(|line| line.contains("\x1b[")));
assert!(
lines
.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 42),
"{plain}"
);
}
#[test]
fn importance_bar_uses_shared_progress() {
let rendered = importance_bar(0.7, TN_CYAN);
let expected = Progress::new()
.value(0.7)
.width(5)
.filled_char('▰')
.empty_char('▱')
.filled_color(TN_CYAN)
.empty_color(TN_GRAY)
.show_percentage(false)
.view();
assert_eq!(rendered, expected);
assert_eq!(a3s_tui::style::strip_ansi(&rendered), "▰▰▰▰▱");
assert_eq!(a3s_tui::style::visible_len(&rendered), 5);
}
#[test]
fn memory_detail_content_rows_use_shared_paragraph() {
let rows = memory_detail_content_rows("alpha beta gamma\n\n中文测试内容 with suffix", 12);
let plain = rows
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>();
assert!(plain.contains(&String::new()), "{plain:?}");
assert!(plain.iter().any(|line| line.contains("alpha")), "{plain:?}");
assert!(plain.iter().any(|line| line.contains("中文")), "{plain:?}");
assert!(
rows.iter()
.filter(|line| !line.is_empty())
.all(|line| line.contains(&TN_FG.fg_ansi())),
"{rows:?}"
);
assert!(
rows.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 12),
"{plain:?}"
);
}
#[test]
fn memory_lines_are_width_bounded_with_styles() {
let line = memory_line(
&Style::new().fg(TN_GRAY).render(
" ↑↓/jk select · g/G top/bottom · PgUp/PgDn scroll · c open ctx source · r refresh · Esc close",
),
40,
);
assert!(
a3s_tui::style::visible_len(&line) <= 40,
"{}",
a3s_tui::style::strip_ansi(&line)
);
}
}