use crate::monitor::dashboard::{PalaceRow, format_count};
use crate::monitor::memory_client::DrawerInfo;
use crate::monitor::memory_tui::activity::{PalaceActivity, activity_label, palace_activity_state};
use crate::monitor::memory_tui::state::MemoryTuiState;
use crate::monitor::tui_common::{self, ThreeWaySortKey, truncate};
use crate::monitor::utils::DaemonStatus;
pub const ALL_LABEL: &str = "All palaces";
const SORT_LABELS: &[&str; 3] = &["Activity", "Name", "Vectors"];
pub fn sort_label(key: ThreeWaySortKey) -> &'static str {
key.label(SORT_LABELS)
}
pub fn palace_has_content(palace: &PalaceRow) -> bool {
palace.vector_count > 0 || palace.kg_triple_count > 0 || palace.drawer_count > 0
}
const DRAWER_SNIPPET_WIDTH: usize = 60;
const DRAWER_CREATOR_WIDTH: usize = 24;
pub fn filtered_sorted_palaces(state: &MemoryTuiState) -> Vec<PalaceRow> {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
tui_common::filtered_sorted(&nonempty, &state.filter, state.sort_key)
}
pub fn visible_palace_ids(state: &MemoryTuiState) -> Vec<String> {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
tui_common::visible_ids(
&nonempty,
&state.filter,
state.sort_key,
state.group_by_project,
)
}
pub fn navigate_up_visible(state: &mut MemoryTuiState) {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
let current_id = state
.selected_id()
.map(str::to_string)
.unwrap_or_else(|| tui_common::ALL_SENTINEL.to_string());
let local_cursor = tui_common::id_to_cursor(&nonempty, ¤t_id).unwrap_or(0);
let new_local = tui_common::navigate_up(
&nonempty,
local_cursor,
&state.filter,
state.sort_key,
state.group_by_project,
);
let new_id = tui_common::current_visible_id(&nonempty, new_local);
state.selected = tui_common::id_to_cursor(&state.palaces, &new_id).unwrap_or(0);
}
pub fn navigate_down_visible(state: &mut MemoryTuiState) {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
let current_id = state
.selected_id()
.map(str::to_string)
.unwrap_or_else(|| tui_common::ALL_SENTINEL.to_string());
let local_cursor = tui_common::id_to_cursor(&nonempty, ¤t_id).unwrap_or(0);
let new_local = tui_common::navigate_down(
&nonempty,
local_cursor,
&state.filter,
state.sort_key,
state.group_by_project,
);
let new_id = tui_common::current_visible_id(&nonempty, new_local);
state.selected = tui_common::id_to_cursor(&state.palaces, &new_id).unwrap_or(0);
}
pub fn visible_selected_row(state: &MemoryTuiState) -> usize {
if state.selected == 0 {
return 0;
}
palace_lines(state)
.iter()
.position(|row| row.selected)
.unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PalaceListRow {
pub text: String,
pub selected: bool,
pub is_all: bool,
pub is_header: bool,
pub activity: Option<PalaceActivity>,
}
pub fn palace_row(palace: &PalaceRow, _selected: bool) -> String {
palace_row_with_activity(palace, PalaceActivity::Idle, 0)
}
pub fn palace_row_with_activity(
palace: &PalaceRow,
activity: PalaceActivity,
tick: usize,
) -> String {
let prefix = activity.prefix(tick);
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
format!(
"{prefix} {:<10} {:>7}v",
truncate(label, 10),
format_count(palace.vector_count),
)
}
pub(crate) fn palace_row_indented_with_activity(
palace: &PalaceRow,
activity: PalaceActivity,
tick: usize,
) -> String {
let prefix = activity.prefix(tick);
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
format!(
" {prefix} {:<9} {:>7}v",
truncate(label, 9),
format_count(palace.vector_count),
)
}
pub fn palace_lines(state: &MemoryTuiState) -> Vec<PalaceListRow> {
palace_lines_at(state, chrono::Utc::now(), 0)
}
pub fn palace_lines_at(
state: &MemoryTuiState,
now: chrono::DateTime<chrono::Utc>,
tick: usize,
) -> Vec<PalaceListRow> {
let mut rows: Vec<PalaceListRow> = Vec::with_capacity(state.palaces.len() + 1);
let total_vectors: u64 = state.palaces.iter().map(|p| p.vector_count).sum();
let all_selected = state.selected == 0;
rows.push(PalaceListRow {
text: format!(" {ALL_LABEL} {}v", format_count(total_vectors)),
selected: all_selected,
is_all: true,
is_header: false,
activity: None,
});
if state.palaces.is_empty() {
let text = if state.daemon_status == DaemonStatus::Connecting {
" Loading…".to_string()
} else {
" (no palaces)".to_string()
};
rows.push(PalaceListRow {
text,
selected: false,
is_all: false,
is_header: false,
activity: None,
});
return rows;
}
let visible = filtered_sorted_palaces(state);
if visible.is_empty() {
rows.push(PalaceListRow {
text: " (no matches)".to_string(),
selected: false,
is_all: false,
is_header: false,
activity: None,
});
return rows;
}
let cursor_for = |p: &PalaceRow| -> usize {
state
.palaces
.iter()
.position(|orig| orig.id == p.id)
.map(|i| i + 1)
.unwrap_or(0)
};
if state.group_by_project {
let mut seen: Vec<String> = Vec::new();
for p in &visible {
let proj = p.project().to_string();
if !seen.iter().any(|s| s == &proj) {
seen.push(proj);
}
}
for project in &seen {
rows.push(PalaceListRow {
text: format!("── {project} ─────"),
selected: false,
is_all: false,
is_header: true,
activity: None,
});
for palace in visible.iter().filter(|p| p.project() == project) {
let cursor = cursor_for(palace);
let selected = cursor == state.selected;
let activity = palace_activity_state(palace, now);
rows.push(PalaceListRow {
text: palace_row_indented_with_activity(palace, activity, tick),
selected,
is_all: false,
is_header: false,
activity: Some(activity),
});
}
}
} else {
for palace in &visible {
let cursor = cursor_for(palace);
let selected = cursor == state.selected;
let activity = palace_activity_state(palace, now);
rows.push(PalaceListRow {
text: palace_row_with_activity(palace, activity, tick),
selected,
is_all: false,
is_header: false,
activity: Some(activity),
});
}
}
rows
}
pub fn stats_lines(state: &MemoryTuiState) -> Vec<String> {
if state.daemon_status == DaemonStatus::Connecting {
return vec!["Loading…".to_string()];
}
if state.is_all_selected() {
let stats = state.status.clone().unwrap_or_default();
let mut lines = vec![
format!("Scope: {ALL_LABEL}"),
format!("Palaces: {}", state.palaces.len()),
format!("Vectors: {}", format_count(stats.total_vectors)),
format!("Drawers: {}", format_count(stats.total_drawers)),
format!("KG triples: {}", format_count(stats.total_kg_triples)),
];
if state.palaces.is_empty() {
lines.push("(no palaces)".to_string());
} else {
lines.push(String::new());
for palace in &state.palaces {
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
lines.push(format!(
" · {:<12} {:>7}v",
truncate(label, 12),
format_count(palace.vector_count),
));
}
}
return lines;
}
match state.palaces.get(state.selected.saturating_sub(1)) {
Some(palace) => {
let label = if palace.name.is_empty() {
"(unnamed)"
} else {
palace.name.as_str()
};
let now = chrono::Utc::now();
let activity = palace_activity_state(palace, now);
let mut lines = vec![
format!("Palace: {label}"),
format!("Vectors: {}", format_count(palace.vector_count)),
format!("Id: {}", palace.id),
String::new(),
"Knowledge Graph".to_string(),
format!(" Nodes: {}", format_count(palace.node_count)),
format!(" Edges: {}", format_count(palace.edge_count)),
format!(" Triples: {}", format_count(palace.kg_triple_count)),
String::new(),
];
match palace.last_write_at {
Some(ts) => {
lines.push(format!(
"Last write: {} ({})",
crate::monitor::memory_tui::activity::format_relative_time(now, ts),
ts.format("%Y-%m-%d %H:%M:%S UTC"),
));
}
None => lines.push("Last write: never".to_string()),
}
lines.push(format!("State: {}", activity_label(activity)));
lines
}
None => vec!["(no palace selected)".to_string()],
}
}
pub fn title_line(state: &MemoryTuiState) -> String {
let (glyph, label) = state.daemon_status.badge();
match &state.daemon_status {
DaemonStatus::Online { version, .. } => {
format!("trusty-memory v{version} [{glyph}] {label}")
}
_ => format!(
"trusty-memory v{VERSION} [{glyph}] {label} {}",
state.base_url
),
}
}
pub fn drawer_panel_lines(state: &MemoryTuiState, total_drawer_count: u64) -> Vec<String> {
let dl = &state.drawer_list;
if dl.palace_id.is_none() {
return vec![];
}
let mut lines: Vec<String> = Vec::with_capacity(dl.drawers.len() + 2);
let from = dl.offset + 1;
let to = dl.offset + dl.drawers.len();
let header = if dl.drawers.is_empty() {
if dl.loading {
"loading drawers…".to_string()
} else if let Some(err) = &dl.last_error {
format!("drawers unavailable: {err}")
} else {
"(no drawers yet)".to_string()
}
} else {
format!(
"drawers {}–{} of {} (page {})",
from,
to,
format_count(total_drawer_count),
dl.page() + 1,
)
};
lines.push(header);
for d in &dl.drawers {
lines.push(format_drawer_row(d));
}
lines
}
pub fn format_drawer_row(drawer: &DrawerInfo) -> String {
let id = truncate(&drawer.id, 8);
let ts = match drawer.created_at {
Some(t) => t.format("%m-%d %H:%M").to_string(),
None => "-- ".to_string(),
};
let creator = truncate(&drawer.creator, DRAWER_CREATOR_WIDTH);
let base = format!("{id} {ts} {creator}");
match drawer
.snippet
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(snippet) => format!("{base} {}", truncate(snippet, DRAWER_SNIPPET_WIDTH)),
None => base,
}
}
pub fn drawer_detail_body(state: &MemoryTuiState) -> String {
if state.drawer_detail_loading {
return "Loading…".to_string();
}
if state.drawer_detail_memories.is_empty() {
return "(no memories returned)".to_string();
}
let mut out = String::new();
for (i, memory) in state.drawer_detail_memories.iter().enumerate() {
if i > 0 {
out.push_str("\n\n──────────────────────────────────────\n\n");
}
let ts = memory
.created_at
.map(|t| t.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "(no timestamp)".to_string());
let creator = crate::monitor::memory_client::creator_label(&memory.tags);
let tag_join = if memory.tags.is_empty() {
"(none)".to_string()
} else {
memory.tags.join(", ")
};
let header_id = if memory.id.is_empty() {
"(no id)".to_string()
} else {
memory.id.clone()
};
out.push_str(&format!("Drawer: {header_id}\n"));
out.push_str(&format!("Time: {ts}\n"));
out.push_str(&format!("By: {creator}\n"));
out.push_str(&format!("Tags: {tag_join}\n"));
out.push('\n');
if memory.content.is_empty() {
out.push_str("(empty content)");
} else {
out.push_str(&memory.content);
}
}
out
}
pub fn help_text() -> String {
[
" Tab cycle focus: palace list → drawer pane → recall bar",
" ↑ / ↓ move the active selection (list, drawers, or modal scroll)",
" ← / → page through drawers in the ACTIVITY panel",
" Enter in DrawerPane: open the selected drawer's detail modal",
" in Input: run a recall query",
" All the top list row fans recalls / stats across every palace",
" / activate the inline palace filter (Esc / Enter close)",
" s cycle palace sort: Activity → Name → Vectors",
" g toggle grouping by inferred project",
" d run a dream cycle across every palace",
" ? toggle this help overlay",
" q / Esc close modal / quit",
]
.join("\n")
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KEY_HINT: &str = "[Tab] focus [↑↓] select [Enter] open/recall [d] dream [/] filter [s] sort [g] group [←→] page [q] quit [?] help";