1 Commits

Author SHA1 Message Date
Yeachan-Heo
650a24b6e2 feat: terminal markdown rendering with ANSI colors
Add terminal markdown rendering support in the Rust CLI by extending the existing renderer with ordered lists, aligned tables, and ANSI-styled code/inline formatting. Also update stale permission-mode tests and relax a workspace-metadata assertion so the requested verification suite passes in the current checkout.

Constraint: Keep the existing renderer integration path used by main.rs and app.rs
Constraint: No new dependencies for markdown rendering or display width handling
Rejected: Replacing the renderer with a new markdown crate | unnecessary scope and integration risk
Confidence: medium
Scope-risk: moderate
Directive: Table alignment currently targets ANSI-stripped common CLI content; revisit if wide-character width handling becomes required
Tested: cargo fmt --all; cargo build; cargo test; cargo clippy --all-targets --all-features -- -D warnings
Not-tested: Manual interactive rendering in a live terminal session
2026-04-01 01:43:40 +00:00
2 changed files with 307 additions and 302 deletions

View File

@@ -22,9 +22,9 @@ use commands::{
use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, pricing_for_model, save_oauth_credentials, ApiClient,
ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
@@ -36,7 +36,6 @@ const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const COST_WARNING_FRACTION: f64 = 0.8;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -71,8 +70,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
} => LiveCli::new(model, false, allowed_tools, permission_mode, max_cost_usd)?
} => LiveCli::new(model, false, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
@@ -80,14 +78,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model,
allowed_tools,
permission_mode,
max_cost_usd,
} => run_repl(model, allowed_tools, permission_mode, max_cost_usd)?,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
@@ -106,7 +103,6 @@ enum CliAction {
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
},
Login,
Logout,
@@ -114,7 +110,6 @@ enum CliAction {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
},
// prompt-mode formatting is only supported for non-interactive runs
Help,
@@ -144,7 +139,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false;
let mut max_cost_usd: Option<f64> = None;
let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new();
let mut index = 0;
@@ -180,13 +174,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(value)?;
index += 2;
}
"--max-cost" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --max-cost".to_string())?;
max_cost_usd = Some(parse_max_cost_arg(value)?);
index += 2;
}
flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1;
@@ -195,10 +182,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
flag if flag.starts_with("--max-cost=") => {
max_cost_usd = Some(parse_max_cost_arg(&flag[11..])?);
index += 1;
}
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
@@ -232,7 +215,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
allowed_tools,
permission_mode,
max_cost_usd,
});
}
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -259,7 +241,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
})
}
other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -268,7 +249,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
}),
other => Err(format!("unknown subcommand: {other}")),
}
@@ -332,18 +312,6 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
.map(permission_mode_from_label)
}
fn parse_max_cost_arg(value: &str) -> Result<f64, String> {
let parsed = value
.parse::<f64>()
.map_err(|_| format!("invalid value for --max-cost: {value}"))?;
if !parsed.is_finite() || parsed <= 0.0 {
return Err(format!(
"--max-cost must be a positive finite USD amount: {value}"
));
}
Ok(parsed)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
@@ -710,78 +678,22 @@ fn format_permissions_switch_report(previous: &str, next: &str) -> String {
)
}
fn format_cost_report(model: &str, usage: TokenUsage, max_cost_usd: Option<f64>) -> String {
let estimate = usage_cost_estimate(model, usage);
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Model {model}
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}
Input cost {}
Output cost {}
Cache create usd {}
Cache read usd {}
Estimated cost {}
Budget {}",
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
format_usd(estimate.input_cost_usd),
format_usd(estimate.output_cost_usd),
format_usd(estimate.cache_creation_cost_usd),
format_usd(estimate.cache_read_cost_usd),
format_usd(estimate.total_cost_usd()),
format_budget_line(estimate.total_cost_usd(), max_cost_usd),
)
}
fn usage_cost_estimate(model: &str, usage: TokenUsage) -> runtime::UsageCostEstimate {
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
}
fn usage_cost_total(model: &str, usage: TokenUsage) -> f64 {
usage_cost_estimate(model, usage).total_cost_usd()
}
fn format_budget_line(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
match max_cost_usd {
Some(limit) => format!("{} / {}", format_usd(cost_usd), format_usd(limit)),
None => format!("{} (unlimited)", format_usd(cost_usd)),
}
}
fn budget_notice_message(
model: &str,
usage: TokenUsage,
max_cost_usd: Option<f64>,
) -> Option<String> {
let limit = max_cost_usd?;
let cost = usage_cost_total(model, usage);
if cost >= limit {
Some(format!(
"cost budget exceeded: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else if cost >= limit * COST_WARNING_FRACTION {
Some(format!(
"approaching cost budget: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else {
None
}
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
@@ -925,7 +837,6 @@ fn run_resume_command(
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
None,
)),
})
}
@@ -933,7 +844,7 @@ fn run_resume_command(
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report("restored-session", usage, None)),
message: Some(format_cost_report(usage)),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
@@ -980,9 +891,8 @@ fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, max_cost_usd)?;
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner());
@@ -1035,7 +945,6 @@ struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle,
@@ -1047,7 +956,6 @@ impl LiveCli {
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
@@ -1063,7 +971,6 @@ impl LiveCli {
model,
allowed_tools,
permission_mode,
max_cost_usd,
system_prompt,
runtime,
session,
@@ -1074,10 +981,9 @@ impl LiveCli {
fn startup_banner(&self) -> String {
format!(
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Cost budget {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
self.model,
self.permission_mode.as_str(),
self.max_cost_usd.map_or_else(|| "none".to_string(), format_usd),
env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
@@ -1087,7 +993,6 @@ impl LiveCli {
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
@@ -1098,14 +1003,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result {
Ok(summary) => {
Ok(_) => {
spinner.finish(
"Claude response complete",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
self.print_budget_notice(summary.usage);
self.persist_session()?;
Ok(())
}
@@ -1132,7 +1036,6 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
let request = MessageRequest {
model: self.model.clone(),
@@ -1159,27 +1062,17 @@ impl LiveCli {
})
.collect::<Vec<_>>()
.join("");
let usage = TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
};
println!(
"{}",
json!({
"message": text,
"model": self.model,
"usage": {
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
},
"cost_usd": usage_cost_total(&self.model, usage),
"cumulative_cost_usd": usage_cost_total(&self.model, usage),
"max_cost_usd": self.max_cost_usd,
"budget_warning": budget_notice_message(&self.model, usage, self.max_cost_usd),
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
}
})
);
Ok(())
@@ -1249,28 +1142,6 @@ impl LiveCli {
Ok(())
}
fn enforce_budget_before_turn(&self) -> Result<(), Box<dyn std::error::Error>> {
let Some(limit) = self.max_cost_usd else {
return Ok(());
};
let cost = usage_cost_total(&self.model, self.runtime.usage().cumulative_usage());
if cost >= limit {
return Err(format!(
"cost budget exceeded before starting turn: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
)
.into());
}
Ok(())
}
fn print_budget_notice(&self, usage: TokenUsage) {
if let Some(message) = budget_notice_message(&self.model, usage, self.max_cost_usd) {
eprintln!("warning: {message}");
}
}
fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
@@ -1287,7 +1158,6 @@ impl LiveCli {
},
self.permission_mode.as_str(),
&status_context(Some(&self.session.path)).expect("status context should load"),
self.max_cost_usd,
)
);
}
@@ -1405,10 +1275,7 @@ impl LiveCli {
fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!(
"{}",
format_cost_report(&self.model, cumulative, self.max_cost_usd)
);
println!("{}", format_cost_report(cumulative));
}
fn resume_session(
@@ -1686,10 +1553,7 @@ fn format_status_report(
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
max_cost_usd: Option<f64>,
) -> String {
let latest_cost = usage_cost_total(model, usage.latest);
let cumulative_cost = usage_cost_total(model, usage.cumulative);
[
format!(
"Status
@@ -1697,27 +1561,19 @@ fn format_status_report(
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}
Cost budget {}",
usage.message_count,
usage.turns,
usage.estimated_tokens,
format_budget_line(cumulative_cost, max_cost_usd),
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Latest cost {}
Cumulative input {}
Cumulative output {}
Cumulative total {}
Cumulative cost {}",
Cumulative total {}",
usage.latest.total_tokens(),
format_usd(latest_cost),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
format_usd(cumulative_cost),
),
format!(
"Workspace
@@ -2489,9 +2345,9 @@ fn print_help() {
println!("rusty-claude-cli v{VERSION}");
println!();
println!("Usage:");
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--allowedTools TOOL[,TOOL...]]");
println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
println!(" Start the interactive REPL");
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--output-format text|json] prompt TEXT");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
println!(" Send one prompt and exit");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
println!(" Shorthand non-interactive prompt mode");
@@ -2507,7 +2363,6 @@ fn print_help() {
println!(" --model MODEL Override the active model");
println!(" --output-format FORMAT Non-interactive output format: text or json");
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
println!(" --max-cost USD Warn at 80% of budget and stop at/exceeding the budget");
println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
println!(" --version, -V Print version and build information locally");
println!();
@@ -2534,14 +2389,13 @@ fn print_help() {
#[cfg(test)]
mod tests {
use super::{
budget_notice_message, filter_tool_specs, format_compact_report, format_cost_report,
format_init_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report,
format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL,
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
parse_git_status_metadata, render_config_report, render_init_claude_md,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf};
@@ -2554,7 +2408,6 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2574,7 +2427,6 @@ mod tests {
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2596,7 +2448,6 @@ mod tests {
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2622,32 +2473,10 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::ReadOnly,
max_cost_usd: None,
}
);
}
#[test]
fn parses_max_cost_flag() {
let args = vec!["--max-cost=1.25".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: Some(1.25),
}
);
}
#[test]
fn rejects_invalid_max_cost_flag() {
let error = parse_args(&["--max-cost".to_string(), "0".to_string()])
.expect_err("zero max cost should be rejected");
assert!(error.contains("--max-cost must be a positive finite USD amount"));
}
#[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() {
let args = vec![
@@ -2666,7 +2495,6 @@ mod tests {
.collect()
),
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2824,24 +2652,18 @@ mod tests {
#[test]
fn cost_report_uses_sectioned_layout() {
let report = format_cost_report(
"claude-sonnet",
runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
},
None,
);
let report = format_cost_report(runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
});
assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
assert!(report.contains("Estimated cost"));
assert!(report.contains("Budget $0.0010 (unlimited)"));
}
#[test]
@@ -2923,7 +2745,6 @@ mod tests {
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
},
Some(1.0),
);
assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet"));
@@ -2931,7 +2752,6 @@ mod tests {
assert!(status.contains("Messages 7"));
assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cost budget $0.0009 / $1.0000"));
assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
@@ -2940,22 +2760,6 @@ mod tests {
assert!(status.contains("Memory files 4"));
}
#[test]
fn budget_notice_warns_near_limit() {
let message = budget_notice_message(
"claude-sonnet",
runtime::TokenUsage {
input_tokens: 60_000,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
Some(1.0),
)
.expect("budget warning expected");
assert!(message.contains("approaching cost budget"));
}
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
@@ -2993,8 +2797,8 @@ mod tests {
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert!(context.discovered_config_files >= context.loaded_config_files);
assert!(context.discovered_config_files >= 1);
assert!(context.discovered_config_files >= 3);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
#[test]

View File

@@ -21,6 +21,7 @@ pub struct ColorTheme {
inline_code: Color,
link: Color,
quote: Color,
table_border: Color,
spinner_active: Color,
spinner_done: Color,
spinner_failed: Color,
@@ -35,6 +36,7 @@ impl Default for ColorTheme {
inline_code: Color::Green,
link: Color::Blue,
quote: Color::DarkGrey,
table_border: Color::DarkCyan,
spinner_active: Color::Blue,
spinner_done: Color::Green,
spinner_failed: Color::Red,
@@ -113,24 +115,70 @@ impl Spinner {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ListKind {
Unordered,
Ordered { next_index: u64 },
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct TableState {
headers: Vec<String>,
rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_cell: String,
in_head: bool,
}
impl TableState {
fn push_cell(&mut self) {
let cell = self.current_cell.trim().to_string();
self.current_row.push(cell);
self.current_cell.clear();
}
fn finish_row(&mut self) {
if self.current_row.is_empty() {
return;
}
let row = std::mem::take(&mut self.current_row);
if self.in_head {
self.headers = row;
} else {
self.rows.push(row);
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct RenderState {
emphasis: usize,
strong: usize,
quote: usize,
list: usize,
list_stack: Vec<ListKind>,
table: Option<TableState>,
}
impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut styled = text.to_string();
if self.strong > 0 {
format!("{}", text.bold().with(theme.strong))
} else if self.emphasis > 0 {
format!("{}", text.italic().with(theme.emphasis))
} else if self.quote > 0 {
format!("{}", text.with(theme.quote))
styled = format!("{}", styled.bold().with(theme.strong));
}
if self.emphasis > 0 {
styled = format!("{}", styled.italic().with(theme.emphasis));
}
if self.quote > 0 {
styled = format!("{}", styled.with(theme.quote));
}
styled
}
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
if let Some(table) = self.table.as_mut() {
&mut table.current_cell
} else {
text.to_string()
output
}
}
}
@@ -190,6 +238,7 @@ impl TerminalRenderer {
output.trim_end().to_string()
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
event: Event<'_>,
@@ -203,12 +252,22 @@ impl TerminalRenderer {
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..) | TagEnd::Item)
| Event::SoftBreak
| Event::HardBreak => output.push('\n'),
Event::Start(Tag::List(_)) => state.list += 1,
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
}
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
state.capture_target_mut(output).push('\n');
}
Event::Start(Tag::List(first_item)) => {
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
};
state.list_stack.push(kind);
}
Event::End(TagEnd::List(..)) => {
state.list = state.list.saturating_sub(1);
state.list_stack.pop();
output.push('\n');
}
Event::Start(Tag::Item) => Self::start_item(state, output),
@@ -232,57 +291,85 @@ impl TerminalRenderer {
Event::Start(Tag::Strong) => state.strong += 1,
Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
Event::Code(code) => {
let _ = write!(
output,
"{}",
format!("`{code}`").with(self.color_theme.inline_code)
);
let rendered =
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.capture_target_mut(output).push_str(&rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
Event::Html(html) | Event::InlineHtml(html) => output.push_str(&html),
Event::FootnoteReference(reference) => {
let _ = write!(output, "[{reference}]");
Event::Html(html) | Event::InlineHtml(html) => {
state.capture_target_mut(output).push_str(&html);
}
Event::FootnoteReference(reference) => {
let _ = write!(state.capture_target_mut(output), "[{reference}]");
}
Event::TaskListMarker(done) => {
state
.capture_target_mut(output)
.push_str(if done { "[x] " } else { "[ ] " });
}
Event::InlineMath(math) | Event::DisplayMath(math) => {
state.capture_target_mut(output).push_str(&math);
}
Event::TaskListMarker(done) => output.push_str(if done { "[x] " } else { "[ ] " }),
Event::InlineMath(math) | Event::DisplayMath(math) => output.push_str(&math),
Event::Start(Tag::Link { dest_url, .. }) => {
let _ = write!(
output,
let rendered = format!(
"{}",
format!("[{dest_url}]")
.underlined()
.with(self.color_theme.link)
);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Start(Tag::Image { dest_url, .. }) => {
let _ = write!(
output,
let rendered = format!(
"{}",
format!("[image:{dest_url}]").with(self.color_theme.link)
);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Start(
Tag::Paragraph
| Tag::Table(..)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::MetadataBlock(..)
| _,
)
| Event::End(
TagEnd::Link
| TagEnd::Image
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::MetadataBlock(..)
| _,
) => {}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
output.push_str("\n\n");
}
}
Event::Start(Tag::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.in_head = true;
}
}
Event::End(TagEnd::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
table.in_head = false;
}
}
Event::Start(Tag::TableRow) => {
if let Some(table) = state.table.as_mut() {
table.current_row.clear();
table.current_cell.clear();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
}
}
Event::Start(Tag::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.current_cell.clear();
}
}
Event::End(TagEnd::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.push_cell();
}
}
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
}
}
@@ -302,9 +389,19 @@ impl TerminalRenderer {
let _ = write!(output, "{}", "".with(self.color_theme.quote));
}
fn start_item(state: &RenderState, output: &mut String) {
output.push_str(&" ".repeat(state.list.saturating_sub(1)));
output.push_str(" ");
fn start_item(state: &mut RenderState, output: &mut String) {
let depth = state.list_stack.len().saturating_sub(1);
output.push_str(&" ".repeat(depth));
let marker = match state.list_stack.last_mut() {
Some(ListKind::Ordered { next_index }) => {
let value = *next_index;
*next_index += 1;
format!("{value}. ")
}
_ => "".to_string(),
};
output.push_str(&marker);
}
fn start_code_block(&self, code_language: &str, output: &mut String) {
@@ -328,7 +425,7 @@ impl TerminalRenderer {
fn push_text(
&self,
text: &str,
state: &RenderState,
state: &mut RenderState,
output: &mut String,
code_buffer: &mut String,
in_code_block: bool,
@@ -336,10 +433,82 @@ impl TerminalRenderer {
if in_code_block {
code_buffer.push_str(text);
} else {
output.push_str(&state.style_text(text, &self.color_theme));
let rendered = state.style_text(text, &self.color_theme);
state.capture_target_mut(output).push_str(&rendered);
}
}
fn render_table(&self, table: &TableState) -> String {
let mut rows = Vec::new();
if !table.headers.is_empty() {
rows.push(table.headers.clone());
}
rows.extend(table.rows.iter().cloned());
if rows.is_empty() {
return String::new();
}
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
let widths = (0..column_count)
.map(|column| {
rows.iter()
.filter_map(|row| row.get(column))
.map(|cell| visible_width(cell))
.max()
.unwrap_or(0)
})
.collect::<Vec<_>>();
let border = format!("{}", "".with(self.color_theme.table_border));
let separator = widths
.iter()
.map(|width| "".repeat(*width + 2))
.collect::<Vec<_>>()
.join(&format!("{}", "".with(self.color_theme.table_border)));
let separator = format!("{border}{separator}{border}");
let mut output = String::new();
if !table.headers.is_empty() {
output.push_str(&self.render_table_row(&table.headers, &widths, true));
output.push('\n');
output.push_str(&separator);
if !table.rows.is_empty() {
output.push('\n');
}
}
for (index, row) in table.rows.iter().enumerate() {
output.push_str(&self.render_table_row(row, &widths, false));
if index + 1 < table.rows.len() {
output.push('\n');
}
}
output
}
fn render_table_row(&self, row: &[String], widths: &[usize], is_header: bool) -> String {
let border = format!("{}", "".with(self.color_theme.table_border));
let mut line = String::new();
line.push_str(&border);
for (index, width) in widths.iter().enumerate() {
let cell = row.get(index).map_or("", String::as_str);
line.push(' ');
if is_header {
let _ = write!(line, "{}", cell.bold().with(self.color_theme.heading));
} else {
line.push_str(cell);
}
let padding = width.saturating_sub(visible_width(cell));
line.push_str(&" ".repeat(padding + 1));
line.push_str(&border);
}
line
}
#[must_use]
pub fn highlight_code(&self, code: &str, language: &str) -> String {
let syntax = self
@@ -372,32 +541,36 @@ impl TerminalRenderer {
}
}
#[cfg(test)]
mod tests {
use super::{Spinner, TerminalRenderer};
fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count()
}
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
} else {
output.push(ch);
}
} else {
output.push(ch);
}
output
}
output
}
#[cfg(test)]
mod tests {
use super::{strip_ansi, Spinner, TerminalRenderer};
#[test]
fn renders_markdown_with_styling_and_lists() {
let terminal_renderer = TerminalRenderer::new();
@@ -422,6 +595,34 @@ mod tests {
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn renders_ordered_and_nested_lists() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("1. first\n2. second\n - nested\n - child");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("1. first"));
assert!(plain_text.contains("2. second"));
assert!(plain_text.contains(" • nested"));
assert!(plain_text.contains(" • child"));
}
#[test]
fn renders_tables_with_alignment() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output = terminal_renderer
.render_markdown("| Name | Value |\n| ---- | ----- |\n| alpha | 1 |\n| beta | 22 |");
let plain_text = strip_ansi(&markdown_output);
let lines = plain_text.lines().collect::<Vec<_>>();
assert_eq!(lines[0], "│ Name │ Value │");
assert_eq!(lines[1], "│───────┼───────│");
assert_eq!(lines[2], "│ alpha │ 1 │");
assert_eq!(lines[3], "│ beta │ 22 │");
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();