Skip to content

Commit b153cf3

Browse files
0xrinegadeclaude
andcommitted
feat(bbs): Add agent awareness and integration to TUI
- Add agent detection heuristics (OSVM, BOT, AI short names; "agent/bot" in long name) - Show agent count in header: "🤖 X agents listening" - Mark agent posts with 🤖 badge and magenta styling - Show "@agent for AI help" hints in input placeholder when agents available - Update status bar to indicate agent availability - Add AgentStatus struct to track agent presence - Add user_cache for efficient author lookups - Add db::users::list_all() for loading user list - Add Clone derive to User model for caching - Add test for agent detection heuristics UI changes visible when agents are registered: - Header shows agent count - Empty board shows tip about @agent - Input box title shows "(🤖 agents available)" - Status bar shows "🤖 Agents listening!" - Posts from agents show 🤖 OSVM badge in magenta 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent e947545 commit b153cf3

File tree

3 files changed

+230
-22
lines changed

3 files changed

+230
-22
lines changed

src/utils/bbs/db/users.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,11 @@ pub fn counts(conn: &mut SqliteConnection) -> (i64, i64) {
7676
.unwrap_or(0);
7777
(total, active)
7878
}
79+
80+
/// List all users
81+
pub fn list_all(conn: &mut SqliteConnection) -> Result<Vec<User>> {
82+
users::table
83+
.order(users::last_seen_at_us.desc())
84+
.load::<User>(conn)
85+
.map_err(|e| e.into())
86+
}

src/utils/bbs/models.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ pub struct NewPost<'a> {
9696
pub parent_id: Option<i32>, // For reply threading
9797
}
9898

