feat: created a new builtin function for agents who can spawn other agents to list available agents via agent__list_available

This commit is contained in:
2026-07-22 12:50:41 -06:00
parent dfacf31f6a
commit 420db4bb88
6 changed files with 199 additions and 130 deletions
+13
View File
@@ -1016,6 +1016,19 @@ pub fn list_agents() -> Vec<String> {
agents agents
} }
pub fn list_agents_with_descriptions() -> Vec<(String, String)> {
list_agents()
.into_iter()
.map(|name| {
let description = AgentConfig::load(&paths::agent_config_file(&name))
.ok()
.map(|c| c.description)
.unwrap_or_default();
(name, description)
})
.collect()
}
pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> { pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {
let config_path = paths::agent_config_file(agent_name); let config_path = paths::agent_config_file(agent_name);
if !config_path.exists() { if !config_path.exists() {
+1
View File
@@ -22,6 +22,7 @@ mod update;
pub use self::agent::{ pub use self::agent::{
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents, Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
list_agents_with_descriptions,
}; };
#[allow(unused_imports)] #[allow(unused_imports)]
pub use self::app_config::AppConfig; pub use self::app_config::AppConfig;
+2 -1
View File
@@ -84,7 +84,8 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. | | `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. |
| `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. | | `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. |
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. | | `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
| `agent__list` | List all spawned agents and their status. | | `agent__list_available` | List all agent types you can spawn (name + description). Use this to discover specialists before calling `agent__spawn`. |
| `agent__list_running` | List all subagents YOU have spawned, with their status. |
| `agent__cancel` | Cancel a running agent by ID. | | `agent__cancel` | Cancel a running agent by ID. |
| `agent__task_create` | Create a task in the dependency-aware task queue. | | `agent__task_create` | Create a task in the dependency-aware task queue. |
| `agent__task_list` | List all tasks and their status/dependencies. | | `agent__task_list` | List all tasks and their status/dependencies. |
+2 -1
View File
@@ -1793,7 +1793,8 @@ mod tests {
assert!(f.contains("agent__spawn")); assert!(f.contains("agent__spawn"));
assert!(f.contains("agent__check")); assert!(f.contains("agent__check"));
assert!(f.contains("agent__collect")); assert!(f.contains("agent__collect"));
assert!(f.contains("agent__list")); assert!(f.contains("agent__list_running"));
assert!(f.contains("agent__list_available"));
assert!(f.contains("agent__cancel")); assert!(f.contains("agent__cancel"));
assert!(f.contains("agent__reply_escalation")); assert!(f.contains("agent__reply_escalation"));
} }
+71 -14
View File
@@ -1,6 +1,8 @@
use super::{FunctionDeclaration, JsonSchema}; use super::{FunctionDeclaration, JsonSchema};
use crate::client::{Model, ModelType, call_chat_completions}; use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{Agent, AppState, Input, RequestContext, Role, RoleLike}; use crate::config::{
Agent, AppState, Input, RequestContext, Role, RoleLike, list_agents_with_descriptions,
};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox}; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor}; use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal}; use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
@@ -193,8 +195,18 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
agent: false, agent: false,
}, },
FunctionDeclaration { FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list"), name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_running"),
description: "List all currently running subagents and their status.".to_string(), description: "List all subagents YOU have spawned that are still tracked by the supervisor, with their status. Use this to see which of your background agents are still active. To discover which agent types you can spawn in the first place, use `agent__list_available` instead.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::new()),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_available"),
description: "List all agent types installed and available to spawn (name + description). Use this to discover what specialists exist before calling `agent__spawn` — especially when you're unsure which agent to delegate to. This is the discovery counterpart to `agent__list_running` (which reports agents you have already spawned).".to_string(),
parameters: JsonSchema { parameters: JsonSchema {
type_value: Some("object".to_string()), type_value: Some("object".to_string()),
properties: Some(IndexMap::new()), properties: Some(IndexMap::new()),
@@ -384,7 +396,8 @@ pub async fn handle_supervisor_tool(
"spawn" => handle_spawn(ctx, args).await, "spawn" => handle_spawn(ctx, args).await,
"check" => handle_check(ctx, args).await, "check" => handle_check(ctx, args).await,
"collect" => handle_collect(ctx, args).await, "collect" => handle_collect(ctx, args).await,
"list" => handle_list(ctx), "list_running" => handle_list_running(ctx),
"list_available" => handle_list_available(),
"cancel" => handle_cancel(ctx, args).await, "cancel" => handle_cancel(ctx, args).await,
"send_message" => handle_send_message(ctx, args), "send_message" => handle_send_message(ctx, args),
"check_inbox" => handle_check_inbox(ctx), "check_inbox" => handle_check_inbox(ctx),
@@ -920,7 +933,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
} }
} }
fn handle_list(ctx: &mut RequestContext) -> Result<Value> { fn handle_list_running(ctx: &mut RequestContext) -> Result<Value> {
let supervisor = ctx let supervisor = ctx
.supervisor .supervisor
.as_ref() .as_ref()
@@ -948,6 +961,26 @@ fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
})) }))
} }
fn handle_list_available() -> Result<Value> {
let entries = list_agents_with_descriptions();
let count = entries.len();
let agents: Vec<Value> = entries
.into_iter()
.map(|(name, description)| {
if description.is_empty() {
json!({ "name": name })
} else {
json!({ "name": name, "description": description })
}
})
.collect();
Ok(json!({
"count": count,
"agents": agents,
}))
}
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> { async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let id = args let id = args
.get("id") .get("id")
@@ -1434,32 +1467,39 @@ mod tests {
} }
#[test] #[test]
fn handle_list_empty_supervisor() { fn handle_list_running_empty_supervisor() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
let result = handle_list(&mut ctx).unwrap(); let result = handle_list_running(&mut ctx).unwrap();
assert_eq!(result["active_count"], 0); assert_eq!(result["active_count"], 0);
assert_eq!(result["max_concurrent"], 4); assert_eq!(result["max_concurrent"], 4);
assert!(result["agents"].as_array().unwrap().is_empty()); assert!(result["agents"].as_array().unwrap().is_empty());
} }
#[test] #[test]
fn handle_list_with_agents() { fn handle_list_running_with_agents() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
register_fake_agent(&mut ctx, "a1", "explore"); register_fake_agent(&mut ctx, "a1", "explore");
register_fake_agent(&mut ctx, "a2", "coder"); register_fake_agent(&mut ctx, "a2", "coder");
let result = handle_list(&mut ctx).unwrap(); let result = handle_list_running(&mut ctx).unwrap();
assert_eq!(result["active_count"], 2); assert_eq!(result["active_count"], 2);
let agents = result["agents"].as_array().unwrap(); let agents = result["agents"].as_array().unwrap();
assert_eq!(agents.len(), 2); assert_eq!(agents.len(), 2);
} }
#[test] #[test]
fn handle_list_no_supervisor_errors() { fn handle_list_running_no_supervisor_errors() {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
let result = handle_list(&mut ctx); let result = handle_list_running(&mut ctx);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn handle_list_available_returns_shape() {
let result = handle_list_available().unwrap();
assert!(result["count"].is_number());
assert!(result["agents"].is_array());
}
#[test] #[test]
fn handle_check_unknown_agent() { fn handle_check_unknown_agent() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
@@ -1753,13 +1793,30 @@ mod tests {
} }
#[test] #[test]
fn dispatch_routes_list() { fn dispatch_routes_list_running() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
let result = let result = run_async(handle_supervisor_tool(
run_async(handle_supervisor_tool(&mut ctx, "agent__list", &json!({}))).unwrap(); &mut ctx,
"agent__list_running",
&json!({}),
))
.unwrap();
assert!(result["active_count"].is_number()); assert!(result["active_count"].is_number());
} }
#[test]
fn dispatch_routes_list_available() {
let mut ctx = ctx_with_supervisor(4, 3);
let result = run_async(handle_supervisor_tool(
&mut ctx,
"agent__list_available",
&json!({}),
))
.unwrap();
assert!(result["count"].is_number());
assert!(result["agents"].is_array());
}
#[test] #[test]
fn dispatch_routes_task_list() { fn dispatch_routes_task_list() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
+110 -114
View File
@@ -22,40 +22,29 @@ static LANG_MAPS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
m m
}); });
static HEADING_RE: LazyLock<Regex> = static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*(#{1,6}) +.+").unwrap());
LazyLock::new(|| Regex::new(r"^\s*(#{1,6}) +.+").unwrap());
static BLOCKQUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap()); static BLOCKQUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
static TASK_ITEM_RE: LazyLock<Regex> = static TASK_ITEM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*[-*+] \[([ xX])\] +.+").unwrap()); LazyLock::new(|| Regex::new(r"^\s*[-*+] \[([ xX])\] +.+").unwrap());
static BULLET_ITEM_RE: LazyLock<Regex> = static BULLET_ITEM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+] +.+").unwrap());
LazyLock::new(|| Regex::new(r"^\s*[-*+] +.+").unwrap()); static NUMBERED_ITEM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\d+\. +.+").unwrap());
static NUMBERED_ITEM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*\d+\. +.+").unwrap());
static HRULE_RE: LazyLock<Regex> = static HRULE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$").unwrap()); LazyLock::new(|| Regex::new(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$").unwrap());
static TABLE_SEPARATOR_RE: LazyLock<Regex> = static TABLE_SEPARATOR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*\|(\s*:?-+:?\s*\|)+\s*$").unwrap()); LazyLock::new(|| Regex::new(r"^\s*\|(\s*:?-+:?\s*\|)+\s*$").unwrap());
static TABLE_ROW_RE: LazyLock<Regex> = static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
static INLINE_CODE_RE: LazyLock<Regex> = static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
static IMAGE_RE: LazyLock<Regex> = static IMAGE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap()); LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
static LINK_RE: LazyLock<Regex> = static LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); static BOLD_AST_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*\n]+)\*\*").unwrap());
static BOLD_AST_RE: LazyLock<Regex> = static BOLD_US_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_\n]+)__").unwrap());
LazyLock::new(|| Regex::new(r"\*\*([^*\n]+)\*\*").unwrap()); static ITALIC_AST_RE: LazyLock<Regex> =
static BOLD_US_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?<![*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)").unwrap());
LazyLock::new(|| Regex::new(r"__([^_\n]+)__").unwrap()); static ITALIC_US_RE: LazyLock<Regex> =
static ITALIC_AST_RE: LazyLock<Regex> = LazyLock::new(|| { LazyLock::new(|| Regex::new(r"(?<![_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!_)").unwrap());
Regex::new(r"(?<![*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)").unwrap() static STRIKETHROUGH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~([^~\n]+)~~").unwrap());
});
static ITALIC_US_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?<![_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!_)").unwrap()
});
static STRIKETHROUGH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~([^~\n]+)~~").unwrap());
static CODE_PLACEHOLDER_RE: LazyLock<Regex> = static CODE_PLACEHOLDER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\x00C(\d+)\x00").unwrap()); LazyLock::new(|| Regex::new(r"\x00C(\d+)\x00").unwrap());
@@ -107,10 +96,7 @@ fn detect_line_kind(line: &str) -> LineKind {
} }
fn parse_table_row(line: &str) -> Vec<String> { fn parse_table_row(line: &str) -> Vec<String> {
let inner = line let inner = line.trim().trim_start_matches('|').trim_end_matches('|');
.trim()
.trim_start_matches('|')
.trim_end_matches('|');
inner.split('|').map(|c| c.trim().to_string()).collect() inner.split('|').map(|c| c.trim().to_string()).collect()
} }
@@ -230,8 +216,7 @@ fn render_blockquote(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16
let prefix_width = 2; let prefix_width = 2;
let leading_width = indent.chars().count(); let leading_width = indent.chars().count();
let effective_width = (wrap_width as usize) let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
.saturating_sub(leading_width + prefix_width);
let wrapped = wrap_plain_content(content, effective_width); let wrapped = wrap_plain_content(content, effective_width);
if wrapped.is_empty() { if wrapped.is_empty() {
return format!("{indent}{prefix}"); return format!("{indent}{prefix}");
@@ -258,8 +243,7 @@ fn render_bullet(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16>) -
let prefix_width = 2; let prefix_width = 2;
let leading_width = indent.chars().count(); let leading_width = indent.chars().count();
let effective_width = (wrap_width as usize) let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
.saturating_sub(leading_width + prefix_width);
let wrapped = wrap_plain_content(content, effective_width); let wrapped = wrap_plain_content(content, effective_width);
if wrapped.is_empty() { if wrapped.is_empty() {
return format!("{indent}{bullet} "); return format!("{indent}{bullet} ");
@@ -299,8 +283,7 @@ fn render_numbered(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16>)
let prefix_width = number.chars().count() + 2; let prefix_width = number.chars().count() + 2;
let leading_width = indent.chars().count(); let leading_width = indent.chars().count();
let effective_width = (wrap_width as usize) let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
.saturating_sub(leading_width + prefix_width);
let wrapped = wrap_plain_content(after, effective_width); let wrapped = wrap_plain_content(after, effective_width);
if wrapped.is_empty() { if wrapped.is_empty() {
return format!("{indent}{number}{styled_dot} "); return format!("{indent}{number}{styled_dot} ");
@@ -343,8 +326,7 @@ fn render_task(
let prefix_width = 4; let prefix_width = 4;
let leading_width = indent.chars().count(); let leading_width = indent.chars().count();
let effective_width = (wrap_width as usize) let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
.saturating_sub(leading_width + prefix_width);
let wrapped = wrap_plain_content(after_brackets, effective_width); let wrapped = wrap_plain_content(after_brackets, effective_width);
if wrapped.is_empty() { if wrapped.is_empty() {
return format!("{indent}{styled_brackets} "); return format!("{indent}{styled_brackets} ");
@@ -706,20 +688,15 @@ impl MarkdownRender {
} }
for row in rows { for row in rows {
let styled_row: Vec<String> = row let styled_row: Vec<String> =
.iter() row.iter().map(|c| apply_inline(c, &self.styles)).collect();
.map(|c| apply_inline(c, &self.styles))
.collect();
table.add_row(styled_row); table.add_row(styled_row);
} }
colorize_box_chars(&table.to_string(), self.styles.table_border) colorize_box_chars(&table.to_string(), self.styles.table_border)
} }
fn check_line( fn check_line(&self, line: &str) -> (LineType, LineKind, Option<SyntaxReference>, bool) {
&self,
line: &str,
) -> (LineType, LineKind, Option<SyntaxReference>, bool) {
let mut line_type = self.prev_line_type; let mut line_type = self.prev_line_type;
let mut code_syntax = self.code_syntax.clone(); let mut code_syntax = self.code_syntax.clone();
let mut is_code = false; let mut is_code = false;
@@ -1034,8 +1011,7 @@ impl MarkdownStyles {
&["string.other.link", "constant"], &["string.other.link", "constant"],
truecolor, truecolor,
); );
let strikethrough = let strikethrough = resolve_scope_style(theme, "markup.deleted", &["invalid"], truecolor);
resolve_scope_style(theme, "markup.deleted", &["invalid"], truecolor);
let hrule = resolve_scope_style(theme, "comment", &["punctuation"], truecolor); let hrule = resolve_scope_style(theme, "comment", &["punctuation"], truecolor);
let table_border = resolve_scope_style( let table_border = resolve_scope_style(
theme, theme,
@@ -1201,26 +1177,18 @@ std::error::Error>> {
fn minimal_root_scope_theme() -> Theme { fn minimal_root_scope_theme() -> Theme {
let mut theme = Theme::default(); let mut theme = Theme::default();
theme.scopes.push(theme_item( theme
"string", .scopes
syntect_rgb(0xff, 0xdd, 0x00), .push(theme_item("string", syntect_rgb(0xff, 0xdd, 0x00), None));
None, theme
)); .scopes
theme.scopes.push(theme_item( .push(theme_item("comment", syntect_rgb(0x88, 0x88, 0x88), None));
"comment", theme
syntect_rgb(0x88, 0x88, 0x88), .scopes
None, .push(theme_item("keyword", syntect_rgb(0xaa, 0x00, 0xff), None));
)); theme
theme.scopes.push(theme_item( .scopes
"keyword", .push(theme_item("constant", syntect_rgb(0x00, 0xcc, 0xff), None));
syntect_rgb(0xaa, 0x00, 0xff),
None,
));
theme.scopes.push(theme_item(
"constant",
syntect_rgb(0x00, 0xcc, 0xff),
None,
));
theme.scopes.push(theme_item( theme.scopes.push(theme_item(
"entity.name.tag", "entity.name.tag",
syntect_rgb(0x11, 0x22, 0x33), syntect_rgb(0x11, 0x22, 0x33),
@@ -1236,11 +1204,9 @@ std::error::Error>> {
syntect_rgb(0xde, 0xad, 0xbe), syntect_rgb(0xde, 0xad, 0xbe),
Some(FontStyle::BOLD), Some(FontStyle::BOLD),
)); ));
theme.scopes.push(theme_item( theme
"invalid", .scopes
syntect_rgb(0xff, 0x00, 0x00), .push(theme_item("invalid", syntect_rgb(0xff, 0x00, 0x00), None));
None,
));
theme.scopes.push(theme_item( theme.scopes.push(theme_item(
"punctuation", "punctuation",
syntect_rgb(0x77, 0x77, 0x77), syntect_rgb(0x77, 0x77, 0x77),
@@ -1269,13 +1235,10 @@ std::error::Error>> {
#[test] #[test]
fn resolve_scope_style_falls_back_when_primary_missing() { fn resolve_scope_style_falls_back_when_primary_missing() {
let mut theme = Theme::default(); let mut theme = Theme::default();
theme.scopes.push(theme_item( theme
"comment", .scopes
syntect_rgb(0x33, 0x44, 0x55), .push(theme_item("comment", syntect_rgb(0x33, 0x44, 0x55), None));
None, let resolved = resolve_scope_style(&theme, "markup.italic", &["nope", "comment"], true);
));
let resolved =
resolve_scope_style(&theme, "markup.italic", &["nope", "comment"], true);
assert_eq!(resolved.fg, Some(rgb(0x33, 0x44, 0x55))); assert_eq!(resolved.fg, Some(rgb(0x33, 0x44, 0x55)));
} }
@@ -1417,10 +1380,7 @@ std::error::Error>> {
#[test] #[test]
fn detect_line_kind_non_table_pipe_line_stays_paragraph() { fn detect_line_kind_non_table_pipe_line_stays_paragraph() {
assert_eq!( assert_eq!(detect_line_kind("use `a | b` for or"), LineKind::Paragraph,);
detect_line_kind("use `a | b` for or"),
LineKind::Paragraph,
);
assert_eq!(detect_line_kind("| trailing"), LineKind::Paragraph); assert_eq!(detect_line_kind("| trailing"), LineKind::Paragraph);
assert_eq!(detect_line_kind("no closer |"), LineKind::Paragraph); assert_eq!(detect_line_kind("no closer |"), LineKind::Paragraph);
} }
@@ -1492,7 +1452,10 @@ std::error::Error>> {
fn colorize_box_chars_wraps_border_runs() { fn colorize_box_chars_wraps_border_runs() {
let input = "┌─┐\nabc\n└─┘"; let input = "┌─┐\nabc\n└─┘";
let output = colorize_box_chars(input, Color::Red); let output = colorize_box_chars(input, Color::Red);
assert!(output.starts_with("\x1b["), "border run starts with SGR: {output:?}"); assert!(
output.starts_with("\x1b["),
"border run starts with SGR: {output:?}"
);
assert!(output.contains("abc"), "non-border content preserved"); assert!(output.contains("abc"), "non-border content preserved");
assert!(output.contains("")); assert!(output.contains(""));
assert!(output.contains("")); assert!(output.contains(""));
@@ -1520,12 +1483,13 @@ std::error::Error>> {
]; ];
let output = render.render_table(header, alignments, rows); let output = render.render_table(header, alignments, rows);
for expected in ["A", "B", "C", "1", "2", "3", "4", "5", "6"] { for expected in ["A", "B", "C", "1", "2", "3", "4", "5", "6"] {
assert!(output.contains(expected), "cell {expected:?} in output: {output:?}"); assert!(
output.contains(expected),
"cell {expected:?} in output: {output:?}"
);
} }
assert!( assert!(
output output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
.chars()
.any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
"table has box-drawing chars: {output:?}", "table has box-drawing chars: {output:?}",
); );
} }
@@ -1610,9 +1574,7 @@ std::error::Error>> {
assert!(output.contains(cell), "cell {cell:?} rendered: {output:?}"); assert!(output.contains(cell), "cell {cell:?} rendered: {output:?}");
} }
assert!( assert!(
output output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
.chars()
.any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
"output has box-drawing chars: {output:?}", "output has box-drawing chars: {output:?}",
); );
assert!(output.contains("after"), "trailing paragraph preserved"); assert!(output.contains("after"), "trailing paragraph preserved");
@@ -1684,8 +1646,7 @@ std::error::Error>> {
fn multiple_tables_in_one_input() { fn multiple_tables_in_one_input() {
let options = RenderOptions::default(); let options = RenderOptions::default();
let mut render = MarkdownRender::init(options).unwrap(); let mut render = MarkdownRender::init(options).unwrap();
let text = let text = "| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |\n";
"| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |\n";
let output = render.render(text); let output = render.render(text);
let tail = render.finalize(); let tail = render.finalize();
let combined = format!("{output}{tail}"); let combined = format!("{output}{tail}");
@@ -1718,10 +1679,7 @@ std::error::Error>> {
output.contains("| A | B |"), output.contains("| A | B |"),
"raw pipes preserved: {output:?}", "raw pipes preserved: {output:?}",
); );
assert!( assert!(render.table_state.is_none(), "no state entered in raw mode",);
render.table_state.is_none(),
"no state entered in raw mode",
);
} }
#[test] #[test]
@@ -1748,7 +1706,10 @@ std::error::Error>> {
assert!(output.contains('\n'), "wrapped output: {output:?}"); assert!(output.contains('\n'), "wrapped output: {output:?}");
let lines: Vec<&str> = output.split('\n').collect(); let lines: Vec<&str> = output.split('\n').collect();
for cont in &lines[1..] { for cont in &lines[1..] {
assert!(cont.starts_with(" "), "4-space indent for `42. `: {cont:?}"); assert!(
cont.starts_with(" "),
"4-space indent for `42. `: {cont:?}"
);
} }
} }
@@ -1775,7 +1736,10 @@ std::error::Error>> {
assert!(output.contains('\n'), "wrapped output: {output:?}"); assert!(output.contains('\n'), "wrapped output: {output:?}");
let lines: Vec<&str> = output.split('\n').collect(); let lines: Vec<&str> = output.split('\n').collect();
for cont in &lines[1..] { for cont in &lines[1..] {
assert!(cont.starts_with(" "), "4-space indent for `[ ] `: {cont:?}"); assert!(
cont.starts_with(" "),
"4-space indent for `[ ] `: {cont:?}"
);
} }
} }
@@ -1822,7 +1786,10 @@ std::error::Error>> {
let line = "- **bold** text with `code` that will wrap onto several lines"; let line = "- **bold** text with `code` that will wrap onto several lines";
let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22)); let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22));
assert!(output.contains('\n'), "wrapped output: {output:?}"); assert!(output.contains('\n'), "wrapped output: {output:?}");
assert!(!output.contains("**bold**"), "asterisks stripped: {output:?}"); assert!(
!output.contains("**bold**"),
"asterisks stripped: {output:?}"
);
assert!(!output.contains("`code`"), "backticks stripped: {output:?}"); assert!(!output.contains("`code`"), "backticks stripped: {output:?}");
assert!(output.contains("bold")); assert!(output.contains("bold"));
assert!(output.contains("code")); assert!(output.contains("code"));
@@ -1849,7 +1816,10 @@ std::error::Error>> {
assert!(output.contains("Some paragraph.")); assert!(output.contains("Some paragraph."));
assert!(output.contains(""), "bullet glyph rendered"); assert!(output.contains(""), "bullet glyph rendered");
assert!(output.contains(""), "blockquote pipe rendered"); assert!(output.contains(""), "blockquote pipe rendered");
assert!(output.contains("A") && output.contains("1"), "table cells rendered"); assert!(
output.contains("A") && output.contains("1"),
"table cells rendered"
);
assert!( assert!(
output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')), output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
"table borders rendered", "table borders rendered",
@@ -2023,7 +1993,10 @@ std::error::Error>> {
assert!(result.contains("foo")); assert!(result.contains("foo"));
assert!(result.contains("bar")); assert!(result.contains("bar"));
assert!(result.contains("baz")); assert!(result.contains("baz"));
assert!(result.contains("\x1b[1m"), "bold applied around code: {result:?}"); assert!(
result.contains("\x1b[1m"),
"bold applied around code: {result:?}"
);
} }
#[test] #[test]
@@ -2069,7 +2042,10 @@ std::error::Error>> {
fn images_emit_labeled_link() { fn images_emit_labeled_link() {
let styles = test_styles(); let styles = test_styles();
let result = apply_inline("![alt text](https://img.example/x.png)", &styles); let result = apply_inline("![alt text](https://img.example/x.png)", &styles);
assert!(!result.contains("!["), "raw image marker removed: {result:?}"); assert!(
!result.contains("!["),
"raw image marker removed: {result:?}"
);
assert!(result.contains("Image: alt text")); assert!(result.contains("Image: alt text"));
assert!(result.contains("https://img.example/x.png")); assert!(result.contains("https://img.example/x.png"));
assert!(result.contains("\x1b]8;;https://img.example/x.png\x1b\\")); assert!(result.contains("\x1b]8;;https://img.example/x.png\x1b\\"));
@@ -2079,7 +2055,10 @@ std::error::Error>> {
fn image_processed_before_link() { fn image_processed_before_link() {
let styles = test_styles(); let styles = test_styles();
let result = apply_inline("![alt](https://example.com)", &styles); let result = apply_inline("![alt](https://example.com)", &styles);
assert!(!result.starts_with('!'), "no stray ! left behind: {result:?}"); assert!(
!result.starts_with('!'),
"no stray ! left behind: {result:?}"
);
assert!(result.contains("Image:")); assert!(result.contains("Image:"));
} }
@@ -2105,7 +2084,10 @@ std::error::Error>> {
let hashes = "#".repeat(level as usize); let hashes = "#".repeat(level as usize);
let line = format!("{hashes} Title"); let line = format!("{hashes} Title");
let result = render_markdown_line(&line, LineKind::Heading(level), &styles, None); let result = render_markdown_line(&line, LineKind::Heading(level), &styles, None);
assert!(result.contains(&hashes), "H{level} keeps hashes: {result:?}"); assert!(
result.contains(&hashes),
"H{level} keeps hashes: {result:?}"
);
assert!(result.contains("Title")); assert!(result.contains("Title"));
assert!(result.contains("\x1b[1m"), "H{level} bold: {result:?}"); assert!(result.contains("\x1b[1m"), "H{level} bold: {result:?}");
} }
@@ -2199,18 +2181,19 @@ std::error::Error>> {
#[test] #[test]
fn render_paragraph_delegates_to_inline() { fn render_paragraph_delegates_to_inline() {
let styles = test_styles(); let styles = test_styles();
let result = let result = render_markdown_line("hello **world**", LineKind::Paragraph, &styles, None);
render_markdown_line("hello **world**", LineKind::Paragraph, &styles, None);
assert!(!result.contains("**"), "bold markers stripped: {result:?}"); assert!(!result.contains("**"), "bold markers stripped: {result:?}");
assert!(result.contains("world")); assert!(result.contains("world"));
assert!(result.contains("\x1b[1m"), "bold applied via inline: {result:?}"); assert!(
result.contains("\x1b[1m"),
"bold applied via inline: {result:?}"
);
} }
#[test] #[test]
fn render_bullet_runs_inline_on_content() { fn render_bullet_runs_inline_on_content() {
let styles = test_styles(); let styles = test_styles();
let result = let result = render_markdown_line("- see `code`", LineKind::BulletItem, &styles, None);
render_markdown_line("- see `code`", LineKind::BulletItem, &styles, None);
assert!(result.contains("")); assert!(result.contains(""));
assert!(!result.contains('`'), "backticks stripped: {result:?}"); assert!(!result.contains('`'), "backticks stripped: {result:?}");
assert!(result.contains("see ")); assert!(result.contains("see "));
@@ -2220,8 +2203,12 @@ std::error::Error>> {
#[test] #[test]
fn render_blockquote_runs_inline_on_content() { fn render_blockquote_runs_inline_on_content() {
let styles = test_styles(); let styles = test_styles();
let result = let result = render_markdown_line(
render_markdown_line("> visit [here](https://example.com)", LineKind::Blockquote, &styles, None); "> visit [here](https://example.com)",
LineKind::Blockquote,
&styles,
None,
);
assert!(result.contains("")); assert!(result.contains(""));
assert!(result.contains("here")); assert!(result.contains("here"));
assert!(result.contains("https://example.com")); assert!(result.contains("https://example.com"));
@@ -2244,7 +2231,10 @@ std::error::Error>> {
let options = RenderOptions::default(); let options = RenderOptions::default();
let render = MarkdownRender::init(options).unwrap(); let render = MarkdownRender::init(options).unwrap();
let partial = render.render_line("**bo"); let partial = render.render_line("**bo");
assert!(partial.contains("**bo"), "unclosed bold preserved: {partial:?}"); assert!(
partial.contains("**bo"),
"unclosed bold preserved: {partial:?}"
);
} }
#[test] #[test]
@@ -2252,7 +2242,10 @@ std::error::Error>> {
let options = RenderOptions::default(); let options = RenderOptions::default();
let render = MarkdownRender::init(options).unwrap(); let render = MarkdownRender::init(options).unwrap();
let partial = render.render_line("[label](https://exa"); let partial = render.render_line("[label](https://exa");
assert!(partial.contains("[label]"), "unclosed link preserved: {partial:?}"); assert!(
partial.contains("[label]"),
"unclosed link preserved: {partial:?}"
);
} }
#[test] #[test]
@@ -2291,7 +2284,10 @@ std::error::Error>> {
let mut render = MarkdownRender::init(options).unwrap(); let mut render = MarkdownRender::init(options).unwrap();
let text = "```rust\nfn main() {}\n```\n"; let text = "```rust\nfn main() {}\n```\n";
let output = render.render(text); let output = render.render(text);
assert!(output.contains("fn main()"), "code content preserved: {output:?}"); assert!(
output.contains("fn main()"),
"code content preserved: {output:?}"
);
} }
#[test] #[test]