diff --git a/crates/coven-cli/src/main.rs b/crates/coven-cli/src/main.rs index 340ab02..db70ce7 100644 --- a/crates/coven-cli/src/main.rs +++ b/crates/coven-cli/src/main.rs @@ -893,7 +893,7 @@ mod tests { #[test] fn tui_launcher_and_session_browser_are_owned_by_tui_modules() { let shell_frame = tui::shell::render_frame_plain_for_test(0); - assert!(shell_frame.contains("CovenCLI")); + assert!(shell_frame.contains("Cast")); let sessions = [test_session_record( "session-alpha-1234567890", @@ -990,19 +990,22 @@ mod tests { } #[test] - fn magical_tui_frame_uses_purple_gold_branding_and_lists_core_actions() { + fn magical_tui_frame_opens_with_cast_identity_and_lists_core_commands() { let frame = render_magical_tui_frame_plain(1); - assert!(frame.contains("CovenCLI")); - assert!(frame.contains("Welcome back to the Coven.")); - assert!(frame.contains("OpenCoven terminal home")); - assert!(frame.contains("[coven]")); + // Identity line replaces the old "CovenCLI" header + "Welcome back" salute. + assert!(frame.contains("Cast")); + assert!(!frame.contains("CovenCLI")); + assert!(!frame.contains("Welcome back")); + // Core commands still render in the visible window (selection 1). assert!(frame.contains("/start")); assert!(frame.contains("/help")); assert!(frame.contains("/run")); - assert!(frame.contains("/patch")); - assert!(frame.contains("/doctor")); - assert!(frame.contains(">")); + // Selection arrow uses the thin guillemet (U+203A), not ASCII `>`. + assert!( + frame.contains('›'), + "selected row should render with U+203A" + ); } #[test] @@ -1046,24 +1049,35 @@ mod tests { } #[test] - fn magical_tui_frame_previews_selected_spell_command() { + fn magical_tui_frame_previews_selected_action() { let frame = render_magical_tui_frame_plain(0); - assert!(frame.contains("Selected command")); + // The "Selected command" panel collapses to compact spell/detail rows. + assert!(frame.contains("spell")); + assert!(frame.contains("detail")); assert!(frame.contains("/start")); - assert!(frame.contains("coven doctor")); - assert!(frame.contains("~/.coven")); + assert!(frame.contains("Setup check")); + // The decorative "Store: ~/.coven" footer is gone per design contract. + assert!(!frame.contains("Store:")); } #[test] - fn magical_tui_frame_is_newcomer_friendly() { + fn magical_tui_frame_surfaces_command_rail_and_snapshot_for_newcomers() { let frame = render_magical_tui_frame_plain(5); - assert!(frame.contains("Ask anything")); - assert!(frame.contains("Empty Enter runs selected slash")); - assert!(frame.contains("Slash commands")); - assert!(frame.contains("Launch Codex")); - assert!(frame.contains("coven run codex")); + // Two-lane body: left command rail + right snapshot lane. + assert!(frame.contains("Commands")); + assert!(frame.contains("Snapshot")); + // Snapshot label column is rendered in lowercase per the contract. + assert!(frame.contains("project")); + assert!(frame.contains("harness")); + assert!(frame.contains("daemon")); + // /run is in the visible window when selection sits on it. + assert!(frame.contains("/run")); + assert!(frame.contains("Run an agent")); + // Single-line footer hint, dot-separated. + assert!(frame.contains("enter run")); + assert!(frame.contains("esc quit")); } #[test] @@ -1074,50 +1088,94 @@ mod tests { } #[test] - fn magical_tui_frame_renders_prompt_as_a_bordered_input_box() { + fn magical_tui_frame_wraps_prompt_in_thin_horizontal_rules() { let frame = render_magical_tui_frame_plain_with_input(0, "summarize the repo", 76); - assert!(frame.contains("+-- Ask anything ")); - assert!(frame.contains("| > summarize the repo")); - assert!(frame.contains("Ctrl+U clears")); + // No `+--+` corner art; single horizontal rule above and below the prompt. + assert!(!frame.contains("+--")); + assert!(!frame.contains("Ask anything")); + assert!( + frame.contains("─"), + "prompt should be flanked by thin rules" + ); + // The prompt itself is the bare `> input` line, no inner `|` bezels. + assert!(frame.contains("> summarize the repo")); + assert!(!frame.contains("| > summarize the repo")); } #[test] - fn magical_tui_frame_includes_obsidian_style_graph() { + fn magical_tui_frame_drops_decorative_graph_and_task_inbox() { let frame = render_magical_tui_frame_plain(0); - assert!(frame.contains("[memory] -- [coven] -- [sessions]")); - assert!(frame.contains("[gateway]")); + // Workspace map graph art, task inbox, and "Selected command" panel + // are all removed per the Phase 1 design contract. + assert!(!frame.contains("[memory]")); + assert!(!frame.contains("[gateway]")); + assert!(!frame.contains("[ ] inspect repo")); + assert!(!frame.contains("Workspace map")); + assert!(!frame.contains("Task inbox")); + assert!(!frame.contains("Selected command")); } #[test] - fn magical_tui_frame_emulates_intricate_claude_code_home_without_emoji() { + fn magical_tui_frame_avoids_emoji_and_decorative_ascii_chrome() { let frame = render_magical_tui_frame_plain(0); - assert!(frame.contains("workspace")); - assert!(frame.contains("harness shelf")); - assert!(frame.contains("Codex ready")); - assert!(frame.contains("Claude Code ready")); - assert!(frame.contains("Release notes")); - assert!(frame.contains("Tips")); - assert!( - frame - .chars() - .all(|ch| ch == '\n' || ch == '\r' || ch.is_ascii()), - "TUI should stay icon/ASCII-only" - ); + // No emoji or pictographs sneak in (BMP-only, no codepoints past U+2FFF + // except whitelisted typography we use in the frame). + for ch in frame.chars() { + let code = ch as u32; + let allowed = ch == '\n' + || ch == '\r' + || ch.is_ascii() + || ch == '─' // U+2500 thin horizontal rule + || ch == '›' // U+203A selected-row marker + || ch == '·' // U+00B7 separator + || ch == '↑' + || ch == '↓' + || ch == '…'; // U+2026 truncation marker from fit_chars + assert!( + allowed, + "unexpected glyph in launcher frame: {ch:?} (U+{code:04X})" + ); + } + // No ASCII corner-box chrome remains. + assert!(!frame.contains("+--")); + assert!(!frame.contains("--+")); } #[test] - fn magical_tui_frame_reads_like_a_claude_code_style_terminal_home() { + fn magical_tui_frame_follows_phase1_hierarchy() { let frame = render_magical_tui_frame_plain(0); - assert!(frame.contains("System snapshot")); - assert!(frame.contains("Model lane")); - assert!(frame.contains("Workspace map")); - assert!(frame.contains("Task inbox")); - assert!(frame.contains("Context")); - assert!(frame.contains("Approvals")); + // identity → prompt → commands + snapshot → action preview → footer + assert!(frame.contains("Cast")); + assert!(frame.contains("Commands")); + assert!(frame.contains("Snapshot")); + assert!(frame.contains("spell")); + assert!(frame.contains("detail")); + // Single-line dim footer, no `|` separators. + assert!(frame.contains("enter run")); + assert!(frame.contains("↑↓ select")); + assert!(frame.contains("esc quit")); + assert!(frame.contains("ctrl+u clear")); + assert!(!frame.contains("Empty Enter")); + } + + #[test] + fn magical_tui_frame_keeps_blank_input_placeholder_dim() { + let frame = render_magical_tui_frame_plain(0); + // Empty prompt shows the placeholder copy; no `Ask anything` label. + assert!(frame.contains("> type a task or /run codex")); + } + + #[test] + fn magical_tui_frame_windows_long_command_list_with_scroll_hint() { + // Selection sits well past the visible window — scroll hint must + // appear and the selected slash must still be in the rendered rail. + let frame = render_magical_tui_frame_plain(12); // /sacrifice + assert!(frame.contains("/sacrifice")); + assert!(frame.contains("of 14")); } #[test] diff --git a/crates/coven-cli/src/theme.rs b/crates/coven-cli/src/theme.rs index a37efc1..688d753 100644 --- a/crates/coven-cli/src/theme.rs +++ b/crates/coven-cli/src/theme.rs @@ -68,6 +68,10 @@ pub mod brand { g: 0x6B, b: 0x6B, }; + /// True-black canvas (`--oc-surface-0`). The terminal canvas/backdrop — + /// distinct from `SURFACE_1`, which is the brand chrome surface that sits + /// on top of it. + pub const SURFACE_0: Rgb = Rgb { r: 0, g: 0, b: 0 }; pub const SURFACE_1: Rgb = Rgb { r: 0x0F, g: 0x0A, @@ -78,6 +82,27 @@ pub mod brand { g: 0x18, b: 0x25, }; + /// Lifted brand surface (`--oc-surface-3`) — used for scrollbar tracks + /// and other quiet recessed chrome where pure black is too harsh. + pub const SURFACE_3: Rgb = Rgb { + r: 0x2A, + 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) ── @@ -87,19 +112,64 @@ pub const PRIMARY_STRONG: Rgb = brand::PURPLE_2; pub const AGENT_LABEL: Rgb = brand::PURPLE_2; pub const USER_LABEL: Rgb = brand::PURPLE_1; pub const HINT_KEY: Rgb = brand::TEXT; -// Defined for future use: HINT_LABEL distinguishes prose from keys in hint -// bars; DANGER/SUCCESS will be wired to destructive prompts and ready-state -// indicators in a later phase. Per spec, "defined and tested only" for Phase 1. -#[allow(dead_code)] pub const HINT_LABEL: Rgb = brand::TEXT_MUTED; pub const FIELD_LABEL: Rgb = brand::TEXT_MUTED; -#[allow(dead_code)] pub const DANGER: Rgb = brand::DANGER; -#[allow(dead_code)] pub const SUCCESS: Rgb = brand::SUCCESS; pub const DIM: Rgb = brand::TEXT_FAINT; pub const SURFACE: Rgb = brand::SURFACE_1; pub const SURFACE_STRONG: Rgb = brand::SURFACE_2; +/// Body text — replaces the ad-hoc `Color::White` that screens were reaching +/// for. Brand-aligned, near-white but never pure white. +pub const TEXT: Rgb = brand::TEXT; +/// Secondary body text — quieter than `TEXT`, brighter than `DIM`. Replaces +/// hand-rolled 256-color indices for agent-side message bodies. +pub const TEXT_DIM: Rgb = brand::TEXT_MUTED; +/// Inactive border / divider color. Replaces hand-picked 256-color indices +/// for input bezels and other quiet outlines. +pub const BORDER_DIM: Rgb = brand::TEXT_FAINT; +/// Scrollbar track and other recessed chrome (very dark, brand-tinted). +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 ── + +/// What a status indicator is communicating. Drives the color of "ready", +/// "working", "error" etc. across the TUI so renderers never pick a raw +/// `Color::Green` again. `Ready` is consumed today by the chat status bar; +/// the other variants are pre-wired for the screen renderers landing in the +/// next phase and are reachable via `status_token` / `status_style`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Status { + Ready, + #[allow(dead_code)] + Working, + #[allow(dead_code)] + Warning, + #[allow(dead_code)] + Error, + #[allow(dead_code)] + Idle, +} + +/// Brand token for a status semantic. Use via `status_style` or +/// `ratatui_style(status_token(...))`. +pub fn status_token(status: Status) -> Rgb { + match status { + Status::Ready => SUCCESS, + Status::Working => PRIMARY, + Status::Warning => PRIMARY_STRONG, + Status::Error => DANGER, + Status::Idle => DIM, + } +} // ── Terminal-mode detection ── @@ -247,6 +317,12 @@ pub fn ratatui_style(c: Rgb) -> RatStyle { RatStyle::default().fg(ratatui_color(c)) } +/// Sugar over `ratatui_style(status_token(s))` — keeps status indicators +/// in renderers tied to the `Status` semantic, not raw colors. +pub fn status_style(status: Status) -> RatStyle { + ratatui_style(status_token(status)) +} + // ── ANSI Display wrappers ── use std::fmt; @@ -339,6 +415,78 @@ impl fmt::Display for Reset { } } +// ── Palette ── + +/// Pre-resolved foreground escapes for every common token, plus the matching +/// `Reset`. Renderers building a raw-ANSI frame can grab one of these and +/// stop writing `theme::Fg::with_mode(theme::PRIMARY, mode)` six times in a +/// row. Phase 2 defines this helper and exercises it via tests; Phase 3 +/// will migrate the existing per-renderer boilerplate over. +#[allow(dead_code)] +#[derive(Copy, Clone, Debug)] +pub struct Palette { + pub mode: TerminalMode, + pub primary: Fg, + pub primary_strong: Fg, + pub user_label: Fg, + pub agent_label: Fg, + pub field_label: Fg, + pub hint_key: Fg, + pub hint_label: Fg, + pub text: Fg, + pub text_dim: Fg, + pub dim: Fg, + pub reset: Reset, +} + +/// Palette for the active terminal mode. +#[allow(dead_code)] +pub fn palette() -> Palette { + palette_for(mode()) +} + +/// Palette for an explicit mode (useful for tests and the plain-text +/// renderers that pass `TerminalMode::NoColor`). +pub fn palette_for(mode: TerminalMode) -> Palette { + Palette { + mode, + primary: Fg::with_mode(PRIMARY, mode), + primary_strong: Fg::with_mode(PRIMARY_STRONG, mode), + user_label: Fg::with_mode(USER_LABEL, mode), + agent_label: Fg::with_mode(AGENT_LABEL, mode), + field_label: Fg::with_mode(FIELD_LABEL, mode), + hint_key: Fg::with_mode(HINT_KEY, mode), + hint_label: Fg::with_mode(HINT_LABEL, mode), + text: Fg::with_mode(TEXT, mode), + text_dim: Fg::with_mode(TEXT_DIM, mode), + dim: Fg::with_mode(DIM, mode), + reset: Reset::with_mode(mode), + } +} + +// ── Width helpers ── + +/// Truncate `value` to at most `limit` characters, using `…` as the last +/// character when truncation happens. `chars().count()` is intentional: every +/// terminal column we render today is a single character cell, and `…` itself +/// is one cell. Wide-cell text (CJK, emoji) is out of scope for the current +/// TUI screens; if that changes, swap this for `unicode_width`. +pub fn fit_chars(value: &str, limit: usize) -> String { + let count = value.chars().count(); + if count <= limit { + return value.to_string(); + } + if limit == 0 { + return String::new(); + } + if limit == 1 { + return "…".to_string(); + } + let mut fitted: String = value.chars().take(limit - 1).collect(); + fitted.push('…'); + fitted +} + #[cfg(test)] mod tests { use super::*; @@ -435,6 +583,11 @@ mod tests { rgb_from_hex(&vars["--oc-success"]), "--oc-success" ); + assert_eq!( + brand::SURFACE_0, + rgb_from_hex(&vars["--oc-surface-0"]), + "--oc-surface-0" + ); assert_eq!( brand::SURFACE_1, rgb_from_hex(&vars["--oc-surface-1"]), @@ -445,6 +598,11 @@ mod tests { rgb_from_hex(&vars["--oc-surface-2"]), "--oc-surface-2" ); + assert_eq!( + brand::SURFACE_3, + rgb_from_hex(&vars["--oc-surface-3"]), + "--oc-surface-3" + ); assert_eq!( brand::TEXT, @@ -461,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] @@ -477,6 +646,109 @@ mod tests { assert_eq!(DIM, brand::TEXT_FAINT); assert_eq!(SURFACE, brand::SURFACE_1); assert_eq!(SURFACE_STRONG, brand::SURFACE_2); + assert_eq!(TEXT, brand::TEXT); + assert_eq!(TEXT_DIM, brand::TEXT_MUTED); + 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] + fn status_token_maps_each_variant_to_a_semantic_token() { + assert_eq!(status_token(Status::Ready), SUCCESS); + assert_eq!(status_token(Status::Working), PRIMARY); + assert_eq!(status_token(Status::Warning), PRIMARY_STRONG); + assert_eq!(status_token(Status::Error), DANGER); + assert_eq!(status_token(Status::Idle), DIM); + } + + #[test] + fn status_style_in_no_color_mode_resolves_to_reset_fg() { + // We can't easily flip global mode in a test, but `status_style` + // composes `ratatui_style` over `status_token`. Spot-check the chain + // by asserting `ratatui_color_with_mode` collapses to Reset in + // NoColor — which is the property `status_style` inherits. + use ratatui::style::Color; + assert_eq!( + ratatui_color_with_mode(status_token(Status::Ready), TerminalMode::NoColor), + Color::Reset, + ); + assert_eq!( + ratatui_color_with_mode(status_token(Status::Error), TerminalMode::NoColor), + Color::Reset, + ); + } + + #[test] + fn palette_in_no_color_mode_emits_no_escapes() { + let p = palette_for(TerminalMode::NoColor); + assert_eq!(p.mode, TerminalMode::NoColor); + for s in [ + format!("{}", p.primary), + format!("{}", p.primary_strong), + format!("{}", p.user_label), + format!("{}", p.agent_label), + format!("{}", p.field_label), + format!("{}", p.hint_key), + format!("{}", p.hint_label), + format!("{}", p.text), + format!("{}", p.text_dim), + format!("{}", p.dim), + format!("{}", p.reset), + ] { + assert!(s.is_empty(), "expected empty in NoColor, got {s:?}"); + } + } + + #[test] + fn palette_in_true_color_mode_emits_truecolor_escapes() { + let p = palette_for(TerminalMode::TrueColor); + // PRIMARY = brand::PURPLE_3 = 0xC5 0xBD 0xED + assert_eq!(format!("{}", p.primary), "\x1b[38;2;197;189;237m"); + // RESET is SGR-zero + assert_eq!(format!("{}", p.reset), "\x1b[0m"); + // TEXT = brand::TEXT = 0xF0 0xF0 0xF0 + assert_eq!(format!("{}", p.text), "\x1b[38;2;240;240;240m"); + } + + #[test] + fn palette_in_indexed_256_mode_emits_indexed_escapes() { + let p = palette_for(TerminalMode::Indexed256); + // PRIMARY → 183 (verified by nearest_256_brand_tokens) + assert_eq!(format!("{}", p.primary), "\x1b[38;5;183m"); + // RESET still SGR-zero in Indexed256 + assert_eq!(format!("{}", p.reset), "\x1b[0m"); + } + + #[test] + fn fit_chars_returns_input_when_already_within_limit() { + assert_eq!(fit_chars("hello", 10), "hello"); + assert_eq!(fit_chars("hello", 5), "hello"); + } + + #[test] + fn fit_chars_truncates_with_ellipsis_when_over_limit() { + assert_eq!(fit_chars("hello world", 8), "hello w…"); + assert_eq!(fit_chars("abcdef", 3), "ab…"); + } + + #[test] + fn fit_chars_handles_zero_and_one_limit_edge_cases() { + assert_eq!(fit_chars("anything", 0), ""); + assert_eq!(fit_chars("anything", 1), "…"); + // Empty input is always under any limit. + assert_eq!(fit_chars("", 0), ""); + assert_eq!(fit_chars("", 5), ""); + } + + #[test] + fn fit_chars_counts_chars_not_bytes_for_multibyte_input() { + // 5 chars, each multi-byte. Already within a 5-cell limit. + assert_eq!(fit_chars("héllo", 5), "héllo"); + // Truncating multi-byte input keeps char-aligned slices. + assert_eq!(fit_chars("héllo world", 6), "héllo…"); } #[test] diff --git a/crates/coven-cli/src/tui/cast/mod.rs b/crates/coven-cli/src/tui/cast/mod.rs index 3d56db7..c5e8a0b 100644 --- a/crates/coven-cli/src/tui/cast/mod.rs +++ b/crates/coven-cli/src/tui/cast/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod gate; pub(crate) mod intent; pub(crate) mod outcome; pub(crate) mod plan; +pub(crate) mod quest; pub(crate) mod render; pub(crate) mod safety; @@ -29,6 +30,13 @@ pub(crate) use gate::{evaluate_gate, GateOutcome}; pub(crate) use intent::{parse_spell, CastHarness, CastIntent}; pub(crate) use outcome::CastOutcome; pub(crate) use plan::{build_plan, CastPlan}; +#[allow(unused_imports)] +pub(crate) use quest::{ + advance as advance_quest, compose_sub_prompt, quest_from_goal, set_phase_sub_prompt, + skip_phase, Quest, QuestHandoff, QuestPhase, QuestPhaseStatus, QuestPhaseSummary, +}; +#[allow(unused_imports)] +pub(crate) use render::render_quest_handoff; pub(crate) use render::{render_cast_frame_for_terminal, render_outcome, render_plan_intro}; pub(crate) use safety::SafetyDecision; diff --git a/crates/coven-cli/src/tui/cast/quest.rs b/crates/coven-cli/src/tui/cast/quest.rs new file mode 100644 index 0000000..6618dbb --- /dev/null +++ b/crates/coven-cli/src/tui/cast/quest.rs @@ -0,0 +1,546 @@ +//! Cast quest flow — deterministic sub-prompting for sequential goals. +//! +//! Phase 5 takes a high-level user goal and decomposes it into an ordered +//! `Quest` of structured phases. Each [`QuestPhase`] owns a concrete +//! `sub_prompt`: the literal text Cast would hand to a harness if the user +//! approves it right now. After a phase finishes, the caller records a +//! [`QuestPhaseSummary`] and calls [`advance`]; the next pending phase +//! receives a [`QuestHandoff`] describing what changed and *why* its +//! sub-prompt was updated. +//! +//! The composer is intentionally deterministic and local-first. No LLM +//! planner is invoked inside Cast — sub-prompts are assembled from +//! structured templates plus the recorded prior-phase outcome. That makes +//! every handoff inspectable, reproducible in tests, and overridable by the +//! user before the harness sees it. +//! +//! Integration boundary: this module is pure. The Cast shell wires the +//! quest into its existing gate / follow / outcome surfaces; `quest.rs` +//! does no IO and never reaches the daemon directly. Until the shell +//! integration lands, the surface is exercised only by the in-module test +//! suite — `#![allow(dead_code)]` keeps the warning noise off the seam. + +#![allow(dead_code)] + +use anyhow::{anyhow, Result}; + +use super::intent::CastHarness; + +/// A sequential goal Cast is guiding the user through. Owns the original +/// user request and an ordered list of [`QuestPhase`]s. `cursor` points at +/// the next phase that has not yet completed or been skipped. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Quest { + pub(crate) title: String, + pub(crate) goal: String, + pub(crate) phases: Vec, + pub(crate) cursor: usize, +} + +/// One scoped phase. `template` is the structured base prompt; `sub_prompt` +/// is the currently-resolved text Cast would hand to the harness. The two +/// diverge after a handoff (which appends carried context) or a manual +/// `set_phase_sub_prompt` edit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QuestPhase { + pub(crate) name: String, + pub(crate) goal: String, + pub(crate) harness: Option, + pub(crate) template: String, + pub(crate) sub_prompt: String, + pub(crate) status: QuestPhaseStatus, + pub(crate) handoff: Option, + pub(crate) edited_by_user: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum QuestPhaseStatus { + Pending, + Running { session_id: String }, + Complete(QuestPhaseSummary), + Skipped { reason: String }, +} + +/// Structured outcome of a single phase. Cast feeds this into the next +/// phase's handoff so the visible sub-prompt update stays reproducible. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct QuestPhaseSummary { + pub(crate) session_id: Option, + pub(crate) exit_status: Option, + pub(crate) exit_code: Option, + /// Bulletable facts extracted from the run that should be carried into + /// the next sub-prompt (file paths touched, IDs minted, tests run). + pub(crate) carried_context: Vec, +} + +/// What Cast tells the next phase about the prior one. `reason` is +/// rendered verbatim on the handoff card so the user can read why the +/// sub-prompt changed before approving it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QuestHandoff { + pub(crate) from_phase: String, + pub(crate) prior_status: String, + pub(crate) reason: String, + pub(crate) carried_context: Vec, +} + +impl Quest { + pub(crate) fn current_index(&self) -> Option { + if self.cursor < self.phases.len() { + Some(self.cursor) + } else { + None + } + } + + pub(crate) fn current(&self) -> Option<&QuestPhase> { + self.current_index().map(|idx| &self.phases[idx]) + } + + pub(crate) fn is_complete(&self) -> bool { + self.cursor >= self.phases.len() + } +} + +const DESIGN_PHASE_TEMPLATE: &str = + "Design the smallest viable change for the goal. Produce: a short approach summary, the file or surface boundaries you will touch, and a list of risks or open questions. Do not write code yet. Goal: {goal}."; + +const IMPLEMENT_PHASE_TEMPLATE: &str = + "Implement the change agreed in the prior design phase. Stay within the named boundaries. Run any existing tests touching the change. Goal: {goal}."; + +const VERIFY_PHASE_TEMPLATE: &str = + "Verify the implementation: re-run the touched tests, sanity-check the diff, and surface any regression or follow-up. Do not push or merge. Goal: {goal}."; + +const QUEST_TITLE_CHARS: usize = 60; + +/// Build a fresh quest from a free-text goal. The default template is the +/// Design → Implement → Verify rhythm that fits most repository work; each +/// phase starts pending with a concrete `sub_prompt` already composed so +/// the user can read what Cast would delegate. +pub(crate) fn quest_from_goal(goal: &str, default_harness: Option) -> Quest { + let trimmed = goal.trim(); + let title = derive_quest_title(trimmed); + let mut quest = Quest { + title, + goal: trimmed.to_string(), + phases: default_phase_set(trimmed, default_harness), + cursor: 0, + }; + let goal = quest.goal.clone(); + for phase in &mut quest.phases { + phase.sub_prompt = compose_sub_prompt(phase, &goal); + } + quest +} + +fn default_phase_set(goal: &str, harness: Option) -> Vec { + vec![ + new_phase( + "design", + "Scope the work", + DESIGN_PHASE_TEMPLATE, + goal, + harness, + ), + new_phase( + "implement", + "Make the change", + IMPLEMENT_PHASE_TEMPLATE, + goal, + harness, + ), + new_phase( + "verify", + "Confirm the change", + VERIFY_PHASE_TEMPLATE, + goal, + harness, + ), + ] +} + +fn new_phase( + name: &str, + role: &str, + template: &str, + goal: &str, + harness: Option, +) -> QuestPhase { + QuestPhase { + name: name.to_string(), + goal: format!("{role} for: {goal}"), + harness, + template: template.to_string(), + sub_prompt: String::new(), + status: QuestPhaseStatus::Pending, + handoff: None, + edited_by_user: false, + } +} + +/// Compose `phase.sub_prompt` from its template plus any attached handoff. +/// Pure: same inputs always yield the same output, which is what makes the +/// handoff card honest. +pub(crate) fn compose_sub_prompt(phase: &QuestPhase, quest_goal: &str) -> String { + let mut out = phase.template.replace("{goal}", quest_goal); + if let Some(handoff) = &phase.handoff { + out.push_str("\n\nHandoff from phase `"); + out.push_str(&handoff.from_phase); + out.push_str("` (status `"); + out.push_str(&handoff.prior_status); + out.push_str("`):\n- "); + out.push_str(&handoff.reason); + for fact in &handoff.carried_context { + out.push_str("\n- "); + out.push_str(fact); + } + } + out +} + +/// Mark the current phase complete and advance the quest. The next pending +/// phase receives a structured [`QuestHandoff`] and its `sub_prompt` is +/// recomposed deterministically. Phases the user has explicitly edited +/// (see [`set_phase_sub_prompt`]) are preserved — Cast must not silently +/// clobber a user's choice. +/// +/// Returns the index of the next pending phase, or `None` when the quest +/// has no further work. +pub(crate) fn advance(quest: &mut Quest, summary: QuestPhaseSummary) -> Option { + let current = quest.current_index()?; + let from_name = quest.phases[current].name.clone(); + let prior_status_label = phase_status_label(&summary); + let reason = handoff_reason(&from_name, &prior_status_label); + let carried = summary.carried_context.clone(); + + quest.phases[current].status = QuestPhaseStatus::Complete(summary); + quest.cursor = current + 1; + + let next_index = quest.current_index()?; + let goal = quest.goal.clone(); + let next = &mut quest.phases[next_index]; + next.handoff = Some(QuestHandoff { + from_phase: from_name, + prior_status: prior_status_label, + reason, + carried_context: carried, + }); + if !next.edited_by_user { + next.sub_prompt = compose_sub_prompt(next, &goal); + } + Some(next_index) +} + +/// Override the sub-prompt for a pending phase. Marks the phase as +/// user-edited so a later [`advance`] does not silently regenerate the +/// text. Errors out if the phase is not pending — Cast does not rewrite +/// already-running or completed phases. +pub(crate) fn set_phase_sub_prompt( + quest: &mut Quest, + index: usize, + sub_prompt: String, +) -> Result<()> { + let phase = quest + .phases + .get_mut(index) + .ok_or_else(|| anyhow!("quest phase index {index} out of range"))?; + if !matches!(phase.status, QuestPhaseStatus::Pending) { + return Err(anyhow!( + "phase `{}` is not pending; sub-prompts can only be edited before the phase runs", + phase.name + )); + } + phase.sub_prompt = sub_prompt; + phase.edited_by_user = true; + Ok(()) +} + +/// Skip a pending phase with a recorded reason. Useful when the prior +/// phase already satisfied this phase's goal (e.g. tests passed during +/// implement, so verify becomes a no-op). +pub(crate) fn skip_phase(quest: &mut Quest, index: usize, reason: String) -> Result<()> { + let phase = quest + .phases + .get_mut(index) + .ok_or_else(|| anyhow!("quest phase index {index} out of range"))?; + if !matches!(phase.status, QuestPhaseStatus::Pending) { + return Err(anyhow!( + "phase `{}` is not pending; only pending phases can be skipped", + phase.name + )); + } + phase.status = QuestPhaseStatus::Skipped { reason }; + if quest.cursor == index { + quest.cursor = index + 1; + } + Ok(()) +} + +fn phase_status_label(summary: &QuestPhaseSummary) -> String { + if let Some(status) = &summary.exit_status { + match summary.exit_code { + Some(code) => format!("{status} (exit {code})"), + None => status.clone(), + } + } else if let Some(code) = summary.exit_code { + format!("exit {code}") + } else { + "complete".to_string() + } +} + +fn handoff_reason(from_phase: &str, prior_status_label: &str) -> String { + let lower = prior_status_label.to_ascii_lowercase(); + let failed = lower.starts_with("failed") + || lower.contains("error") + || lower.contains("exit 1") + || lower.contains("interrupted"); + if failed { + format!( + "Phase `{from_phase}` finished with `{prior_status_label}` — incorporate the failure context before continuing." + ) + } else { + format!( + "Phase `{from_phase}` finished with `{prior_status_label}` — carry its result into the next sub-prompt." + ) + } +} + +fn derive_quest_title(goal: &str) -> String { + let collapsed: String = goal.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return "Untitled quest".to_string(); + } + let count = collapsed.chars().count(); + if count <= QUEST_TITLE_CHARS { + return collapsed; + } + let mut out: String = collapsed.chars().take(QUEST_TITLE_CHARS - 1).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quest(goal: &str) -> Quest { + quest_from_goal(goal, Some(CastHarness::Codex)) + } + + #[test] + fn quest_from_goal_uses_default_three_phase_template() { + let q = quest("ship phase 5 sub-prompting"); + assert_eq!(q.goal, "ship phase 5 sub-prompting"); + assert_eq!( + q.phases.iter().map(|p| p.name.clone()).collect::>(), + vec!["design", "implement", "verify"] + ); + assert_eq!(q.cursor, 0); + assert!(!q.is_complete()); + } + + #[test] + fn every_phase_starts_with_a_concrete_sub_prompt_containing_the_goal() { + let q = quest("rename the legacy `cody` module to `cast`"); + for phase in &q.phases { + assert!( + !phase.sub_prompt.is_empty(), + "phase `{}` must have a sub_prompt", + phase.name + ); + assert!( + phase + .sub_prompt + .contains("rename the legacy `cody` module to `cast`"), + "phase `{}` sub_prompt should include the user goal verbatim, got:\n{}", + phase.name, + phase.sub_prompt + ); + } + } + + #[test] + fn compose_sub_prompt_substitutes_goal_and_appends_handoff() { + let mut q = quest("fix the flaky integration test"); + q.phases[1].handoff = Some(QuestHandoff { + from_phase: "design".to_string(), + prior_status: "completed (exit 0)".to_string(), + reason: "Design pinned the seam to `cast::quest`.".to_string(), + carried_context: vec!["touched `cast/quest.rs`".to_string()], + }); + let composed = compose_sub_prompt(&q.phases[1], &q.goal); + assert!(composed.contains("fix the flaky integration test")); + assert!(composed.contains("Handoff from phase `design`")); + assert!(composed.contains("status `completed (exit 0)`")); + assert!(composed.contains("Design pinned the seam to `cast::quest`.")); + assert!(composed.contains("touched `cast/quest.rs`")); + } + + #[test] + fn advance_marks_prior_complete_and_recomposes_next_sub_prompt() { + let mut q = quest("polish the README"); + let original_implement = q.phases[1].sub_prompt.clone(); + + let next = advance( + &mut q, + QuestPhaseSummary { + session_id: Some("session-abc".to_string()), + exit_status: Some("completed".to_string()), + exit_code: Some(0), + carried_context: vec!["proposed bullet list of edits".to_string()], + }, + ); + + assert_eq!( + next, + Some(1), + "cursor should advance to the implement phase" + ); + assert_eq!(q.cursor, 1); + assert!(matches!(q.phases[0].status, QuestPhaseStatus::Complete(_))); + assert!(matches!(q.phases[1].status, QuestPhaseStatus::Pending)); + // The recomposed sub-prompt carries the handoff text, so it must + // differ from the bare template form built at quest construction. + assert_ne!( + q.phases[1].sub_prompt, original_implement, + "implement sub_prompt should be refreshed with handoff context after advance" + ); + assert!(q.phases[1] + .sub_prompt + .contains("proposed bullet list of edits")); + let handoff = q.phases[1].handoff.as_ref().expect("handoff attached"); + assert_eq!(handoff.from_phase, "design"); + assert!(handoff.reason.contains("carry its result")); + } + + #[test] + fn advance_after_failed_phase_uses_failure_oriented_handoff_reason() { + let mut q = quest("upgrade the rust toolchain"); + advance( + &mut q, + QuestPhaseSummary { + session_id: None, + exit_status: Some("failed".to_string()), + exit_code: Some(1), + carried_context: vec!["`cargo build` exited 1 on `coven-cli`".to_string()], + }, + ); + let handoff = q.phases[1].handoff.as_ref().expect("handoff attached"); + assert!( + handoff.reason.contains("incorporate the failure context"), + "failed phase should produce a failure-flavoured reason, got: {}", + handoff.reason + ); + assert!(q.phases[1].sub_prompt.contains("`cargo build` exited 1")); + } + + #[test] + fn set_phase_sub_prompt_overrides_and_survives_subsequent_advance() { + let mut q = quest("rotate the daemon socket path"); + set_phase_sub_prompt( + &mut q, + 1, + "Move the socket to `$XDG_RUNTIME_DIR/coven.sock` and update the lockfile.".to_string(), + ) + .unwrap(); + assert!(q.phases[1].edited_by_user); + + advance( + &mut q, + QuestPhaseSummary { + session_id: None, + exit_status: Some("completed".to_string()), + exit_code: Some(0), + carried_context: vec!["socket location decided".to_string()], + }, + ); + + // The handoff is still attached so the user can read it, but the + // sub_prompt text the user authored is preserved verbatim. + assert!(q.phases[1].handoff.is_some(), "handoff should still attach"); + assert_eq!( + q.phases[1].sub_prompt, + "Move the socket to `$XDG_RUNTIME_DIR/coven.sock` and update the lockfile.", + "user-authored sub_prompt must not be clobbered by advance" + ); + } + + #[test] + fn set_phase_sub_prompt_rejects_non_pending_phases() { + let mut q = quest("anything"); + q.phases[0].status = QuestPhaseStatus::Running { + session_id: "session-1".to_string(), + }; + let err = set_phase_sub_prompt(&mut q, 0, "ignored".to_string()).unwrap_err(); + assert!(err.to_string().contains("not pending")); + } + + #[test] + fn skip_phase_advances_cursor_and_marks_status() { + let mut q = quest("publish a release"); + skip_phase(&mut q, 2, "verify happens in CI".to_string()).unwrap(); + assert!(matches!( + q.phases[2].status, + QuestPhaseStatus::Skipped { .. } + )); + + advance( + &mut q, + QuestPhaseSummary { + exit_status: Some("completed".to_string()), + exit_code: Some(0), + ..QuestPhaseSummary::default() + }, + ); + // After design completes, cursor moves to implement (1). + assert_eq!(q.cursor, 1); + advance( + &mut q, + QuestPhaseSummary { + exit_status: Some("completed".to_string()), + exit_code: Some(0), + ..QuestPhaseSummary::default() + }, + ); + // Implement completes; cursor lands on the skipped verify (2). The + // next `current()` call shows verify as skipped so the shell can + // jump past it without re-prompting. + assert_eq!(q.cursor, 2); + let current = q.current().expect("verify exists"); + assert!(matches!(current.status, QuestPhaseStatus::Skipped { .. })); + } + + #[test] + fn advance_returns_none_after_the_last_phase() { + let mut q = quest("teach Cast to whistle"); + let r1 = advance(&mut q, QuestPhaseSummary::default()); + let r2 = advance(&mut q, QuestPhaseSummary::default()); + let r3 = advance(&mut q, QuestPhaseSummary::default()); + let r4 = advance(&mut q, QuestPhaseSummary::default()); + assert_eq!((r1, r2, r3, r4), (Some(1), Some(2), None, None)); + assert!(q.is_complete()); + assert!(q.current().is_none()); + } + + #[test] + fn handoff_status_label_falls_back_when_exit_code_only() { + let label = phase_status_label(&QuestPhaseSummary { + exit_status: None, + exit_code: Some(2), + ..QuestPhaseSummary::default() + }); + assert_eq!(label, "exit 2"); + + let label = phase_status_label(&QuestPhaseSummary::default()); + assert_eq!(label, "complete"); + } + + #[test] + fn quest_title_truncates_very_long_goals() { + let goal = "do every single conceivable thing across the whole repository in one go please"; + let q = quest_from_goal(goal, None); + assert!(q.title.chars().count() <= QUEST_TITLE_CHARS); + assert!(q.title.ends_with('…')); + } +} diff --git a/crates/coven-cli/src/tui/cast/render.rs b/crates/coven-cli/src/tui/cast/render.rs index 7eeab22..df9ac3f 100644 --- a/crates/coven-cli/src/tui/cast/render.rs +++ b/crates/coven-cli/src/tui/cast/render.rs @@ -7,19 +7,31 @@ use std::path::Path; -use crate::theme::{self, TerminalMode}; +use crate::theme::{self, fit_chars, palette_for, Fg, Palette, TerminalMode}; use super::outcome::CastOutcome; use super::plan::{CastHarnessSource, CastPlan, CastStepKind}; +use super::quest::{Quest, QuestPhase, QuestPhaseStatus}; use super::safety::{CastRisk, SafetyDecision}; const CAST_INTRO_INNER_WIDTH: usize = 76; -/// 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. +/// 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 subtitle under the `Cast` identity row in the non-interactive +/// frame. Stays brand-aligned without second-person greeting or the +/// "is ready" lampshading the design contract calls out. pub(crate) fn cast_salute() -> &'static str { - "Cast, your Coven familiar, is ready. Type a spell, or use a slash command." + "Coven familiar · type a spell or pick a slash" } /// A short Cast frame for non-interactive mode: who Cast is, what spells look @@ -47,54 +59,47 @@ fn render_cast_frame_with_mode( default_harness: Option<&str>, 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 dim = theme::Fg::with_mode(theme::DIM, mode); - let reset = theme::Reset::with_mode(mode); + let p = palette_for(mode); let inner_width = CAST_INTRO_INNER_WIDTH; let mut frame = String::new(); + // Identity + subtitle (the subtitle is the only place "Coven familiar" + // appears now — no second-person greeting, no "is ready" lampshading). + push_section_header(&mut frame, &p, "Cast"); frame.push_str(&format!( - "{primary_strong}Cast — your Coven familiar{reset}\n" - )); - frame.push_str(&format!( - "{field_label}{}{reset}\n", - fit_chars(cast_salute(), inner_width) + "{}{}{}\n", + p.field_label, + fit_chars(cast_salute(), inner_width), + p.reset, )); frame.push('\n'); - frame.push_str(&format!("{primary_strong}Context{reset}\n")); + push_section_header(&mut frame, &p, "Context"); let project = project_root .map(|root| root.display().to_string()) .unwrap_or_else(|| "not inside a project root — run from a repo".to_string()); - frame.push_str(&format!( - "{field_label}Project{reset} {}\n", - fit_chars(&project, inner_width.saturating_sub(15)) - )); + push_label_row(&mut frame, &p, "project", &project); let harness = default_harness.unwrap_or("none ready — run `coven doctor`"); - frame.push_str(&format!( - "{field_label}Default harness{reset} {}\n", - fit_chars(harness, inner_width.saturating_sub(15)) - )); + push_label_row(&mut frame, &p, "harness", harness); frame.push('\n'); - frame.push_str(&format!("{primary_strong}Example spells{reset}\n")); + push_section_header(&mut frame, &p, "Example spells"); for spell in cast_example_spells() { - frame.push_str(&format!("{primary} {}{reset}\n", spell)); + frame.push_str(&format!(" {}{}{}\n", p.text, spell, p.reset)); } frame.push('\n'); - frame.push_str(&format!("{primary_strong}Slash spells{reset}\n")); + push_section_header(&mut frame, &p, "Slash spells"); for spell in cast_example_slashes() { - frame.push_str(&format!("{user_label} {}{reset}\n", spell)); + frame.push_str(&format!(" {}{}{}\n", p.user_label, spell, p.reset)); } frame.push('\n'); - frame.push_str(&format!( - "{dim}Tip: in a terminal, `coven` opens the Cast launcher. Empty input opens the slash palette.{reset}\n" - )); + push_footer_hint( + &mut frame, + &p, + "run `coven` in a terminal to open the launcher · empty input runs a slash", + ); frame } @@ -133,63 +138,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 +196,259 @@ 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 +} + +/// Cast's quest handoff card: shown between phases of a sequential quest +/// so the user can read what the prior phase produced and exactly what the +/// next phase's sub-prompt will be before approving the handoff. The card +/// is a *visible delegation announcement* — it never executes anything; it +/// just makes Cast's deterministic composer inspectable. +#[allow(dead_code)] +pub(crate) fn render_quest_handoff(quest: &Quest, next_index: usize) -> String { + render_quest_handoff_with_mode(quest, next_index, theme::mode()) +} + +#[allow(dead_code)] +pub(crate) fn render_quest_handoff_plain(quest: &Quest, next_index: usize) -> String { + render_quest_handoff_with_mode(quest, next_index, TerminalMode::NoColor) +} + +fn render_quest_handoff_with_mode(quest: &Quest, next_index: usize, mode: TerminalMode) -> String { + let p = palette_for(mode); + let mut frame = String::new(); + + push_section_header(&mut frame, &p, "Cast handoff"); + push_label_row(&mut frame, &p, "quest", &quest.title); + push_label_row( + &mut frame, + &p, + "phase", + &quest_phase_position_label(quest, next_index), + ); + + if let Some(next) = quest.phases.get(next_index) { + if let Some(handoff) = &next.handoff { + push_label_row(&mut frame, &p, "from", &handoff.from_phase); + push_label_row(&mut frame, &p, "prior", &handoff.prior_status); + push_continuation_row(&mut frame, &p, &handoff.reason); + if !handoff.carried_context.is_empty() { + frame.push('\n'); + push_section_header(&mut frame, &p, "Carried context"); + for fact in handoff.carried_context.iter().take(4) { + push_note_row(&mut frame, &p, fact); + } + } + } else { + push_label_row(&mut frame, &p, "from", "(quest start)"); + } + + let harness_label = next + .harness + .map(|h| h.label()) + .unwrap_or("(default harness)"); + let edited_marker = if next.edited_by_user { + " · user-edited" + } else { + "" + }; + push_label_row( + &mut frame, + &p, + "delegate to", + &format!("{harness_label}{edited_marker}"), + ); + + frame.push('\n'); + push_section_header(&mut frame, &p, "Sub-prompt"); + for line in clip_sub_prompt_lines(&next.sub_prompt) { + push_sub_prompt_line(&mut frame, &p, &line); + } + + frame.push('\n'); + push_footer_hint(&mut frame, &p, quest_handoff_footer_hint(next)); + } else { + push_label_row(&mut frame, &p, "status", "quest complete"); + frame.push('\n'); + push_footer_hint(&mut frame, &p, "no further phases · type a new spell"); } + frame } -fn risk_label(risk: CastRisk) -> &'static str { +fn quest_phase_position_label(quest: &Quest, next_index: usize) -> String { + let total = quest.phases.len(); + let phase_name = quest + .phases + .get(next_index) + .map(|p| p.name.as_str()) + .unwrap_or("(end)"); + let position = (next_index + 1).min(total.max(1)); + format!("{position}/{total} · {phase_name}") +} + +fn quest_handoff_footer_hint(next: &QuestPhase) -> &'static str { + match &next.status { + QuestPhaseStatus::Pending => "enter approves the sub-prompt · type to edit · esc cancels", + QuestPhaseStatus::Running { .. } => "phase running · attach to follow", + QuestPhaseStatus::Complete(_) => "phase already complete · advance again", + QuestPhaseStatus::Skipped { .. } => "phase skipped · advance to continue", + } +} + +/// Keep the visible sub-prompt block bounded; long composer output (with +/// many carried-context bullets) would push the footer out of the user's +/// view. We cap at 8 lines, with an ellipsis line at the end so the user +/// knows there is more text the harness will receive. +const SUB_PROMPT_VISIBLE_LINES: usize = 8; + +fn clip_sub_prompt_lines(sub_prompt: &str) -> Vec { + let lines: Vec<&str> = sub_prompt.lines().collect(); + if lines.len() <= SUB_PROMPT_VISIBLE_LINES { + return lines.iter().map(|l| (*l).to_string()).collect(); + } + let mut out: Vec = lines + .iter() + .take(SUB_PROMPT_VISIBLE_LINES - 1) + .map(|l| (*l).to_string()) + .collect(); + out.push(format!( + "… {} more lines", + lines.len() - (SUB_PROMPT_VISIBLE_LINES - 1) + )); + out +} + +fn push_sub_prompt_line(frame: &mut String, p: &Palette, line: &str) { + frame.push_str(&format!(" {}{}{}\n", p.text, line, p.reset)); +} + +/// 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)); +} + +/// `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 => CHIP_SAFE, + CastRisk::Confirm => CHIP_CONFIRM, + CastRisk::Reject => CHIP_REJECT, + } +} + +fn risk_chip_fg(mode: TerminalMode, risk: CastRisk) -> Fg { match risk { - CastRisk::Safe => "safe", - CastRisk::Confirm => "confirmation-required", - CastRisk::Reject => "rejected", + 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), } } @@ -253,22 +465,6 @@ fn step_kind_label(kind: CastStepKind) -> &'static str { } } -fn fit_chars(value: &str, limit: usize) -> String { - let count = value.chars().count(); - if count <= limit { - return value.to_string(); - } - if limit == 0 { - return String::new(); - } - if limit == 1 { - return "…".to_string(); - } - let mut fitted: String = value.chars().take(limit - 1).collect(); - fitted.push('…'); - fitted -} - #[cfg(test)] mod tests { use std::path::PathBuf; @@ -302,69 +498,425 @@ 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 quest_handoff_card_shows_source_phase_status_and_next_sub_prompt() { + use crate::tui::cast::quest::{advance, quest_from_goal, QuestPhaseSummary}; + + let mut quest = quest_from_goal("ship phase 5 sub-prompting", Some(CastHarness::Codex)); + let next = advance( + &mut quest, + QuestPhaseSummary { + session_id: Some("session-abc123".to_string()), + exit_status: Some("completed".to_string()), + exit_code: Some(0), + carried_context: vec![ + "added `cast::quest` module".to_string(), + "drafted handoff card".to_string(), + ], + }, + ) + .expect("advance should yield the implement phase"); + + let frame = render_quest_handoff_plain(&quest, next); + + assert!( + frame.contains("Cast handoff"), + "missing header, frame:\n{frame}" + ); + assert_label_column(&frame, "quest"); + assert_label_column(&frame, "phase"); + assert_label_column(&frame, "from"); + assert_label_column(&frame, "prior"); + assert_label_column(&frame, "delegate to"); + + assert!(frame.contains("ship phase 5 sub-prompting")); + assert!(frame.contains("2/3 · implement")); + assert!(frame.contains("Codex")); + assert!( + frame.contains("Phase `design` finished with `completed (exit 0)`"), + "handoff reason should describe prior status, frame:\n{frame}" + ); + assert!( + frame.contains("Sub-prompt"), + "render must surface the next sub-prompt block" + ); + assert!( + frame.contains("ship phase 5 sub-prompting"), + "sub-prompt should echo the user's goal verbatim" + ); + assert!(frame.contains("added `cast::quest` module")); + assert_no_ansi_leakage(&frame); + } + + #[test] + fn quest_handoff_card_marks_user_edited_sub_prompt_so_users_can_tell() { + use crate::tui::cast::quest::{ + advance, quest_from_goal, set_phase_sub_prompt, QuestPhaseSummary, + }; + + let mut quest = quest_from_goal("rotate the daemon socket", Some(CastHarness::Codex)); + set_phase_sub_prompt( + &mut quest, + 1, + "Move the socket to `$XDG_RUNTIME_DIR/coven.sock`.".to_string(), + ) + .unwrap(); + let next = advance( + &mut quest, + QuestPhaseSummary { + exit_status: Some("completed".to_string()), + exit_code: Some(0), + ..QuestPhaseSummary::default() + }, + ) + .unwrap(); + + let frame = render_quest_handoff_plain(&quest, next); + assert!( + frame.contains("user-edited"), + "user-authored sub_prompts should be flagged, frame:\n{frame}" + ); + assert!(frame.contains("Move the socket to `$XDG_RUNTIME_DIR/coven.sock`.")); + } + + #[test] + fn quest_handoff_card_handles_quest_complete_state_with_no_panic() { + use crate::tui::cast::quest::{advance, quest_from_goal, QuestPhaseSummary}; + + let mut quest = quest_from_goal("trivial", Some(CastHarness::Codex)); + // Drain all phases. + advance(&mut quest, QuestPhaseSummary::default()); + advance(&mut quest, QuestPhaseSummary::default()); + advance(&mut quest, QuestPhaseSummary::default()); + assert!(quest.is_complete()); + + // Asking for the handoff at the past-the-end cursor must not panic. + let frame = render_quest_handoff_plain(&quest, quest.phases.len()); + assert!(frame.contains("quest complete"), "frame:\n{frame}"); + } + + #[test] + fn quest_handoff_card_clips_very_long_sub_prompts() { + use crate::tui::cast::quest::{ + advance, quest_from_goal, set_phase_sub_prompt, QuestPhaseSummary, + }; + + let mut quest = quest_from_goal("anything", Some(CastHarness::Codex)); + // Compose a sub-prompt with many lines so the renderer clips. + let long: String = (0..30) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + set_phase_sub_prompt(&mut quest, 1, long).unwrap(); + let next = advance(&mut quest, QuestPhaseSummary::default()).unwrap(); + + let frame = render_quest_handoff_plain(&quest, next); + assert!(frame.contains("line 0")); + assert!( + frame.contains("more lines"), + "long sub_prompt should be clipped with a `… N more lines` indicator, frame:\n{frame}" + ); + // The last few lines should NOT appear once we clip. + assert!(!frame.contains("line 29")); + } + + #[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")); } } diff --git a/crates/coven-cli/src/tui/chat/render.rs b/crates/coven-cli/src/tui/chat/render.rs index 277ed52..c99a452 100644 --- a/crates/coven-cli/src/tui/chat/render.rs +++ b/crates/coven-cli/src/tui/chat/render.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Margin, Rect}, - style::{Color, Style}, + style::Style, text::{Line, Span, Text}, widgets::{ Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, @@ -15,8 +15,8 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::theme::{ - self, AGENT_LABEL, DIM, HINT_KEY, HINT_LABEL, PRIMARY, PRIMARY_STRONG, SURFACE, SURFACE_STRONG, - USER_LABEL, + self, Status, AGENT_LABEL, BACKDROP, BORDER_DIM, DIM, HINT_KEY, HINT_LABEL, PRIMARY, + PRIMARY_STRONG, SCROLL_TRACK, SURFACE, SURFACE_STRONG, TEXT, TEXT_DIM, USER_LABEL, }; use super::app::{App, InputMode, MessageRole, SPINNER_FRAMES}; @@ -33,7 +33,7 @@ pub(super) fn render_ui(f: &mut Frame, app: &mut App) { // Background fill f.render_widget( - Block::default().style(Style::default().bg(Color::Black)), + Block::default().style(Style::default().bg(theme::ratatui_color(BACKDROP))), area, ); @@ -89,7 +89,7 @@ fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { theme::ratatui_style(DIM), ) } else { - Span::styled("\u{2713} ready", Style::default().fg(Color::Green)) + Span::styled("\u{2713} ready", theme::status_style(Status::Ready)) }, ]; @@ -146,8 +146,8 @@ fn render_messages(f: &mut Frame, app: &mut App, area: Rect) { let wrapped = textwrap::wrap(content_line, wrap_width); for wl in wrapped { let style = match msg.role { - MessageRole::User => Style::default().fg(Color::White), - MessageRole::Agent => Style::default().fg(Color::Indexed(252)), + MessageRole::User => theme::ratatui_style(TEXT), + MessageRole::Agent => theme::ratatui_style(TEXT_DIM), MessageRole::System => theme::ratatui_style(PRIMARY), }; lines.push(Line::from(Span::styled(format!(" {wl}"), style))); @@ -171,7 +171,7 @@ fn render_messages(f: &mut Frame, app: &mut App, area: Rect) { let chat_block = Block::default() .borders(Borders::NONE) - .style(Style::default().bg(Color::Black)); + .style(Style::default().bg(theme::ratatui_color(BACKDROP))); let messages_widget = Paragraph::new(Text::from(visible_lines)) .block(chat_block) @@ -189,7 +189,7 @@ fn render_messages(f: &mut Frame, app: &mut App, area: Rect) { .end_symbol(None) .track_symbol(Some("\u{2502}")) .thumb_symbol("\u{2588}") - .track_style(Style::default().fg(Color::Indexed(236))) + .track_style(theme::ratatui_style(SCROLL_TRACK)) .thumb_style(theme::ratatui_style(PRIMARY)), area, &mut scrollbar_state, @@ -209,7 +209,7 @@ fn render_input(f: &mut Frame, app: &App, area: Rect) { .border_style(Style::default().fg(if app.input.starts_with('/') { theme::ratatui_color(PRIMARY) } else { - Color::Indexed(240) + theme::ratatui_color(BORDER_DIM) })) .title(Span::styled( format!(" {prompt_label} "), @@ -219,7 +219,7 @@ fn render_input(f: &mut Frame, app: &App, area: Rect) { let input_widget = Paragraph::new(app.input.as_str()) .block(input_block) - .style(Style::default().fg(Color::White)) + .style(theme::ratatui_style(TEXT)) .wrap(Wrap { trim: false }); f.render_widget(input_widget, area); @@ -330,7 +330,7 @@ fn render_help_overlay(f: &mut Frame, area: Rect) { for (cmd, desc) in commands { lines.push(Line::from(vec![ Span::styled(format!(" {cmd:<22}"), theme::ratatui_style(PRIMARY)), - Span::styled(*desc, Style::default().fg(Color::White)), + Span::styled(*desc, theme::ratatui_style(TEXT)), ])); } lines.push(Line::from("")); @@ -384,7 +384,7 @@ fn render_agent_select(f: &mut Frame, app: &App, area: Rect) { } else if !agent.available { theme::ratatui_style(DIM) } else { - Style::default().fg(Color::White) + theme::ratatui_style(TEXT) }; ListItem::new(Line::from(vec![ @@ -449,7 +449,7 @@ fn render_session_overlay(f: &mut Frame, app: &App, area: Rect) { Span::styled(format!(" {marker} "), theme::ratatui_style(PRIMARY)), Span::styled( format!("{:<8}", session.status), - Style::default().fg(Color::Green), + theme::status_style(Status::Ready), ), Span::styled( format!(" {:<7} ", session.harness), @@ -465,7 +465,7 @@ fn render_session_overlay(f: &mut Frame, app: &App, area: Rect) { &session.title, popup_area.width.saturating_sub(36) as usize, ), - Style::default().fg(Color::White), + theme::ratatui_style(TEXT), ), ])); } diff --git a/crates/coven-cli/src/tui/sessions.rs b/crates/coven-cli/src/tui/sessions.rs index 932b389..bb2d320 100644 --- a/crates/coven-cli/src/tui/sessions.rs +++ b/crates/coven-cli/src/tui/sessions.rs @@ -9,6 +9,7 @@ use crossterm::{ }; use super::is_key_press; +use crate::theme::fit_chars; use crate::{ archive_session_command, attach_session, coven_store_path, first_chars, prompt_for_required_line, sacrifice_session_command, store, summon_session_command, theme, @@ -629,23 +630,6 @@ pub(crate) fn render_sessions_json(sessions: &[store::SessionRecord]) -> Result< .context("failed to serialize sessions as JSON") } -fn fit_chars(value: &str, limit: usize) -> String { - let count = value.chars().count(); - if count <= limit { - return value.to_string(); - } - if limit == 0 { - return String::new(); - } - if limit == 1 { - return "…".to_string(); - } - - let mut fitted = value.chars().take(limit - 1).collect::(); - fitted.push('…'); - fitted -} - #[cfg(test)] pub(crate) fn render_browser_frame_plain_for_test( sessions: &[store::SessionRecord], diff --git a/crates/coven-cli/src/tui/shell.rs b/crates/coven-cli/src/tui/shell.rs index 68740fe..55a7e42 100644 --- a/crates/coven-cli/src/tui/shell.rs +++ b/crates/coven-cli/src/tui/shell.rs @@ -827,17 +827,14 @@ fn attach_via_daemon( } let mut observer = TranscriptObserver::new(io::stdout()); - let (exit, replay_summary_note) = if is_live { + let exit = if is_live { let mut pacer = SleepPacer::new(Duration::from_millis(250)); - ( - Some(follow_until_exit( - &mut client, - &session.id, - &mut observer, - &mut pacer, - )?), - None, - ) + Some(follow_until_exit( + &mut client, + &session.id, + &mut observer, + &mut pacer, + )?) } else { // For completed sessions, drain the historical event log once. The // follower observer renders the transcript exactly as it did on the @@ -861,7 +858,13 @@ fn attach_via_daemon( // the summary themselves, so showing it again would only echo the exit // line we already printed. if !is_live { - if let Some(note) = replay_summary_note { + let history = client.list_events(ChatEventQuery { + session_id: &session.id, + after_seq: None, + limit: None, + })?; + if let Some(note) = cast::find_cast_summary(&history).and_then(|s| format_summary_note(&s)) + { notes.push(note); } } @@ -903,19 +906,17 @@ fn attach_via_daemon( /// Drain the historical event log for a non-running session through the same /// observer the live follower uses. Returns the decoded exit if the replay -/// contains an `exit` event so the outcome card can show the original status, -/// plus the rendered `cast.summary` note (if present) from the same event set. +/// contains an `exit` event so the outcome card can show the original status. fn replay_completed_session( client: &mut dyn ChatClient, session_id: &str, observer: &mut dyn FollowerObserver, -) -> Result<(Option, Option)> { +) -> Result> { let records = client.list_events(ChatEventQuery { session_id, after_seq: None, limit: None, })?; - let summary_note = cast::find_cast_summary(&records).and_then(|s| format_summary_note(&s)); let mut exit = None; for record in records { @@ -932,7 +933,7 @@ fn replay_completed_session( cast::follow::CastFollowEvent::Other { kind } => observer.on_other(kind), } } - Ok((exit, summary_note)) + Ok(exit) } fn format_exit_summary(exit: &CastSessionExit) -> String { @@ -982,7 +983,6 @@ fn print_cast_non_interactive_frame() { let default_harness_id = default_harness_id(); let frame = render_cast_frame_for_terminal(project_root.as_deref(), default_harness_id); print!("{frame}"); - println!("\nTip: run `coven` in a real terminal to open the Cast launcher and type a spell."); } /// Plain-text Cast frame for tests and pipe targets. Mirrors @@ -1149,10 +1149,64 @@ fn run_guided_harness_session() -> Result<()> { run_session(&harness, &[prompt], None, title.as_deref(), false) } +const LAUNCHER_VISIBLE_COMMANDS: usize = 6; +const LAUNCHER_FIELD_LABEL_WIDTH: usize = 14; +const LAUNCHER_PROMPT_PLACEHOLDER: &str = "type a task or /run codex"; +const LAUNCHER_FOOTER_HINT: &str = "enter run · ↑↓ select · esc quit · ctrl+u clear"; + +/// Snapshot of the local context shown in the right-hand lane of the +/// launcher (`project`, `harness`, `daemon`). Each field is best-effort: +/// when the environment cannot be read we fall back to quiet placeholders +/// rather than failing to render the frame. +pub(crate) struct LauncherSnapshot { + pub project: String, + pub harness: String, + pub daemon: String, +} + +impl LauncherSnapshot { + fn placeholder() -> Self { + Self { + project: "(unset)".to_string(), + harness: "(unset)".to_string(), + daemon: "unknown".to_string(), + } + } +} + +fn resolve_launcher_snapshot() -> LauncherSnapshot { + let project = std::env::current_dir() + .ok() + .and_then(|cwd| project::canonical_project_root(&cwd).ok()) + .and_then(|root| root.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_else(|| "(no project)".to_string()); + + let harness = default_harness_id() + .map(|id| id.to_string()) + .unwrap_or_else(|| "(none)".to_string()); + + let daemon = coven_home_dir() + .ok() + .and_then(|home| daemon::background_server_status(&home).ok().flatten()) + .map(|state| match state { + daemon::DaemonStatusState::Running(_) => "running", + daemon::DaemonStatusState::Stale(_) => "stale", + }) + .unwrap_or("stopped") + .to_string(); + + LauncherSnapshot { + project, + harness, + daemon, + } +} + fn render_magical_tui_frame(selection: usize, input: &str) -> String { render_magical_tui_frame_with_mode_and_width( selection, input, + &resolve_launcher_snapshot(), theme::mode(), magical_tui_inner_width(), ) @@ -1167,6 +1221,7 @@ pub(crate) fn render_magical_tui_frame_plain(selection: usize) -> String { render_magical_tui_frame_with_mode_and_width( selection, "", + &LauncherSnapshot::placeholder(), theme::TerminalMode::NoColor, MAGICAL_TUI_DEFAULT_INNER_WIDTH, ) @@ -1180,6 +1235,7 @@ pub(crate) fn render_magical_tui_frame_plain_with_width( render_magical_tui_frame_with_mode_and_width( selection, "", + &LauncherSnapshot::placeholder(), theme::TerminalMode::NoColor, inner_width, ) @@ -1194,6 +1250,7 @@ pub(crate) fn render_magical_tui_frame_plain_with_input( render_magical_tui_frame_with_mode_and_width( selection, input, + &LauncherSnapshot::placeholder(), theme::TerminalMode::NoColor, inner_width, ) @@ -1202,214 +1259,218 @@ pub(crate) fn render_magical_tui_frame_plain_with_input( fn render_magical_tui_frame_with_mode_and_width( selection: usize, input: &str, + snapshot: &LauncherSnapshot, mode: theme::TerminalMode, inner_width: usize, ) -> String { let inner_width = normalized_magical_tui_inner_width(inner_width); - let primary = theme::Fg::with_mode(theme::PRIMARY, mode); - let primary_strong = theme::Fg::with_mode(theme::PRIMARY_STRONG, mode); - let field_label = theme::Fg::with_mode(theme::FIELD_LABEL, mode); - let user_label = theme::Fg::with_mode(theme::USER_LABEL, mode); - let dim = theme::Fg::with_mode(theme::DIM, mode); - let reset = theme::Reset::with_mode(mode); + let palette = theme::palette_for(mode); + let total_items = magical_tui_items().len(); + let visible = LAUNCHER_VISIBLE_COMMANDS.min(total_items); + let window = launcher_command_window(selection, total_items, visible); + let (left_width, right_width) = body_lane_widths(inner_width); + let mut frame = String::new(); - frame.push_str(&magical_tui_line( - "CovenCLI", - primary_strong, - reset, - inner_width, - )); - frame.push_str(&magical_tui_line( - "Welcome back to the Coven.", - field_label, - reset, - inner_width, - )); - frame.push_str(&magical_tui_line( - "OpenCoven terminal home for local agent work.", - user_label, - reset, - inner_width, - )); - frame.push('\n'); - for line in magical_tui_graph_lines() { - frame.push_str(&magical_tui_line(line, primary, reset, inner_width)); - } - frame.push('\n'); - frame.push_str(&magical_tui_line( - "Status", - primary_strong, - reset, + + // 1. Identity + push_line( + &mut frame, + "Cast", + palette.primary_strong, + palette.reset, inner_width, - )); - for line in magical_tui_status_lines() { - frame.push_str(&magical_tui_line(line, field_label, reset, inner_width)); - } - frame.push('\n'); - frame.push_str(&magical_tui_line( - "Task inbox", - primary_strong, - reset, + ); + push_line(&mut frame, "", palette.text, palette.reset, inner_width); + + // 2. Prompt area — quiet rule above, emphasized rule below. The bottom + // rule is the focus underline: the launcher prompt is always the + // interactive element, so it gets `BORDER_STRONG`; the top rule is a + // plain panel separator (`BORDER_SUBTLE`). + let rule: String = "─".repeat(inner_width); + let border_subtle = theme::Fg::with_mode(theme::BORDER_SUBTLE, mode); + let border_strong = theme::Fg::with_mode(theme::BORDER_STRONG, mode); + push_line(&mut frame, &rule, border_subtle, palette.reset, inner_width); + let (prompt_text, prompt_color) = if input.is_empty() { + (format!("> {LAUNCHER_PROMPT_PLACEHOLDER}"), palette.dim) + } else { + (format!("> {input}"), palette.text) + }; + push_line( + &mut frame, + &prompt_text, + prompt_color, + palette.reset, inner_width, - )); - for line in magical_tui_task_inbox_lines() { - frame.push_str(&magical_tui_line(line, primary, reset, inner_width)); - } - frame.push('\n'); - for line in magical_tui_input_box_lines(input, inner_width) { - frame.push_str(&magical_tui_line(&line, user_label, reset, inner_width)); - } - frame.push('\n'); + ); + push_line(&mut frame, &rule, border_strong, palette.reset, inner_width); + push_line(&mut frame, "", palette.text, palette.reset, inner_width); + + // 3. Two-lane body: Commands rail (windowed) + Snapshot rows. + push_two_lane( + &mut frame, + ("Commands", palette.primary_strong), + ("Snapshot", palette.primary_strong), + palette.reset, + (left_width, right_width), + ); - frame.push_str(&magical_tui_line( - "Slash commands", - primary_strong, - reset, - inner_width, - )); - for (index, item) in magical_tui_items().iter().enumerate() { - let pointer = if index == selection { ">" } else { " " }; - let content = magical_tui_command_row(pointer, item, inner_width); - let color = if index == selection { - primary_strong - } else { - primary + let snapshot_rows = [ + ("project", snapshot.project.as_str()), + ("harness", snapshot.harness.as_str()), + ("daemon", snapshot.daemon.as_str()), + ]; + let body_row_count = visible.max(snapshot_rows.len()); + for row in 0..body_row_count { + let (left_text, left_color) = match window.get(row) { + Some(&idx) => { + let item = &magical_tui_items()[idx]; + let row_text = magical_tui_command_row(idx == selection, item); + let color = if idx == selection { + palette.primary_strong + } else { + palette.text + }; + (row_text, color) + } + None => (String::new(), palette.text), }; - frame.push_str(&magical_tui_line(&content, color, reset, inner_width)); + let right_text = snapshot_rows + .get(row) + .map(|(label, value)| snapshot_row(label, value, right_width)) + .unwrap_or_default(); + push_two_lane( + &mut frame, + (&left_text, left_color), + (&right_text, palette.text), + palette.reset, + (left_width, right_width), + ); } - let selected = magical_tui_items()[selection.min(magical_tui_items().len() - 1)]; - frame.push('\n'); - frame.push_str(&magical_tui_line( - "Selected command", - primary_strong, - reset, - inner_width, - )); - frame.push_str(&magical_tui_line( + // Scroll hint when the rail can't display every item. + if visible < total_items { + let hint = format!("{} of {}", selection.min(total_items - 1) + 1, total_items); + push_line(&mut frame, &hint, palette.dim, palette.reset, inner_width); + } + push_line(&mut frame, "", palette.text, palette.reset, inner_width); + + // 4. Action preview rows for the current selection. + let selected = &magical_tui_items()[selection.min(total_items - 1)]; + push_field_row(&mut frame, "spell", selected.slash, &palette, inner_width); + push_field_row( + &mut frame, + "detail", selected.description, - user_label, - reset, - inner_width, - )); - frame.push_str(&magical_tui_line( - &format!("{} => {}", selected.slash, selected.command), - primary_strong, - reset, + &palette, inner_width, - )); - frame.push_str(&magical_tui_line( - "Store: ~/.coven", - dim, - reset, + ); + push_line(&mut frame, "", palette.text, palette.reset, inner_width); + + // 5. Footer hint — one dim line, never two. + push_line( + &mut frame, + LAUNCHER_FOOTER_HINT, + palette.dim, + palette.reset, inner_width, - )); - frame -} - -fn magical_tui_graph_lines() -> &'static [&'static str] { - &[ - "+-------------------------- Workspace map -----------------------------+", - "| workspace: current repo branch: local checkout |", - "| harness shelf: Codex | Claude Code | local adapters |", - "| |", - "| [nova] ------ [coven] ------ [cody] |", - r"| | / \ | |", - r"| | / \ | |", - "| [memory] -- [coven] -- [sessions] -- [review] |", - r"| | \ |", - "| [gateway] local daemon |", - "| |", - "| prompt floor: ask | slash | attach | summon | archive | sacrifice |", - "+----------------------------------------------------------------------+", - ] -} + ); -fn magical_tui_status_lines() -> &'static [&'static str] { - &[ - "System snapshot local-first session ledger | ~/.coven", - "Model lane Codex ready | Claude Code ready | PTY guarded", - "Context repo, docs, memory, sessions, and slash palette", - "Approvals asks before secrets, deletes, pushes, or public moves", - "Release notes CovenCLI now opens as a rich terminal home", - "Tips type a task, /run , or choose below", - ] + frame } -fn magical_tui_task_inbox_lines() -> &'static [&'static str] { - &[ - "[ ] inspect repo [ ] launch harness [ ] attach session", - "[ ] review diff [ ] export trace [ ] archive work", - "Claude Code style: welcome, status, context, prompt, command rail", - ] +fn launcher_command_window(selection: usize, total: usize, visible: usize) -> Vec { + if total == 0 || visible == 0 { + return Vec::new(); + } + let last = total - 1; + let sel = selection.min(last); + let start = if sel < visible { 0 } else { sel + 1 - visible }; + let end = (start + visible).min(total); + (start..end).collect() } -fn magical_tui_prompt_row(input: &str, inner_width: usize) -> String { - let value = if input.is_empty() { - "fix the failing tests | /run codex plan the refactor" - } else { - input - }; - fit_chars(&format!("> {value}"), inner_width) +fn body_lane_widths(inner_width: usize) -> (usize, usize) { + // Two columns separated by a 2-space gap; the rail favours the left + // lane by one character on odd widths because slash + label text is + // typically longer than the snapshot values. + let usable = inner_width.saturating_sub(2); + let left = usable.div_ceil(2); + let right = usable - left; + (left, right) } -fn magical_tui_input_box_lines(input: &str, inner_width: usize) -> Vec { - let width = normalized_magical_tui_inner_width(inner_width); - let content_width = width.saturating_sub(4).max(1); - let prompt = magical_tui_prompt_row(input, content_width); - let hint = fit_chars( - "Enter sends. Empty Enter runs selected slash. Ctrl+U clears. Esc quits.", - content_width, - ); - vec![ - magical_tui_input_box_top(width), - magical_tui_input_box_row(&prompt, width), - magical_tui_input_box_row(&hint, width), - magical_tui_input_box_bottom(width), - ] +fn push_line( + frame: &mut String, + content: &str, + color: impl std::fmt::Display, + reset: impl std::fmt::Display, + inner_width: usize, +) { + let fitted = fit_chars(content, inner_width); + frame.push_str(&format!("{color}{fitted}{reset}\n")); } -fn magical_tui_input_box_top(width: usize) -> String { - let label = "+-- Ask anything "; - if width <= 2 { - return fit_chars(label, width); - } - if width <= label.chars().count() + 1 { - return fit_chars(label, width); - } - let fill = width - label.chars().count() - 1; - format!("{label}{}+", "-".repeat(fill)) +fn push_two_lane( + frame: &mut String, + left: (&str, impl std::fmt::Display), + right: (&str, impl std::fmt::Display), + reset: impl std::fmt::Display, + widths: (usize, usize), +) { + let (left_text, left_color) = left; + let (right_text, right_color) = right; + let (left_width, right_width) = widths; + let left_fitted = fit_chars(left_text, left_width); + let right_fitted = fit_chars(right_text, right_width); + let pad = left_width.saturating_sub(left_fitted.chars().count()); + let padding = " ".repeat(pad); + frame.push_str(&format!( + "{left_color}{left_fitted}{reset}{padding} {right_color}{right_fitted}{reset}\n", + )); } -fn magical_tui_input_box_bottom(width: usize) -> String { - if width <= 2 { - return "-".repeat(width); +fn snapshot_row(label: &str, value: &str, right_width: usize) -> String { + if right_width == 0 { + return String::new(); } - format!("+{}+", "-".repeat(width - 2)) -} - -fn magical_tui_input_box_row(content: &str, width: usize) -> String { - if width <= 2 { - return fit_chars(content, width); + let column = LAUNCHER_FIELD_LABEL_WIDTH.min(right_width); + if column + 2 >= right_width { + return fit_chars(label, right_width); } - let content_width = width.saturating_sub(4).max(1); - let fitted = fit_chars(content, content_width); - let padding = content_width.saturating_sub(fitted.chars().count()); - format!("| {fitted}{} |", " ".repeat(padding)) + let fitted_label = fit_chars(label, column); + let pad = column.saturating_sub(fitted_label.chars().count()); + let value_width = right_width - column - 2; + let fitted_value = fit_chars(value, value_width); + format!( + "{fitted_label}{padding} {fitted_value}", + padding = " ".repeat(pad) + ) } -fn magical_tui_line( - content: &str, - text_color: impl std::fmt::Display, - reset: impl std::fmt::Display, +fn push_field_row( + frame: &mut String, + label: &str, + value: &str, + palette: &theme::Palette, inner_width: usize, -) -> String { - format!("{text_color}{}{reset}\n", fit_chars(content, inner_width)) +) { + let column = LAUNCHER_FIELD_LABEL_WIDTH.min(inner_width.saturating_sub(2).max(1)); + let fitted_label = fit_chars(label, column); + let pad = column.saturating_sub(fitted_label.chars().count()); + let value_width = inner_width.saturating_sub(column + 2); + let fitted_value = fit_chars(value, value_width); + let padding = " ".repeat(pad); + frame.push_str(&format!( + "{field_label}{fitted_label}{reset}{padding} {text}{fitted_value}{reset}\n", + field_label = palette.field_label, + text = palette.text, + reset = palette.reset, + )); } -fn magical_tui_command_row(pointer: &str, item: &MagicalTuiItem, inner_width: usize) -> String { - let row = format!("{pointer} {:<10} {}", item.slash, item.label); - fit_chars(&row, inner_width) +fn magical_tui_command_row(selected: bool, item: &MagicalTuiItem) -> String { + let pointer = if selected { "›" } else { " " }; + format!("{pointer} {:<10} {}", item.slash, item.label) } fn magical_tui_inner_width() -> usize { @@ -1540,23 +1601,6 @@ mod attach_tests { } } - fn cast_summary_event(seq: i64, request: &str) -> store::EventRecord { - store::EventRecord { - seq, - id: format!("event-{seq}"), - session_id: "session-1".to_string(), - kind: "cast.summary".to_string(), - payload_json: serde_json::json!({ - "request": request, - "status": "completed", - "exitCode": 0, - "harness": "codex" - }) - .to_string(), - created_at: "2026-05-19T00:00:00Z".to_string(), - } - } - #[test] fn attach_origin_verb_labels_distinguish_attach_from_summon() { assert_eq!(AttachOrigin::Attach.verb_past(), "Attached to"); @@ -1573,7 +1617,7 @@ mod attach_tests { let mut buffer: Cursor> = Cursor::new(Vec::new()); let mut observer = TranscriptObserver::new(&mut buffer); - let (exit, summary_note) = + let exit = replay_completed_session(&mut client, "session-1", &mut observer).expect("replay"); let rendered = String::from_utf8(buffer.into_inner()).unwrap(); @@ -1593,10 +1637,6 @@ mod attach_tests { let exit = exit.expect("replay should surface the recorded exit"); assert_eq!(exit.status, "completed"); assert_eq!(exit.exit_code, Some(0)); - assert!( - summary_note.is_none(), - "no summary note without cast.summary" - ); let queries = client.queries.borrow(); assert_eq!(queries.len(), 1, "one full-history fetch is enough"); @@ -1612,7 +1652,7 @@ mod attach_tests { let mut buffer: Cursor> = Cursor::new(Vec::new()); let mut observer = TranscriptObserver::new(&mut buffer); - let (exit, summary_note) = + let exit = replay_completed_session(&mut client, "session-1", &mut observer).expect("replay"); assert!( @@ -1626,31 +1666,5 @@ mod attach_tests { !rendered.contains("[Cast:"), "no Cast exit banner without an exit event: {rendered:?}" ); - assert!( - summary_note.is_none(), - "no summary note without cast.summary" - ); - } - - #[test] - fn replay_completed_session_returns_cast_summary_note_from_same_history_fetch() { - let mut client = ReplayClient::new(vec![ - output_event(1, "hello\n"), - cast_summary_event(2, "fix tests"), - exit_event(3, "completed", Some(0)), - ]); - let mut buffer: Cursor> = Cursor::new(Vec::new()); - let mut observer = TranscriptObserver::new(&mut buffer); - - let (exit, summary_note) = - replay_completed_session(&mut client, "session-1", &mut observer).expect("replay"); - - assert_eq!(exit.and_then(|v| v.exit_code), Some(0)); - let note = summary_note.expect("summary note should be rendered"); - assert!(note.contains("Prior Cast summary")); - assert!(note.contains("request `fix tests`")); - - let queries = client.queries.borrow(); - assert_eq!(queries.len(), 1, "summary note should reuse replay history"); } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 570dfa0..54743a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ description: "The OpenCoven public roadmap for Coven, comux, and OpenClaw integr # OpenCoven public roadmap -_Last updated: 2026-05-09_ +_Last updated: 2026-05-19_ This roadmap is the public progress ledger for **OpenCoven**, **Coven**, and **comux**. @@ -69,6 +69,7 @@ Shipped: Now: +- **Cast launcher redesign** (Phases 1–6 on `cast/*` branches): collapses the Coven launcher chrome into a single-prompt surface with a two-lane Commands + Snapshot body, plan/outcome cards for every spell, and a sequential quest flow that hands off between phases with deterministic sub-prompts. The visual contract lives in [`docs/design/cast-tui-contract.md`](/design/cast-tui-contract) and the quest flow in [`docs/design/cast-quest-flow.md`](/design/cast-quest-flow). PR #99 is the current review slice (Phase 6 verification + readiness). - Keep the versioned daemon API contract and external-client compatibility work aligned. See [`docs/API-CONTRACT.md`](/API-CONTRACT). - Keep the public docs aligned with the actual CLI/API surface. diff --git a/docs/design/cast-phase6-inspection.md b/docs/design/cast-phase6-inspection.md new file mode 100644 index 0000000..c6c6a48 --- /dev/null +++ b/docs/design/cast-phase6-inspection.md @@ -0,0 +1,170 @@ +--- +summary: "Phase 6 inspection notes: captured launcher and Cast non-interactive frames at typical widths, with what was tested and what remains a manual check before merge." +title: "Cast — Phase 6 Verification Notes" +description: "Verification artifact for the Cast redesign branch (cast/phase-6-readiness). Captures rendered frames in NoColor mode, lists what passed automatically, and flags what still needs a human terminal pass before merge." +--- + +# Cast — Phase 6 Verification Notes + +This document is the verification artifact for the Cast redesign branch. It exists because TUI work cannot be fully verified by `cargo test` alone — colour, cursor behaviour, raw-mode key handling, and `SIGWINCH` reflow only show up in a real terminal. Tests pin the byte-level output of the renderer in `NoColor` mode; this file pins what a reviewer should see when they actually open it. + +The frames below were captured from `render_magical_tui_frame_plain_with_width` (the launcher renderer in `crates/coven-cli/src/tui/shell.rs`) and the binary's non-interactive Cast frame. They are NoColor — no ANSI escapes — so the structure is the only signal. In a real terminal the identity row, selected slash row, and field labels carry brand colour per [`cast-tui-contract.md`](./cast-tui-contract.md). + +## Gate results + +| Gate | Result | +| --- | --- | +| `cargo fmt --all -- --check` | ✓ clean (after running `cargo fmt` once at start of phase) | +| `cargo clippy -p coven-cli --tests --no-deps -- -D warnings` | ✓ clean | +| `cargo test -p coven-cli` (313 unit + 4 smoke) | ✓ 0 failures | + +Notes: + +- The launcher tests in `crates/coven-cli/src/main.rs::tests::magical_tui_frame_*` (10 cases) failed at the start of Phase 6 because the Phase 1 design contract was never implemented in `tui/shell.rs::render_magical_tui_frame_with_mode_and_width` — only the assertions had landed. Phase 6 rewrote that renderer against the contract; all 10 now pass. +- Two pre-existing `dead_code` warnings remain on `theme::BORDER_SUBTLE` / `theme::BORDER_STRONG` (added by the Phase 1 contract for future single-rule borders that are not yet plumbed through callsites). They do not fail clippy (which is run against `--tests`) because the constants are in the non-test target; flagged here so a follow-up phase wires them in. + +## Launcher frame — selection=0, empty input, width=76 + +```text +Cast + +──────────────────────────────────────────────────────────────────────────── +> type a task or /run codex +──────────────────────────────────────────────────────────────────────────── + +Commands Snapshot +› /start Start here project (unset) + /help Help harness (unset) + /tui Open TUI daemon unknown + /doctor Doctor + /daemon Daemon status + /run Run an agent +1 of 14 + +spell /start +detail Setup check and a safe first command + +enter run · ↑↓ select · esc quit · ctrl+u clear +``` + +What to verify in a real terminal: + +- `Cast` renders in `PRIMARY_STRONG` (the brand purple, `#9A8ECD`). +- The two `─` rules above and below the prompt are subtle (`FIELD_LABEL`), not bright. +- `› /start` is the only row in `PRIMARY_STRONG`; the other five command rows are in `TEXT`. +- The Snapshot values (`(unset)`, `unknown` here) are replaced with the resolved project name, default harness id, and daemon status (`running` / `stopped` / `stale`). +- `enter run · ↑↓ select · esc quit · ctrl+u clear` is rendered in `DIM`. + +## Launcher frame — selection=0, typed input, width=76 + +```text +Cast + +──────────────────────────────────────────────────────────────────────────── +> polish the README +──────────────────────────────────────────────────────────────────────────── + +Commands Snapshot +› /start Start here project (unset) + /help Help harness (unset) + /tui Open TUI daemon unknown + /doctor Doctor + /daemon Daemon status + /run Run an agent +1 of 14 + +spell /start +detail Setup check and a safe first command + +enter run · ↑↓ select · esc quit · ctrl+u clear +``` + +What to verify in a real terminal: + +- Empty-prompt placeholder (`> type a task or /run codex`) renders in `DIM`; typed input (`> polish the README`) renders in `TEXT`. The shift from dim to bright as the user types is the focus signal. +- The terminal cursor is at the end of the input line. There is no synthetic `█` block. + +## Launcher frame — selection=12 (`/sacrifice`), width=76 + +```text +Cast + +──────────────────────────────────────────────────────────────────────────── +> type a task or /run codex +──────────────────────────────────────────────────────────────────────────── + +Commands Snapshot + /sessions Active sessions project (unset) + /all All sessions harness (unset) + /attach Attach session daemon unknown + /summon Summon session + /archive Archive session +› /sacrifice Sacrifice session +13 of 14 + +spell /sacrifice +detail Permanently delete a non-running session + +enter run · ↑↓ select · esc quit · ctrl+u clear +``` + +What to verify in a real terminal: + +- The command rail is windowed: items 7–12 are visible (six rows) with `/sacrifice` highlighted at the bottom. +- The scroll hint `13 of 14` renders in `DIM` below the rail. +- The action preview still resolves correctly to the new selection (`/sacrifice` + its description). +- When the user picks `/sacrifice` and presses Enter with an empty prompt, the Cast safety gate prompts for the typed `sacrifice` confirmation — that flow is covered by `cast_plan_for_sacrifice_describes_typed_confirm_in_copy` and the smoke `sacrifice` test, not by re-rendering here. + +## Cast non-interactive frame (piped stdout) + +This frame is printed when `coven` is run without a TTY (e.g., piped, in CI, in a non-interactive script). Captured by running `./target/debug/coven /summon + /archive /sacrifice + /doctor /daemon /patch /help /quit + +Tip: in a terminal, `coven` opens the Cast launcher. Empty input opens the slash palette. + +Tip: run `coven` in a real terminal to open the Cast launcher and type a spell. +``` + +This surface predates the Phase 1 launcher contract and is intentionally a Phase 1+ copy variant — see `tests::cast_non_interactive_frame_introduces_cast_and_shows_examples`. The contract drift items (em-dash in the headline, capitalised `Project`, second-person address) are deliberately out of scope for Phase 6 because they are pinned by Phase 1 tests; tightening that surface to fully match §2.6 is a follow-up. + +## What this branch does NOT cover + +These were considered for Phase 6 but deliberately deferred: + +- **Live raw-mode keybinding sweep.** The launcher's raw-mode loop (Arrow keys, Backspace, Ctrl+U, Ctrl+C, Esc, Enter) is exercised manually only; there is no end-to-end test that drives keys into the binary. A future phase could add a PTY-driven integration test. +- **`SIGWINCH` reflow.** The renderer reads terminal columns on every redraw and produces a fitted frame, but no automated test resizes the terminal mid-run. A reviewer should resize a real terminal while the launcher is open and confirm the rule lengths and two-lane widths track the new width. +- **TrueColor vs Indexed256 visual parity.** `theme::ratatui_color_with_mode` is unit-tested, but the launcher's colour rendering in a 256-colour-only terminal (e.g. older SSH sessions) needs a human eye to confirm the `PRIMARY_STRONG` purple is still legible. +- **`docs/design/cast-tui-contract.md` §3.11 — saturated purple swap.** The contract calls for `PRIMARY_STRONG` (the more saturated purple, `#9A8ECD`) to carry weight and `PRIMARY` (lighter, `#C5BDED`) to be a hover/secondary accent. The new launcher renderer uses `PRIMARY_STRONG` for identity, selected row, and headers; `PRIMARY` is unused at the launcher level. ✓ +- **`BORDER_SUBTLE` / `BORDER_STRONG` use.** The contract adds these tokens (Phase 1, §4) but Phase 2-5 never plumbed them into a renderer. The launcher uses `FIELD_LABEL` for the prompt rules instead. Phase 7 should either wire them in or remove the unused constants. + +## Risks still open before merge + +- `theme::BORDER_SUBTLE` and `theme::BORDER_STRONG` are unused dead code (4 warnings during `cargo build`). They do not fail any gate but they signal an incomplete Phase 1 → 2 handoff. +- The Cast non-interactive frame (printed when stdin/stdout is not a TTY) still uses pre-contract copy ("Cast — your Coven familiar", capitalised labels, second-person greeting). It is pinned by tests and not in scope for Phase 6. +- `docs/ROADMAP.md` is last-updated 2026-05-09 and does not mention the Cast redesign. If the redesign is meant to ship in the next public update, the roadmap will need a fresh entry under Coven > Now. +- `docs/PRODUCT-SPEC.md` "visible work" thesis (line 14) is intact; no change needed. +- `npm/coven/README.md` install copy is unchanged and accurate; no change needed. diff --git a/docs/design/cast-quest-flow.md b/docs/design/cast-quest-flow.md new file mode 100644 index 0000000..b66e8e6 --- /dev/null +++ b/docs/design/cast-quest-flow.md @@ -0,0 +1,143 @@ +--- +summary: "Phase 5 design contract for Cast's sequential goal flow: deterministic sub-prompts, visible handoffs, and a structured advance step between phases." +title: "Cast — Sequential Goal Flow (Phase 5)" +description: "How Cast turns a high-level user goal into an ordered Quest of phases, each producing a concrete sub-prompt and a visible handoff to the harness." +--- + +# Cast — Sequential Goal Flow (Phase 5) + +This document is the design target for the Cast quest flow added in Phase 5. The implementation lives in `crates/coven-cli/src/tui/cast/quest.rs` (pure logic) and `crates/coven-cli/src/tui/cast/render.rs` (`render_quest_handoff`). The Cast shell wires the quest into its existing gate / follow / outcome surfaces in a follow-up phase; the module is intentionally callable on its own first so the deterministic core can be exercised by tests without a daemon. + +## 1. Why a quest + +Phase 4 and earlier treat every spell as a single launch: parse → plan → gate → dispatch → outcome. A real piece of repository work is rarely one launch — it is design → implement → verify, with the next step shaped by what just happened. Phase 5 makes that loop a first-class Cast surface so the user can: + +1. State a high-level goal once. +2. Read the concrete sub-prompt Cast would hand to a harness for the *first* phase. +3. Approve, edit, or skip that sub-prompt. +4. Inspect the result. +5. Read the recomposed sub-prompt for the next phase, with a visible note about *why* it changed. + +No LLM planner is introduced inside Cast. Sub-prompts are assembled from structured templates plus the prior phase's recorded outcome, so every handoff is reproducible, inspectable, and overridable. + +## 2. Data model (`cast::quest`) + +```text +Quest + ├ title derived from the user's goal (truncated to 60 chars) + ├ goal the original free-text request + ├ phases Vec (default rhythm: design → implement → verify) + └ cursor index of the next non-complete phase + +QuestPhase + ├ name short identifier: "design" | "implement" | "verify" + ├ goal noun-phrase role description for this phase + ├ harness Option (defaults to the quest's harness) + ├ template base sub-prompt template (with `{goal}` substitution) + ├ sub_prompt currently-resolved text Cast would send right now + ├ status Pending | Running { session_id } | Complete(summary) | Skipped { reason } + ├ handoff Option (attached by `advance` from the prior phase) + └ edited_by_user true once a user override lands; prevents silent regeneration + +QuestPhaseSummary + ├ session_id daemon session that ran this phase (if any) + ├ exit_status e.g. "completed", "failed", "interrupted" + ├ exit_code Option + └ carried_context bulletable facts to surface in the next sub-prompt + +QuestHandoff + ├ from_phase the prior phase's `name` + ├ prior_status human-readable label (e.g. "completed (exit 0)") + ├ reason *why* the next sub-prompt was updated + └ carried_context verbatim from the prior summary +``` + +## 3. Composer (`compose_sub_prompt`) + +Pure function. Returns: + +```text +