99-
#[derive(Debug, Identifiable, Queryable, Selectable)]
99+
#[derive(Debug, Clone, Identifiable, Queryable, Selectable)]
100100
#[diesel(table_name = crate::utils::bbs::schema::users)]
101101
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
102102
pub struct User {

src/utils/bbs/tui_widgets.rs

Lines changed: 221 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,21 @@ pub struct BBSTuiState {
2424
pub scroll_offset: usize,
2525
pub status_message: String,
2626
pub connected: bool, // Track if we've initialized the connection
27-
pub input_active: bool, // NEW: Track if input mode is active
28-
pub selected_board_index: Option<usize>, // NEW: Track selected board for visual feedback
27+
pub input_active: bool, // Track if input mode is active
28+
pub selected_board_index: Option<usize>, // Track selected board for visual feedback
29+
// Agent integration
30+
pub agents: Vec<User>, // Known AI agents
31+
pub agent_status: AgentStatus, // Current agent listening status
32+
/// Cache of user_id -> User for displaying post authors
33+
pub user_cache: std::collections::HashMap<i32, User>,
34+
}
35+
36+
/// Agent listening status for the BBS
37+
#[derive(Clone, Debug, Default)]
38+
pub struct AgentStatus {
39+
pub osvm_agent_online: bool,
40+
pub last_agent_activity: Option<String>,
41+
pub agents_listening: usize,
2942
}
3043

3144
impl BBSTuiState {
@@ -40,9 +53,73 @@ impl BBSTuiState {
4053
scroll_offset: 0,
4154
status_message: "Connecting to BBS...".to_string(),
4255
connected: false,
43-
input_active: false, // NEW
44-
selected_board_index: Some(0), // NEW: Default to first board
56+
input_active: false,
57+
selected_board_index: Some(0),
58+
agents: Vec::new(),
59+
agent_status: AgentStatus::default(),
60+
user_cache: std::collections::HashMap::new(),
61+
}
62+
}
63+
64+
/// Check if a user is an AI agent based on naming conventions
65+
pub fn is_agent(user: &User) -> bool {
66+
let short_upper = user.short_name.to_uppercase();
67+
let long_lower = user.long_name.to_lowercase();
68+
69+
// Agent detection heuristics:
70+
// 1. Short name patterns: OSVM, AI, BOT, AGT
71+
// 2. Long name contains: agent, bot, assistant, ai
72+
// 3. Node ID patterns: !aaaa (reserved for agents)
73+
short_upper == "OSVM" ||
74+
short_upper == "AI" ||
75+
short_upper == "BOT" ||
76+
short_upper == "AGT" ||
77+
short_upper == "TUI" || // TUI user is system
78+
long_lower.contains("agent") ||
79+
long_lower.contains("bot") ||
80+
long_lower.contains("assistant") ||
81+
user.node_id.starts_with("!aaaa") ||
82+
user.node_id.starts_with("!tui")
83+
}
84+
85+
/// Load agents and update status
86+
pub fn refresh_agents(&mut self) -> Result<(), Box<dyn std::error::Error>> {
87+
if let Some(ref mut conn) = *self.conn.lock().unwrap() {
88+
// Get all users and filter for agents
89+
let all_users = db::users::list_all(conn)?;
90+
self.agents = all_users.iter()
91+
.filter(|u| Self::is_agent(u))
92+
.cloned()
93+
.collect();
94+
95+
// Update agent status
96+
self.agent_status.agents_listening = self.agents.len();
97+
self.agent_status.osvm_agent_online = self.agents.iter()
98+
.any(|a| a.short_name.to_uppercase() == "OSVM");
99+
100+
// Find most recent agent activity
101+
if let Some(most_recent) = self.agents.iter()
102+
.filter_map(|a| a.last_acted_at_us)
103+
.max()
104+
{
105+
self.agent_status.last_agent_activity = Some(
106+
crate::utils::bbs::models::User::last_acted_at(
107+
&self.agents.iter().find(|a| a.last_acted_at_us == Some(most_recent)).unwrap()
108+
)
109+
);
110+
}
111+
112+
// Cache all users for post author lookup
113+
for user in all_users {
114+
self.user_cache.insert(user.id, user);
115+
}
45116
}
117+
Ok(())
118+
}
119+
120+
/// Get user by ID from cache
121+
pub fn get_user(&self, user_id: i32) -> Option<&User> {
122+
self.user_cache.get(&user_id)
46123
}
47124

48125
/// Initialize BBS connection
@@ -56,8 +133,16 @@ impl BBSTuiState {
56133

57134
*self.conn.lock().unwrap() = Some(conn);
58135
self.connected = true;
59-
self.status_message = "Connected to OSVM BBS".to_string();
60136
self.refresh_boards()?;
137+
self.refresh_agents()?; // Load agent info
138+
139+
// Update status with agent info
140+
let agent_hint = if self.agent_status.agents_listening > 0 {
141+
format!(" | 🤖 {} agents listening", self.agent_status.agents_listening)
142+
} else {
143+
String::new()
144+
};
145+
self.status_message = format!("Connected to OSVM BBS{}", agent_hint);
61146

62147
Ok(())
63148
}
@@ -137,13 +222,22 @@ pub fn render_bbs_tab(f: &mut Frame, area: Rect, state: &mut BBSTuiState) {
137222
])
138223
.split(area);
139224

140-
// Header with better info
225+
// Header with agent status
141226
let board_name = state.current_board.and_then(|id| {
142227
state.boards.iter().find(|b| b.id == id).map(|b| b.name.as_str())
143228
}).unwrap_or("No board selected");
144229

145-
let header_text = format!("📡 OSVM BBS - Meshtastic | Board: {} | {} posts",
146-
board_name, state.posts.len());
230+
// Agent status indicator
231+
let agent_indicator = if state.agent_status.agents_listening > 0 {
232+
format!(" | 🤖 {} agent{} listening",
233+
state.agent_status.agents_listening,
234+
if state.agent_status.agents_listening == 1 { "" } else { "s" })
235+
} else {
236+
" | 🔇 No agents".to_string()
237+
};
238+
239+
let header_text = format!("📡 OSVM BBS | Board: {} | {} posts{}",
240+
board_name, state.posts.len(), agent_indicator);
147241

