mneme-brain 0.3.1

Persistent memory system for AI coding agents — Rust + SQLite + FTS5 + MCP. Hybrid search, age encryption, CRDT P2P sync, WASM plugins, TUI with knowledge graph.
Documentation
use crate::store::memory::MemoryType;
use crate::tui::app::{App, Screen};
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
    Frame,
};

const BG: Color = Color::Rgb(13, 17, 23);
const BG2: Color = Color::Rgb(22, 27, 34);
const TXT: Color = Color::Rgb(201, 209, 217);
const DIM: Color = Color::Rgb(110, 118, 129);
const HDR: Color = Color::Rgb(240, 246, 252);
const BLU: Color = Color::Rgb(88, 166, 255);
const GRN: Color = Color::Rgb(126, 231, 135);
const YLW: Color = Color::Rgb(210, 153, 34);
const RED: Color = Color::Rgb(218, 54, 51);

pub fn render(frame: &mut Frame, app: &App) {
    let r = frame.size();
    let hdr = format!(
        " mneme v{}  {} projects  {} memories  [q]uit  [/]search  [?]help",
        env!("CARGO_PKG_VERSION"),
        app.projects.len(),
        app.total_mems
    );
    frame.render_widget(
        Paragraph::new(Span::styled(&hdr, Style::default().fg(HDR).bg(BG2)))
            .style(Style::default().bg(BG2)),
        Rect::new(0, 0, r.width, 1),
    );

    let body = Rect::new(0, 1, r.width, r.height.saturating_sub(2));

    match app.screen {
        Screen::Help => render_help(frame, body),
        Screen::Projects => render_projects(frame, app, body),
        Screen::Memories => render_memories(frame, app, body),
        Screen::Detail => render_detail(frame, app, body),
        Screen::Sessions => render_sessions(frame, app, body),
    }
    render_status(frame, app, r);
    render_search(frame, app, r);
}

fn render_status(frame: &mut Frame, app: &App, r: Rect) {
    let status = match app.screen {
        Screen::Projects => " [↑↓] nav  [Enter] open  [/] search  [s] sessions  [q] quit".into(),
        Screen::Memories => {
            if app.searching {
                " type to search · enter to confirm · esc to cancel".into()
            } else {
                format!(
                    " [↑↓] nav  [Enter] detail  [/] search  [d] delete  [Esc] back  [q] quit  {}",
                    app.msg
                )
            }
        }
        Screen::Detail => " [↑↓/j/k] scroll  [Esc] back  [d] delete".into(),
        Screen::Sessions => " [Esc] back".into(),
        Screen::Help => " [q] quit".into(),
    };
    frame.render_widget(
        Paragraph::new(Span::styled(status, Style::default().fg(DIM).bg(BG2)))
            .style(Style::default().bg(BG2)),
        Rect::new(0, r.height.saturating_sub(1), r.width, 1),
    );
}

fn render_search(frame: &mut Frame, app: &App, r: Rect) {
    if app.searching {
        let a = centered(50, 3, r);
        frame.render_widget(Clear, a);
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(BLU))
            .style(Style::default().bg(BG));
        frame.render_widget(block, a);
        let inner = Rect::new(a.x + 1, a.y + 1, a.width.saturating_sub(2), 1);
        frame.render_widget(
            Paragraph::new(Span::styled(
                format!("> {}", app.search),
                Style::default().fg(TXT),
            )),
            inner,
        );
    }
}

fn render_projects(frame: &mut Frame, app: &App, area: Rect) {
    let items: Vec<ListItem> = app
        .projects
        .iter()
        .enumerate()
        .map(|(i, p)| {
            let style = if i == app.proj_sel {
                Style::default().bg(BG2).fg(BLU)
            } else {
                Style::default().fg(TXT)
            };
            let last = p
                .last_activity
                .map(|d| d.format("%Y-%m-%d").to_string())
                .unwrap_or_default();
            ListItem::new(format!(
                " {:<25} {:>3} mems  {:>3} sessions  {}",
                p.name, p.memory_count, p.session_count, last
            ))
            .style(style)
        })
        .collect();
    frame.render_widget(
        List::new(items).block(Block::default().borders(Borders::ALL).title("📁 Projects")),
        area,
    );
}

