use super::super::*;
use a3s_tui::components::{divider_line_with, DetailPanel, DetailRow, MenuItem, MenuPanel};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AssetRow {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) category: String,
pub(crate) kind: String,
pub(crate) status: String,
pub(crate) visibility: String,
pub(crate) owner: String,
pub(crate) updated: String,
pub(crate) access_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RuntimeActivityRow {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) asset_category: String,
pub(crate) kind: String,
pub(crate) status: String,
pub(crate) image: String,
pub(crate) access_url: Option<String>,
pub(crate) updated: String,
pub(crate) source: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AssetListFetch {
pub(crate) rows: Vec<AssetRow>,
pub(crate) note: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RuntimeActivityFetch {
pub(crate) rows: Vec<RuntimeActivityRow>,
pub(crate) note: String,
}
pub(crate) struct AssetListPanel {
pub(crate) rows: Vec<AssetRow>,
pub(crate) sel: usize,
pub(crate) scroll: usize,
pub(crate) category: Option<String>,
pub(crate) query: String,
pub(crate) searching: bool,
pub(crate) loading: bool,
pub(crate) note: String,
}
pub(crate) struct RuntimeActivityPanel {
pub(crate) rows: Vec<RuntimeActivityRow>,
pub(crate) sel: usize,
pub(crate) scroll: usize,
pub(crate) category: Option<String>,
pub(crate) query: String,
pub(crate) searching: bool,
pub(crate) loading: bool,
pub(crate) note: String,
}
struct ResourcePanelRender<'a> {
header: &'a str,
query: &'a str,
searching: bool,
note: &'a str,
sel: usize,
scroll: usize,
total: usize,
hint: &'a str,
}
fn resource_line(rendered: &str, width: usize) -> String {
if width == 0 {
String::new()
} else {
pad_to(&truncate(rendered, width), width)
}
}
fn resource_columns(width: usize) -> (usize, usize, usize) {
if width == 0 {
return (0, 0, 0);
}
let sep_w = if width >= 3 { 3 } else { 0 };
let available = width.saturating_sub(sep_w);
let left = if available < 64 {
available / 2
} else {
(width / 2).clamp(34, 70).min(available.saturating_sub(24))
};
let right = available.saturating_sub(left);
(left, right, sep_w)
}
fn runtime_activity_hint() -> &'static str {
"↑↓/jk select · / search · Enter/o open selected · r refresh · Esc close"
}
fn http() -> Result<reqwest::Client, String> {
let builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
#[cfg(test)]
let builder = builder.no_proxy();
builder.build().map_err(|e| e.to_string())
}
fn with_query(origin: &str, path: &str, query: &str) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&format!("{}{}", origin.trim_end_matches('/'), path))
.map_err(|e| e.to_string())?;
let has_limit = url.query_pairs().any(|(key, _)| key == "limit");
{
let mut pairs = url.query_pairs_mut();
if !has_limit {
pairs.append_pair("limit", "100");
}
if !query.trim().is_empty() {
pairs.append_pair("search", query.trim());
}
}
Ok(url)
}
fn with_asset_query(origin: &str, query: &str) -> Result<reqwest::Url, String> {
let (category, search) = asset_query_parts(query);
let mut url = with_query(origin, "/api/v1/assets", &search)?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("scope", "mine");
pairs.append_pair("status", "all");
if let Some(category) = category {
pairs.append_pair("category", &category);
}
}
Ok(url)
}
fn with_runtime_query(origin: &str, path: &str, query: &str) -> Result<reqwest::Url, String> {
let (_, search) = activity_query_parts(query);
with_query(origin, path, &search)
}
fn path_segment(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn envelope_data(v: &Value) -> &Value {
v.get("data").unwrap_or(v)
}
fn items_of(v: &Value) -> Vec<Value> {
let data = envelope_data(v);
for ptr in [
"/items",
"/list",
"/results",
"/rows",
"/assets",
"/services",
"/deployments",
"/processes",
"/invocations",
"/batches",
] {
if let Some(items) = data.pointer(ptr).and_then(|a| a.as_array()) {
return items
.iter()
.filter(|item| item.is_object())
.cloned()
.collect();
}
}
if let Some(items) = data.as_array() {
return items
.iter()
.filter(|item| item.is_object())
.cloned()
.collect();
}
Vec::new()
}
fn str_at<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
for key in keys {
if let Some(s) = v
.get(*key)
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
{
return Some(s);
}
}
None
}
fn nested_str_at<'a>(v: &'a Value, paths: &[&str]) -> Option<&'a str> {
for path in paths {
if let Some(s) = v
.pointer(path)
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
{
return Some(s);
}
}
None
}
fn bool_flag(v: &Value, keys: &[&str]) -> Option<bool> {
for key in keys {
if let Some(b) = v.get(*key).and_then(|x| x.as_bool()) {
return Some(b);
}
}
None
}
fn short_time(s: &str) -> String {
s.replace('T', " ")
.trim_end_matches('Z')
.chars()
.take(19)
.collect()
}
fn asset_from_value(v: &Value) -> Option<AssetRow> {
let id = str_at(v, &["id", "_id", "uuid", "assetId", "ref"])
.or_else(|| str_at(v, &["name"]))
.unwrap_or("asset")
.to_string();
let name = str_at(v, &["name", "title", "displayName", "label"])
.unwrap_or(&id)
.to_string();
let category = str_at(v, &["category", "assetCategory", "type", "kind"])
.unwrap_or("asset")
.to_string();
let kind = str_at(v, &["agentKind", "appKind", "assetKind", "runtimeKind"])
.unwrap_or("")
.to_string();
let status = str_at(v, &["status", "state", "phase", "publishStatus"])
.map(str::to_string)
.or_else(|| {
bool_flag(v, &["published", "isPublished"])
.map(|b| if b { "published" } else { "draft" }.to_string())
})
.unwrap_or_else(|| "available".to_string());
let visibility = str_at(v, &["visibility", "scope"])
.unwrap_or("")
.to_string();
let owner = nested_str_at(v, &["/owner/name", "/owner/displayName", "/owner/id"])
.or_else(|| str_at(v, &["ownerName", "createdBy", "userId"]))
.unwrap_or("")
.to_string();
let updated = str_at(v, &["updatedAt", "modifiedAt", "createdAt", "lastSeenAt"])
.map(short_time)
.unwrap_or_default();
let access_url = str_at(
v,
&["accessUrl", "gatewayUrl", "publicUrl", "url", "endpoint"],
)
.map(str::to_string);
Some(AssetRow {
id,
name,
category,
kind,
status,
visibility,
owner,
updated,
access_url,
})
}
fn inferred_activity_category(v: &Value) -> Option<&'static str> {
let runtime_plane = str_at(v, &["runtimePlane"])
.unwrap_or("")
.to_ascii_lowercase();
let row_source = str_at(v, &["source"]).unwrap_or("").to_ascii_lowercase();
let target_kind = str_at(v, &["targetKind"])
.or_else(|| nested_str_at(v, &["/metadata/targetKind"]))
.unwrap_or("")
.to_ascii_lowercase();
let kind = str_at(v, &["kind", "type", "resourceType", "runtimeKind"])
.unwrap_or("")
.to_ascii_lowercase();
let protocol = nested_str_at(v, &["/metadata/protocol", "/runtime/protocol"])
.unwrap_or("")
.to_ascii_lowercase();
if target_kind == "workflow"
|| kind == "workflow"
|| runtime_plane == "workflow-core"
|| row_source == "workflow-execution"
{
return Some("workflow");
}
if protocol == "mcp" || target_kind == "mcp" || kind == "mcp" {
return Some("mcp");
}
if protocol == "skill" || target_kind == "skill" || kind == "skill" {
return Some("skill");
}
if target_kind == "agent"
|| target_kind == "agent-session"
|| kind == "agent"
|| kind == "agentic"
|| kind == "tool"
|| kind == "agent-session"
|| runtime_plane == "agent-core"
|| row_source == "kernel-session-runtime"
{
return Some("agent");
}
None
}
fn activity_from_value(v: &Value, source: &str) -> Option<RuntimeActivityRow> {
let id = str_at(
v,
&[
"id",
"_id",
"uuid",
"serviceId",
"deploymentId",
"invocationId",
"batchId",
"name",
],
)?
.to_string();
let name = str_at(
v,
&[
"name",
"serviceName",
"appName",
"project",
"assetName",
"functionName",
"worker",
],
)
.unwrap_or(&id)
.to_string();
let asset_category = str_at(v, &["assetCategory", "category", "assetType", "assetKind"])
.or_else(|| {
nested_str_at(
v,
&[
"/asset/category",
"/metadata/assetCategory",
"/metadata/category",
"/metadata/assetType",
],
)
})
.map(str::to_string)
.or_else(|| inferred_activity_category(v).map(str::to_string))
.unwrap_or_default();
let kind = str_at(v, &["kind", "type", "resourceType", "runtimeKind"])
.unwrap_or_else(|| {
if source.contains("function") {
"function"
} else if source.contains("deployment") {
"deployment"
} else {
"service"
}
})
.to_string();
let status = str_at(v, &["status", "state", "phase", "runtimeStatus"])
.unwrap_or("unknown")
.to_string();
let image = str_at(v, &["image", "containerImage", "ref", "asset", "worker"])
.unwrap_or("")
.to_string();
let access_url = str_at(
v,
&["accessUrl", "gatewayUrl", "publicUrl", "url", "endpoint"],
)
.map(str::to_string);
let updated = str_at(
v,
&[
"updatedAt",
"modifiedAt",
"startedAt",
"createdAt",
"lastSeenAt",
],
)
.map(short_time)
.unwrap_or_default();
Some(RuntimeActivityRow {
id,
name,
asset_category,
kind,
status,
image,
access_url,
updated,
source: source.to_string(),
})
}
pub(crate) async fn fetch_asset_list(
address: &str,
token: &str,
query: &str,
) -> Result<AssetListFetch, String> {
let origin = crate::a3s_os::os_origin(address);
let url = with_asset_query(&origin, query)?;
let resp = http()?
.get(url.clone())
.bearer_auth(token)
.header("accept", "application/json")
.send()
.await
.map_err(|e| format!("request to {url} failed: {e}"))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!(
"assets returned HTTP {}: {}",
status.as_u16(),
truncate(&text, 160)
));
}
let json: Value =
serde_json::from_str(&text).map_err(|_| "assets returned non-JSON".to_string())?;
let mut rows = items_of(&json)
.iter()
.filter_map(asset_from_value)
.collect::<Vec<_>>();
if !query.trim().is_empty() {
rows.retain(|row| asset_matches(row, query));
}
Ok(AssetListFetch {
note: format!("{} asset(s) · {}", rows.len(), url.path()),
rows,
})
}
const RUNTIME_ACTIVITY_ENDPOINTS: &[&str] = &[
"/api/v1/runtime/services",
"/api/v1/runtime/deployments",
"/api/v1/runtime/jobs",
"/api/v1/deployments",
"/api/v1/services",
"/api/v1/functions/batches",
"/api/v1/functions/invocations",
];
pub(crate) async fn fetch_runtime_activity(
address: &str,
token: &str,
query: &str,
) -> Result<RuntimeActivityFetch, String> {
let origin = crate::a3s_os::os_origin(address);
let client = http()?;
let mut rows = Vec::new();
let mut ok_sources = Vec::new();
let mut errors = Vec::new();
for endpoint in RUNTIME_ACTIVITY_ENDPOINTS {
let url = with_runtime_query(&origin, endpoint, query)?;
let resp = match client
.get(url.clone())
.bearer_auth(token)
.header("accept", "application/json")
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
errors.push(format!("{endpoint}: {e}"));
continue;
}
};
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
errors.push(format!("{endpoint}: HTTP {}", status.as_u16()));
continue;
}
ok_sources.push(endpoint.trim_start_matches("/api/v1/").to_string());
if let Ok(json) = serde_json::from_str::<Value>(&text) {
rows.extend(
items_of(&json)
.iter()
.filter_map(|item| activity_from_value(item, endpoint)),
);
}
}
if ok_sources.is_empty() {
return Err(if errors.is_empty() {
"no OS runtime activity endpoint responded".to_string()
} else {
errors.join(" · ")
});
}
if !query.trim().is_empty() {
rows.retain(|row| activity_matches(row, query));
}
rows.sort_by_key(|row| row.name.to_lowercase());
rows.dedup_by(|a, b| a.id == b.id && a.name == b.name);
Ok(RuntimeActivityFetch {
note: format!("{} runtime row(s) · {}", rows.len(), ok_sources.join(", ")),
rows,
})
}
fn asset_matches(row: &AssetRow, query: &str) -> bool {
let (category, q) = asset_query_parts(query);
let q = q.to_ascii_lowercase();
if let Some(category) = category {
let row_category = row.category.to_lowercase();
if row_category != category && !row_category.contains(&category) {
return false;
}
}
q.is_empty()
|| [
&row.id,
&row.name,
&row.category,
&row.kind,
&row.status,
&row.visibility,
&row.owner,
]
.iter()
.any(|s| s.to_lowercase().contains(&q))
}
fn asset_query_parts(query: &str) -> (Option<String>, String) {
let mut category = None;
let mut terms = Vec::new();
for part in query.split_whitespace() {
if let Some(value) = part.strip_prefix("category:") {
if !value.trim().is_empty() {
category = Some(value.trim().to_ascii_lowercase());
}
} else {
terms.push(part);
}
}
(category, terms.join(" "))
}
fn activity_matches(row: &RuntimeActivityRow, query: &str) -> bool {
let (category, search) = activity_query_parts(query);
if let Some(category) = category {
let exact_category = row.asset_category.to_ascii_lowercase();
if !exact_category.is_empty()
&& exact_category != category
&& !exact_category.contains(&category)
{
return false;
}
}
let terms = search
.split_whitespace()
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
if terms.is_empty() {
return true;
}
let fields = [
&row.id,
&row.name,
&row.asset_category,
&row.kind,
&row.status,
&row.image,
]
.iter()
.map(|s| s.to_lowercase())
.collect::<Vec<_>>();
terms
.iter()
.all(|term| fields.iter().any(|field| field.contains(term)))
}
fn activity_query_parts(query: &str) -> (Option<String>, String) {
asset_query_parts(query)
}
fn scoped_query(category: Option<&str>, query: &str) -> String {
let (_, query) = asset_query_parts(query);
let category = category.map(str::trim).filter(|value| !value.is_empty());
match (category, query.trim().is_empty()) {
(Some(category), true) => format!("category:{category}"),
(Some(category), false) => format!("category:{category} {}", query.trim()),
(None, true) => String::new(),
(None, false) => query,
}
}
fn selected_asset(panel: &AssetListPanel) -> Option<AssetRow> {
let query = scoped_query(panel.category.as_deref(), &panel.query);
panel
.rows
.iter()
.filter(|row| asset_matches(row, &query))
.nth(panel.sel)
.cloned()
}
fn selected_activity(panel: &RuntimeActivityPanel) -> Option<RuntimeActivityRow> {
let query = scoped_query(panel.category.as_deref(), &panel.query);
panel
.rows
.iter()
.filter(|row| activity_matches(row, &query))
.nth(panel.sel)
.cloned()
}
fn edit_search(query: &mut String, key: &KeyEvent) -> bool {
match key.code {
KeyCode::Backspace => {
query.pop();
true
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
query.push(c);
true
}
_ => false,
}
}
impl App {
pub(crate) fn open_asset_list_panel(&mut self, query: String) -> Option<Cmd<Msg>> {
let Some(session) = self.os_session.clone() else {
self.push_line(&os_required_alert("asset list", self.os_config.is_some()));
return None;
};
let (category, query) = asset_query_parts(&query);
let q = scoped_query(category.as_deref(), &query);
self.asset_list = Some(AssetListPanel {
rows: Vec::new(),
sel: 0,
scroll: 0,
category,
query,
searching: false,
loading: true,
note: "loading assets…".to_string(),
});
Some(cmd::cmd(move || async move {
Msg::AssetListLoaded(
fetch_asset_list(&session.address, &session.access_token, &q).await,
)
}))
}
pub(crate) fn open_runtime_activity_panel(&mut self, query: String) -> Option<Cmd<Msg>> {
let Some(session) = self.os_session.clone() else {
self.push_line(&os_required_alert(
"runtime activity",
self.os_config.is_some(),
));
return None;
};
let (category, query) = activity_query_parts(&query);
let q = scoped_query(category.as_deref(), &query);
self.runtime_activity = Some(RuntimeActivityPanel {
rows: Vec::new(),
sel: 0,
scroll: 0,
category,
query,
searching: false,
loading: true,
note: "loading runtime activity…".to_string(),
});
Some(cmd::cmd(move || async move {
Msg::RuntimeActivityLoaded(
fetch_runtime_activity(&session.address, &session.access_token, &q).await,
)
}))
}
fn reload_asset_list(&self) -> Option<Cmd<Msg>> {
let session = self.os_session.clone()?;
let query = self
.asset_list
.as_ref()
.map(|p| scoped_query(p.category.as_deref(), &p.query))
.unwrap_or_default();
Some(cmd::cmd(move || async move {
Msg::AssetListLoaded(
fetch_asset_list(&session.address, &session.access_token, &query).await,
)
}))
}
fn reload_runtime_activity(&self) -> Option<Cmd<Msg>> {
let session = self.os_session.clone()?;
let query = self
.runtime_activity
.as_ref()
.map(|p| scoped_query(p.category.as_deref(), &p.query))
.unwrap_or_default();
Some(cmd::cmd(move || async move {
Msg::RuntimeActivityLoaded(
fetch_runtime_activity(&session.address, &session.access_token, &query).await,
)
}))
}
pub(crate) fn handle_asset_list_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
if self.asset_list.as_ref().is_some_and(|p| p.searching) {
match key.code {
KeyCode::Esc => {
if let Some(p) = self.asset_list.as_mut() {
p.searching = false;
}
}
KeyCode::Enter => {
if let Some(p) = self.asset_list.as_mut() {
p.searching = false;
p.loading = true;
p.note = format!("searching `{}`…", p.query);
}
return self.reload_asset_list();
}
_ => {
if let Some(p) = self.asset_list.as_mut() {
if edit_search(&mut p.query, key) {
p.sel = 0;
p.scroll = 0;
}
}
}
}
return None;
}
match key.code {
KeyCode::Esc => {
self.asset_list = None;
None
}
KeyCode::Char('/') | KeyCode::Char('s') => {
if let Some(p) = self.asset_list.as_mut() {
p.searching = true;
}
None
}
KeyCode::Char('r') => {
if let Some(p) = self.asset_list.as_mut() {
p.loading = true;
p.note = "refreshing…".to_string();
}
self.reload_asset_list()
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(p) = self.asset_list.as_mut() {
p.sel = p.sel.saturating_sub(1);
p.scroll = p.scroll.min(p.sel);
}
None
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(p) = self.asset_list.as_mut() {
let query = scoped_query(p.category.as_deref(), &p.query);
let last = p
.rows
.iter()
.filter(|row| asset_matches(row, &query))
.count()
.saturating_sub(1);
p.sel = (p.sel + 1).min(last);
}
None
}
KeyCode::PageUp => {
if let Some(p) = self.asset_list.as_mut() {
p.sel = p.sel.saturating_sub(10);
p.scroll = p.scroll.saturating_sub(10);
}
None
}
KeyCode::PageDown => {
if let Some(p) = self.asset_list.as_mut() {
let query = scoped_query(p.category.as_deref(), &p.query);
let last = p
.rows
.iter()
.filter(|row| asset_matches(row, &query))
.count()
.saturating_sub(1);
p.sel = (p.sel + 10).min(last);
}
None
}
KeyCode::Enter | KeyCode::Char('o') => {
let row = self.asset_list.as_ref().and_then(selected_asset);
if let Some(row) = row.as_ref() {
self.open_asset_view(row);
}
None
}
_ => None,
}
}
pub(crate) fn handle_runtime_activity_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
if self.runtime_activity.as_ref().is_some_and(|p| p.searching) {
match key.code {
KeyCode::Esc => {
if let Some(p) = self.runtime_activity.as_mut() {
p.searching = false;
}
}
KeyCode::Enter => {
if let Some(p) = self.runtime_activity.as_mut() {
p.searching = false;
p.loading = true;
p.note = format!("searching `{}`…", p.query);
}
return self.reload_runtime_activity();
}
_ => {
if let Some(p) = self.runtime_activity.as_mut() {
if edit_search(&mut p.query, key) {
p.sel = 0;
p.scroll = 0;
}
}
}
}
return None;
}
match key.code {
KeyCode::Esc => {
self.runtime_activity = None;
None
}
KeyCode::Char('/') | KeyCode::Char('s') => {
if let Some(p) = self.runtime_activity.as_mut() {
p.searching = true;
}
None
}
KeyCode::Char('r') => {
if let Some(p) = self.runtime_activity.as_mut() {
p.loading = true;
p.note = "refreshing…".to_string();
}
self.reload_runtime_activity()
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(p) = self.runtime_activity.as_mut() {
p.sel = p.sel.saturating_sub(1);
p.scroll = p.scroll.min(p.sel);
}
None
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(p) = self.runtime_activity.as_mut() {
let query = scoped_query(p.category.as_deref(), &p.query);
let last = p
.rows
.iter()
.filter(|row| activity_matches(row, &query))
.count()
.saturating_sub(1);
p.sel = (p.sel + 1).min(last);
}
None
}
KeyCode::PageUp => {
if let Some(p) = self.runtime_activity.as_mut() {
p.sel = p.sel.saturating_sub(10);
p.scroll = p.scroll.saturating_sub(10);
}
None
}
KeyCode::PageDown => {
if let Some(p) = self.runtime_activity.as_mut() {
let query = scoped_query(p.category.as_deref(), &p.query);
let last = p
.rows
.iter()
.filter(|row| activity_matches(row, &query))
.count()
.saturating_sub(1);
p.sel = (p.sel + 10).min(last);
}
None
}
KeyCode::Enter | KeyCode::Char('o') => {
let row = self.runtime_activity.as_ref().and_then(selected_activity);
if let Some(row) = row.as_ref() {
self.open_runtime_view(row);
}
None
}
_ => None,
}
}
pub(crate) fn on_asset_list(&mut self, result: Result<AssetListFetch, String>) {
let Some(panel) = self.asset_list.as_mut() else {
return;
};
panel.loading = false;
match result {
Ok(fetch) => {
panel.rows = fetch.rows;
panel.sel = panel.sel.min(panel.rows.len().saturating_sub(1));
panel.note = fetch.note;
}
Err(e) => panel.note = format!("✗ {e}"),
}
}
pub(crate) fn on_runtime_activity(&mut self, result: Result<RuntimeActivityFetch, String>) {
let Some(panel) = self.runtime_activity.as_mut() else {
return;
};
panel.loading = false;
match result {
Ok(fetch) => {
panel.rows = fetch.rows;
panel.sel = panel.sel.min(panel.rows.len().saturating_sub(1));
panel.note = fetch.note;
}
Err(e) => panel.note = format!("✗ {e}"),
}
}
fn open_asset_view(&mut self, row: &AssetRow) {
let Some(session) = self.os_session.as_ref() else {
return;
};
let origin = crate::a3s_os::os_origin(&session.address);
let id_path = path_segment(&row.id);
let url = row
.access_url
.clone()
.unwrap_or_else(|| format!("{origin}/admin/assets/{id_path}?embed=1"));
let label = format!("OS asset view · {}", truncate(&row.name, 48));
let spec = remote_ui::ViewSpec {
url,
width: Some(1440),
height: Some(900),
embeddable: true,
};
self.last_view = Some(spec.clone());
self.push_line(&gutter(
ACCENT,
&remote_view_button(&format!("{label} · click or /view reopens")),
));
self.open_remote_view(&spec);
}
fn open_runtime_view(&mut self, row: &RuntimeActivityRow) {
let Some(session) = self.os_session.as_ref() else {
return;
};
let origin = crate::a3s_os::os_origin(&session.address);
let id_path = path_segment(&row.id);
let url = row.access_url.clone().unwrap_or_else(|| {
format!("{origin}/admin/infrastructure/batch?service={id_path}&embed=1")
});
let label = format!("OS runtime activity · {}", truncate(&row.name, 48));
let spec = remote_ui::ViewSpec {
url,
width: Some(1440),
height: Some(900),
embeddable: true,
};
self.last_view = Some(spec.clone());
self.push_line(&gutter(
ACCENT,
&remote_view_button(&format!("{label} · click or /view reopens")),
));
self.open_remote_view(&spec);
}
pub(crate) fn render_asset_list(&self, panel: &AssetListPanel) -> String {
let query = scoped_query(panel.category.as_deref(), &panel.query);
let rows = panel
.rows
.iter()
.filter(|row| asset_matches(row, &query))
.collect::<Vec<_>>();
let scope = panel
.category
.as_deref()
.map(|category| format!(" · category:{category}"))
.unwrap_or_default();
let header = format!(
"OS assets{scope} · {} shown / {} total{}",
rows.len(),
panel.rows.len(),
if panel.loading { " · loading" } else { "" }
);
let hint = "↑↓/jk select · / search · Enter/o open selected · r refresh · Esc close";
let spec = ResourcePanelRender {
header: &header,
query: &panel.query,
searching: panel.searching,
note: &panel.note,
sel: panel.sel,
scroll: panel.scroll,
total: rows.len(),
hint,
};
let items = rows
.iter()
.map(|row| asset_list_item(row))
.collect::<Vec<_>>();
self.render_resource_panel(&spec, items, |width| {
asset_detail(rows.get(panel.sel).copied(), width)
})
}
pub(crate) fn render_runtime_activity(&self, panel: &RuntimeActivityPanel) -> String {
let query = scoped_query(panel.category.as_deref(), &panel.query);
let rows = panel
.rows
.iter()
.filter(|row| activity_matches(row, &query))
.collect::<Vec<_>>();
let scope = panel
.category
.as_deref()
.map(|category| format!(" · category:{category}"))
.unwrap_or_default();
let header = format!(
"OS runtime activity{scope} · {} shown / {} total{}",
rows.len(),
panel.rows.len(),
if panel.loading { " · loading" } else { "" }
);
let spec = ResourcePanelRender {
header: &header,
query: &panel.query,
searching: panel.searching,
note: &panel.note,
sel: panel.sel,
scroll: panel.scroll,
total: rows.len(),
hint: runtime_activity_hint(),
};
let items = rows
.iter()
.map(|row| activity_list_item(row))
.collect::<Vec<_>>();
self.render_resource_panel(&spec, items, |width| {
activity_detail(rows.get(panel.sel).copied(), width)
})
}
fn render_resource_panel<Detail>(
&self,
spec: &ResourcePanelRender<'_>,
items: Vec<MenuItem>,
detail: Detail,
) -> String
where
Detail: Fn(usize) -> Vec<String>,
{
let width = self.width as usize;
let h = self.height as usize;
let (left_w, right_w, sep_w) = resource_columns(width);
let sep = if sep_w == 0 {
String::new()
} else {
Style::new().fg(TN_GRAY).render(" │ ")
};
let mut out = vec![
resource_line(
&Style::new()
.fg(ACCENT)
.bold()
.render(&format!(" {}", spec.header)),
width,
),
resource_line(
&Style::new().fg(TN_GRAY).render(&format!(
" {}{}",
if spec.searching {
"search> "
} else {
"search: "
},
if spec.query.is_empty() {
"—"
} else {
spec.query
}
)),
width,
),
resource_line(
&Style::new()
.fg(if spec.note.starts_with('✗') {
TN_RED
} else {
TN_GRAY
})
.render(&format!(" {}", spec.note)),
width,
),
resource_line(
÷r_line_with(width.min(u16::MAX as usize) as u16, "─", TN_GRAY),
width,
),
];
let body = h.saturating_sub(5);
let sel = spec.sel.min(spec.total.saturating_sub(1));
let start = if sel < spec.scroll {
sel
} else if sel >= spec.scroll + body {
sel + 1 - body
} else {
spec.scroll
}
.min(spec.total.saturating_sub(1));
let detail_lines = detail(right_w);
let left_lines = resource_menu_lines(items, sel, start, left_w, body);
for i in 0..body {
let left = if spec.total == 0 && i == 0 {
Style::new()
.fg(TN_GRAY)
.render(&resource_line(" no rows match", left_w))
} else {
left_lines
.get(i)
.cloned()
.unwrap_or_else(|| " ".repeat(left_w))
};
let right = detail_lines.get(i).cloned().unwrap_or_default();
out.push(format!("{left}{sep}{}", resource_line(&right, right_w)));
}
out.push(resource_line(
&Style::new().fg(TN_GRAY).render(&format!(" {}", spec.hint)),
width,
));
out.truncate(h);
while out.len() < h {
out.push(String::new());
}
out.join("\n")
}
}
fn resource_menu_lines(
items: Vec<MenuItem>,
selected: usize,
scroll: usize,
width: usize,
height: usize,
) -> Vec<String> {
if width == 0 || height == 0 {
return Vec::new();
}
MenuPanel::without_title()
.items(items)
.selected(selected)
.scroll(scroll)
.max_items(height)
.show_scroll(true)
.indent(1)
.marker("▸")
.text_color(TN_FG)
.muted_color(TN_GRAY)
.selected_colors(Color::BrightWhite, ACCENT)
.view(width.min(u16::MAX as usize) as u16, height)
.lines()
.map(str::to_string)
.collect()
}
fn asset_list_item(row: &AssetRow) -> MenuItem {
MenuItem::new(format!(
"{} {}",
truncate(&row.category, 13),
truncate(&row.status, 11)
))
.description(row.name.clone())
}
fn activity_list_item(row: &RuntimeActivityRow) -> MenuItem {
MenuItem::new(format!(
"{} {}",
truncate(&row.kind, 12),
truncate(&row.status, 11)
))
.description(row.name.clone())
}
fn asset_detail(row: Option<&AssetRow>, width: usize) -> Vec<String> {
let Some(row) = row else {
return DetailPanel::without_title()
.show_separator(false)
.indent(0)
.muted_color(TN_GRAY)
.row(DetailRow::muted("select an asset"))
.view(width.min(u16::MAX as usize) as u16, 1)
.lines()
.map(str::to_string)
.collect();
};
let mut panel = DetailPanel::new(row.name.clone())
.show_separator(false)
.indent(0)
.title_color(TN_FG)
.label_color(TN_GRAY)
.value_color(TN_FG)
.label_width(10)
.unlimited_rows();
for (label, value) in [
("id", row.id.as_str()),
("category", row.category.as_str()),
("kind", row.kind.as_str()),
("status", row.status.as_str()),
("visibility", row.visibility.as_str()),
("owner", row.owner.as_str()),
("updated", row.updated.as_str()),
] {
if !value.is_empty() {
panel = panel.pair(label, value);
}
}
if let Some(url) = &row.access_url {
panel = panel.pair("access", url);
}
panel
.view(
width.min(u16::MAX as usize) as u16,
panel.rows().len().saturating_add(1),
)
.lines()
.map(str::to_string)
.collect()
}
fn activity_detail(row: Option<&RuntimeActivityRow>, width: usize) -> Vec<String> {
let Some(row) = row else {
return DetailPanel::without_title()
.show_separator(false)
.indent(0)
.muted_color(TN_GRAY)
.row(DetailRow::muted("select a runtime row"))
.view(width.min(u16::MAX as usize) as u16, 1)
.lines()
.map(str::to_string)
.collect();
};
let mut panel = DetailPanel::new(row.name.clone())
.show_separator(false)
.indent(0)
.title_color(TN_FG)
.label_color(TN_GRAY)
.value_color(TN_FG)
.label_width(10)
.unlimited_rows();
for (label, value) in [
("id", row.id.as_str()),
("asset", row.asset_category.as_str()),
("kind", row.kind.as_str()),
("status", row.status.as_str()),
("image/ref", row.image.as_str()),
("source", row.source.as_str()),
("updated", row.updated.as_str()),
] {
if !value.is_empty() {
panel = panel.pair(label, value);
}
}
if let Some(url) = &row.access_url {
panel = panel.pair("access", url);
}
panel
.view(
width.min(u16::MAX as usize) as u16,
panel.rows().len().saturating_add(1),
)
.lines()
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn v(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}
async fn spawn_mock_os(captured: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let requests = captured.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).into_owned();
let line = req.lines().next().unwrap_or("").to_string();
requests.lock().unwrap().push(req);
let (status, payload) = mock_response(&line);
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
payload.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
});
}
});
origin
}
fn mock_response(line: &str) -> (&'static str, &'static str) {
if line.starts_with("GET /api/v1/assets?") {
return (
"200 OK",
r#"{"data":{"items":[{"id":"asset 1?","name":"Payment Agent","category":"agent","status":"published"},{"id":"workflow-1","name":"Unrelated Flow","category":"workflow","status":"published"}]}}"#,
);
}
if line.starts_with("GET /api/v1/runtime/services?") {
if line.contains("search=mcp-api") {
return (
"200 OK",
r#"{"data":{"services":[{"serviceId":"svc-mcp","serviceName":"mcp-api","metadata":{"protocol":"mcp"},"state":"running","image":"img:mcp"},{"serviceId":"svc-workflow","serviceName":"mcp-api","targetKind":"workflow","state":"running","image":"img:workflow"}]}}"#,
);
}
return (
"200 OK",
r#"{"data":{"services":[{"serviceId":"svc 1?","serviceName":"api","state":"running","image":"img:v1"},{"serviceId":"svc 2","serviceName":"unrelated-worker","state":"running","image":"img:v2"}]}}"#,
);
}
("404 Not Found", r#"{"error":"not found"}"#)
}
fn captured_lines(captured: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
captured
.lock()
.unwrap()
.iter()
.map(|req| req.lines().next().unwrap_or("").to_string())
.collect()
}
#[test]
fn resource_columns_fit_narrow_width() {
let (left, right, sep) = resource_columns(30);
assert!(left > 0, "left column should not collapse");
assert!(right > 0, "right column should not collapse");
assert!(left + right + sep <= 30);
}
#[test]
fn resource_lines_are_width_bounded_with_styles() {
let line = resource_line(
&Style::new()
.fg(ACCENT)
.bold()
.render(" OS runtime activity · 999 shown / 999 total · loading"),
32,
);
assert!(
a3s_tui::style::visible_len(&line) <= 32,
"{}",
a3s_tui::style::strip_ansi(&line)
);
}
#[test]
fn resource_menu_lines_use_shared_menu_rows_and_bound_width() {
let items = vec![
asset_list_item(&AssetRow {
id: "asset-1".into(),
name: "very-long-asset-name-that-would-overflow-the-resource-list-panel".into(),
category: "knowledge".into(),
kind: "tool".into(),
status: "published".into(),
visibility: "private".into(),
owner: "roy".into(),
updated: String::new(),
access_url: None,
}),
asset_list_item(&AssetRow {
id: "asset-2".into(),
name: "ops-agent".into(),
category: "agent".into(),
kind: "tool".into(),
status: "draft".into(),
visibility: "private".into(),
owner: "roy".into(),
updated: String::new(),
access_url: None,
}),
];
let lines = resource_menu_lines(items, 0, 0, 36, 4);
let plain = lines
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n");
assert!(plain.contains("knowledge"), "{plain}");
assert!(plain.contains("very-long-a"), "{plain}");
assert!(plain.contains('…'), "{plain}");
assert!(
lines
.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 36),
"{plain}"
);
}
#[test]
fn resource_menu_lines_scroll_selected_row_into_view() {
let items = (0..16)
.map(|index| {
activity_list_item(&RuntimeActivityRow {
id: format!("svc-{index}"),
name: format!("runtime-{index}"),
asset_category: "mcp".into(),
kind: "service".into(),
status: "running".into(),
image: "img:v1".into(),
access_url: None,
updated: String::new(),
source: "runtime".into(),
})
})
.collect::<Vec<_>>();
let plain = resource_menu_lines(items, 14, 0, 40, 6)
.into_iter()
.map(|line| a3s_tui::style::strip_ansi(&line))
.collect::<Vec<_>>()
.join("\n");
assert!(plain.contains("runtime-14"), "{plain}");
assert!(plain.contains("↑↓ 15/16"), "{plain}");
}
#[test]
fn asset_detail_uses_shared_detail_panel_and_bounds_width() {
let row = AssetRow {
id: "asset-1".into(),
name: "Payment Agent With A Very Long Display Name".into(),
category: "agent".into(),
kind: "tool".into(),
status: "published".into(),
visibility: "private".into(),
owner: "roy".into(),
updated: "2026-07-04T12:30:00Z".into(),
access_url: Some("https://os.example/assets/asset-1/launch".into()),
};
let lines = asset_detail(Some(&row), 36);
let plain = lines
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n");
assert!(plain.contains("Payment Agent"), "{plain}");
assert!(plain.contains("category"), "{plain}");
assert!(plain.contains("published"), "{plain}");
assert!(plain.contains("access"), "{plain}");
assert!(
lines
.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 36),
"{plain}"
);
let empty = asset_detail(None, 24)
.into_iter()
.map(|line| a3s_tui::style::strip_ansi(&line))
.collect::<Vec<_>>()
.join("\n");
assert!(empty.contains("select an asset"), "{empty}");
}
#[test]
fn activity_detail_uses_shared_detail_panel_and_bounds_width() {
let row = RuntimeActivityRow {
id: "svc-1".into(),
name: "Runtime Worker With A Very Long Display Name".into(),
asset_category: "workflow".into(),
kind: "service".into(),
status: "running".into(),
image: "registry.example/a3s/runtime-worker:2026-07-04".into(),
access_url: Some("https://runtime.example/services/svc-1".into()),
updated: "2026-07-04T12:31:00Z".into(),
source: "runtime".into(),
};
let lines = activity_detail(Some(&row), 38);
let plain = lines
.iter()
.map(|line| a3s_tui::style::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n");
assert!(plain.contains("Runtime Worker"), "{plain}");
assert!(plain.contains("workflow"), "{plain}");
assert!(plain.contains("image/ref"), "{plain}");
assert!(plain.contains("runtime"), "{plain}");
assert!(
lines
.iter()
.all(|line| a3s_tui::style::visible_len(line) <= 38),
"{plain}"
);
}
#[test]
fn items_of_accepts_common_envelopes() {
assert_eq!(items_of(&v(r#"{"data":{"items":[{"id":"a"}]}}"#)).len(), 1);
assert_eq!(
items_of(&v(r#"{"items":[{"id":"a"},{"id":"b"}]}"#)).len(),
2
);
assert_eq!(items_of(&v(r#"[{"id":"a"}]"#)).len(), 1);
}
#[test]
fn parses_asset_rows_leniently() {
let row = asset_from_value(&v(
r#"{"id":"a1","name":"demo","category":"application","agentKind":"tool","published":true,"owner":{"name":"me"},"updatedAt":"2026-07-03T01:02:03Z"}"#,
))
.unwrap();
assert_eq!(row.id, "a1");
assert_eq!(row.category, "application");
assert_eq!(row.kind, "tool");
assert_eq!(row.status, "published");
assert_eq!(row.owner, "me");
assert_eq!(row.updated, "2026-07-03 01:02:03");
}
#[test]
fn parses_runtime_activity_rows_leniently() {
let row = activity_from_value(
&v(r#"{"deploymentId":"d1","serviceName":"api","state":"running","containerImage":"img:v1","accessUrl":"https://x"}"#),
"/api/v1/runtime/deployments",
)
.unwrap();
assert_eq!(row.id, "d1");
assert_eq!(row.name, "api");
assert_eq!(row.asset_category, "");
assert_eq!(row.kind, "deployment");
assert_eq!(row.status, "running");
assert_eq!(row.access_url.as_deref(), Some("https://x"));
let runtime_service = activity_from_value(
&v(r#"{"id":"wf:1","name":"daily-flow","targetKind":"workflow","runtimePlane":"workflow-core","state":"running"}"#),
"/api/v1/runtime/services",
)
.unwrap();
assert_eq!(runtime_service.asset_category, "workflow");
let mcp_service = activity_from_value(
&v(r#"{"id":"fn:1","name":"weather-mcp","kind":"serving","metadata":{"protocol":"mcp"},"state":"running"}"#),
"/api/v1/runtime/services",
)
.unwrap();
assert_eq!(mcp_service.asset_category, "mcp");
}
#[test]
fn activity_category_filter_only_hard_filters_explicit_asset_categories() {
let explicit_workflow = activity_from_value(
&v(r#"{"serviceId":"svc-workflow","serviceName":"api","assetCategory":"workflow","state":"running"}"#),
"/api/v1/runtime/services",
)
.unwrap();
assert!(
!activity_matches(&explicit_workflow, "category:mcp api"),
"explicit workflow activity must not leak into MCP activity"
);
let implicit_runtime_kind = activity_from_value(
&v(r#"{"serviceId":"svc-agent","serviceName":"api","runtimeBinding":{"kind":"tool"},"state":"running"}"#),
"/api/v1/runtime/services",
)
.unwrap();
assert_eq!(
implicit_runtime_kind.asset_category, "",
"runtime binding kind is not the asset family"
);
assert!(
activity_matches(&implicit_runtime_kind, "category:agent api"),
"missing asset category should not hide a search-matching runtime row"
);
}
#[test]
fn search_matches_across_visible_fields() {
let row = AssetRow {
id: "a1".into(),
name: "Payment Agent".into(),
category: "agent".into(),
kind: "tool".into(),
status: "published".into(),
visibility: "private".into(),
owner: "roy".into(),
updated: String::new(),
access_url: None,
};
assert!(asset_matches(&row, "payment"));
assert!(asset_matches(&row, "tool"));
assert!(!asset_matches(&row, "workflow"));
}
#[test]
fn scoped_query_keeps_asset_scope_outside_editable_search_terms() {
assert_eq!(scoped_query(Some("agent"), ""), "category:agent");
assert_eq!(
scoped_query(Some("agent"), "Payment Agent"),
"category:agent Payment Agent"
);
assert_eq!(
scoped_query(Some("agent"), "category:workflow Payment Agent"),
"category:agent Payment Agent",
"editable search text must not override the fixed asset-family scope"
);
assert_eq!(scoped_query(None, "category:mcp api"), "api");
}
#[test]
fn path_segment_encodes_reserved_chars() {
assert_eq!(path_segment("asset 1?#"), "asset%201%3F%23");
assert_eq!(path_segment("café"), "caf%C3%A9");
}
#[test]
fn runtime_activity_hint_stays_read_only() {
let hint = runtime_activity_hint();
assert!(hint.contains("open"), "{hint}");
assert!(hint.contains("refresh"), "{hint}");
assert!(!hint.contains("runtime view"), "{hint}");
assert!(!hint.contains(" O "), "{hint}");
assert!(!hint.contains("stop"), "{hint}");
assert!(!hint.contains("cancel"), "{hint}");
}
#[tokio::test]
async fn fetch_asset_list_uses_bearer_token_category_and_search_query() {
let captured = Arc::new(Mutex::new(Vec::new()));
let origin = spawn_mock_os(captured.clone()).await;
let fetch = fetch_asset_list(&origin, "tok-asset", "category:agent Payment Agent")
.await
.unwrap();
assert_eq!(fetch.rows.len(), 1);
assert_eq!(fetch.rows[0].name, "Payment Agent");
assert_eq!(fetch.rows[0].category, "agent");
assert!(
!fetch.rows.iter().any(|row| row.name == "Unrelated Flow"),
"fetch should keep asset lists category-scoped even if OS ignores filters"
);
let requests = captured.lock().unwrap();
let req = requests.first().unwrap();
let request_line = req.lines().next().unwrap();
assert!(request_line.contains("/api/v1/assets?"));
assert!(request_line.contains("limit=100"));
assert!(request_line.contains("scope=mine"), "{request_line}");
assert!(request_line.contains("status=all"), "{request_line}");
assert!(
request_line.contains("search=Payment+Agent")
|| request_line.contains("search=Payment%20Agent"),
"{request_line}"
);
assert!(request_line.contains("category=agent"), "{request_line}");
assert!(req
.to_ascii_lowercase()
.contains("authorization: bearer tok-asset"));
}
#[tokio::test]
async fn fetch_runtime_activity_reads_runtime_activity_endpoint() {
let captured = Arc::new(Mutex::new(Vec::new()));
let origin = spawn_mock_os(captured.clone()).await;
let fetch = fetch_runtime_activity(&origin, "tok-service", "api")
.await
.unwrap();
assert_eq!(fetch.rows.len(), 1);
assert_eq!(fetch.rows[0].id, "svc 1?");
assert_eq!(fetch.rows[0].name, "api");
assert_eq!(fetch.rows[0].status, "running");
assert!(
!fetch.rows.iter().any(|row| row.name == "unrelated-worker"),
"fetch should keep runtime activity asset-scoped even if OS ignores search"
);
let lines = captured_lines(&captured);
assert!(lines
.iter()
.any(|line| line.contains("/api/v1/runtime/services?limit=100&search=api")));
}
#[tokio::test]
async fn fetch_runtime_activity_keeps_category_local_and_filters_same_named_assets() {
let captured = Arc::new(Mutex::new(Vec::new()));
let origin = spawn_mock_os(captured.clone()).await;
let fetch = fetch_runtime_activity(&origin, "tok-service", "category:mcp mcp-api")
.await
.unwrap();
assert_eq!(fetch.rows.len(), 1);
assert_eq!(fetch.rows[0].id, "svc-mcp");
assert_eq!(fetch.rows[0].name, "mcp-api");
assert_eq!(fetch.rows[0].asset_category, "mcp");
assert!(
!fetch.rows.iter().any(|row| row.id == "svc-workflow"),
"same-name workflow activity must not leak into /mcp activity"
);
let lines = captured_lines(&captured);
assert!(lines.iter().any(|line| {
line.contains("/api/v1/runtime/services?")
&& line.contains("search=mcp-api")
&& !line.contains("category=mcp")
}));
}
}