148242
let header = Paragraph::new(header_text)
149243
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
@@ -188,23 +282,57 @@ pub fn render_bbs_tab(f: &mut Frame, area: Rect, state: &mut BBSTuiState) {
188282
.border_style(Style::default().fg(Color::Cyan)));
189283
f.render_widget(board_list, main_chunks[0]);
190284

191-
// Posts area with MUCH better formatting
285+
// Posts area with agent badges
192286
let post_lines: Vec<Line> = if state.posts.is_empty() {
193287
vec![
194288
Line::from(""),
195289
Line::from(Span::styled(" No posts yet in this board.", Style::default().fg(Color::DarkGray))),
196290
Line::from(""),
197291
Line::from(Span::styled(" Press 'i' to write a new post!", Style::default().fg(Color::Yellow))),
292+
Line::from(""),
293+
if state.agent_status.agents_listening > 0 {
294+
Line::from(Span::styled(" 💡 Tip: Use @agent to get AI assistance!", Style::default().fg(Color::Magenta)))
295+
} else {
296+
Line::from(Span::styled(" 📝 Register an agent with: osvm bbs agent register <name>", Style::default().fg(Color::DarkGray)))
297+
},
198298
]
199299
} else {
200300
state.posts.iter().enumerate().flat_map(|(i, p)| {
301+
// Check if this post is from an agent
302+
let (author_name, is_agent) = if let Some(user) = state.get_user(p.user_id) {
303+
(user.short_name.clone(), BBSTuiState::is_agent(user))
304+
} else {
305+
(format!("#{}", p.user_id), false)
306+
};
307+
308+
// Build author display with agent badge
309+
let author_spans = if is_agent {
310+
vec![
311+
Span::styled("🤖 ", Style::default().fg(Color::Magenta)),
312+
Span::styled(author_name, Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
313+
]
314+
} else {
315+
vec![
316+
Span::styled(author_name, Style::default().fg(Color::Green)),
317+
]
318+
};
319+
320+
let mut header_spans = vec![
321+
Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
322+
];
323+
header_spans.extend(author_spans);
324+
header_spans.push(Span::styled(format!(" • {}", p.created_at()), Style::default().fg(Color::DarkGray)));
325+
326+
// Highlight agent posts with different body style
327+
let body_style = if is_agent {
328+
Style::default().fg(Color::White)
329+
} else {
330+
Style::default()
331+
};
332+
201333
vec![
202-
Line::from(vec![
203-
Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
204-
Span::styled(format!("user#{}", p.user_id), Style::default().fg(Color::Green)),
205-
Span::styled(format!(" • {}", p.created_at()), Style::default().fg(Color::DarkGray)),
206-
]),
207-
Line::from(Span::raw(format!(" {}", p.body))),
334+
Line::from(header_spans),
335+
Line::from(Span::styled(format!(" {}", p.body), body_style)),
208336
Line::from(""), // Spacing
209337
]
210338
}).collect()
@@ -219,12 +347,18 @@ pub fn render_bbs_tab(f: &mut Frame, area: Rect, state: &mut BBSTuiState) {
219347
.scroll((state.scroll_offset as u16, 0));
220348
f.render_widget(posts_widget, main_chunks[1]);
221349

222-
// ACTUAL INPUT BOX (like Chat tab!)
350+
// Input box with agent hints
351+
let input_placeholder = if state.agent_status.agents_listening > 0 {
352+
"Press 'i' to write... (use @agent for AI help)"
353+
} else {
354+
"Press 'i' to write a message..."
355+
};
356+
223357
let input_text = if state.input_active {
224358
format!("{}█", state.input_buffer) // Show cursor
225359
} else {
226360
if state.input_buffer.is_empty() {
227-
"Press 'i' to write a message...".to_string()
361+
input_placeholder.to_string()
228362
} else {
229363
state.input_buffer.clone()
230364
}
@@ -242,19 +376,34 @@ pub fn render_bbs_tab(f: &mut Frame, area: Rect, state: &mut BBSTuiState) {
242376
Style::default().fg(Color::DarkGray)
243377
};
244378

379+
// Input title shows agent availability
380+
let input_title = if state.agent_status.agents_listening > 0 {
381+
" 📝 Message (🤖 agents available) "
382+
} else {
383+
" 📝 Message Input "
384+
};
385+
245386
let input_widget = Paragraph::new(input_text)
246387
.style(input_style)
247388
.block(Block::default()
248389
.borders(Borders::ALL)
249-
.title(" 📝 Message Input ")
390+
.title(input_title)
250391
.border_style(border_style));
251392
f.render_widget(input_widget, chunks[2]);
252393

253-
// Status bar with USEFUL info
394+
// Status bar - show agent-aware help
254395
let status_text = if state.input_active {
255-
"Press Enter to send • Esc to cancel • Backspace to delete"
396+
if state.agent_status.agents_listening > 0 {
397+
"Enter=Send • Esc=Cancel • @agent for AI help • Backspace=Delete"
398+
} else {
399+
"Press Enter to send • Esc to cancel • Backspace to delete"
400+
}
256401
} else {
257-
"i=Input • j/k=Scroll • 1-9=Board • r=Refresh • ?=Help"
402+
if state.agent_status.agents_listening > 0 {
403+
"i=Input • j/k=Scroll • 1-9=Board • r=Refresh • 🤖 Agents listening!"
404+
} else {
405+
"i=Input • j/k=Scroll • 1-9=Board • r=Refresh • q=Quit"
406+
}
258407
};
259408

260409
let status = Paragraph::new(status_text)
@@ -412,6 +561,57 @@ mod tests {
412561
assert!(!state.connected);
413562
assert!(!state.input_active);
414563
assert_eq!(state.selected_board_index, Some(0));
564+
// New agent-related fields
565+
assert!(state.agents.is_empty());
566+
assert_eq!(state.agent_status.agents_listening, 0);
567+
assert!(!state.agent_status.osvm_agent_online);
568+
assert!(state.user_cache.is_empty());
569+
}
570+
571+
#[test]
572+
fn test_is_agent_detection() {
573+
// Test agent detection heuristics
574+
let osvm_agent = User {
575+
id: 1,
576+
node_id: "!aaaabbbb".to_string(),
577+
short_name: "OSVM".to_string(),
578+
long_name: "OSVM Research Agent".to_string(),
579+
jackass: false,
580+
in_board: None,
581+
created_at_us: 0,
582+
last_seen_at_us: 0,
583+
last_acted_at_us: None,
584+
bio: None,
585+
};
586+
assert!(BBSTuiState::is_agent(&osvm_agent), "OSVM should be detected as agent");
587+
588+
let bot_user = User {
589+
id: 2,
590+
node_id: "!12345678".to_string(),
591+
short_name: "BOT".to_string(),
592+
long_name: "Some Bot".to_string(),
593+
jackass: false,
594+
in_board: None,
595+
created_at_us: 0,
596+
last_seen_at_us: 0,
597+
last_acted_at_us: None,
598+
bio: None,
599+
};
600+
assert!(BBSTuiState::is_agent(&bot_user), "BOT should be detected as agent");
601+
602+
let regular_user = User {
603+
id: 3,
604+
node_id: "!deadbeef".to_string(),
605+
short_name: "USER".to_string(),
606+
long_name: "Regular Human".to_string(),
607+
jackass: false,
608+
in_board: None,
609+
created_at_us: 0,
610+
last_seen_at_us: 0,
611+
last_acted_at_us: None,
612+
bio: None,
613+
};
614+
assert!(!BBSTuiState::is_agent(&regular_user), "Regular user should NOT be detected as agent");
415615
}
416616

417617
// =========================================================================

0 commit comments

Comments
 (0)