fn render_memories(frame: &mut Frame, app: &App, area: Rect) {
    let items: Vec<ListItem> = app
        .memories
        .iter()
        .enumerate()
        .map(|(i, m)| {
            let style = if i == app.mem_sel {
                Style::default().bg(BG2).fg(BLU)
            } else {
                Style::default().fg(TXT)
            };
            let t = if m.title.len() > 40 {
                format!("{}...", &m.title[..37])
            } else {
                m.title.clone()
            };
            let mt = format!("{:?}", m.memory_type).to_lowercase();
            let snippet = if let Some(pos) = m.content.find('\n') {
                format!("{}...", &m.content[..pos.min(50)])
            } else if m.content.len() > 50 {
                format!("{}...", &m.content[..47])
            } else {
                m.content.clone()
            };
            ListItem::new(format!(" [{}] {:<40}  {}", mt, t, snippet)).style(style)
        })
        .collect();

    let title = if app.searching {
        format!("🔍 Search")
    } else {
        format!("📝 {} ({})", app.project, app.memories.len())
    };
    frame.render_widget(
        List::new(items).block(Block::default().borders(Borders::ALL).title(title.as_str())),
        area,
    );
}

fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
    if let Some(ref m) = app.detail {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(5), Constraint::Min(1)])
            .split(area);

        let lines = vec![
            Line::from(Span::styled(
                &m.title,
                Style::default().fg(HDR).add_modifier(Modifier::BOLD),
            )),
            Line::from(format!(
                "  Type: {:?}  |  Importance: {:?}  |  Project: {}",
                m.memory_type, m.importance, m.project
            )),
            Line::from(format!(
                "  Created: {}  |  ID: {}",
                m.created_at.format("%Y-%m-%d %H:%M"),
                &m.id.to_string()[..8]
            )),
            Line::from(format!("  Tags: {}", m.tags.join(", "))),
            if let Some(ref why) = m.why {
                if !why.is_empty() {
                    Line::from(format!("  Why: {}", why))
                } else {
                    Line::from("")
                }
            } else {
                Line::from("")
            },
        ];
        frame.render_widget(
            Paragraph::new(lines).block(Block::default().borders(Borders::ALL)),
            chunks[0],
        );

        let content: Vec<Line> = m
            .content
            .lines()
            .skip(app.detail_scroll)
            .take(30)
            .map(|l| {
                if l.starts_with("**What**")
                    || l.starts_with("**Why**")
                    || l.starts_with("**Where**")
                    || l.starts_with("**Learned**")
                {
                    Line::from(Span::styled(
                        l,
                        Style::default().fg(YLW).add_modifier(Modifier::BOLD),
                    ))
                } else {
                    Line::from(l)
                }
            })
            .collect();
        frame.render_widget(
            Paragraph::new(content)
                .block(Block::default().borders(Borders::ALL).title("Content"))
                .wrap(Wrap { trim: false }),
            chunks[1],
        );
    }
}

fn render_sessions(frame: &mut Frame, app: &App, area: Rect) {
    let items: Vec<ListItem> = app
        .sessions
        .iter()
        .map(|s| {
            ListItem::new(format!(
                " {}  |  {}",
                s.started_at.format("%Y-%m-%d %H:%M"),
                s.summary.as_deref().unwrap_or("(no summary)")
            ))
        })
        .collect();
    frame.render_widget(
        List::new(items).block(Block::default().borders(Borders::ALL).title("📋 Sessions")),
        area,
    );
}

fn render_help(frame: &mut Frame, area: Rect) {
    let text = vec![
        Line::from(Span::styled(
            "mneme TUI Help",
            Style::default().fg(BLU).add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from(Span::styled(
            "Navigation",
            Style::default().fg(GRN).add_modifier(Modifier::BOLD),
        )),
        Line::from("  ↑↓/jk     Navigate lists"),
        Line::from("  Enter     Open project / memory detail"),
        Line::from("  Esc       Go back"),
        Line::from(""),
        Line::from(Span::styled(
            "Search & Actions",
            Style::default().fg(GRN).add_modifier(Modifier::BOLD),
        )),
        Line::from("  /         Search across all projects"),
        Line::from("  d         Delete selected memory"),
        Line::from("  s         View sessions for project"),
        Line::from(""),
        Line::from(Span::styled(
            "Global",
            Style::default().fg(GRN).add_modifier(Modifier::BOLD),
        )),
        Line::from("  q         Quit"),
        Line::from("  ?         This help"),
    ];
    let block = Block::default()
        .borders(Borders::ALL)
        .style(Style::default().bg(BG));
    frame.render_widget(Paragraph::new(text).block(block), centered(50, 16, area));
}

fn centered(w: u16, h: u16, r: Rect) -> Rect {
    Rect::new(
        r.x + r.width.saturating_sub(w) / 2,
        r.y + r.height.saturating_sub(h) / 2,
        w.min(r.width),
        h.min(r.height),
    )
}