Files
buzz/crates/buzz-cli/src/commands/tui.rs
2026-07-08 16:46:20 -04:00

2071 lines
66 KiB
Rust

use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::io;
use std::time::{Duration, Instant};
use buzz_core::kind::{
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_MANAGED_AGENT, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_V2, KIND_TEXT_NOTE,
KIND_WORKFLOW_APPROVAL_DENIED, KIND_WORKFLOW_APPROVAL_GRANTED,
KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use chrono::{Local, TimeZone};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures_util::stream::{self, StreamExt};
use nostr::PublicKey;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use crate::client::BuzzClient;
use crate::commands::messages::{send_message_literal, SendMessageParams};
use crate::error::CliError;
const CHANNEL_LIMIT: u32 = 500;
const DEFAULT_MESSAGE_LIMIT: u32 = 80;
const INPUT_POLL_MS: u64 = 50;
const ACTIVITY_CONTEXT_CONCURRENCY: usize = 8;
const MESSAGE_KINDS: &[u32] = &[
KIND_TEXT_NOTE,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_DIFF,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
];
const THREAD_KINDS: &[u32] = &[
KIND_TEXT_NOTE,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_DIFF,
KIND_FORUM_COMMENT,
];
const INBOX_MENTION_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_TEXT_NOTE,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
];
const INBOX_NEEDS_ACTION_KINDS: &[u32] = &[
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_WORKFLOW_APPROVAL_GRANTED,
KIND_WORKFLOW_APPROVAL_DENIED,
];
const INBOX_CATEGORIES: &[InboxCategory] = &[
InboxCategory::NeedsAction,
InboxCategory::Mention,
InboxCategory::Activity,
];
pub async fn dispatch(cmd: crate::TuiCmd, client: &BuzzClient) -> Result<(), CliError> {
let mut app = TuiApp::new(
cmd.limit.unwrap_or(DEFAULT_MESSAGE_LIMIT).clamp(1, 200),
cmd.poll_seconds,
cmd.channel,
cmd.inbox,
);
app.load_initial(client).await?;
let mut session = TerminalSession::enter()?;
let mut needs_draw = true;
loop {
if app.should_poll() {
app.status = "Refreshing...".to_string();
app.mark_refreshed();
session.draw(&app)?;
if let Err(err) = app.refresh_current(client).await {
app.status = format!("Refresh failed: {err}");
}
needs_draw = true;
}
if needs_draw {
session.draw(&app)?;
needs_draw = false;
}
if !event::poll(Duration::from_millis(INPUT_POLL_MS)).map_err(terminal_err)? {
continue;
}
let Event::Key(key) = event::read().map_err(terminal_err)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let action = app.handle_key(key);
needs_draw = true;
match action {
TuiAction::None => {}
TuiAction::Quit => break,
TuiAction::Refresh => {
app.status = "Refreshing...".to_string();
app.mark_refreshed();
session.draw(&app)?;
if let Err(err) = app.refresh_current(client).await {
app.status = format!("Refresh failed: {err}");
}
needs_draw = true;
}
TuiAction::OpenSelection => {
app.status = "Loading selection...".to_string();
session.draw(&app)?;
if let Err(err) = app.open_sidebar_selection(client).await {
app.status = format!("Load failed: {err}");
}
needs_draw = true;
}
TuiAction::OpenThread => {
app.status = "Loading thread...".to_string();
session.draw(&app)?;
if let Err(err) = app.open_selected_thread(client).await {
app.status = format!("Thread load failed: {err}");
}
needs_draw = true;
}
TuiAction::BackToChannel => {
app.status = "Loading channel...".to_string();
session.draw(&app)?;
if let Err(err) = app.back_to_channel(client).await {
app.status = format!("Channel load failed: {err}");
}
needs_draw = true;
}
TuiAction::SwitchSidebar(mode) => {
app.status = "Loading sidebar...".to_string();
session.draw(&app)?;
if let Err(err) = app.switch_sidebar(client, mode).await {
app.status = format!("Sidebar load failed: {err}");
}
needs_draw = true;
}
TuiAction::SendDraft => {
app.status = "Sending...".to_string();
session.draw(&app)?;
app.send_draft(client).await;
needs_draw = true;
}
}
}
Ok(())
}
struct TerminalSession {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
}
impl TerminalSession {
fn enter() -> Result<Self, CliError> {
enable_raw_mode().map_err(terminal_err)?;
let mut stdout = io::stdout();
if let Err(err) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(terminal_err(err));
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(terminal_err)?;
terminal.clear().map_err(terminal_err)?;
terminal.hide_cursor().map_err(terminal_err)?;
Ok(Self { terminal })
}
fn draw(&mut self, app: &TuiApp) -> Result<(), CliError> {
self.terminal
.draw(|frame| draw_tui(frame, app))
.map(|_| ())
.map_err(terminal_err)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SidebarMode {
Channels,
Inbox,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Sidebar,
Messages,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum MessageTarget {
None,
Channel {
channel_id: String,
channel_name: String,
},
Thread {
channel_id: String,
channel_name: String,
root_id: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ComposeTarget {
New,
Reply { event_id: String, event_kind: u32 },
}
enum TuiAction {
None,
Quit,
Refresh,
OpenSelection,
OpenThread,
BackToChannel,
SwitchSidebar(SidebarMode),
SendDraft,
}
struct TuiApp {
channels: Vec<ChannelView>,
inbox: Vec<InboxRow>,
messages: Vec<EventView>,
profiles: HashMap<String, String>,
channel_names: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
expanded_agent_events: BTreeSet<String>,
sidebar_mode: SidebarMode,
focus: Focus,
target: MessageTarget,
selected_channel: usize,
selected_inbox: usize,
selected_message: usize,
draft: String,
compose_target: Option<ComposeTarget>,
status: String,
last_refresh: Option<Instant>,
last_refresh_label: String,
limit: u32,
poll_seconds: u64,
initial_channel: Option<String>,
start_inbox: bool,
}
impl TuiApp {
fn new(
limit: u32,
poll_seconds: u64,
initial_channel: Option<String>,
start_inbox: bool,
) -> Self {
Self {
channels: Vec::new(),
inbox: Vec::new(),
messages: Vec::new(),
profiles: HashMap::new(),
channel_names: HashMap::new(),
agent_pubkeys: BTreeSet::new(),
expanded_agent_events: BTreeSet::new(),
sidebar_mode: if start_inbox {
SidebarMode::Inbox
} else {
SidebarMode::Channels
},
focus: Focus::Messages,
target: MessageTarget::None,
selected_channel: 0,
selected_inbox: 0,
selected_message: 0,
draft: String::new(),
compose_target: None,
status: "Loading...".to_string(),
last_refresh: None,
last_refresh_label: String::new(),
limit,
poll_seconds,
initial_channel,
start_inbox,
}
}
async fn load_initial(&mut self, client: &BuzzClient) -> Result<(), CliError> {
let snapshot = fetch_channels(client).await?;
self.apply_channels(snapshot);
if self.start_inbox {
self.switch_sidebar(client, SidebarMode::Inbox).await?;
} else {
self.select_initial_channel();
self.open_sidebar_selection(client).await?;
}
self.mark_refreshed();
Ok(())
}
fn should_poll(&self) -> bool {
self.poll_seconds > 0
&& self
.last_refresh
.map(|last| last.elapsed() >= Duration::from_secs(self.poll_seconds))
.unwrap_or(false)
}
fn mark_refreshed(&mut self) {
self.last_refresh = Some(Instant::now());
self.last_refresh_label = Local::now().format("%H:%M:%S").to_string();
}
async fn refresh_current(&mut self, client: &BuzzClient) -> Result<(), CliError> {
match self.sidebar_mode {
SidebarMode::Channels => {
let selected_id = self
.selected_channel()
.map(|channel| channel.id.clone())
.or_else(|| self.current_channel_id().map(str::to_string));
self.apply_channels(fetch_channels(client).await?);
if let Some(id) = selected_id {
if let Some(index) = self.channels.iter().position(|channel| channel.id == id) {
self.selected_channel = index;
}
}
}
SidebarMode::Inbox => {
self.apply_inbox(fetch_inbox(client, self.limit).await?);
}
}
match self.target.clone() {
MessageTarget::None => self.open_sidebar_selection(client).await?,
MessageTarget::Channel {
channel_id,
channel_name,
} => self.load_channel(client, channel_id, channel_name).await?,
MessageTarget::Thread {
channel_id,
channel_name,
root_id,
} => {
self.load_thread(client, channel_id, channel_name, root_id)
.await?;
}
}
self.mark_refreshed();
Ok(())
}
async fn switch_sidebar(
&mut self,
client: &BuzzClient,
mode: SidebarMode,
) -> Result<(), CliError> {
self.sidebar_mode = mode;
self.focus = Focus::Sidebar;
match mode {
SidebarMode::Channels => {
if self.channels.is_empty() {
self.apply_channels(fetch_channels(client).await?);
}
}
SidebarMode::Inbox => {
if self.inbox.is_empty() {
self.apply_inbox(fetch_inbox(client, self.limit).await?);
}
}
}
self.open_sidebar_selection(client).await
}
async fn open_sidebar_selection(&mut self, client: &BuzzClient) -> Result<(), CliError> {
match self.sidebar_mode {
SidebarMode::Channels => {
let Some(channel) = self.selected_channel().cloned() else {
self.messages.clear();
self.target = MessageTarget::None;
self.status = "No channels found.".to_string();
return Ok(());
};
self.load_channel(client, channel.id, channel.name).await
}
SidebarMode::Inbox => {
let Some(row) = self.selected_inbox_row().cloned() else {
self.messages.clear();
self.target = MessageTarget::None;
self.status = "No inbox items found.".to_string();
return Ok(());
};
let Some(channel_id) = row.event.channel_id.clone() else {
self.messages = vec![row.event.clone()];
self.target = MessageTarget::None;
self.status = "Inbox item has no channel context.".to_string();
return Ok(());
};
let channel_name = self.channel_label(&channel_id);
let root_id = thread_key(&row.event);
self.load_thread(client, channel_id, channel_name, root_id)
.await
}
}
}
async fn load_channel(
&mut self,
client: &BuzzClient,
channel_id: String,
channel_name: String,
) -> Result<(), CliError> {
let snapshot = fetch_channel_messages(client, &channel_id, self.limit).await?;
self.apply_message_snapshot(snapshot);
self.target = MessageTarget::Channel {
channel_id,
channel_name,
};
self.selected_message = self.messages.len().saturating_sub(1);
self.focus = Focus::Messages;
self.status = format!(
"Loaded {} message{}. n new, r reply, Enter thread.",
self.messages.len(),
if self.messages.len() == 1 { "" } else { "s" }
);
Ok(())
}
async fn load_thread(
&mut self,
client: &BuzzClient,
channel_id: String,
channel_name: String,
root_id: String,
) -> Result<(), CliError> {
let snapshot = fetch_thread_messages(client, &channel_id, &root_id, self.limit).await?;
self.apply_message_snapshot(snapshot);
self.target = MessageTarget::Thread {
channel_id,
channel_name,
root_id,
};
self.selected_message = self.messages.len().saturating_sub(1);
self.focus = Focus::Messages;
self.status = format!(
"Loaded thread with {} event{}. Esc returns to channel.",
self.messages.len(),
if self.messages.len() == 1 { "" } else { "s" }
);
Ok(())
}
async fn open_selected_thread(&mut self, client: &BuzzClient) -> Result<(), CliError> {
let Some(event) = self.selected_event().cloned() else {
self.status = "No message selected.".to_string();
return Ok(());
};
let Some(channel_id) = event
.channel_id
.clone()
.or_else(|| self.current_channel_id().map(str::to_string))
else {
self.status = "Selected message has no channel context.".to_string();
return Ok(());
};
let channel_name = self.channel_label(&channel_id);
let root_id = thread_key(&event);
self.load_thread(client, channel_id, channel_name, root_id)
.await
}
async fn back_to_channel(&mut self, client: &BuzzClient) -> Result<(), CliError> {
let MessageTarget::Thread {
channel_id,
channel_name,
..
} = self.target.clone()
else {
self.focus = Focus::Sidebar;
self.status = "Sidebar focused.".to_string();
return Ok(());
};
self.load_channel(client, channel_id, channel_name).await
}
fn apply_channels(&mut self, channels: ChannelSnapshot) {
self.channel_names
.extend(channels.channels.iter().map(|channel| {
(
channel.id.clone(),
if channel.name.is_empty() {
short_hex(&channel.id)
} else {
channel.name.clone()
},
)
}));
self.channels = channels.channels;
self.selected_channel = self
.selected_channel
.min(self.channels.len().saturating_sub(1));
}
fn apply_inbox(&mut self, snapshot: InboxSnapshot) {
self.profiles.extend(snapshot.profiles);
self.channel_names.extend(snapshot.channel_names);
self.agent_pubkeys.extend(snapshot.agent_pubkeys);
self.inbox = snapshot.rows;
self.selected_inbox = self.selected_inbox.min(self.inbox.len().saturating_sub(1));
}
fn apply_message_snapshot(&mut self, snapshot: MessageSnapshot) {
self.profiles.extend(snapshot.profiles);
self.channel_names.extend(snapshot.channel_names);
self.agent_pubkeys.extend(snapshot.agent_pubkeys);
self.messages = snapshot.events;
self.selected_message = self
.selected_message
.min(self.messages.len().saturating_sub(1));
}
fn select_initial_channel(&mut self) {
let Some(raw) = self.initial_channel.as_deref() else {
return;
};
let needle = raw.to_ascii_lowercase();
if let Some(index) = self.channels.iter().position(|channel| {
channel.id.eq_ignore_ascii_case(raw) || channel.name.to_ascii_lowercase() == needle
}) {
self.selected_channel = index;
}
}
fn selected_channel(&self) -> Option<&ChannelView> {
self.channels.get(self.selected_channel)
}
fn selected_inbox_row(&self) -> Option<&InboxRow> {
self.inbox.get(self.selected_inbox)
}
fn selected_event(&self) -> Option<&EventView> {
self.messages.get(self.selected_message)
}
fn current_channel_id(&self) -> Option<&str> {
match &self.target {
MessageTarget::Channel { channel_id, .. }
| MessageTarget::Thread { channel_id, .. } => Some(channel_id),
MessageTarget::None => None,
}
}
fn current_channel_name(&self) -> Option<&str> {
match &self.target {
MessageTarget::Channel { channel_name, .. }
| MessageTarget::Thread { channel_name, .. } => Some(channel_name),
MessageTarget::None => None,
}
}
fn channel_label(&self, channel_id: &str) -> String {
self.channel_names
.get(channel_id)
.cloned()
.unwrap_or_else(|| short_hex(channel_id))
}
fn author_label(&self, pubkey: &str) -> String {
self.profiles
.get(&pubkey.to_ascii_lowercase())
.filter(|name| !name.trim().is_empty())
.cloned()
.unwrap_or_else(|| short_hex(pubkey))
}
fn is_agent_pubkey(&self, pubkey: &str) -> bool {
normalize_hex64(pubkey)
.as_ref()
.is_some_and(|pubkey| self.agent_pubkeys.contains(pubkey))
}
fn is_collapsed_agent_reply(&self, event: &EventView) -> bool {
self.is_agent_pubkey(&event.pubkey)
&& is_thread_reply(&event.tags)
&& !self.expanded_agent_events.contains(&event.id)
}
fn toggle_agent_replies(&mut self) {
let agent_reply_ids: Vec<String> = self
.messages
.iter()
.filter(|event| self.is_agent_pubkey(&event.pubkey) && is_thread_reply(&event.tags))
.map(|event| event.id.clone())
.collect();
if agent_reply_ids.is_empty() {
self.status = "No agent replies in this view.".to_string();
return;
}
let has_collapsed = agent_reply_ids
.iter()
.any(|id| !self.expanded_agent_events.contains(id));
if has_collapsed {
for id in &agent_reply_ids {
self.expanded_agent_events.insert(id.clone());
}
self.status = format!("Expanded {} agent replies.", agent_reply_ids.len());
} else {
for id in &agent_reply_ids {
self.expanded_agent_events.remove(id);
}
self.status = format!("Collapsed {} agent replies.", agent_reply_ids.len());
}
}
fn start_new_message(&mut self) {
if self.current_channel_id().is_none() {
self.status = "Open a channel before composing.".to_string();
return;
}
self.compose_target = Some(ComposeTarget::New);
self.draft.clear();
self.status = "Composing new message. Enter sends, Esc cancels.".to_string();
}
fn start_reply(&mut self) {
let Some(event) = self.selected_event() else {
self.status = "Select a message before replying.".to_string();
return;
};
if self.current_channel_id().is_none() {
self.status = "Selected message has no channel context.".to_string();
return;
}
let event_id = event.id.clone();
let event_kind = event.kind;
self.compose_target = Some(ComposeTarget::Reply {
event_id: event_id.clone(),
event_kind,
});
self.draft.clear();
self.status = format!(
"Replying to {}. Enter sends, Esc cancels.",
short_hex(&event_id)
);
}
async fn send_draft(&mut self, client: &BuzzClient) {
let content = self.draft.trim().to_string();
if content.is_empty() {
self.status = "Message is empty.".to_string();
return;
}
let Some(channel_id) = self.current_channel_id().map(str::to_string) else {
self.status = "No channel target for message.".to_string();
return;
};
let Some(compose_target) = self.compose_target.clone() else {
self.status = "No active composer.".to_string();
return;
};
let (reply_to, kind) = match compose_target {
ComposeTarget::New => (None, None),
ComposeTarget::Reply {
event_id,
event_kind,
} => {
let kind = matches!(event_kind, KIND_FORUM_POST | KIND_FORUM_COMMENT)
.then_some(KIND_FORUM_COMMENT as u16);
(Some(event_id), kind)
}
};
let params = SendMessageParams {
channel_id,
content,
kind,
reply_to,
broadcast: false,
files: Vec::new(),
};
match send_message_literal(client, params).await {
Ok(raw) => {
let sent_id = parse_write_event_id(&raw)
.map(|id| short_hex(&id))
.unwrap_or_else(|| "accepted".to_string());
self.compose_target = None;
self.draft.clear();
let refresh_result = match self.target.clone() {
MessageTarget::Channel {
channel_id,
channel_name,
} => self.load_channel(client, channel_id, channel_name).await,
MessageTarget::Thread {
channel_id,
channel_name,
root_id,
} => {
self.load_thread(client, channel_id, channel_name, root_id)
.await
}
MessageTarget::None => Ok(()),
};
self.status = match refresh_result {
Ok(()) => format!("Sent ({sent_id})."),
Err(err) => format!("Sent ({sent_id}), but refresh failed: {err}"),
};
}
Err(err) => {
self.status = format!("Send failed: {err}");
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> TuiAction {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return TuiAction::Quit;
}
if self.compose_target.is_some() {
return self.handle_composing_key(key);
}
match key.code {
KeyCode::Char('q') => TuiAction::Quit,
KeyCode::Char('R') => TuiAction::Refresh,
KeyCode::Tab => {
self.focus = match self.focus {
Focus::Sidebar => Focus::Messages,
Focus::Messages => Focus::Sidebar,
};
TuiAction::None
}
KeyCode::Char('c') => TuiAction::SwitchSidebar(SidebarMode::Channels),
KeyCode::Char('i') => TuiAction::SwitchSidebar(SidebarMode::Inbox),
KeyCode::Char('n') => {
self.start_new_message();
TuiAction::None
}
KeyCode::Char('r') => {
self.start_reply();
TuiAction::None
}
KeyCode::Char('x') => {
self.toggle_agent_replies();
TuiAction::None
}
KeyCode::Esc => {
if matches!(self.target, MessageTarget::Thread { .. }) {
TuiAction::BackToChannel
} else {
self.focus = Focus::Sidebar;
TuiAction::None
}
}
_ => match self.focus {
Focus::Sidebar => self.handle_sidebar_key(key),
Focus::Messages => self.handle_messages_key(key),
},
}
}
fn handle_sidebar_key(&mut self, key: KeyEvent) -> TuiAction {
match key.code {
KeyCode::Enter => TuiAction::OpenSelection,
KeyCode::Down | KeyCode::Char('j') => {
self.move_sidebar(1);
TuiAction::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.move_sidebar(-1);
TuiAction::None
}
KeyCode::Home => {
*self.selected_sidebar_mut() = 0;
TuiAction::None
}
KeyCode::End => {
*self.selected_sidebar_mut() = self.sidebar_len().saturating_sub(1);
TuiAction::None
}
_ => TuiAction::None,
}
}
fn handle_messages_key(&mut self, key: KeyEvent) -> TuiAction {
match key.code {
KeyCode::Enter => TuiAction::OpenThread,
KeyCode::Down | KeyCode::Char('j') => {
self.move_message(1);
TuiAction::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.move_message(-1);
TuiAction::None
}
KeyCode::Home => {
self.selected_message = 0;
TuiAction::None
}
KeyCode::End => {
self.selected_message = self.messages.len().saturating_sub(1);
TuiAction::None
}
_ => TuiAction::None,
}
}
fn handle_composing_key(&mut self, key: KeyEvent) -> TuiAction {
match key.code {
KeyCode::Esc => {
self.compose_target = None;
self.draft.clear();
self.status = "Composer cancelled.".to_string();
TuiAction::None
}
KeyCode::Enter => TuiAction::SendDraft,
KeyCode::Backspace => {
self.draft.pop();
TuiAction::None
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.draft.clear();
TuiAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
self.draft.push(c);
TuiAction::None
}
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::ALT) => {
self.draft.push(c);
TuiAction::None
}
_ => TuiAction::None,
}
}
fn move_sidebar(&mut self, delta: isize) {
let len = self.sidebar_len();
if len == 0 {
return;
}
let current = self.selected_sidebar_index();
let next = if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
(current + delta as usize).min(len - 1)
};
*self.selected_sidebar_mut() = next;
}
fn move_message(&mut self, delta: isize) {
if self.messages.is_empty() {
return;
}
self.selected_message = if delta.is_negative() {
self.selected_message.saturating_sub(delta.unsigned_abs())
} else {
(self.selected_message + delta as usize).min(self.messages.len() - 1)
};
}
fn sidebar_len(&self) -> usize {
match self.sidebar_mode {
SidebarMode::Channels => self.channels.len(),
SidebarMode::Inbox => self.inbox.len(),
}
}
fn selected_sidebar_index(&self) -> usize {
match self.sidebar_mode {
SidebarMode::Channels => self.selected_channel,
SidebarMode::Inbox => self.selected_inbox,
}
}
fn selected_sidebar_mut(&mut self) -> &mut usize {
match self.sidebar_mode {
SidebarMode::Channels => &mut self.selected_channel,
SidebarMode::Inbox => &mut self.selected_inbox,
}
}
}
#[derive(Clone, Debug)]
struct ChannelView {
id: String,
name: String,
about: Option<String>,
archived: bool,
}
#[derive(Clone, Debug)]
struct EventView {
id: String,
pubkey: String,
kind: u32,
content: String,
created_at: i64,
tags: Vec<Vec<String>>,
channel_id: Option<String>,
}
#[derive(Clone, Debug)]
struct InboxRow {
event: EventView,
categories: Vec<InboxCategory>,
latest_activity_at: i64,
group_count: usize,
}
impl InboxRow {
fn primary_category(&self) -> InboxCategory {
self.categories
.iter()
.copied()
.min_by_key(|category| category.priority())
.unwrap_or(InboxCategory::Mention)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum InboxCategory {
NeedsAction,
Mention,
Activity,
}
impl InboxCategory {
fn feed_type(self) -> &'static str {
match self {
Self::NeedsAction => "needs_action",
Self::Mention => "mentions",
Self::Activity => "activity",
}
}
fn label(self) -> &'static str {
match self {
Self::NeedsAction => "Needs action",
Self::Mention => "Mention",
Self::Activity => "Activity",
}
}
fn priority(self) -> usize {
match self {
Self::NeedsAction => 0,
Self::Mention => 1,
Self::Activity => 2,
}
}
}
struct ChannelSnapshot {
channels: Vec<ChannelView>,
}
struct InboxSnapshot {
rows: Vec<InboxRow>,
profiles: HashMap<String, String>,
channel_names: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
}
struct MessageSnapshot {
events: Vec<EventView>,
profiles: HashMap<String, String>,
channel_names: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
}
fn draw_tui(frame: &mut Frame<'_>, app: &TuiApp) {
let area = frame.area();
let bottom_height = if app.compose_target.is_some() { 5 } else { 4 };
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(bottom_height)])
.split(area);
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(32), Constraint::Percentage(68)])
.split(vertical[0]);
draw_sidebar(frame, app, panes[0]);
draw_messages(frame, app, panes[1]);
draw_footer(frame, app, vertical[1]);
}
fn draw_sidebar(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let focused = app.focus == Focus::Sidebar && app.compose_target.is_none();
let title = match app.sidebar_mode {
SidebarMode::Channels => {
if focused {
"Channels *"
} else {
"Channels"
}
}
SidebarMode::Inbox => {
if focused {
"Inbox *"
} else {
"Inbox"
}
}
};
let items = match app.sidebar_mode {
SidebarMode::Channels => app
.channels
.iter()
.map(|channel| {
let muted = channel.archived;
let style = if muted {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let about = channel
.about
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| truncate_for_list(&single_line(value), 72))
.unwrap_or_else(|| short_hex(&channel.id));
ListItem::new(Text::from(vec![
Line::from(Span::styled(format!("#{}", channel.name), style)),
Line::from(Span::styled(about, Style::default().fg(Color::DarkGray))),
]))
})
.collect::<Vec<_>>(),
SidebarMode::Inbox => app
.inbox
.iter()
.map(|row| {
let count = if row.group_count > 1 {
format!(" +{}", row.group_count - 1)
} else {
String::new()
};
let author = app.author_label(&row.event.pubkey);
let channel = row
.event
.channel_id
.as_deref()
.map(|id| format!("#{}", app.channel_label(id)))
.unwrap_or_else(|| "global".to_string());
let preview = truncate_for_list(&single_line(&row.event.content), 84);
ListItem::new(Text::from(vec![
Line::from(vec![
Span::styled(
row.primary_category().label(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(count),
Span::raw(" "),
Span::styled(author, Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(channel, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(
format_timestamp(row.latest_activity_at),
Style::default().fg(Color::DarkGray),
),
]),
Line::from(Span::raw(preview)),
]))
})
.collect::<Vec<_>>(),
};
let list = List::new(items)
.block(Block::default().title(title).borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("> ");
let mut state = ListState::default();
if app.sidebar_len() > 0 {
state.select(Some(app.selected_sidebar_index()));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_messages(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let focused = app.focus == Focus::Messages && app.compose_target.is_none();
let title = match &app.target {
MessageTarget::None => {
if focused {
"Messages *".to_string()
} else {
"Messages".to_string()
}
}
MessageTarget::Channel { channel_name, .. } => {
if focused {
format!("#{channel_name} *")
} else {
format!("#{channel_name}")
}
}
MessageTarget::Thread {
channel_name,
root_id,
..
} => {
if focused {
format!("Thread {} #{channel_name} *", short_hex(root_id))
} else {
format!("Thread {} #{channel_name}", short_hex(root_id))
}
}
};
let items: Vec<ListItem<'_>> = if app.messages.is_empty() {
vec![ListItem::new(Text::from(Line::from(Span::styled(
"No messages loaded.",
Style::default().fg(Color::DarkGray),
))))]
} else {
app.messages
.iter()
.map(|message| ListItem::new(Text::from(message_lines(app, message))))
.collect()
};
let list = List::new(items)
.block(Block::default().title(title).borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("> ");
let mut state = ListState::default();
if !app.messages.is_empty() {
state.select(Some(app.selected_message.min(app.messages.len() - 1)));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn message_lines<'a>(app: &'a TuiApp, message: &'a EventView) -> Vec<Line<'a>> {
let is_agent = app.is_agent_pubkey(&message.pubkey);
let is_reply = is_thread_reply(&message.tags);
let is_collapsed = app.is_collapsed_agent_reply(message);
let mut lines = Vec::new();
let mut header = vec![
Span::styled(
app.author_label(&message.pubkey),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format_timestamp(message.created_at),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(short_hex(&message.id), Style::default().fg(Color::DarkGray)),
];
if is_reply {
header.push(Span::raw(" "));
header.push(Span::styled("reply", Style::default().fg(Color::DarkGray)));
}
if is_agent {
header.push(Span::raw(" "));
header.push(Span::styled(
if is_collapsed {
"agent hidden"
} else {
"agent"
},
Style::default().fg(Color::DarkGray),
));
}
lines.push(Line::from(header));
if is_collapsed {
lines.push(Line::from(Span::styled(
" [agent reply hidden] Press x to expand agent replies in this view.",
Style::default().fg(Color::DarkGray),
)));
return lines;
}
if message.content.trim().is_empty() {
lines.push(Line::from(Span::styled(
" (no content)",
Style::default().fg(Color::DarkGray),
)));
return lines;
}
for content_line in message.content.lines().take(12) {
lines.push(Line::from(Span::raw(format!(
" {}",
truncate_for_list(content_line, 160)
))));
}
if message.content.lines().count() > 12 {
lines.push(Line::from(Span::styled(
" ...",
Style::default().fg(Color::DarkGray),
)));
}
lines
}
fn draw_footer(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let (title, body, style) = if app.compose_target.is_some() {
let target = match app.compose_target.as_ref() {
Some(ComposeTarget::New) => app
.current_channel_name()
.map(|name| format!("New message in #{name}"))
.unwrap_or_else(|| "New message".to_string()),
Some(ComposeTarget::Reply { event_id, .. }) => {
format!("Reply to {}", short_hex(event_id))
}
None => "Compose".to_string(),
};
(
target,
if app.draft.is_empty() {
"Type a message. Enter sends, Esc cancels, Ctrl-U clears.".to_string()
} else {
app.draft.clone()
},
Style::default().fg(Color::White),
)
} else {
(
"Keys".to_string(),
format!(
"Tab focus c/i tabs j/k move Enter open n new r reply x agents R/q\n{}{}",
app.status,
if app.last_refresh_label.is_empty() {
String::new()
} else {
format!(" {}", app.last_refresh_label)
}
),
Style::default().fg(Color::Gray),
)
};
let paragraph = Paragraph::new(body)
.block(Block::default().title(title).borders(Borders::ALL))
.style(style)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
async fn fetch_channels(client: &BuzzClient) -> Result<ChannelSnapshot, CliError> {
let my_pk = client.keys().public_key().to_hex();
let member_filter = serde_json::json!({
"kinds": [39002],
"#p": [my_pk],
"limit": CHANNEL_LIMIT,
});
let member_raw = client.query(&member_filter).await?;
let member_events: Vec<serde_json::Value> = serde_json::from_str(&member_raw)
.map_err(|e| CliError::Other(format!("failed to parse channel memberships: {e}")))?;
let channel_ids: Vec<String> = unique_owned(
member_events
.iter()
.filter_map(|event| first_tag_value(&parse_tags(event.get("tags")), "d"))
.collect(),
);
let metadata_filter = if channel_ids.is_empty() {
serde_json::json!({
"kinds": [39000],
"limit": CHANNEL_LIMIT,
})
} else {
serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
"limit": CHANNEL_LIMIT,
})
};
let metadata_raw = client.query(&metadata_filter).await?;
let metadata_events: Vec<serde_json::Value> = serde_json::from_str(&metadata_raw)
.map_err(|e| CliError::Other(format!("failed to parse channels: {e}")))?;
let mut channels: Vec<ChannelView> = metadata_events
.iter()
.filter_map(channel_from_value)
.collect();
channels.sort_by(|a, b| {
a.archived
.cmp(&b.archived)
.then_with(|| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
})
.then_with(|| a.id.cmp(&b.id))
});
Ok(ChannelSnapshot { channels })
}
async fn fetch_channel_messages(
client: &BuzzClient,
channel_id: &str,
limit: u32,
) -> Result<MessageSnapshot, CliError> {
let filter = serde_json::json!({
"kinds": MESSAGE_KINDS,
"#h": [channel_id],
"limit": limit,
});
let raw = client.query(&filter).await?;
let values: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse messages: {e}")))?;
let mut events: Vec<EventView> = values.iter().filter_map(event_from_value).collect();
events.sort_by_key(|event| event.created_at);
message_snapshot(client, events).await
}
async fn fetch_thread_messages(
client: &BuzzClient,
channel_id: &str,
root_id: &str,
limit: u32,
) -> Result<MessageSnapshot, CliError> {
let reply_filter = serde_json::json!({
"kinds": THREAD_KINDS,
"#h": [channel_id],
"#e": [root_id],
"limit": limit,
});
let root_filter = serde_json::json!({
"ids": [root_id],
"limit": 1,
});
let raw = client.query_multi(&[reply_filter, root_filter]).await?;
let values: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse thread: {e}")))?;
let mut events = dedupe_events(values.iter().filter_map(event_from_value).collect());
events.sort_by_key(|event| event.created_at);
message_snapshot(client, events).await
}
async fn message_snapshot(
client: &BuzzClient,
events: Vec<EventView>,
) -> Result<MessageSnapshot, CliError> {
let pubkeys: Vec<String> = events.iter().map(|event| event.pubkey.clone()).collect();
let profiles = fetch_profiles(client, &pubkeys).await.unwrap_or_default();
let agent_pubkeys = fetch_agent_pubkeys(client, &pubkeys)
.await
.unwrap_or_default();
let channel_ids: Vec<String> = events
.iter()
.filter_map(|event| event.channel_id.clone())
.collect();
let channel_names = fetch_channel_names(client, &channel_ids)
.await
.unwrap_or_default();
Ok(MessageSnapshot {
events,
profiles,
channel_names,
agent_pubkeys,
})
}
async fn fetch_inbox(client: &BuzzClient, limit: u32) -> Result<InboxSnapshot, CliError> {
let mut entries = Vec::new();
let my_pk = client.keys().public_key().to_hex().to_ascii_lowercase();
for category in INBOX_CATEGORIES {
let query_limit = if *category == InboxCategory::Activity {
limit.saturating_mul(5).clamp(limit, 100)
} else {
limit
};
let filter = inbox_filter(*category, &my_pk, query_limit);
let raw = client.query(&filter).await?;
let values: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse inbox: {e}")))?;
let mut events: Vec<EventView> = values.iter().filter_map(event_from_value).collect();
if *category == InboxCategory::Activity {
events = filter_relevant_activity(client, events, &my_pk).await;
}
entries.extend(events.into_iter().map(|event| (*category, event)));
}
let rows = group_inbox(entries);
let pubkeys: Vec<String> = rows.iter().map(|row| row.event.pubkey.clone()).collect();
let profiles = fetch_profiles(client, &pubkeys).await.unwrap_or_default();
let agent_pubkeys = fetch_agent_pubkeys(client, &pubkeys)
.await
.unwrap_or_default();
let channel_ids: Vec<String> = rows
.iter()
.filter_map(|row| row.event.channel_id.clone())
.collect();
let channel_names = fetch_channel_names(client, &channel_ids)
.await
.unwrap_or_default();
Ok(InboxSnapshot {
rows,
profiles,
channel_names,
agent_pubkeys,
})
}
fn inbox_filter(category: InboxCategory, my_pubkey: &str, limit: u32) -> serde_json::Value {
match category {
InboxCategory::Mention => serde_json::json!({
"kinds": INBOX_MENTION_KINDS,
"#p": [my_pubkey],
"limit": limit,
}),
InboxCategory::NeedsAction => serde_json::json!({
"kinds": INBOX_NEEDS_ACTION_KINDS,
"#p": [my_pubkey],
"limit": limit.min(20),
}),
InboxCategory::Activity => serde_json::json!({
"#p": [my_pubkey],
"feed_types": [category.feed_type()],
"limit": limit,
}),
}
}
async fn filter_relevant_activity(
client: &BuzzClient,
events: Vec<EventView>,
my_pubkey: &str,
) -> Vec<EventView> {
let mut candidates = Vec::new();
let mut context_requests = Vec::new();
let mut requested_roots = BTreeSet::new();
for event in events {
if !is_relevant_activity_candidate(&event, my_pubkey) {
continue;
}
let Some(channel_id) = event.channel_id.as_deref() else {
continue;
};
let root_id = thread_key(&event);
if requested_roots.insert(root_id.clone()) {
context_requests.push((root_id.clone(), channel_id.to_string()));
}
candidates.push((root_id, event));
}
let context_results =
stream::iter(context_requests.into_iter().map(|(root_id, channel_id)| {
let client = client.clone();
async move {
let context = fetch_thread_events(&client, &channel_id, &root_id)
.await
.ok();
(root_id, context)
}
}))
.buffer_unordered(ACTIVITY_CONTEXT_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let context_by_root: HashMap<String, Vec<EventView>> = context_results
.into_iter()
.filter_map(|(root_id, context)| context.map(|context| (root_id, context)))
.collect();
candidates
.into_iter()
.filter_map(|(root_id, event)| {
let context = context_by_root.get(&root_id)?;
thread_context_relevant_to_user(&event, context, my_pubkey).then_some(event)
})
.collect()
}
async fn fetch_thread_events(
client: &BuzzClient,
channel_id: &str,
root_id: &str,
) -> Result<Vec<EventView>, CliError> {
let reply_filter = serde_json::json!({
"kinds": THREAD_KINDS,
"#h": [channel_id],
"#e": [root_id],
"limit": 100,
});
let root_filter = serde_json::json!({
"ids": [root_id],
"limit": 1,
});
let raw = client.query_multi(&[reply_filter, root_filter]).await?;
let values: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse thread context: {e}")))?;
let mut events = dedupe_events(values.iter().filter_map(event_from_value).collect());
events.sort_by_key(|event| event.created_at);
Ok(events)
}
fn is_relevant_activity_candidate(event: &EventView, my_pubkey: &str) -> bool {
event.pubkey.to_ascii_lowercase() != my_pubkey
&& is_thread_reply(&event.tags)
&& !has_mention_for_pubkey(event, my_pubkey)
&& !is_broadcast_reply(&event.tags)
}
fn thread_context_relevant_to_user(
candidate: &EventView,
context: &[EventView],
my_pubkey: &str,
) -> bool {
let root_id = thread_key(candidate);
context.iter().any(|event| {
event.id != candidate.id
&& (event.pubkey.to_ascii_lowercase() == my_pubkey
|| has_mention_for_pubkey(event, my_pubkey)
|| (event.id == root_id && event.pubkey.to_ascii_lowercase() == my_pubkey))
})
}
async fn fetch_profiles(
client: &BuzzClient,
pubkeys: &[String],
) -> Result<HashMap<String, String>, CliError> {
let authors = unique_non_empty(pubkeys);
if authors.is_empty() {
return Ok(HashMap::new());
}
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse profiles: {e}")))?;
let mut profiles = HashMap::new();
for event in events {
let Some(pubkey) = event.get("pubkey").and_then(|value| value.as_str()) else {
continue;
};
let Some(content) = event.get("content").and_then(|value| value.as_str()) else {
continue;
};
let Ok(profile) = serde_json::from_str::<serde_json::Value>(content) else {
continue;
};
let label = profile
.get("display_name")
.or_else(|| profile.get("name"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string);
if let Some(label) = label {
profiles.insert(pubkey.to_ascii_lowercase(), label);
}
}
Ok(profiles)
}
async fn fetch_channel_names(
client: &BuzzClient,
channel_ids: &[String],
) -> Result<HashMap<String, String>, CliError> {
let ids = unique_non_empty(channel_ids);
if ids.is_empty() {
return Ok(HashMap::new());
}
let filter = serde_json::json!({
"kinds": [39000],
"#d": ids,
"limit": ids.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse channel names: {e}")))?;
let mut channels = HashMap::new();
for event in events {
let tags = parse_tags(event.get("tags"));
let Some(id) = first_tag_value(&tags, "d") else {
continue;
};
let Some(name) = first_tag_value(&tags, "name") else {
continue;
};
channels.insert(id, name);
}
Ok(channels)
}
async fn fetch_agent_pubkeys(
client: &BuzzClient,
pubkeys: &[String],
) -> Result<BTreeSet<String>, CliError> {
let mut agent_pubkeys = BTreeSet::new();
let authors = unique_owned(
pubkeys
.iter()
.filter_map(|value| normalize_hex64(value))
.collect(),
);
if !authors.is_empty() {
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse agent profiles: {e}")))?;
for event in events {
if !profile_has_valid_oa_owner_value(&event) {
continue;
}
let Some(pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(normalize_hex64)
else {
continue;
};
agent_pubkeys.insert(pubkey);
}
}
let owner_pubkey = client
.auth_tag_owner_hex()
.unwrap_or_else(|| client.keys().public_key().to_hex());
if let Some(owner_pubkey) = normalize_hex64(&owner_pubkey) {
let filter = serde_json::json!({
"kinds": [KIND_MANAGED_AGENT],
"authors": [owner_pubkey],
"limit": 200,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse managed agents: {e}")))?;
for event in events {
for tag in parse_tags(event.get("tags")) {
if tag.first().map(|value| value.as_str()) != Some("d") {
continue;
}
let Some(pubkey) = tag.get(1).and_then(|value| normalize_hex64(value)) else {
continue;
};
agent_pubkeys.insert(pubkey);
}
}
}
Ok(agent_pubkeys)
}
fn profile_has_valid_oa_owner_value(event: &serde_json::Value) -> bool {
let Some(target_pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(normalize_hex64)
.and_then(|value| PublicKey::from_hex(&value).ok())
else {
return false;
};
for tag in parse_tags(event.get("tags")) {
if tag.first().map(|value| value.as_str()) != Some("auth") || tag.len() != 4 {
continue;
}
let Ok(json) = serde_json::to_string(&tag) else {
continue;
};
if buzz_sdk::nip_oa::verify_auth_tag(&json, &target_pubkey).is_ok() {
return true;
}
}
false
}
fn channel_from_value(value: &serde_json::Value) -> Option<ChannelView> {
let tags = parse_tags(value.get("tags"));
Some(ChannelView {
id: first_tag_value(&tags, "d")?,
name: first_tag_value(&tags, "name")?,
about: first_tag_value(&tags, "about"),
archived: first_tag_value(&tags, "archived").as_deref() == Some("true"),
})
}
fn event_from_value(value: &serde_json::Value) -> Option<EventView> {
let tags = parse_tags(value.get("tags"));
Some(EventView {
id: value.get("id")?.as_str()?.to_string(),
pubkey: value.get("pubkey")?.as_str()?.to_string(),
kind: value.get("kind")?.as_u64()? as u32,
content: value
.get("content")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
created_at: value.get("created_at")?.as_i64()?,
channel_id: first_tag_value(&tags, "h"),
tags,
})
}
fn group_inbox(entries: Vec<(InboxCategory, EventView)>) -> Vec<InboxRow> {
struct Group {
event: EventView,
categories: Vec<InboxCategory>,
event_ids: BTreeSet<String>,
latest_activity_at: i64,
}
let mut groups: HashMap<String, Group> = HashMap::new();
for (category, event) in entries {
let key = thread_key(&event);
let group = groups.entry(key).or_insert_with(|| Group {
event: event.clone(),
categories: Vec::new(),
event_ids: BTreeSet::new(),
latest_activity_at: event.created_at,
});
if !group.categories.contains(&category) {
group.categories.push(category);
group.categories.sort_by_key(|value| value.priority());
}
group.event_ids.insert(event.id.clone());
if event.created_at >= group.latest_activity_at {
group.latest_activity_at = event.created_at;
group.event = event;
}
}
let mut rows: Vec<InboxRow> = groups
.into_values()
.map(|group| InboxRow {
event: group.event,
categories: group.categories,
latest_activity_at: group.latest_activity_at,
group_count: group.event_ids.len(),
})
.collect();
rows.sort_by_key(|row| Reverse(row.latest_activity_at));
rows
}
fn parse_tags(value: Option<&serde_json::Value>) -> Vec<Vec<String>> {
value
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let parts: Vec<String> = tag
.as_array()?
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
(!parts.is_empty()).then_some(parts)
})
.collect()
})
.unwrap_or_default()
}
fn first_tag_value(tags: &[Vec<String>], key: &str) -> Option<String> {
tags.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
.and_then(|tag| tag.get(1))
.filter(|value| !value.is_empty())
.cloned()
}
fn has_mention_for_pubkey(event: &EventView, pubkey: &str) -> bool {
let normalized = pubkey.to_ascii_lowercase();
event.tags.iter().any(|tag| {
tag.first().map(|value| value.as_str()) == Some("p")
&& tag
.get(1)
.map(|value| value.to_ascii_lowercase() == normalized)
.unwrap_or(false)
})
}
fn is_thread_reply(tags: &[Vec<String>]) -> bool {
thread_reference(tags).0.is_some()
}
fn is_broadcast_reply(tags: &[Vec<String>]) -> bool {
tags.iter().any(|tag| {
tag.first().map(|value| value.as_str()) == Some("broadcast")
&& tag.get(1).map(|value| value.as_str()) == Some("1")
})
}
fn thread_reference(tags: &[Vec<String>]) -> (Option<String>, Option<String>) {
let mut root = None;
let mut reply = None;
for tag in tags {
if tag.first().map(|value| value.as_str()) != Some("e") {
continue;
}
let Some(id) = tag.get(1).filter(|id| is_hex64(id)) else {
continue;
};
match tag.get(3).map(|value| value.as_str()) {
Some("root") => root = Some(id.clone()),
Some("reply") => reply = Some(id.clone()),
_ => {}
}
}
let Some(parent_id) = reply else {
return (None, None);
};
let root_id = root.unwrap_or_else(|| parent_id.clone());
(Some(parent_id), Some(root_id))
}
fn thread_key(event: &EventView) -> String {
thread_reference(&event.tags)
.1
.unwrap_or_else(|| event.id.clone())
}
fn dedupe_events(events: Vec<EventView>) -> Vec<EventView> {
let mut by_id = HashMap::new();
for event in events {
by_id.insert(event.id.clone(), event);
}
by_id.into_values().collect()
}
fn unique_non_empty(values: &[String]) -> Vec<&str> {
let mut seen = BTreeSet::new();
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert((*value).to_string()))
.collect()
}
fn unique_owned(values: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
values
.into_iter()
.filter(|value| !value.trim().is_empty())
.filter(|value| seen.insert(value.clone()))
.collect()
}
fn parse_write_event_id(raw: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(raw)
.ok()?
.get("event_id")?
.as_str()
.map(str::to_string)
}
fn single_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_for_list(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let suffix = "...";
let mut out = value
.chars()
.take(max_chars.saturating_sub(suffix.len()))
.collect::<String>();
out.push_str(suffix);
out
}
fn short_hex(value: &str) -> String {
value.chars().take(8).collect()
}
fn is_hex64(value: &str) -> bool {
value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn normalize_hex64(value: &str) -> Option<String> {
let value = value.trim();
is_hex64(value).then(|| value.to_ascii_lowercase())
}
fn format_timestamp(unix_seconds: i64) -> String {
Local
.timestamp_opt(unix_seconds, 0)
.single()
.map(|dt| dt.format("%b %-d %H:%M").to_string())
.unwrap_or_else(|| unix_seconds.to_string())
}
fn terminal_err(err: io::Error) -> CliError {
CliError::Other(format!("terminal error: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SELF_PK: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const AGENT_PK: &str = "2222222222222222222222222222222222222222222222222222222222222222";
const CHANNEL_ID: &str = "80a927fd-a695-4895-971e-e49c974b0fff";
fn event_with_pubkey(
id: &str,
pubkey: &str,
created_at: i64,
tags: Vec<Vec<&str>>,
) -> EventView {
let value = json!({
"id": id,
"pubkey": pubkey,
"kind": 9,
"content": "body",
"created_at": created_at,
"tags": tags,
});
event_from_value(&value).expect("valid event")
}
fn rendered_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn agent_thread_replies_are_hidden_until_expanded() {
let root = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let reply = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let mut event = event_with_pubkey(
reply,
AGENT_PK,
2,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", root, "", "reply"],
],
);
event.content = "agent body should be hidden".to_string();
let mut app = TuiApp::new(50, 0, None, false);
app.messages = vec![event];
app.agent_pubkeys.insert(AGENT_PK.to_string());
let collapsed = rendered_text(&message_lines(&app, &app.messages[0]));
assert!(collapsed.contains("[agent reply hidden]"));
assert!(!collapsed.contains("agent body should be hidden"));
app.expanded_agent_events.insert(reply.to_string());
let expanded = rendered_text(&message_lines(&app, &app.messages[0]));
assert!(expanded.contains("agent body should be hidden"));
}
#[test]
fn top_level_agent_messages_are_not_hidden() {
let mut event = event_with_pubkey(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
AGENT_PK,
2,
vec![vec!["h", CHANNEL_ID]],
);
event.content = "top-level agent body".to_string();
let mut app = TuiApp::new(50, 0, None, false);
app.messages = vec![event];
app.agent_pubkeys.insert(AGENT_PK.to_string());
let rendered = rendered_text(&message_lines(&app, &app.messages[0]));
assert!(!rendered.contains("[agent reply hidden]"));
assert!(rendered.contains("top-level agent body"));
}
#[test]
fn activity_candidate_requires_external_non_mention_thread_reply() {
let root = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let parent = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let candidate = event_with_pubkey(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
AGENT_PK,
2,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
assert!(is_relevant_activity_candidate(&candidate, SELF_PK));
let mention = event_with_pubkey(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
AGENT_PK,
2,
vec![
vec!["h", CHANNEL_ID],
vec!["p", SELF_PK],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
assert!(!is_relevant_activity_candidate(&mention, SELF_PK));
let broadcast = event_with_pubkey(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
AGENT_PK,
2,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
vec!["broadcast", "1"],
],
);
assert!(!is_relevant_activity_candidate(&broadcast, SELF_PK));
}
}