diff --git a/crates/coven-cli/src/theme.rs b/crates/coven-cli/src/theme.rs index bbb6802..688d753 100644 --- a/crates/coven-cli/src/theme.rs +++ b/crates/coven-cli/src/theme.rs @@ -89,6 +89,20 @@ pub mod brand { g: 0x24, b: 0x38, }; + /// `--oc-border-subtle` flattened on black: rgba(255,255,255,0.08) → + /// 0x14 per channel. Use for unfocused rules and divider lines. + pub const BORDER_SUBTLE: Rgb = Rgb { + r: 0x14, + g: 0x14, + b: 0x14, + }; + /// `--oc-border-strong` flattened on black: rgba(255,255,255,0.14) → + /// 0x24 per channel. Use for focused rules and emphasized dividers. + pub const BORDER_STRONG: Rgb = Rgb { + r: 0x24, + g: 0x24, + b: 0x24, + }; } // ── Semantic tokens (what callsites import) ── @@ -118,6 +132,12 @@ pub const BORDER_DIM: Rgb = brand::TEXT_FAINT; pub const SCROLL_TRACK: Rgb = brand::SURFACE_3; /// Bottom-most canvas color behind every TUI screen. pub const BACKDROP: Rgb = brand::SURFACE_0; +/// Quiet divider line (e.g. unfocused single-rule input area). Mirrors +/// `--oc-border-subtle` from `brand/ui/color-tokens.css`. +pub const BORDER_SUBTLE: Rgb = brand::BORDER_SUBTLE; +/// Emphasized divider line (e.g. focused input area, active rule). +/// Mirrors `--oc-border-strong`. +pub const BORDER_STRONG: Rgb = brand::BORDER_STRONG; // ── Status semantics ── @@ -599,6 +619,17 @@ mod tests { flatten_on_black(&vars["--oc-text-faint"]), "--oc-text-faint" ); + + assert_eq!( + brand::BORDER_SUBTLE, + flatten_on_black(&vars["--oc-border-subtle"]), + "--oc-border-subtle" + ); + assert_eq!( + brand::BORDER_STRONG, + flatten_on_black(&vars["--oc-border-strong"]), + "--oc-border-strong" + ); } #[test] @@ -620,6 +651,8 @@ mod tests { assert_eq!(BORDER_DIM, brand::TEXT_FAINT); assert_eq!(SCROLL_TRACK, brand::SURFACE_3); assert_eq!(BACKDROP, brand::SURFACE_0); + assert_eq!(BORDER_SUBTLE, brand::BORDER_SUBTLE); + assert_eq!(BORDER_STRONG, brand::BORDER_STRONG); } #[test] diff --git a/crates/coven-cli/src/tui/cast/render.rs b/crates/coven-cli/src/tui/cast/render.rs index 9132e49..82e7bf5 100644 --- a/crates/coven-cli/src/tui/cast/render.rs +++ b/crates/coven-cli/src/tui/cast/render.rs @@ -7,7 +7,7 @@ use std::path::Path; -use crate::theme::{self, fit_chars, TerminalMode}; +use crate::theme::{self, fit_chars, palette_for, Fg, Palette, TerminalMode}; use super::outcome::CastOutcome; use super::plan::{CastHarnessSource, CastPlan, CastStepKind}; @@ -15,6 +15,17 @@ use super::safety::{CastRisk, SafetyDecision}; const CAST_INTRO_INNER_WIDTH: usize = 76; +/// Width of the field-label column shared by every Cast card. Matches the +/// 14-char rule from `docs/design/cast-tui-contract.md` so the eye locks +/// onto the value column across plan, outcome, and launcher frames. +const LABEL_COLUMN_WIDTH: usize = 14; + +/// Fixed-width risk chip. The text-only form is what gets asserted in tests; +/// the colored form is wrapped by `risk_chip_fg`. +const CHIP_SAFE: &str = "[ SAFE ]"; +const CHIP_CONFIRM: &str = "[CONFIRM ]"; +const CHIP_REJECT: &str = "[ REJECT ]"; + /// One-line salute at the top of every Cast frame. Used by both the /// interactive launcher (woven into the shell frame) and the non-interactive /// fallback so logs and pipes always show the familiar's name. @@ -133,63 +144,48 @@ pub(crate) fn render_plan_intro_plain(plan: &CastPlan) -> String { } fn render_plan_intro_with_mode(plan: &CastPlan, mode: TerminalMode) -> String { - let primary_strong = theme::Fg::with_mode(theme::PRIMARY_STRONG, mode); - let primary = theme::Fg::with_mode(theme::PRIMARY, mode); - let field_label = theme::Fg::with_mode(theme::FIELD_LABEL, mode); - let user_label = theme::Fg::with_mode(theme::USER_LABEL, mode); - let reset = theme::Reset::with_mode(mode); + let p = palette_for(mode); let mut frame = String::new(); - frame.push_str(&format!("{primary_strong}Cast plan{reset}\n")); - frame.push_str(&format!("{field_label}Spell:{reset} {}\n", plan.headline)); + push_section_header(&mut frame, &p, "Cast plan"); + + push_label_row(&mut frame, &p, "spell", &plan_spell_value(plan)); if let Some(plan_harness) = plan.harness { let source = match plan_harness.source { CastHarnessSource::UserChose => "user-chosen", CastHarnessSource::SafeDefault => "Cast default", }; - frame.push_str(&format!( - "{field_label}Harness:{reset} {} ({})\n", - plan_harness.harness.label(), - source - )); + let value = format!("{} · {}", plan_harness.harness.label(), source); + push_label_row(&mut frame, &p, "harness", &value); + } else if let Some(session_id) = &plan.session_id { + push_label_row(&mut frame, &p, "session", session_id); + } else { + // System actions (sessions, doctor, daemon, help, start, tui, patch, + // quit) have no harness or session id — surface what Cast understood + // so the card still answers "what did you pick?". + push_label_row(&mut frame, &p, "intent", &plan.headline); } - if let Some(title) = &plan.title { - frame.push_str(&format!("{field_label}Session title:{reset} {}\n", title)); + push_risk_row(&mut frame, &p, mode, plan.risk()); + if let SafetyDecision::Confirm { reason, .. } = &plan.decision { + push_continuation_row(&mut frame, &p, reason); } - - frame.push_str(&format!( - "{field_label}Risk:{reset} {}\n", - risk_label(plan.risk()) - )); - if let SafetyDecision::Confirm { reason, suggestion } = &plan.decision { - frame.push_str(&format!( - "{user_label} ! {} — {}{reset}\n", - reason, suggestion - )); - } - if let SafetyDecision::Reject { - reason, - alternative, - } = &plan.decision - { - frame.push_str(&format!( - "{user_label} X {} — {}{reset}\n", - reason, alternative - )); + if let SafetyDecision::Reject { reason, .. } = &plan.decision { + push_continuation_row(&mut frame, &p, reason); } if !plan.steps.is_empty() { - frame.push_str(&format!("{primary_strong}Steps{reset}\n")); - for step in &plan.steps { - frame.push_str(&format!( - "{primary} - [{}] {}{reset}\n", - step_kind_label(step.kind), - step.note - )); + frame.push('\n'); + push_section_header(&mut frame, &p, "Steps"); + for (idx, step) in plan.steps.iter().take(4).enumerate() { + push_step_row(&mut frame, &p, idx + 1, step.kind, &step.note); } } + + frame.push('\n'); + push_footer_hint(&mut frame, &p, plan_footer_hint(plan)); + frame } @@ -206,37 +202,187 @@ pub(crate) fn render_outcome_plain(outcome: &CastOutcome) -> String { } fn render_outcome_with_mode(outcome: &CastOutcome, mode: TerminalMode) -> String { - let primary_strong = theme::Fg::with_mode(theme::PRIMARY_STRONG, mode); - let primary = theme::Fg::with_mode(theme::PRIMARY, mode); - let field_label = theme::Fg::with_mode(theme::FIELD_LABEL, mode); - let reset = theme::Reset::with_mode(mode); + let p = palette_for(mode); let mut frame = String::new(); - frame.push_str(&format!("{primary_strong}Cast outcome{reset}\n")); - frame.push_str(&format!("{field_label}Spell:{reset} {}\n", outcome.request)); + push_section_header(&mut frame, &p, "Cast outcome"); + push_label_row(&mut frame, &p, "spell", &outcome.request); if let Some(launched) = &outcome.launched { - frame.push_str(&format!("{field_label}Launched:{reset} {}\n", launched)); + push_label_row(&mut frame, &p, "launched", launched); } if let Some(session_id) = &outcome.session_id { - frame.push_str(&format!("{field_label}Session id:{reset} {}\n", session_id)); + push_label_row(&mut frame, &p, "session", session_id); } + if !outcome.notes.is_empty() { - frame.push_str(&format!("{primary_strong}Notes{reset}\n")); - for note in &outcome.notes { - frame.push_str(&format!("{primary} - {}{reset}\n", note)); + frame.push('\n'); + push_section_header(&mut frame, &p, "Notes"); + for note in outcome.notes.iter().take(3) { + push_note_row(&mut frame, &p, note); } } + if let Some(next) = &outcome.next_step { - frame.push_str(&format!("{field_label}Next:{reset} {}\n", next)); + frame.push('\n'); + push_label_row(&mut frame, &p, "next", next); } + frame } -fn risk_label(risk: CastRisk) -> &'static str { +/// What the user typed (or, if Cast built the plan without raw input, the +/// most descriptive fallback). The visual contract calls this `spell`. +fn plan_spell_value(plan: &CastPlan) -> String { + if !plan.raw_spell.is_empty() { + plan.raw_spell.clone() + } else if let Some(title) = &plan.title { + title.clone() + } else { + plan.headline.clone() + } +} + +/// One-line, DIM footer that tells the user how to leave or continue. The +/// risk level changes the verb so the message tracks what the gate will +/// actually ask for. +fn plan_footer_hint(plan: &CastPlan) -> &'static str { + use crate::tui::cast::intent::CastIntent; + + if matches!(plan.intent, CastIntent::SacrificeSession { .. }) { + return "type sacrifice to confirm · esc cancels"; + } + match plan.risk() { + CastRisk::Safe => "press enter to cast · esc cancels", + CastRisk::Confirm => "review the risk note · y/N to confirm · esc cancels", + CastRisk::Reject => "Cast will not run this · type to reframe", + } +} + +/// Section heading rendered in `PRIMARY_STRONG`, Title Case, no decoration. +fn push_section_header(frame: &mut String, p: &Palette, title: &str) { + frame.push_str(&format!("{}{}{}\n", p.primary_strong, title, p.reset)); +} + +/// Maximum inner width for Cast rows. This keeps label/value output within +/// the renderer's wrap contract so terminals never auto-wrap from column 0. +const CAST_ROW_MAX_INNER_WIDTH: usize = 96; + +fn wrap_label_value_lines(value: &str) -> Vec { + let value_width = CAST_ROW_MAX_INNER_WIDTH.saturating_sub(LABEL_COLUMN_WIDTH + 2).max(1); + let mut lines = Vec::new(); + + for raw_line in value.split('\n') { + if raw_line.is_empty() { + lines.push(String::new()); + continue; + } + + let mut chunk = String::new(); + let mut chunk_len = 0usize; + for ch in raw_line.chars() { + if chunk_len == value_width { + lines.push(chunk); + chunk = String::new(); + chunk_len = 0; + } + chunk.push(ch); + chunk_len += 1; + } + + if !chunk.is_empty() { + lines.push(chunk); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} + +/// `label value` row with a fixed 14-char label column. Two-space gap +/// before the value so the eye locks onto a single value column across the +/// whole frame. +fn push_label_row(frame: &mut String, p: &Palette, label: &str, value: &str) { + let label_block = format!("{: &'static str { match risk { - CastRisk::Safe => "safe", - CastRisk::Confirm => "confirmation-required", - CastRisk::Reject => "rejected", + CastRisk::Safe => CHIP_SAFE, + CastRisk::Confirm => CHIP_CONFIRM, + CastRisk::Reject => CHIP_REJECT, + } +} + +fn risk_chip_fg(mode: TerminalMode, risk: CastRisk) -> Fg { + match risk { + CastRisk::Safe => Fg::with_mode(theme::PRIMARY_STRONG, mode), + CastRisk::Confirm => Fg::with_mode(theme::PRIMARY, mode), + CastRisk::Reject => Fg::with_mode(theme::DANGER, mode), } } @@ -286,69 +432,291 @@ mod tests { assert!(frame.contains("coven doctor")); } - #[test] - fn intro_card_shows_safe_default_source_and_session_title() { - let plan = build_plan( + fn natural_plan(prompt: &str) -> CastPlan { + build_plan( CastIntent::NaturalSpell { - prompt: "fix the failing tests".to_string(), + prompt: prompt.to_string(), }, codex, ) - .unwrap(); + .unwrap() + .with_raw_spell(prompt) + } + fn slash_plan(raw: &str) -> CastPlan { + let intent = crate::tui::cast::intent::parse_spell(raw).unwrap(); + build_plan(intent, codex).unwrap().with_raw_spell(raw) + } + + fn assert_no_ansi_leakage(frame: &str) { + assert!( + !frame.contains('\x1b'), + "plain-mode frame leaked an ANSI escape sequence:\n{frame}" + ); + } + + fn assert_label_column(frame: &str, label: &str) { + let expected = format!("{: build_plan(intent, codex).unwrap().with_raw_spell(raw), + Err(_) => continue, + }; + assert_no_ansi_leakage(&render_plan_intro_plain(&plan)); + } } #[test] - fn outcome_card_includes_session_id_and_next_step() { + fn outcome_card_uses_section_headers_and_field_columns() { let outcome = CastOutcome { request: "fix the failing tests".to_string(), launched: Some("Codex session (project-scoped)".to_string()), session_id: Some("abcdef-1234".to_string()), - next_step: Some("`coven attach abcdef-1234` to follow live output".to_string()), - notes: vec!["risk: safe".to_string()], + next_step: Some("Run `coven attach abcdef-1234` to revisit".to_string()), + notes: vec!["Session finished: status `clean`, exit code 0".to_string()], }; let frame = render_outcome_plain(&outcome); assert!(frame.contains("Cast outcome")); - assert!(frame.contains("Launched: Codex session")); - assert!(frame.contains("Session id: abcdef-1234")); + assert!(frame.contains("Notes")); + assert_label_column(&frame, "spell"); + assert_label_column(&frame, "launched"); + assert_label_column(&frame, "session"); + assert_label_column(&frame, "next"); + // Old colon-suffixed labels are gone. + assert!(!frame.contains("Launched:")); + assert!(!frame.contains("Session id:")); + // The next-step value remains copy-pastable. assert!(frame.contains("coven attach abcdef-1234")); - assert!(frame.contains("risk: safe")); + // Note prefix is a thin middle dot per the contract — no hyphen bullets. + assert!( + frame.contains("· Session finished"), + "notes should use a `·` bullet, frame:\n{frame}" + ); + assert!( + !frame.contains("- Session finished"), + "notes must not use hyphen bullets, frame:\n{frame}" + ); + assert_no_ansi_leakage(&frame); + } + + #[test] + fn outcome_card_omits_optional_rows_when_unset() { + let outcome = CastOutcome::for_request("/sessions"); + let frame = render_outcome_plain(&outcome); + assert!(frame.contains("Cast outcome")); + assert_label_column(&frame, "spell"); + assert!(!frame.contains("launched")); + assert!(!frame.contains("session ")); + assert!(!frame.contains("Notes")); + assert!(!frame.contains("next")); + } + + #[test] + fn outcome_card_caps_notes_to_three_visible() { + let outcome = CastOutcome { + request: "fix tests".to_string(), + launched: None, + session_id: None, + next_step: None, + notes: (0..6).map(|i| format!("note {i}")).collect(), + }; + let frame = render_outcome_plain(&outcome); + assert!(frame.contains("note 0")); + assert!(frame.contains("note 2")); + assert!( + !frame.contains("note 4"), + "outcome should clip to 3 notes, frame:\n{frame}" + ); + } + + #[test] + fn risk_chip_colors_change_with_severity_in_true_color_mode() { + // The chip text stays the same; the foreground escape changes by + // severity. Spot-check the escapes by rendering against a TrueColor + // palette directly so we don't depend on the cached `mode()` value. + let safe_plan = natural_plan("fix the failing tests"); + let confirm_plan = natural_plan("git push to main"); + let reject_plan = natural_plan("rm -rf / now"); + + let safe = render_plan_intro_with_mode(&safe_plan, TerminalMode::TrueColor); + let confirm = render_plan_intro_with_mode(&confirm_plan, TerminalMode::TrueColor); + let reject = render_plan_intro_with_mode(&reject_plan, TerminalMode::TrueColor); + + // PRIMARY_STRONG (0x9A, 0x8E, 0xCD) for SAFE. + assert!(safe.contains("\x1b[38;2;154;142;205m")); + // PRIMARY (0xC5, 0xBD, 0xED) for CONFIRM. + assert!(confirm.contains("\x1b[38;2;197;189;237m")); + // DANGER (0xFF, 0x3B, 0x30) for REJECT. + assert!(reject.contains("\x1b[38;2;255;59;48m")); } }