diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..136aaa2 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 47ecd98..162ce7f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2797,7 +2797,7 @@ 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_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); } diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index e55b42e..18423b3 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -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, + rows: Vec>, + current_row: Vec, + 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, + table: Option, } 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::>(); + + let border = format!("{}", "│".with(self.color_theme.table_border)); + let separator = widths + .iter() + .map(|width| "─".repeat(*width + 2)) + .collect::>() + .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::>(); + + 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();