commands ui

This commit is contained in:
jaberjaber23
2026-04-19 21:42:33 +03:00
parent 3db5d3a825
commit 88eeaa6a4d
4 changed files with 307 additions and 45 deletions

View File

@@ -6,7 +6,6 @@ use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use dashmap::DashMap;
use openfang_channels::bridge::channel_command_specs;
use openfang_kernel::triggers::{TriggerId, TriggerPattern};
use openfang_kernel::workflow::{
ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep,
@@ -10746,40 +10745,82 @@ pub async fn pairing_notify(
.into_response()
}
/// GET /api/commands — List available chat commands (for dynamic slash menu).
pub async fn list_commands(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let mut commands: Vec<serde_json::Value> = channel_command_specs()
.iter()
.map(|spec| {
/// GET /api/commands?surface=web|cli|channel|all — List slash commands from the
/// unified command registry, filtered by surface.
///
/// Query params:
/// - `surface`: one of `web` (default), `cli`, `channel`, `all`.
///
/// Returns:
/// ```json
/// {
/// "surface": "web",
/// "commands": [
/// {
/// "name": "new",
/// "aliases": ["reset"],
/// "description": "Reset session (clear history)",
/// "category": "session",
/// "requires_agent": true
/// },
/// ...
/// ]
/// }
/// ```
///
/// Unknown surface values return 400.
pub async fn list_commands(Query(params): Query<CommandsQuery>) -> impl IntoResponse {
use openfang_types::commands::{self, CommandCategory, Surfaces};
let surface_raw = params.surface.as_deref().unwrap_or("web");
let surface = match surface_raw.to_ascii_lowercase().as_str() {
"web" => Surfaces::WEB,
"cli" => Surfaces::CLI,
"channel" => Surfaces::CHANNEL,
"all" => Surfaces::ALL,
other => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!(
"Unknown surface '{other}'. Valid: web, cli, channel, all."
),
})),
)
.into_response();
}
};
let category_slug = |c: CommandCategory| -> &'static str {
match c {
CommandCategory::General => "general",
CommandCategory::Session => "session",
CommandCategory::Model => "model",
CommandCategory::Memory => "memory",
CommandCategory::Control => "control",
CommandCategory::Info => "info",
CommandCategory::Automation => "automation",
CommandCategory::Monitoring => "monitoring",
}
};
let commands: Vec<serde_json::Value> = commands::list_for_surface(surface)
.map(|def| {
serde_json::json!({
"cmd": format!("/{}", spec.name),
"desc": spec.desc,
"source": "channel",
"name": def.name,
"aliases": def.aliases,
"description": def.description,
"category": category_slug(def.category),
"requires_agent": def.requires_agent,
})
})
.collect();
commands.extend([
serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}),
serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}),
serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}),
serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}),
serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}),
]);
// Add skill-registered tool names as potential commands
if let Ok(registry) = state.kernel.skill_registry.read() {
for skill in registry.list() {
let desc: String = skill.manifest.skill.description.chars().take(80).collect();
commands.push(serde_json::json!({
"cmd": format!("/{}", skill.manifest.skill.name),
"desc": if desc.is_empty() { format!("Skill: {}", skill.manifest.skill.name) } else { desc },
"source": "skill",
}));
}
}
Json(serde_json::json!({"commands": commands}))
Json(serde_json::json!({
"surface": surface_raw.to_ascii_lowercase(),
"commands": commands,
}))
.into_response()
}
/// SECURITY: Validate webhook bearer token using constant-time comparison.

View File

@@ -107,3 +107,11 @@ pub struct ClawHubInstallRequest {
/// ClawHub skill slug (e.g., "github-helper").
pub slug: String,
}
/// Query parameters for `GET /api/commands`.
#[derive(Debug, Deserialize)]
pub struct CommandsQuery {
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
#[serde(default)]
pub surface: Option<String>,
}

View File

