Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions crates/codeoid-protocol/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,38 @@ pub struct SettingsSnapshot {
pub secrets: HashMap<String, SecretStatus>,
pub config_path: String,
pub env_path: String,
/// Read-only registry MCP servers + live health (cross-backend mounter).
/// Absent from older daemons β€” defaults to empty so deserialization is
/// forward-compatible.
#[serde(default)]
pub mcp_servers: Vec<McpServerStatus>,
}

/// Read-only status of one registry MCP server, mirrored from the TS protocol
/// (`McpServerStatus`). Config comes from the daemon's registry; `health`/`tools`
/// reflect what the daemon-owned client has observed so far (no live probe).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerStatus {
pub name: String,
/// "stdio" | "http" | "in-process".
pub transport: String,
/// "readonly" | "prompt".
pub trust: String,
/// "global" | "workspace" | "session".
pub scope: String,
/// Backends this server mounts on; `None` = all.
#[serde(default)]
pub backends: Option<Vec<String>>,
pub enabled: bool,
/// `codeoid_memory` β€” always present, not user-declared.
pub builtin: bool,
/// "connected" | "error" | "idle" | "disabled".
pub health: String,
pub tool_count: u32,
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
10 changes: 5 additions & 5 deletions crates/codeoid-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ pub use client::{
};
pub use daemon::{
AuthOkMsg, ClaudeConfigAgent, ClaudeConfigHook, ClaudeConfigMcpServer, ClaudeConfigScope,
ClaudeConfigSkill, DaemonMessage, ErrorCode, ModelInfo, ProviderCommand, SecretStatus,
SessionExportCounts, SessionExportManifest, SessionExportMetaSlim, SessionExportPayload,
SessionExportWorkdir, SessionSearchHit, SessionSearchSnippet, SessionUiRequestMsg,
SettingError, SettingField, SettingOption, SettingState, SettingsGroup, SettingsManifest,
SettingsSnapshot, SettingsTab, UiRequestMethod, UiResolvedReason,
ClaudeConfigSkill, DaemonMessage, ErrorCode, McpServerStatus, ModelInfo, ProviderCommand,
SecretStatus, SessionExportCounts, SessionExportManifest, SessionExportMetaSlim,
SessionExportPayload, SessionExportWorkdir, SessionSearchHit, SessionSearchSnippet,
SessionUiRequestMsg, SettingError, SettingField, SettingOption, SettingState, SettingsGroup,
SettingsManifest, SettingsSnapshot, SettingsTab, UiRequestMethod, UiResolvedReason,
};
pub use message::{
ContentPart, IdentityType, MessageIdentity, MessageRole, SessionMessage, SessionMessageDelta,
Expand Down
2 changes: 2 additions & 0 deletions crates/codeoid-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ impl App {
if let Some(Modal::Settings(m)) = state.modal.as_mut() {
if m.pending_get_id.as_deref() == Some(request_id.as_str()) {
m.snapshot = Some(snapshot);
m.clamp_tab();
m.pending_get_id = None;
if m.manifest.is_some() {
m.loading = false;
Expand All @@ -1192,6 +1193,7 @@ impl App {
if m.pending_set_id.as_deref() == Some(request_id.as_str()) {
m.pending_set_id = None;
m.snapshot = Some(snapshot);
m.clamp_tab();
if ok {
m.dirty.clear();
if restart_required {
Expand Down
91 changes: 90 additions & 1 deletion crates/codeoid-tui/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -817,9 +817,40 @@ impl SettingsModal {
.collect()
}

/// True when the daemon reported registry MCP servers β€” surfaced as a
/// synthetic read-only tab appended after the manifest tabs.
#[must_use]
pub fn has_mcp_tab(&self) -> bool {
self.snapshot
.as_ref()
.is_some_and(|s| !s.mcp_servers.is_empty())
}

/// True when the synthetic MCP servers tab is the active tab.
#[must_use]
pub fn on_mcp_tab(&self) -> bool {
self.has_mcp_tab()
&& self
.manifest
.as_ref()
.is_some_and(|m| self.tab == m.tabs.len())
}

#[must_use]
Comment thread
saucam marked this conversation as resolved.
pub fn tab_count(&self) -> usize {
self.manifest.as_ref().map_or(0, |m| m.tabs.len())
self.manifest.as_ref().map_or(0, |m| m.tabs.len()) + usize::from(self.has_mcp_tab())
}

/// Clamp the active tab into range after the tab set changes β€” e.g. the
/// synthetic MCP tab appears/disappears when a fresh snapshot arrives. Keeps
/// a stale out-of-range index (parked on the MCP tab, then servers vanish)
/// from leaving a blank body until the next tab keypress.
pub fn clamp_tab(&mut self) {
let n = self.tab_count();
if self.tab >= n {
self.tab = n.saturating_sub(1);
self.selected = 0;
}
}

/// The field the cursor is on (cloned so callers avoid borrow conflicts).
Expand Down Expand Up @@ -964,6 +995,64 @@ mod tests {
assert_eq!(state.scroll_offset, 40);
}

#[test]
fn mcp_tab_is_appended_after_manifest_tabs_when_servers_exist() {
use codeoid_protocol::{McpServerStatus, SettingsManifest, SettingsSnapshot, SettingsTab};
let mut m = SettingsModal::new();
m.manifest = Some(SettingsManifest {
version: 1,
tabs: vec![SettingsTab {
id: "general".into(),
title: "General".into(),
icon: None,
description: None,
groups: vec![],
}],
});
// No snapshot β†’ no synthetic MCP tab.
assert!(!m.has_mcp_tab());
assert_eq!(m.tab_count(), 1);

// Snapshot with a registry server β†’ the MCP tab is appended.
m.snapshot = Some(SettingsSnapshot {
values: HashMap::new(),
secrets: HashMap::new(),
config_path: "c".into(),
env_path: "e".into(),
mcp_servers: vec![McpServerStatus {
name: "github".into(),
transport: "stdio".into(),
trust: "prompt".into(),
scope: "workspace".into(),
backends: None,
enabled: true,
builtin: false,
health: "idle".into(),
tool_count: 0,
tools: vec![],
error: None,
}],
});
assert!(m.has_mcp_tab());
assert_eq!(m.tab_count(), 2);

// The last tab is the MCP tab; it's read-only, so it has no fields.
m.tab = 1;
assert!(m.on_mcp_tab());
assert!(m.tab_fields().is_empty());
assert!(m.selected_field().is_none());

// Parked on the MCP tab when it vanishes: the index is stale until a
// fresh snapshot triggers clamp_tab, which pulls it back into range.
m.snapshot.as_mut().unwrap().mcp_servers.clear();
assert!(!m.has_mcp_tab());
assert_eq!(m.tab_count(), 1);
assert!(!m.on_mcp_tab());
assert_eq!(m.tab, 1); // stale
m.clamp_tab();
assert_eq!(m.tab, 0); // clamped to the last manifest tab
}

#[test]
fn set_sessions_prunes_state_for_dead_sessions() {
let mut state = AppState::new(AuthOkMsg {
Expand Down
159 changes: 158 additions & 1 deletion crates/codeoid-tui/src/ui/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) {
pills.push(Span::styled(label, style));
pills.push(Span::raw(" "));
}
// Synthetic read-only tab for registry MCP servers (snapshot-backed,
// appended after the manifest tabs).
if m.has_mcp_tab() {
let active = m.tab == manifest.tabs.len();
let style = if active {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
pills.push(Span::styled(" πŸ”Œ MCP Servers ".to_string(), style));
pills.push(Span::raw(" "));
}
header_rows.push(Line::from(pills));
} else if m.loading {
header_rows.push(hint_line("loading…"));
Expand All @@ -89,7 +104,9 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) {
Style::default().fg(Color::Red),
)));
}
if let Some(manifest) = &m.manifest {
if m.on_mcp_tab() {
body_rows.extend(mcp_server_rows(m));
} else if let Some(manifest) = &m.manifest {
if let Some(tab) = manifest.tabs.get(m.tab) {
if let Some(desc) = &tab.description {
body_rows.push(Line::from(Span::styled(
Expand Down Expand Up @@ -212,6 +229,81 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) {
);
}

/// Body rows for the synthetic MCP servers tab: each registry server with its
/// health, transport/trust, tools, and any error. Read-only (no field editing).
fn mcp_server_rows(m: &SettingsModal) -> Vec<Line<'static>> {
let mut rows: Vec<Line<'static>> = Vec::new();
rows.push(Line::from(Span::styled(
"Registry MCP servers, mounted on every backend (config + imported from ~/.claude.json). Read-only.".to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)));
rows.push(Line::raw(""));
let Some(snap) = &m.snapshot else {
return rows;
};
for s in &snap.mcp_servers {
let (hlabel, hcolor) = match s.health.as_str() {
"connected" => ("connected", Color::Green),
"error" => ("error", Color::Red),
"disabled" => ("disabled", Color::DarkGray),
_ => ("idle", Color::DarkGray),
};
let mut head: Vec<Span<'static>> = vec![Span::styled(
format!("● {}", s.name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
if s.builtin {
head.push(Span::styled(
" built-in".to_string(),
Style::default().fg(Color::Magenta),
));
}
head.push(Span::raw(" "));
head.push(Span::styled(
format!("[{hlabel}]"),
Style::default().fg(hcolor),
));
rows.push(Line::from(head));

let mut meta = format!(" {} Β· {}", s.transport, s.trust);
if s.tool_count > 0 {
let plural = if s.tool_count == 1 { "" } else { "s" };
meta.push_str(&format!(" Β· {} tool{plural}", s.tool_count));
}
rows.push(Line::from(Span::styled(
meta,
Style::default().fg(Color::DarkGray),
)));

if let Some(err) = &s.error {
rows.push(Line::from(vec![
Span::raw(" "),
Span::styled(err.clone(), Style::default().fg(Color::Red)),
]));
}
if !s.tools.is_empty() {
rows.push(Line::from(vec![
Span::raw(" "),
Span::styled("tools: ", Style::default().fg(Color::DarkGray)),
Span::styled(s.tools.join(", "), Style::default().fg(Color::Gray)),
]));
}
if let Some(backends) = &s.backends {
rows.push(Line::from(vec![
Span::raw(" "),
Span::styled("backends: ", Style::default().fg(Color::DarkGray)),
Span::styled(backends.join(", "), Style::default().fg(Color::Gray)),
]));
}
rows.push(Line::raw(""));
}
rows
}

/// One field row: cursor + label + its current control state + badges.
fn settings_field_line(
m: &SettingsModal,
Expand Down Expand Up @@ -993,6 +1085,71 @@ mod tests {
assert!(text.contains("Enter submit"), "{text}");
}

#[test]
fn settings_mcp_tab_renders_servers_with_health() {
use crate::state::SettingsModal;
use codeoid_protocol::{McpServerStatus, SettingsManifest, SettingsSnapshot, SettingsTab};

let mut m = SettingsModal::new();
m.loading = false;
m.manifest = Some(SettingsManifest {
version: 1,
tabs: vec![SettingsTab {
id: "general".into(),
title: "General".into(),
icon: None,
description: None,
groups: vec![],
}],
});
m.snapshot = Some(SettingsSnapshot {
values: std::collections::HashMap::new(),
secrets: std::collections::HashMap::new(),
config_path: "/c".into(),
env_path: "/e".into(),
mcp_servers: vec![
McpServerStatus {
name: "codeoid_memory".into(),
transport: "in-process".into(),
trust: "readonly".into(),
scope: "session".into(),
backends: None,
enabled: true,
builtin: true,
health: "connected".into(),
tool_count: 2,
tools: vec!["recall".into(), "get_episode".into()],
error: None,
},
McpServerStatus {
name: "github".into(),
transport: "stdio".into(),
trust: "prompt".into(),
scope: "workspace".into(),
backends: None,
enabled: true,
builtin: false,
health: "idle".into(),
tool_count: 0,
tools: vec![],
error: None,
},
],
});
m.tab = 1; // the synthetic MCP tab (after the single manifest tab)
assert!(m.on_mcp_tab());

let mut state = mk_state();
state.modal = Some(Modal::Settings(m));
let text = render_to_text(&mut state);
assert!(text.contains("MCP Servers"), "{text}"); // header pill
assert!(text.contains("codeoid_memory"), "{text}");
assert!(text.contains("built-in"), "{text}");
assert!(text.contains("connected"), "{text}"); // health chip
assert!(text.contains("github"), "{text}");
assert!(text.contains("recall"), "{text}"); // tool list
}

#[test]
fn confirm_dialog_renders_yn_hints_and_countdown_title() {
let mut state = mk_state();
Expand Down
Loading