use super::super::*;
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 imp_bar(importance: f32) -> String {
let filled = (importance.clamp(0.0, 1.0) * 5.0).round() as usize;
format!("{}{}", "▰".repeat(filled), "▱".repeat(5 - filled))
}
impl App {
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;
}
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') => {
m.entries = memutil::load_timeline(&m.dir);
m.sel = m.sel.min(m.entries.len().saturating_sub(1));
m.note = format!("{} entries", m.entries.len());
m.refresh_detail();
}
_ => {}
}
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()),
})
}))
}
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 · {} {} {}",
m.entries.len(),
Style::new().fg(TN_GRAY).render("entries · ~/.a3s/memory"),
Style::new().fg(TN_GRAY).render(&m.note),
);
let mut out = vec![
pad_to(&header, width),
pad_to(&Style::new().fg(TN_GRAY).render(&"─".repeat(width)), width),
];
let body = h.saturating_sub(3);
if m.entries.is_empty() {
out.push(pad_to(
&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(pad_to(
&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 prefix = 15;
let rows = timeline_rows(&m.entries, now);
let sel_row = rows
.iter()
.position(|r| matches!(r, TlRow::Node(i) if *i == m.sel))
.unwrap_or(0);
let start = sel_row.saturating_sub(body.saturating_sub(1));
let right_lines = self.memory_detail_lines(m, now, width.saturating_sub(tw + 4));
for i in 0..body {
let left = match rows.get(start + i) {
Some(TlRow::Day(label)) => {
let head = format!(" ── {label} ");
let bar = "─".repeat(tw.saturating_sub(a3s_tui::style::visible_len(&head)));
Style::new()
.fg(TN_GRAY)
.render(&pad_to(&format!("{head}{bar}"), tw))
}
Some(TlRow::Node(idx)) => {
let e = &m.entries[*idx];
let (badge, color) = mem_type_style(&e.memory_type);
let time = rel_time(e.timestamp, now);
let preview = e.content_lower.lines().next().unwrap_or("");
let preview = truncate(preview, tw.saturating_sub(prefix));
if *idx == m.sel {
let plain = format!(" ● {time:>4} {badge:<4} {preview}");
Style::new()
.fg(Color::Black)
.bg(color)
.render(&pad_to(&plain, tw))
} else {
let rail = Style::new().fg(color).render(" ●");
let t = Style::new().fg(TN_GRAY).render(&format!(" {time:>4}"));
let b = Style::new().fg(color).render(&format!(" {badge:<4}"));
let pv = Style::new().fg(TN_FG).render(&format!(" {preview}"));
pad_to(&format!("{rail}{t}{b}{pv}"), tw)
}
}
None => " ".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 open ctx source · r refresh · Esc close";
while out.len() + 1 < h {
out.push(String::new());
}
out.push(pad_to(&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;
};
let (_, color) = mem_type_style(&e.memory_type);
let ty = if e.memory_type.is_empty() {
"memory"
} else {
&e.memory_type
};
lines.push(format!(
"{} {} {}",
Style::new().fg(color).bold().render(&format!("● {ty}")),
Style::new().fg(color).render(&imp_bar(e.importance)),
Style::new().fg(TN_GRAY).render(&format!(
"importance {:.2} · {}",
e.importance,
rel_time(e.timestamp, now)
)),
));
if !e.tags.is_empty() {
lines.push(
Style::new()
.fg(TN_CYAN)
.render(&format!("tags: {}", e.tags.join(", "))),
);
}
let created = e.timestamp.format("%Y-%m-%d %H:%M").to_string();
let accessed = match m.detail.last_accessed {
Some(la) => format!(" · last {}", la.format("%Y-%m-%d %H:%M")),
None => String::new(),
};
lines.push(Style::new().fg(TN_GRAY).render(&format!(
"created {created} · {}× accessed{accessed}",
m.detail.access_count
)));
for (k, v) in &m.detail.metadata {
let v = truncate(v, w.saturating_sub(k.len() + 2));
lines.push(Style::new().fg(TN_GRAY).render(&format!("{k}: {v}")));
}
lines.push(Style::new().fg(TN_GRAY).render(&"─".repeat(w.min(48))));
let content = if m.detail.content.is_empty() {
e.content_lower.as_str()
} else {
m.detail.content.as_str()
};
for raw in content.lines() {
if raw.is_empty() {
lines.push(String::new());
} else {
for wl in wrap_words(raw, w.max(8)) {
lines.push(Style::new().fg(TN_FG).render(&wl));
}
}
}
lines
}
}