@@ -304,22 +304,38 @@ function chatPage() {
this._slashCommandsLoaded = true;
},
// Fetch dynamic slash commands from server
// Fetch slash commands from the unified registry (/api/commands?surface=web).
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
// the help panel and autocomplete stay in sync with the backend registry.
fetchCommands: function() {
var self = this;
OpenFangAPI.get('/api/commands').then(function(data) {
if (data.commands && data.commands.length) {
// Build a set of known cmds to avoid duplicates
var existing = {};
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
data.commands.forEach(function(c) {
if (!existing[c.cmd]) {
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
existing[c.cmd] = true;
}
});
}
}).catch(function() { /* silent — use hardcoded list */ });
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
var cmds = (data && data.commands) || [];
if (!cmds.length) return;
self.slashCommands = cmds.map(function(c) {
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
// Fall back to legacy { cmd, desc } shape so older shims keep working.
if (c.name) {
return {
cmd: '/' + c.name,
desc: c.description || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: 'registry'
};
}
return {
cmd: c.cmd,
desc: c.desc || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: c.source || 'server'
};
});
self._slashCommandsLoaded = true;
}).catch(function() { /* silent — keep hardcoded fallback list */ });
},
get filteredSlashCommands() {
@@ -330,6 +346,44 @@ function chatPage() {
});
},
// Render `/help` output grouped by category, mirroring the
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
// categories are not populated (pre-fetch hardcoded list).
renderHelpText: function() {
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
var labels = {
general: 'General', session: 'Session', model: 'Model', control: 'Control',
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
};
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
if (!anyCategorised) {
return this.slashCommands.map(function(c) {
return '`' + c.cmd + '` \u2014 ' + c.desc;
}).join('\n');
}
var groups = {};
this.slashCommands.forEach(function(c) {
var cat = c.category || 'general';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
var lines = ['**Available commands:**'];
order.forEach(function(cat) {
var list = groups[cat];
if (!list || !list.length) return;
lines.push('');
lines.push('**' + (labels[cat] || cat) + '**');
list.forEach(function(c) {
var aliasText = '';
if (c.aliases && c.aliases.length) {
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
}
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
});
});
return lines.join('\n');
},
// Clear any stuck typing indicator after 120s
_resetTypingTimeout: function() {
var self = this;
@@ -355,7 +409,7 @@ function chatPage() {
cmdArgs = cmdArgs || '';
switch (cmd) {
case '/help':
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
self.scrollToBottom();
break;
case '/agents':

View File

@@ -122,6 +122,7 @@ async fn start_test_server_with_provider(
axum::routing::get(routes::list_workflow_runs),
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.route("/api/commands", axum::routing::get(routes::list_commands))
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
@@ -983,3 +984,161 @@ async fn test_auth_disabled_when_no_key() {
.unwrap();
assert_eq!(resp.status(), 200);
}
// ---------------------------------------------------------------------------
// /api/commands — unified command registry endpoint
// ---------------------------------------------------------------------------
/// Default (no surface query) returns web-surface commands.
#[tokio::test]
async fn test_commands_default_returns_web() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "web");
let commands = body["commands"].as_array().expect("commands is array");
assert!(!commands.is_empty(), "web surface should have commands");
// Every entry has the documented shape.
for c in commands {
assert!(c["name"].is_string());
assert!(c["aliases"].is_array());
assert!(c["description"].is_string());
assert!(c["category"].is_string());
assert!(c["requires_agent"].is_boolean());
}
// Sanity: web surface must include `/help` and `/verbose` and must NOT
// include CLI-only `/kill`.
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"help"));
assert!(names.contains(&"verbose"));
assert!(!names.contains(&"kill"));
}
/// `?surface=cli` returns CLI-only commands and includes the alias array.
#[tokio::test]
async fn test_commands_cli_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=cli", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "cli");
let commands = body["commands"].as_array().unwrap();
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"kill"));
assert!(names.contains(&"clear"));
assert!(names.contains(&"exit"));
// `start` is channel-only — must not appear on CLI.
assert!(!names.contains(&"start"));
// `/exit` carries the `quit` alias.
let exit = commands
.iter()
.find(|c| c["name"] == "exit")
.expect("exit command must be present on CLI");
let aliases = exit["aliases"].as_array().unwrap();
assert!(
aliases.iter().any(|a| a == "quit"),
"quit alias should be attached to /exit"
);
}
/// `?surface=all` includes commands from every surface.
#[tokio::test]
async fn test_commands_all_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=all", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "all");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
// Surface-specific probes: all three unique-per-surface commands appear.
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
assert!(
names.contains(&"start"),
"channel-only /start missing from /all"
);
assert!(
names.contains(&"verbose"),
"web-only /verbose missing from /all"
);
}
/// `?surface=channel` returns channel commands only.
#[tokio::test]
async fn test_commands_channel_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=channel", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "channel");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"start"));
// CLI-only must not appear here.
assert!(!names.contains(&"kill"));
}
/// Unknown surface returns 400 with a JSON error body.
#[tokio::test]
async fn test_commands_invalid_surface_400() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=bogus", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(err.contains("bogus"), "error should mention the bad value: {err}");
}