diff --git a/Cargo.lock b/Cargo.lock index ec22d89..b3d688f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "cortex-app-server" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-channel", @@ -987,7 +987,7 @@ dependencies = [ [[package]] name = "cortex-apply-patch" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "serde", @@ -1013,7 +1013,7 @@ dependencies = [ [[package]] name = "cortex-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "base64", @@ -1063,7 +1063,7 @@ dependencies = [ [[package]] name = "cortex-collab" -version = "0.1.2" +version = "0.1.3" dependencies = [ "serde", "thiserror 2.0.20", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "cortex-commands" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "dirs 6.0.0", @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "cortex-common" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "cortex-protocol", @@ -1122,7 +1122,7 @@ dependencies = [ [[package]] name = "cortex-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "ahash", "anyhow", @@ -1146,7 +1146,7 @@ dependencies = [ [[package]] name = "cortex-engine" -version = "0.1.2" +version = "0.1.3" dependencies = [ "aes-gcm", "anyhow", @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "cortex-exec" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-engine", "cortex-protocol", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "cortex-execpolicy" -version = "0.1.2" +version = "0.1.3" dependencies = [ "serde", "serde_json", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "cortex-feedback" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "cortex-file-search" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "ignore", @@ -1310,7 +1310,7 @@ dependencies = [ [[package]] name = "cortex-keyring-store" -version = "0.1.2" +version = "0.1.3" dependencies = [ "keyring", "thiserror 2.0.20", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "cortex-lmstudio" -version = "0.1.2" +version = "0.1.3" dependencies = [ "reqwest 0.13.4", "serde", @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "cortex-login" -version = "0.1.2" +version = "0.1.3" dependencies = [ "aes-gcm", "anyhow", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "cortex-mcp-client" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -1415,7 +1415,7 @@ dependencies = [ [[package]] name = "cortex-mcp-server" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -1428,7 +1428,7 @@ dependencies = [ [[package]] name = "cortex-mcp-types" -version = "0.1.2" +version = "0.1.3" dependencies = [ "schemars", "serde", @@ -1445,7 +1445,7 @@ dependencies = [ [[package]] name = "cortex-network-proxy" -version = "0.1.2" +version = "0.1.3" dependencies = [ "serde", "thiserror 2.0.20", @@ -1455,7 +1455,7 @@ dependencies = [ [[package]] name = "cortex-otel" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-common", "opentelemetry", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "cortex-plugins" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -1496,14 +1496,14 @@ dependencies = [ [[package]] name = "cortex-process-hardening" -version = "0.1.2" +version = "0.1.3" dependencies = [ "libc", ] [[package]] name = "cortex-prompt-harness" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "indexmap", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "cortex-protocol" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "base64", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "cortex-sandbox" -version = "0.1.2" +version = "0.1.3" dependencies = [ "dirs 6.0.0", "landlock", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "cortex-shell-snapshot" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "dirs 6.0.0", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "cortex-skills" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "dashmap", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "cortex-slack" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "axum", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "cortex-tui" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "arboard", @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "cortex-tui-buffer" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-tui-core", "smallvec", @@ -1732,12 +1732,13 @@ dependencies = [ [[package]] name = "cortex-tui-capture" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", "cortex-core", "cortex-tui-buffer", + "cortex-tui-components", "cortex-tui-core", "dirs 6.0.0", "ratatui", @@ -1753,7 +1754,7 @@ dependencies = [ [[package]] name = "cortex-tui-components" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-core", "crossterm", @@ -1765,7 +1766,7 @@ dependencies = [ [[package]] name = "cortex-tui-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "bitflags 2.13.1", "smallvec", @@ -1774,7 +1775,7 @@ dependencies = [ [[package]] name = "cortex-tui-framework" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1788,7 +1789,7 @@ dependencies = [ [[package]] name = "cortex-tui-input" -version = "0.1.2" +version = "0.1.3" dependencies = [ "bitflags 2.13.1", "crossterm", @@ -1797,7 +1798,7 @@ dependencies = [ [[package]] name = "cortex-tui-layout" -version = "0.1.2" +version = "0.1.3" dependencies = [ "slotmap", "taffy", @@ -1805,7 +1806,7 @@ dependencies = [ [[package]] name = "cortex-tui-syntax" -version = "0.1.2" +version = "0.1.3" dependencies = [ "ahash", "cortex-tui-text", @@ -1819,7 +1820,7 @@ dependencies = [ [[package]] name = "cortex-tui-terminal" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1829,7 +1830,7 @@ dependencies = [ [[package]] name = "cortex-tui-text" -version = "0.1.2" +version = "0.1.3" dependencies = [ "smallvec", "unicode-segmentation", @@ -1838,7 +1839,7 @@ dependencies = [ [[package]] name = "cortex-tui-widgets" -version = "0.1.2" +version = "0.1.3" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1849,7 +1850,7 @@ dependencies = [ [[package]] name = "cortex-update" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "cortex-common", @@ -1877,11 +1878,11 @@ dependencies = [ [[package]] name = "cortex-utils-cache" -version = "0.1.2" +version = "0.1.3" [[package]] name = "cortex-utils-file-safety" -version = "0.1.2" +version = "0.1.3" dependencies = [ "tempfile", "thiserror 2.0.20", @@ -1889,11 +1890,11 @@ dependencies = [ [[package]] name = "cortex-utils-git" -version = "0.1.2" +version = "0.1.3" [[package]] name = "cortex-utils-image" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "base64", @@ -1901,7 +1902,7 @@ dependencies = [ [[package]] name = "cortex-utils-pty" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "portable-pty", @@ -1909,7 +1910,7 @@ dependencies = [ [[package]] name = "cortex-utils-string" -version = "0.1.2" +version = "0.1.3" dependencies = [ "unicode-width", ] diff --git a/docs/guides/tui.md b/docs/guides/tui.md index 5db0ada..71b62a6 100644 --- a/docs/guides/tui.md +++ b/docs/guides/tui.md @@ -41,7 +41,7 @@ The timeline is the transcript. It renders several kinds of row: | Tool call | `◐`/`●` then the tool name and a short argument summary | A tool the agent invoked | | Tool result | `⎿ …` indented under the call | What the tool returned | | Subagent task | `● Task ` with a todo list underneath | Work delegated to a subagent | -| Welcome card | A bordered card | Shown while the session is empty | +| Welcome card | Bordered card with the ASCII mascot, greeting, and tips | Shown while the session is empty | Tool rows collapse to a summary. Press `e` while the timeline has focus to expand or collapse the details of the selected tool call. diff --git a/docs/media/intro.gif b/docs/media/intro.gif index fe0366a..b16f58c 100644 Binary files a/docs/media/intro.gif and b/docs/media/intro.gif differ diff --git a/src/cortex-tui-capture/Cargo.toml b/src/cortex-tui-capture/Cargo.toml index ae07b93..db3dac0 100644 --- a/src/cortex-tui-capture/Cargo.toml +++ b/src/cortex-tui-capture/Cargo.toml @@ -30,6 +30,9 @@ cortex-tui-core = { workspace = true } # Product theme, so the recorded demo tracks the real TUI palette cortex-core = { workspace = true } +# Same welcome card / mascot the live TUI mounts, so the GIF cannot drift +cortex-tui-components = { workspace = true } + # Async runtime tokio = { workspace = true, features = ["full", "sync", "time", "macros", "fs"] } diff --git a/src/cortex-tui-capture/src/demo/mod.rs b/src/cortex-tui-capture/src/demo/mod.rs index 354f5f4..fd1733c 100644 --- a/src/cortex-tui-capture/src/demo/mod.rs +++ b/src/cortex-tui-capture/src/demo/mod.rs @@ -241,7 +241,7 @@ mod tests { let recording = recording(); let first = &recording.frames[0]; assert!( - first.plain.contains("Cortex Code"), + first.plain.contains("Cortex CLI"), "welcome frame missing the product name:\n{}", first.plain ); @@ -250,6 +250,36 @@ mod tests { "welcome frame missing the public endpoint:\n{}", first.plain ); + for glyph in cortex_tui_components::mascot::MASCOT_MINIMAL_LINES { + let needle = glyph.trim(); + assert!( + first.plain.contains(needle), + "welcome frame missing mascot line {needle:?}:\n{}", + first.plain + ); + } + assert!( + first.plain.contains("Welcome!"), + "welcome frame missing the greeting:\n{}", + first.plain + ); + } + + #[test] + fn welcome_beats_keep_the_mascot_on_screen() { + let recording = recording(); + for frame in recording + .frames + .iter() + .filter(|frame| frame.label == "welcome") + { + assert!( + frame.plain.contains("▄█▀▀▀▀█▄"), + "welcome beat {} lost the mascot:\n{}", + frame.index, + frame.plain + ); + } } #[test] diff --git a/src/cortex-tui-capture/src/demo/render.rs b/src/cortex-tui-capture/src/demo/render.rs index 97a9c46..c15f47e 100644 --- a/src/cortex-tui-capture/src/demo/render.rs +++ b/src/cortex-tui-capture/src/demo/render.rs @@ -4,9 +4,9 @@ //! real TUI palette instead of a second, drifting copy of it. use cortex_core::style::{ - BORDER, BORDER_FOCUS, CYAN_PRIMARY, SKY_BLUE, SUCCESS, SURFACE_0, TEXT, TEXT_DIM, TEXT_MUTED, - VOID, + BORDER, BORDER_FOCUS, CYAN_PRIMARY, SKY_BLUE, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, VOID, }; +use cortex_tui_components::welcome_card::{InfoCard, InfoCardPair, ToLines, WelcomeCard}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -47,56 +47,46 @@ pub fn draw_scene(frame: &mut Frame, scene: &Scene) { draw_hints(frame, hints_area, scene); } +/// Empty-session frame: the same `WelcomeCard` + info cards the live TUI paints +/// via `generate_welcome_lines`, including `MASCOT_MINIMAL_LINES`. fn draw_welcome(frame: &mut Frame, area: Rect, scene: &Scene) { - let card_width = area.width.min(82); - let card_height = 11.min(area.height); - let card = Rect { - x: area.x, - y: area.y + area.height.saturating_sub(card_height) / 2, - width: card_width, - height: card_height, - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER)) - .style(Style::default().bg(SURFACE_0)) - .padding(Padding::new(2, 2, 1, 0)) - .title(Span::styled( - " Cortex Code ", - Style::default() - .fg(CYAN_PRIMARY) - .add_modifier(Modifier::BOLD), - )); - - let lines = vec![ - Line::from(Span::styled( - "A coding agent that reads, edits, runs and tests your project.", - Style::default().fg(TEXT), - )), - Line::default(), - field_line("workspace", &scene.workspace), - field_line("endpoint", &scene.endpoint), - field_line( - "mode", - &format!("{} · {} autonomy", scene.mode, scene.autonomy), - ), - Line::default(), - Line::from(Span::styled( - "Describe a change and Cortex Code works through it, tool call by tool call.", - Style::default().fg(TEXT_MUTED), - )), - ]; - - frame.render_widget(Paragraph::new(lines).block(block), card); -} + let welcome_card = WelcomeCard::new() + .subtitle("Your AI-powered coding assistant.") + .version(env!("CARGO_PKG_VERSION")) + .tips(&[ + "Send /help for available commands.", + "Use Tab for autocomplete. Press Esc to cancel.", + ]) + .accent_color(CYAN_PRIMARY) + .text_color(TEXT) + .dim_color(TEXT_DIM) + .border_color(CYAN_PRIMARY); + + let mut lines = welcome_card.to_lines(area.width); + lines.push(Line::default()); + + let left_card = InfoCard::new() + .add("Directory", &scene.workspace) + .add("Endpoint", &scene.endpoint) + .dim_color(TEXT_DIM) + .text_color(TEXT) + .border_color(CYAN_PRIMARY); + + let right_card = InfoCard::new() + .add("Mode", &scene.mode) + .add("Autonomy", &scene.autonomy) + .dim_color(TEXT_DIM) + .text_color(TEXT) + .border_color(CYAN_PRIMARY); + + lines.extend( + InfoCardPair::new(left_card, right_card) + .gap(2) + .right_width(25) + .to_lines(area.width), + ); -fn field_line(label: &str, value: &str) -> Line<'static> { - Line::from(vec![ - Span::styled(format!("{label:<11}"), Style::default().fg(TEXT_MUTED)), - Span::styled(value.to_string(), Style::default().fg(TEXT_DIM)), - ]) + frame.render_widget(Paragraph::new(lines).style(Style::default().bg(VOID)), area); } fn draw_timeline(frame: &mut Frame, area: Rect, scene: &Scene) { diff --git a/src/cortex-tui-components/src/mascot.rs b/src/cortex-tui-components/src/mascot.rs index 6a63cf0..23476b9 100644 --- a/src/cortex-tui-components/src/mascot.rs +++ b/src/cortex-tui-components/src/mascot.rs @@ -171,4 +171,16 @@ mod tests { let expr = MascotExpression::default(); assert_eq!(expr.art(), MASCOT); } + + #[test] + fn minimal_mascot_is_four_block_brain_lines() { + assert_eq!(MASCOT_MINIMAL_LINES.len(), 4); + assert_eq!(MASCOT_MINIMAL_LINES[0].trim(), "▄█▀▀▀▀█▄"); + assert_eq!(MASCOT_MINIMAL_LINES[1].trim(), "██ ▌ ▐ ██"); + assert_eq!(MASCOT_MINIMAL_LINES[2].trim(), "█▄▄▄▄▄▄█"); + assert_eq!(MASCOT_MINIMAL_LINES[3].trim(), "█ █"); + assert!(MASCOT_MINIMAL.contains("▄█▀▀▀▀█▄")); + assert!(MASCOT_MINIMAL.contains("▌")); + assert!(MASCOT_MINIMAL.contains("▐")); + } } diff --git a/src/cortex-tui-components/src/welcome_card.rs b/src/cortex-tui-components/src/welcome_card.rs index b70d6ad..215173f 100644 --- a/src/cortex-tui-components/src/welcome_card.rs +++ b/src/cortex-tui-components/src/welcome_card.rs @@ -630,4 +630,77 @@ mod tests { let card = WelcomeCard::new().tips(&["Tip 1", "Tip 2"]); assert!(card.required_height() >= 10); } + + fn buffer_text(buf: &Buffer) -> String { + let area = buf.area(); + let mut out = String::new(); + for y in 0..area.height { + for x in 0..area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + fn assert_contains_mascot(text: &str) { + for line in MASCOT_MINIMAL_LINES { + let needle = line.trim(); + assert!( + text.contains(needle), + "missing mascot line {needle:?}:\n{text}" + ); + } + } + + #[test] + fn welcome_card_to_lines_includes_mascot() { + let card = WelcomeCard::new() + .subtitle("Your AI-powered coding assistant.") + .tips(&[ + "Send /help for available commands.", + "Use Tab for autocomplete. Press Esc to cancel.", + ]); + let text = card + .to_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_contains_mascot(&text); + assert!(text.contains("Welcome!")); + assert!(text.contains("Cortex CLI")); + } + + #[test] + fn welcome_card_widget_renders_mascot() { + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 16)); + WelcomeCard::new() + .subtitle("Your AI-powered coding assistant.") + .tips(&["Send /help for available commands."]) + .render(Rect::new(0, 0, 80, 16), &mut buf); + let text = buffer_text(&buf); + assert_contains_mascot(&text); + assert!(text.contains("Welcome!")); + } + + #[test] + fn welcome_card_narrow_and_wide_viewports_keep_the_mascot() { + for (width, height) in [(40, 12), (120, 40)] { + let card = WelcomeCard::new().tips(&["Send /help for available commands."]); + let text = card + .to_lines(width) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_contains_mascot(&text); + + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + WelcomeCard::new() + .tips(&["Send /help for available commands."]) + .render(Rect::new(0, 0, width, height), &mut buf); + assert_contains_mascot(&buffer_text(&buf)); + } + } } diff --git a/src/cortex-tui/src/runner/login_screen.rs b/src/cortex-tui/src/runner/login_screen.rs index f1ab021..07bb2ec 100644 --- a/src/cortex-tui/src/runner/login_screen.rs +++ b/src/cortex-tui/src/runner/login_screen.rs @@ -19,6 +19,7 @@ use ratatui::widgets::{Clear, Paragraph}; use tokio::sync::mpsc; use cortex_login::{SecureAuthData, save_auth_with_fallback}; +use cortex_tui_components::mascot::MASCOT_MINIMAL_LINES; use cortex_tui_components::spinner::SpinnerStyle; // ============================================================================ @@ -398,27 +399,29 @@ impl LoginScreen { ])); f.render_widget(welcome, chunks[0]); - // Line 3: Mascot top - let mascot_top = Paragraph::new(" ▄█▀▀▀▀█▄ ").style(Style::default().fg(PRIMARY)); - f.render_widget(mascot_top, chunks[2]); - - // Line 4: Mascot + waiting message - let mascot_middle = Paragraph::new(Line::from(vec![ - Span::styled("██ ▌ ▐ ██ ", Style::default().fg(PRIMARY)), - Span::styled( - format!("Waiting for browser authentication {}", spinner), - Style::default().fg(PRIMARY), - ), - ])); - f.render_widget(mascot_middle, chunks[3]); - - // Line 5: Mascot bottom - let mascot_bottom = Paragraph::new(" █▄▄▄▄▄▄█ ").style(Style::default().fg(PRIMARY)); - f.render_widget(mascot_bottom, chunks[4]); - - // Line 6: Mascot legs - let mascot_legs = Paragraph::new(" █ █").style(Style::default().fg(PRIMARY)); - f.render_widget(mascot_legs, chunks[5]); + // Lines 3-6: official welcome mascot (`MASCOT_MINIMAL_LINES`). + f.render_widget( + Paragraph::new(MASCOT_MINIMAL_LINES[0]).style(Style::default().fg(PRIMARY)), + chunks[2], + ); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(MASCOT_MINIMAL_LINES[1], Style::default().fg(PRIMARY)), + Span::styled( + format!(" Waiting for browser authentication {spinner}"), + Style::default().fg(PRIMARY), + ), + ])), + chunks[3], + ); + f.render_widget( + Paragraph::new(MASCOT_MINIMAL_LINES[2]).style(Style::default().fg(PRIMARY)), + chunks[4], + ); + f.render_widget( + Paragraph::new(MASCOT_MINIMAL_LINES[3]).style(Style::default().fg(PRIMARY)), + chunks[5], + ); // Line 8: Browser message let copy_hint = if self.copied_notification.is_some() { @@ -960,6 +963,13 @@ mod tests { || waiting.contains("Cortex"), "{waiting}" ); + for glyph in MASCOT_MINIMAL_LINES { + let needle = glyph.trim(); + assert!( + waiting.contains(needle), + "login waiting screen missing mascot line {needle:?}: {waiting}" + ); + } screen.state = LoginState::SelectMethod; screen.error_message = Some("The coding service is temporarily unavailable".into()); diff --git a/src/cortex-tui/src/views/minimal_session/tests.rs b/src/cortex-tui/src/views/minimal_session/tests.rs index e7cb450..ed6ea28 100644 --- a/src/cortex-tui/src/views/minimal_session/tests.rs +++ b/src/cortex-tui/src/views/minimal_session/tests.rs @@ -45,9 +45,16 @@ mod harness_snapshots { dump_snapshot("home", &text); assert!(!text.to_lowercase().contains("grok")); assert!( - text.contains("Cortex") || text.contains("session") || !text.trim().is_empty(), - "home session should render: {text}" + text.contains("Cortex CLI"), + "home session should render WelcomeCard: {text}" ); + for glyph in ["▄█▀▀▀▀█▄", "██ ▌ ▐ ██", "█▄▄▄▄▄▄█", "█ █"] + { + assert!( + text.contains(glyph), + "home session missing mascot line {glyph:?}: {text}" + ); + } } #[test]