From 722ad39b199a098eda0e7f60bed83c9ceb00c99c Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 19 May 2026 07:32:58 -0500 Subject: [PATCH 01/14] =?UTF-8?q?cast:=20phase=202=20=E2=80=94=20Cast-nati?= =?UTF-8?q?ve=20attach=20with=20follower-backed=20transcript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #93 already shipped the Cast event follower, transcript renderer, exit summary, `cast.summary` event writer, and stdin forwarding for *newly launched* sessions. The remaining Phase 2 gap was `/attach` and `/summon`: those still routed through the legacy `attach_session` loop in main.rs, which uses a HashSet for dedup and reads from the SQLite store directly — bypassing the daemon's `/events` endpoint and `afterSeq` cursor. This commit closes the gap. New `cast/attach.rs`: - `CastAttachSummary` + `find_cast_summary` decode the most recent `cast.summary` event from a session's history so the attach outcome can describe what the prior run did. - `summary_already_recorded` lets the launch-side writer skip a duplicate summary when attach replays existing events through the same follower. - `format_summary_note` renders the decoded summary as a one-line outcome card note (status, exit code, harness, request — truncated to 60 chars). `shell.rs` wiring: - New `attach_via_cast` dispatcher: live sessions stream through `follow_until_exit` with the same `afterSeq` cursor as launches and accept stdin forwarding; completed sessions replay the full event log through the same `TranscriptObserver` so the user sees the transcript shape they'd see at original launch, then surface the prior `cast.summary` in the outcome. - Falls back to the legacy `attach_session` loop when the daemon isn't running, with an outcome note explaining the fallback. - `write_cast_summary_event` is now idempotent — guards against double-logging when an attach replay reaches the exit event a second time. - New `AttachOrigin` enum distinguishes `/attach` from `/summon` in the outcome card's `launched` label. `main.rs`: - Extract `summon_only_command` from `summon_session_command` so Cast can un-archive without dragging in the legacy attach loop. The top-level `coven summon` CLI behaviour is unchanged. Tests: 14 new (11 in `cast::attach`, 3 in `shell::attach_tests` covering AttachOrigin labels and `replay_completed_session` over a stub client). Total: 275 unit + 4 smoke tests pass. cargo fmt clean, clippy clean, diff --check clean. Non-interactive `coven` smoke renders the Cast frame and exits 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/coven-cli/src/main.rs | 11 +- crates/coven-cli/src/tui/cast/attach.rs | 287 ++++++++++++++++++ crates/coven-cli/src/tui/cast/mod.rs | 2 + crates/coven-cli/src/tui/shell.rs | 376 ++++++++++++++++++++++-- 4 files changed, 650 insertions(+), 26 deletions(-) create mode 100644 crates/coven-cli/src/tui/cast/attach.rs diff --git a/crates/coven-cli/src/main.rs b/crates/coven-cli/src/main.rs index c769355..43b2e36 100644 --- a/crates/coven-cli/src/main.rs +++ b/crates/coven-cli/src/main.rs @@ -613,6 +613,15 @@ fn archive_session_command(session_id: &str) -> Result<()> { } fn summon_session_command(session_id: &str) -> Result<()> { + summon_only_command(session_id)?; + attach_session(session_id) +} + +/// Un-archive a session if needed, without attaching. Returns the session +/// record so callers (Cast's attach dispatcher) can decide what to do next. +/// Pulled out of `summon_session_command` so the Cast TUI path can summon +/// then re-enter through Cast's follower instead of the legacy attach loop. +pub(crate) fn summon_only_command(session_id: &str) -> Result { let store_path = coven_store_path()?; let conn = store::open_store(&store_path)?; let Some(session) = store::get_session(&conn, session_id)? else { @@ -624,7 +633,7 @@ fn summon_session_command(session_id: &str) -> Result<()> { eprintln!("summoned session from the archive"); } - attach_session(session_id) + Ok(session) } fn sacrifice_session_command(session_id: &str, yes: bool) -> Result<()> { diff --git a/crates/coven-cli/src/tui/cast/attach.rs b/crates/coven-cli/src/tui/cast/attach.rs new file mode 100644 index 0000000..1f85d14 --- /dev/null +++ b/crates/coven-cli/src/tui/cast/attach.rs @@ -0,0 +1,287 @@ +//! Cast attach helpers. +//! +//! Phase 2 lets the user re-enter a previously-launched session through the +//! same follower the launch path uses. The pure helpers in this module decode +//! the `cast.summary` event Cast wrote at the end of the original run so the +//! attach outcome card can describe what already happened — and tell the +//! launch-side writer when a summary already exists so it does not double-log. + +use serde_json::Value; + +use crate::store; + +/// Event kind Cast writes when a launched session finishes. See +/// `shell::write_cast_summary_event` for the producer side. +pub(crate) const CAST_SUMMARY_KIND: &str = "cast.summary"; + +/// Decoded `cast.summary` event. All fields are optional because Cast may +/// have written a partial payload (e.g. an older Cast that didn't record +/// `headline`), and the renderer should degrade gracefully. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct CastAttachSummary { + pub(crate) request: Option, + pub(crate) headline: Option, + pub(crate) harness: Option, + pub(crate) status: Option, + pub(crate) exit_code: Option, +} + +/// Return the most recent `cast.summary` event from `events`, decoded. Cast +/// only writes one summary per session today, but the helper returns the +/// last one so re-runs (which append) remain readable. +pub(crate) fn find_cast_summary(events: &[store::EventRecord]) -> Option { + events + .iter() + .rev() + .find(|event| event.kind == CAST_SUMMARY_KIND) + .map(decode_summary) +} + +/// True when at least one `cast.summary` event has been recorded for this +/// session. Producers use this to keep the summary writer idempotent. +pub(crate) fn summary_already_recorded(events: &[store::EventRecord]) -> bool { + events.iter().any(|event| event.kind == CAST_SUMMARY_KIND) +} + +/// One-line outcome-card note describing what Cast saw on the prior run. +/// Returns `None` when none of the fields are populated, so the caller can +/// skip the note entirely instead of printing an empty bullet. +pub(crate) fn format_summary_note(summary: &CastAttachSummary) -> Option { + let mut parts: Vec = Vec::new(); + if let Some(status) = &summary.status { + match summary.exit_code { + Some(code) => parts.push(format!("status `{status}` (exit code {code})")), + None => parts.push(format!("status `{status}`")), + } + } else if let Some(code) = summary.exit_code { + parts.push(format!("exit code {code}")); + } + if let Some(harness) = &summary.harness { + parts.push(format!("harness {harness}")); + } + if let Some(request) = summary.request.as_ref().or(summary.headline.as_ref()) { + let trimmed = first_chars(request, 60); + parts.push(format!("request `{trimmed}`")); + } + if parts.is_empty() { + None + } else { + Some(format!("Prior Cast summary: {}.", parts.join(", "))) + } +} + +fn decode_summary(event: &store::EventRecord) -> CastAttachSummary { + let payload = match serde_json::from_str::(&event.payload_json) { + Ok(value) => value, + Err(_) => return CastAttachSummary::default(), + }; + CastAttachSummary { + request: payload + .get("request") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + headline: payload + .get("headline") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + harness: payload + .get("harness") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + status: payload + .get("status") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + exit_code: payload + .get("exitCode") + .and_then(Value::as_i64) + .map(|v| v as i32) + .or_else(|| { + payload + .get("exit_code") + .and_then(Value::as_i64) + .map(|v| v as i32) + }), + } +} + +fn first_chars(value: &str, limit: usize) -> String { + let count = value.chars().count(); + if count <= limit { + return value.to_string(); + } + let mut out: String = value.chars().take(limit.saturating_sub(1)).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary_event(seq: i64, payload: serde_json::Value) -> store::EventRecord { + store::EventRecord { + seq, + id: format!("event-{seq}"), + session_id: "session-1".to_string(), + kind: CAST_SUMMARY_KIND.to_string(), + payload_json: payload.to_string(), + created_at: "2026-05-19T00:00:00Z".to_string(), + } + } + + fn output_event(seq: i64, data: &str) -> store::EventRecord { + store::EventRecord { + seq, + id: format!("event-{seq}"), + session_id: "session-1".to_string(), + kind: "output".to_string(), + payload_json: serde_json::json!({ "data": data }).to_string(), + created_at: "2026-05-19T00:00:00Z".to_string(), + } + } + + #[test] + fn find_cast_summary_returns_none_when_no_summary_event_exists() { + let events = vec![output_event(1, "hello\n"), output_event(2, "bye\n")]; + assert!(find_cast_summary(&events).is_none()); + } + + #[test] + fn find_cast_summary_decodes_request_status_and_exit_code() { + let events = vec![ + output_event(1, "working\n"), + summary_event( + 2, + serde_json::json!({ + "request": "fix the failing tests", + "headline": "Cast a project-scoped spell", + "harness": "codex", + "status": "completed", + "exitCode": 0, + }), + ), + ]; + + let summary = find_cast_summary(&events).expect("summary should be decoded"); + assert_eq!(summary.request.as_deref(), Some("fix the failing tests")); + assert_eq!(summary.harness.as_deref(), Some("codex")); + assert_eq!(summary.status.as_deref(), Some("completed")); + assert_eq!(summary.exit_code, Some(0)); + } + + #[test] + fn find_cast_summary_accepts_snake_case_exit_code() { + let events = vec![summary_event( + 1, + serde_json::json!({ "status": "failed", "exit_code": 137 }), + )]; + + let summary = find_cast_summary(&events).expect("summary should be decoded"); + assert_eq!(summary.exit_code, Some(137)); + } + + #[test] + fn find_cast_summary_returns_most_recent_when_multiple_exist() { + let events = vec![ + summary_event(1, serde_json::json!({ "status": "failed", "exitCode": 1 })), + output_event(2, "retrying\n"), + summary_event( + 3, + serde_json::json!({ "status": "completed", "exitCode": 0 }), + ), + ]; + + let summary = find_cast_summary(&events).expect("summary should be decoded"); + assert_eq!(summary.status.as_deref(), Some("completed")); + assert_eq!(summary.exit_code, Some(0)); + } + + #[test] + fn find_cast_summary_yields_default_summary_for_malformed_payload() { + let mut event = summary_event(1, serde_json::json!({})); + event.payload_json = "not json".to_string(); + let events = vec![event]; + + let summary = find_cast_summary(&events).expect("summary should still be returned"); + assert_eq!(summary, CastAttachSummary::default()); + } + + #[test] + fn summary_already_recorded_only_true_when_summary_event_present() { + let only_output = vec![output_event(1, "hi\n")]; + assert!(!summary_already_recorded(&only_output)); + + let with_summary = vec![ + output_event(1, "hi\n"), + summary_event(2, serde_json::json!({ "status": "completed" })), + ]; + assert!(summary_already_recorded(&with_summary)); + } + + #[test] + fn format_summary_note_returns_none_for_empty_summary() { + assert_eq!( + format_summary_note(&CastAttachSummary::default()), + None, + "an empty summary should yield no note" + ); + } + + #[test] + fn format_summary_note_shows_status_exit_code_harness_and_request() { + let summary = CastAttachSummary { + request: Some("fix the failing tests".to_string()), + headline: Some("Cast a project-scoped spell".to_string()), + harness: Some("codex".to_string()), + status: Some("completed".to_string()), + exit_code: Some(0), + }; + + let note = format_summary_note(&summary).expect("note should be produced"); + assert!(note.contains("Prior Cast summary")); + assert!(note.contains("status `completed`")); + assert!(note.contains("exit code 0")); + assert!(note.contains("harness codex")); + assert!(note.contains("request `fix the failing tests`")); + } + + #[test] + fn format_summary_note_falls_back_to_headline_when_request_missing() { + let summary = CastAttachSummary { + request: None, + headline: Some("Cast a project-scoped spell".to_string()), + ..Default::default() + }; + + let note = format_summary_note(&summary).expect("note should be produced"); + assert!(note.contains("request `Cast a project-scoped spell`")); + } + + #[test] + fn format_summary_note_truncates_long_request_with_ellipsis() { + let long_request = "a".repeat(120); + let summary = CastAttachSummary { + request: Some(long_request), + ..Default::default() + }; + + let note = format_summary_note(&summary).expect("note should be produced"); + assert!( + note.contains('…'), + "long request should be truncated with ellipsis: {note}" + ); + } + + #[test] + fn format_summary_note_handles_status_without_exit_code() { + let summary = CastAttachSummary { + status: Some("interrupted".to_string()), + ..Default::default() + }; + + let note = format_summary_note(&summary).expect("note should be produced"); + assert!(note.contains("status `interrupted`")); + assert!(!note.contains("exit code")); + } +} diff --git a/crates/coven-cli/src/tui/cast/mod.rs b/crates/coven-cli/src/tui/cast/mod.rs index 1eb5f92..17cff4c 100644 --- a/crates/coven-cli/src/tui/cast/mod.rs +++ b/crates/coven-cli/src/tui/cast/mod.rs @@ -10,6 +10,7 @@ //! all remain the authority. Cast is orchestration and presentation: it //! never bypasses the daemon and never invents a second runtime. +pub(crate) mod attach; pub(crate) mod follow; pub(crate) mod gate; pub(crate) mod intent; @@ -22,6 +23,7 @@ use anyhow::Result; use crate::harness; +pub(crate) use attach::{find_cast_summary, format_summary_note, summary_already_recorded}; pub(crate) use follow::{follow_until_exit, CastSessionExit, FollowerObserver, FollowerPacer}; pub(crate) use gate::{evaluate_gate, GateOutcome}; pub(crate) use intent::{parse_spell, CastHarness, CastIntent}; diff --git a/crates/coven-cli/src/tui/shell.rs b/crates/coven-cli/src/tui/shell.rs index ee24be7..9b2e2ac 100644 --- a/crates/coven-cli/src/tui/shell.rs +++ b/crates/coven-cli/src/tui/shell.rs @@ -13,17 +13,17 @@ use crossterm::{ use uuid::Uuid; use super::cast::{ - self, evaluate_gate, follow_until_exit, render_cast_frame_for_terminal, render_outcome, - render_plan_intro, CastIntent, CastOutcome, CastPlan, CastSessionExit, FollowerObserver, - FollowerPacer, GateOutcome, SafetyDecision, + self, evaluate_gate, follow_until_exit, format_summary_note, render_cast_frame_for_terminal, + render_outcome, render_plan_intro, summary_already_recorded, CastIntent, CastOutcome, CastPlan, + CastSessionExit, FollowerObserver, FollowerPacer, GateOutcome, SafetyDecision, }; -use super::chat::client::{ChatClient, DaemonChatClient, LaunchRequest}; +use super::chat::client::{ChatClient, ChatEventQuery, DaemonChatClient, LaunchRequest}; use super::{is_key_press, sessions}; use crate::{ archive_session_command, attach_session, coven_home_dir, coven_store_path, current_timestamp, daemon, default_harness_id, project, prompt_for_optional_line, prompt_for_required_line, run_daemon_command, run_doctor, run_patch_openclaw, run_session, sacrifice_session_command, - store, summon_session_command, theme, DaemonCommand, + store, summon_only_command, theme, DaemonCommand, RUNNING_SESSION_STATUS, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -355,28 +355,13 @@ fn dispatch_cast_plan(plan: CastPlan) -> Result<()> { } } CastIntent::AttachSession { session_id } => { - attach_session(&session_id)?; - CastOutcome { - request: request_text, - launched: Some(format!("Attached to session {session_id}")), - session_id: Some(session_id), - next_step: Some( - "Detach with Ctrl+C; the session keeps running under the daemon.".to_string(), - ), - notes: vec![], - } + attach_via_cast(&plan, &session_id, &request_text, AttachOrigin::Attach)? } CastIntent::SummonSession { session_id } => { - summon_session_command(&session_id)?; - CastOutcome { - request: request_text, - launched: Some(format!("Summoned session {session_id}")), - session_id: Some(session_id), - next_step: Some( - "The session is now active again — attach to follow it.".to_string(), - ), - notes: vec![], - } + // Un-archive first (cheap, no follower), then re-enter through Cast + // so the resumed session also gets the Cast transcript / summary. + summon_only_command(&session_id)?; + attach_via_cast(&plan, &session_id, &request_text, AttachOrigin::Summon)? } CastIntent::ArchiveSession { session_id } => { archive_session_command(&session_id)?; @@ -734,6 +719,14 @@ fn write_cast_summary_event( ) -> Result<()> { let store_path = coven_store_path()?; let conn = store::open_store(&store_path)?; + // Cast attach replays existing events through the same follower, which + // would re-trigger the summary writer at the end. Skip the write when a + // summary already exists so the ledger keeps exactly one `cast.summary` + // per session. + let existing = store::list_events(&conn, session_id)?; + if summary_already_recorded(&existing) { + return Ok(()); + } let payload = serde_json::json!({ "request": plan.raw_spell, "headline": plan.headline, @@ -750,6 +743,192 @@ fn write_cast_summary_event( ) } +/// What brought the user into this attach. Affects the outcome card's +/// `launched` label so the user can tell the two flows apart. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AttachOrigin { + Attach, + Summon, +} + +impl AttachOrigin { + fn verb_past(self) -> &'static str { + match self { + AttachOrigin::Attach => "Attached to", + AttachOrigin::Summon => "Summoned and attached to", + } + } +} + +/// Cast-native attach. Live sessions stream through the follower with the +/// same `afterSeq` cursor the launch path uses (so no duplicate output) and +/// accept follow-up input from stdin. Completed sessions replay their full +/// event log through the same observer so the user sees the same transcript +/// shape, then surface any existing `cast.summary` in the outcome card. +/// +/// Falls back to the legacy `attach_session` loop when the daemon is not +/// reachable; the outcome card explains why the follower was skipped. +fn attach_via_cast( + plan: &CastPlan, + session_id: &str, + request_text: &str, + origin: AttachOrigin, +) -> Result { + match daemon_runtime_state()? { + DaemonRuntimeState::Running => attach_via_daemon(plan, session_id, request_text, origin), + DaemonRuntimeState::NotReady(reason) => { + attach_session(session_id)?; + Ok(CastOutcome { + request: request_text.to_string(), + launched: Some(format!( + "{} session {session_id} (legacy attach fallback)", + origin.verb_past() + )), + session_id: Some(session_id.to_string()), + next_step: Some( + "Start the daemon for streamed transcripts: `coven daemon start`.".to_string(), + ), + notes: vec![format!("Cast event follower skipped: {reason}.")], + }) + } + } +} + +fn attach_via_daemon( + plan: &CastPlan, + session_id: &str, + request_text: &str, + origin: AttachOrigin, +) -> Result { + let mut client = DaemonChatClient::default(); + let session = client + .get_session(session_id) + .with_context(|| format!("Cast could not look up session `{session_id}` via the daemon"))?; + + println!(); + println!( + "Cast transcript — session {} ({}). Press Enter at any time to send input.", + session.id, session.harness + ); + + let is_live = session.status == RUNNING_SESSION_STATUS; + if is_live { + // Mirror the launch path: forward stdin lines into the daemon for + // follow-up input while the follower streams output. The thread is + // detached and exits when stdin closes. + maybe_spawn_cast_input_forwarder(coven_home_dir()?, session.id.clone()); + } + + let mut observer = TranscriptObserver::new(io::stdout()); + 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, + )?) + } else { + // For completed sessions, drain the historical event log once. The + // follower observer renders the transcript exactly as it did on the + // original run and reports the exit when it lands in the replay. + replay_completed_session(&mut client, &session.id, &mut observer)? + }; + + if is_live { + if let Some(exit) = exit.as_ref() { + // Idempotent — skips when a `cast.summary` already exists. + write_cast_summary_event(&session.id, plan, &session.harness, exit)?; + } + } + + let mut notes = plan_outcome_notes(plan); + if let Some(exit) = exit.as_ref() { + notes.push(format_exit_summary(exit)); + } + // For completed/replayed sessions, surface the original Cast summary so + // the user can see what the prior run was about. Live attaches just wrote + // the summary themselves, so showing it again would only echo the exit + // line we already printed. + if !is_live { + 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); + } + } + + let launched = if is_live { + format!( + "{} live session {} via daemon", + origin.verb_past(), + session.id + ) + } else { + format!( + "{} completed session {} (replayed via daemon)", + origin.verb_past(), + session.id + ) + }; + + let next_step = if is_live { + Some(format!( + "Run `coven attach {}` later to revisit; events are durable.", + session.id + )) + } else { + Some(format!( + "Run `coven sessions` to manage this session, or `/sacrifice {}` to delete it.", + session.id + )) + }; + + Ok(CastOutcome { + request: request_text.to_string(), + launched: Some(launched), + session_id: Some(session.id), + next_step, + notes, + }) +} + +/// 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. +fn replay_completed_session( + client: &mut dyn ChatClient, + session_id: &str, + observer: &mut dyn FollowerObserver, +) -> Result> { + let records = client.list_events(ChatEventQuery { + session_id, + after_seq: None, + limit: None, + })?; + + let mut exit = None; + for record in records { + let decoded = cast::follow::decode_event(&record); + match &decoded { + cast::follow::CastFollowEvent::Output(chunk) => observer.on_output(chunk), + cast::follow::CastFollowEvent::Exit { status, exit_code } => { + observer.on_exit(status, *exit_code); + exit = Some(CastSessionExit { + status: status.clone(), + exit_code: *exit_code, + }); + } + cast::follow::CastFollowEvent::Other { kind } => observer.on_other(kind), + } + } + Ok(exit) +} + fn format_exit_summary(exit: &CastSessionExit) -> String { match exit.exit_code { Some(code) => format!( @@ -1275,3 +1454,150 @@ pub(crate) fn move_magical_tui_selection(current: usize, direction: MagicalTuiMo pub(crate) fn render_frame_plain_for_test(selection: usize) -> String { render_magical_tui_frame_plain(selection) } + +#[cfg(test)] +mod attach_tests { + use std::cell::RefCell; + use std::io::Cursor; + + use super::*; + use crate::tui::chat::client::LaunchRequest; + + /// Stub client that returns a canned event log on every `list_events` call + /// and records which session id was queried. Mirrors the stub in + /// `cast::follow` tests but kept local to shell.rs so the wiring (not the + /// already-tested decode pipeline) is what's under test here. + struct ReplayClient { + events: Vec, + queries: RefCell>, + } + + impl ReplayClient { + fn new(events: Vec) -> Self { + Self { + events, + queries: RefCell::new(Vec::new()), + } + } + } + + impl ChatClient for ReplayClient { + fn launch_session(&mut self, _request: LaunchRequest) -> Result { + unimplemented!("not exercised by replay tests") + } + + fn get_session(&mut self, _session_id: &str) -> Result { + unimplemented!("not exercised by replay tests") + } + + fn list_sessions(&mut self) -> Result> { + unimplemented!("not exercised by replay tests") + } + + fn list_events(&mut self, query: ChatEventQuery<'_>) -> Result> { + self.queries.borrow_mut().push(query.session_id.to_string()); + Ok(self.events.clone()) + } + + fn send_input(&mut self, _session_id: &str, _data: &str) -> Result<()> { + unimplemented!("not exercised by replay tests") + } + + fn kill_session(&mut self, _session_id: &str) -> Result<()> { + unimplemented!("not exercised by replay tests") + } + } + + fn output_event(seq: i64, data: &str) -> store::EventRecord { + store::EventRecord { + seq, + id: format!("event-{seq}"), + session_id: "session-1".to_string(), + kind: "output".to_string(), + payload_json: serde_json::json!({ "data": data }).to_string(), + created_at: "2026-05-19T00:00:00Z".to_string(), + } + } + + fn exit_event(seq: i64, status: &str, exit_code: Option) -> store::EventRecord { + let payload = match exit_code { + Some(code) => serde_json::json!({ "status": status, "exitCode": code }), + None => serde_json::json!({ "status": status }), + }; + store::EventRecord { + seq, + id: format!("event-{seq}"), + session_id: "session-1".to_string(), + kind: "exit".to_string(), + payload_json: payload.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"); + assert_eq!(AttachOrigin::Summon.verb_past(), "Summoned and attached to"); + } + + #[test] + fn replay_completed_session_streams_full_transcript_into_observer() { + let mut client = ReplayClient::new(vec![ + output_event(1, "hello\n"), + output_event(2, "world\n"), + exit_event(3, "completed", Some(0)), + ]); + let mut buffer: Cursor> = Cursor::new(Vec::new()); + let mut observer = TranscriptObserver::new(&mut buffer); + + let exit = + replay_completed_session(&mut client, "session-1", &mut observer).expect("replay"); + + let rendered = String::from_utf8(buffer.into_inner()).unwrap(); + assert!( + rendered.contains("hello"), + "transcript missing first chunk: {rendered:?}" + ); + assert!( + rendered.contains("world"), + "transcript missing second chunk" + ); + assert!( + rendered.contains("[Cast: session completed (exit code 0)]"), + "transcript should include Cast exit banner: {rendered:?}" + ); + + let exit = exit.expect("replay should surface the recorded exit"); + assert_eq!(exit.status, "completed"); + assert_eq!(exit.exit_code, Some(0)); + + let queries = client.queries.borrow(); + assert_eq!(queries.len(), 1, "one full-history fetch is enough"); + assert_eq!(queries[0], "session-1"); + } + + #[test] + fn replay_completed_session_returns_none_when_no_exit_event_in_log() { + let mut client = ReplayClient::new(vec![ + output_event(1, "still going\n"), + output_event(2, "no exit yet\n"), + ]); + let mut buffer: Cursor> = Cursor::new(Vec::new()); + let mut observer = TranscriptObserver::new(&mut buffer); + + let exit = + replay_completed_session(&mut client, "session-1", &mut observer).expect("replay"); + + assert!( + exit.is_none(), + "replay must not invent an exit when the log has none" + ); + let rendered = String::from_utf8(buffer.into_inner()).unwrap(); + assert!(rendered.contains("still going")); + assert!(rendered.contains("no exit yet")); + assert!( + !rendered.contains("[Cast:"), + "no Cast exit banner without an exit event: {rendered:?}" + ); + } +} From 8443209130f1fd037500d2c921b7913c110107f7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 19 May 2026 08:43:58 -0500 Subject: [PATCH 02/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/coven-cli/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/coven-cli/src/main.rs b/crates/coven-cli/src/main.rs index 43b2e36..340ab02 100644 --- a/crates/coven-cli/src/main.rs +++ b/crates/coven-cli/src/main.rs @@ -631,6 +631,10 @@ pub(crate) fn summon_only_command(session_id: &str) -> Result Date: Tue, 19 May 2026 08:44:09 -0500 Subject: [PATCH 03/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/coven-cli/src/tui/shell.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/coven-cli/src/tui/shell.rs b/crates/coven-cli/src/tui/shell.rs index 9b2e2ac..e06961d 100644 --- a/crates/coven-cli/src/tui/shell.rs +++ b/crates/coven-cli/src/tui/shell.rs @@ -805,13 +805,21 @@ fn attach_via_daemon( .get_session(session_id) .with_context(|| format!("Cast could not look up session `{session_id}` via the daemon"))?; + let is_live = session.status == RUNNING_SESSION_STATUS; + println!(); - println!( - "Cast transcript — session {} ({}). Press Enter at any time to send input.", - session.id, session.harness - ); + if is_live { + println!( + "Cast transcript — session {} ({}). Press Enter at any time to send input.", + session.id, session.harness + ); + } else { + println!( + "Cast transcript — session {} ({}) replay.", + session.id, session.harness + ); + } - let is_live = session.status == RUNNING_SESSION_STATUS; if is_live { // Mirror the launch path: forward stdin lines into the daemon for // follow-up input while the follower streams output. The thread is From de0b0dd7eff0cc4edf8984aa933f0a11a1ccc24e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 13:49:53 +0000 Subject: [PATCH 04/14] perf: avoid full event log scan for cast.summary existence check Agent-Logs-Url: https://github.com/OpenCoven/coven/sessions/2b765f92-b723-47d6-b4ab-4172234a81d1 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- crates/coven-cli/src/store.rs | 40 +++++++++++++++++++++++++ crates/coven-cli/src/tui/cast/attach.rs | 21 +------------ crates/coven-cli/src/tui/cast/mod.rs | 2 +- crates/coven-cli/src/tui/shell.rs | 7 ++--- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/crates/coven-cli/src/store.rs b/crates/coven-cli/src/store.rs index 706c9b9..da69603 100644 --- a/crates/coven-cli/src/store.rs +++ b/crates/coven-cli/src/store.rs @@ -327,6 +327,21 @@ pub fn list_events(conn: &Connection, session_id: &str) -> Result Result { + use rusqlite::OptionalExtension; + + let exists = conn + .query_row( + "SELECT 1 FROM events WHERE session_id = ?1 AND kind = ?2 LIMIT 1", + params![session_id, kind], + |_| Ok(()), + ) + .optional() + .context("failed to query event kind existence")? + .is_some(); + Ok(exists) +} + pub fn list_events_with_options( conn: &Connection, session_id: &str, @@ -692,6 +707,31 @@ mod tests { Ok(()) } + #[test] + fn event_kind_exists_detects_kind_without_loading_event_payloads() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let conn = open_store(&temp_dir.path().join("coven.db"))?; + insert_session(&conn, &session_record("session-1", "2026-04-27T06:00:00Z"))?; + insert_json_event( + &conn, + "session-1", + "output", + &serde_json::json!({ "data": "hello" }), + "2026-04-27T06:01:00Z", + )?; + insert_json_event( + &conn, + "session-1", + "cast.summary", + &serde_json::json!({ "status": "completed", "exitCode": 0 }), + "2026-04-27T06:02:00Z", + )?; + + assert!(!event_kind_exists(&conn, "session-1", "input")?); + assert!(event_kind_exists(&conn, "session-1", "cast.summary")?); + Ok(()) + } + #[test] fn list_events_with_after_event_id_returns_tail() -> Result<()> { let temp_dir = tempfile::tempdir()?; diff --git a/crates/coven-cli/src/tui/cast/attach.rs b/crates/coven-cli/src/tui/cast/attach.rs index 1f85d14..f00ba47 100644 --- a/crates/coven-cli/src/tui/cast/attach.rs +++ b/crates/coven-cli/src/tui/cast/attach.rs @@ -3,8 +3,7 @@ //! Phase 2 lets the user re-enter a previously-launched session through the //! same follower the launch path uses. The pure helpers in this module decode //! the `cast.summary` event Cast wrote at the end of the original run so the -//! attach outcome card can describe what already happened — and tell the -//! launch-side writer when a summary already exists so it does not double-log. +//! attach outcome card can describe what already happened. use serde_json::Value; @@ -37,12 +36,6 @@ pub(crate) fn find_cast_summary(events: &[store::EventRecord]) -> Option bool { - events.iter().any(|event| event.kind == CAST_SUMMARY_KIND) -} - /// One-line outcome-card note describing what Cast saw on the prior run. /// Returns `None` when none of the fields are populated, so the caller can /// skip the note entirely instead of printing an empty bullet. @@ -207,18 +200,6 @@ mod tests { assert_eq!(summary, CastAttachSummary::default()); } - #[test] - fn summary_already_recorded_only_true_when_summary_event_present() { - let only_output = vec![output_event(1, "hi\n")]; - assert!(!summary_already_recorded(&only_output)); - - let with_summary = vec![ - output_event(1, "hi\n"), - summary_event(2, serde_json::json!({ "status": "completed" })), - ]; - assert!(summary_already_recorded(&with_summary)); - } - #[test] fn format_summary_note_returns_none_for_empty_summary() { assert_eq!( diff --git a/crates/coven-cli/src/tui/cast/mod.rs b/crates/coven-cli/src/tui/cast/mod.rs index 17cff4c..3d56db7 100644 --- a/crates/coven-cli/src/tui/cast/mod.rs +++ b/crates/coven-cli/src/tui/cast/mod.rs @@ -23,7 +23,7 @@ use anyhow::Result; use crate::harness; -pub(crate) use attach::{find_cast_summary, format_summary_note, summary_already_recorded}; +pub(crate) use attach::{find_cast_summary, format_summary_note}; pub(crate) use follow::{follow_until_exit, CastSessionExit, FollowerObserver, FollowerPacer}; pub(crate) use gate::{evaluate_gate, GateOutcome}; pub(crate) use intent::{parse_spell, CastHarness, CastIntent}; diff --git a/crates/coven-cli/src/tui/shell.rs b/crates/coven-cli/src/tui/shell.rs index e06961d..1309f2f 100644 --- a/crates/coven-cli/src/tui/shell.rs +++ b/crates/coven-cli/src/tui/shell.rs @@ -14,8 +14,8 @@ use uuid::Uuid; use super::cast::{ self, evaluate_gate, follow_until_exit, format_summary_note, render_cast_frame_for_terminal, - render_outcome, render_plan_intro, summary_already_recorded, CastIntent, CastOutcome, CastPlan, - CastSessionExit, FollowerObserver, FollowerPacer, GateOutcome, SafetyDecision, + render_outcome, render_plan_intro, CastIntent, CastOutcome, CastPlan, CastSessionExit, + FollowerObserver, FollowerPacer, GateOutcome, SafetyDecision, }; use super::chat::client::{ChatClient, ChatEventQuery, DaemonChatClient, LaunchRequest}; use super::{is_key_press, sessions}; @@ -723,8 +723,7 @@ fn write_cast_summary_event( // would re-trigger the summary writer at the end. Skip the write when a // summary already exists so the ledger keeps exactly one `cast.summary` // per session. - let existing = store::list_events(&conn, session_id)?; - if summary_already_recorded(&existing) { + if store::event_kind_exists(&conn, session_id, "cast.summary")? { return Ok(()); } let payload = serde_json::json!({ From 07fd7429e2d4dd5593bed5c739b1af6628e23887 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 19 May 2026 08:32:29 -0500 Subject: [PATCH 05/14] docs(cast): Phase 1 TUI visual contract (#94) Defines the sleek-minimalist Cast Codes target so Phase 2 has rules to implement against: surfaces in scope, the 14-char field column, hierarchy order, color roles re-anchored on PRIMARY_STRONG (#9A8ECD), the chip system for risk states, copy tone, and twelve explicit anti-patterns lifted from the current launcher (workspace map graph, fake task inbox, +---+ ASCII chrome, repeated brand voice, Store footer, etc.). Ends with the file seam Phase 2 will edit and a done-when checklist. Co-authored-by: Claude Opus 4.7 (1M context) --- docs/design/cast-tui-contract.md | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/design/cast-tui-contract.md diff --git a/docs/design/cast-tui-contract.md b/docs/design/cast-tui-contract.md new file mode 100644 index 0000000..4846829 --- /dev/null +++ b/docs/design/cast-tui-contract.md @@ -0,0 +1,154 @@ +--- +summary: "Phase 1 visual contract for the Cast/Coven TUI: sleek minimalist Cast Codes target. Defines surfaces, spacing, typography, color roles, panel hierarchy, copy tone, and explicit anti-patterns." +title: "Cast TUI — Visual Contract (Phase 1)" +description: "Design contract the Cast TUI must implement against. Not for end users; the audience is OpenCoven contributors working on the Coven CLI." +--- + +# Cast TUI — Visual Contract (Phase 1) + +This document is the design target for the Coven CLI's interactive surfaces: the launcher ("magical TUI"), the Cast plan/outcome cards, and the non‑interactive Cast frame. It is intentionally short. Phase 2+ implements against it; do not deviate without amending this file. + +Anchors: [DESIGN.md](../../DESIGN.md), [BRAND.md](../BRAND.md), [`brand/ui/color-tokens.css`](../../brand/ui/color-tokens.css), [`brand/ui/typography.css`](../../brand/ui/typography.css). + +## 1. Surfaces in scope + +| Surface | Source | Notes | +| --- | --- | --- | +| Launcher frame ("Coven home") | `crates/coven-cli/src/tui/shell.rs::render_magical_tui_frame_with_mode_and_width` | The screen `coven` opens to. Today contains workspace map, status block, task inbox, input box, slash list, selected‑command panel, store footer. | +| Cast non‑interactive frame | `crates/coven-cli/src/tui/cast/render.rs::render_cast_frame_with_mode` | Printed when stdout is piped or stdin is not a TTY. | +| Cast plan intro card | `crates/coven-cli/src/tui/cast/render.rs::render_plan_intro_with_mode` | Shown before any side effect; describes the resolved intent and safety decision. | +| Cast outcome card | `crates/coven-cli/src/tui/cast/render.rs::render_outcome_with_mode` | Shown after the spell finishes; describes what landed and the next step. | +| Cast transcript banner | `crates/coven-cli/src/tui/shell.rs::dispatch_via_daemon` (`println!("Cast transcript — session …")`) | The "Press Enter at any time to send input." line printed before live output streams. | +| Cast exit summary line | `crates/coven-cli/src/tui/shell.rs::TranscriptObserver::on_exit` | `[Cast: session (exit code N)]` line that closes a transcript. | + +Out of scope (Phase 1): +- The ratatui‑based chat TUI surfaces (`crates/coven-cli/src/tui/chat/`) — separate visual track. +- Session browser (`tui/sessions.rs`) — touched in a later phase, not now. +- Doctor, patch, daemon‑status output — these are CLI prose, not surfaces. + +## 2. Design contract + +The whole product is one frame: black surface, monospace, sparse. Cast Codes should feel like reading a quiet status board, not a dashboard. + +### 2.1 Surfaces and panels + +- One surface tone: `--oc-surface-0` (`#000000`). No nested filled boxes. If a panel needs separation, separate it with a single line of `--oc-border-subtle` (`rgba(255,255,255,0.08)`) drawn as a single thin rule, never with `+---+` corner art. +- At most **two** visual panels per frame: + 1. A header band (3–4 lines max: brand name, one‑line context, one‑line status). + 2. A body region (plan/outcome fields *or* slash list, never both stacked deep). +- Replace the current `+--- Workspace map ---+`, `+-- Ask anything --+`, and `Selected command` blocks with a single bordered prompt area + an unbordered list. Heavy ASCII boxes are removed; if a border is needed at all, use a single horizontal rule above and below the content (`────`), no corners. + +### 2.2 Spacing and density + +- One blank line between sections. Never two. Never zero. +- Field rows: `label value` with a fixed label column of 14 chars (the longest field label, e.g. `Default harness`). All callsites use the same column so the eye locks onto value text. +- Wrap at the smaller of terminal width − 2 and 96 columns (`MAGICAL_TUI_MAX_INNER_WIDTH`). Truncate with `…` only at the end of a logical row; never mid‑label. +- Maximum 6 visible items in the slash list before scrolling indicator. The current frame shows 13 items always — that violates the "generous negative space" rule. + +### 2.3 Typography (within a monospace TUI) + +The DESIGN.md type stacks (Inter / Satoshi) do not apply inside a terminal — everything is `--oc-font-mono`. What we adapt: + +- **Headers**: rendered as a single line in `PRIMARY_STRONG` (`#9A8ECD`), Title Case, no decoration. *No* leading sigils, em‑dashes, or `>` glyphs. +- **Labels**: rendered in `FIELD_LABEL` (`TEXT_MUTED`), lowercase, no colon at the end of the rendered string — the column gap is the separator. Allowed exception: ALL‑CAPS short label chips like `SAFE`, `CONFIRM`, `REJECT`, `LIVE` (semantic only, two‑word max). +- **Body values**: `TEXT` (default white at 94% on black). +- **Hints / footer**: `DIM` (`TEXT_FAINT`), one line, never more than 80 chars. +- No mixed‑case slash commands; commands always render as `/`. +- Headlines never end in punctuation. Body sentences do. + +### 2.4 Color roles + +Honour the 90/10 rule. The renderer must use **only** these semantic tokens; raw `Rgb` literals or new hex strings are forbidden outside `theme.rs`. + +| Role | Token | When | +| --- | --- | --- | +| Default text | `TEXT` (`brand::TEXT`) | All body content and values. | +| Muted label | `FIELD_LABEL` (= `TEXT_MUTED`) | Field labels, hints inside a panel. | +| Faint hint | `DIM` (= `TEXT_FAINT`) | Footer hints, keyboard shortcuts, "Esc quits". | +| Strong accent | `PRIMARY_STRONG` (`#9A8ECD`) | Headlines, the selected slash row, the active risk chip if `safe`. | +| Soft accent | `PRIMARY` (`#C5BDED`) | Hover/secondary highlight; cursor underline; the `>` selection arrow. | +| Danger | `DANGER` (`#FF3B30`) | `REJECT` chip; destructive confirmation prompts. | +| Success | `SUCCESS` (`#30D158`) | `LIVE`, `OK`, `exit 0` chip. | +| Info | `ACCENT_BLUE` (`#0A84FF`) | Reserved for actionable links/keys only (e.g. inline `coven attach ` references); never used as a generic UI accent. | + +Surfaces stay `--oc-surface-0` (pure black). `SURFACE_1`/`SURFACE_2` are not used by the TUI in Phase 2; they exist for future ratatui panels. + +`USER_LABEL` (`#7A6DAA`) loses its current launcher role (it is currently used for everything from welcome to input‑box rows) — in the new contract it is reserved for differentiating the *user prompt line* from agent output inside the transcript only. + +### 2.5 Hierarchy + +Every Cast frame reads top‑to‑bottom in this order; sections may be omitted but never reordered: + +1. **Identity** — one line. `Cast` in `PRIMARY_STRONG`, nothing else. +2. **Context** — at most three field rows: `project`, `harness`, `daemon`. Single column. +3. **Body** — one of: + - Plan: `spell`, `harness`, `risk` (+ optional reason line), then a numbered step list (max 4 visible). + - Outcome: `spell`, `launched`, `session`, then optional `notes` (max 3), then `next`. + - Launcher: prompt input, then the slash list (max 6 visible). +4. **Footer hint** — one `DIM` line. Never two. + +The current `Welcome back to the Coven.` / `OpenCoven terminal home for local agent work.` / `Workspace map` / `Status` / `Task inbox` / `Slash commands` / `Selected command` / `Store: ~/.coven` stack collapses to: identity → context (3 rows) → prompt → slash list → footer. + +### 2.6 Copy tone + +- Confident, restrained, direct. Lifted from BRAND.md: "Arcane but precise, technical not gimmicky." +- One sentence per surface, max. Plan and outcome fields are noun phrases, not sentences. +- No second‑person address ("Welcome back"), no first‑person Cast ("I will…"). +- Forbidden tropes: "circle fades", "magical mode", "magical terminal mode", "the circle", "your Coven familiar is ready" repeated. The familiar's name does the work; the prose does not need to lampshade it. +- Risk reasons are noun‑first: `push to remote — pre‑flight required`, not `! push to remote — please confirm`. + +### 2.7 Selection and interaction + +- Slash list selection: the selected row is rendered in `PRIMARY_STRONG`; unselected rows in `TEXT_MUTED`. The leading marker is a single space for unselected and a thin `›` (U+203A) for selected — not `>`. +- Input area: single thin horizontal rule above and below (`────`), no corners, no label inside the rule. The placeholder is rendered in `DIM`. The cursor is implicit (terminal cursor); no synthetic `█` block. +- Focus glow (DESIGN.md §9) translates to: when the prompt has focus the underline rule below the input is `PRIMARY_STRONG`; otherwise it is `--oc-border-subtle`. +- Keys hint: a single `DIM` line at the bottom — `enter run · ↑↓ select · esc quit · ctrl+u clear`. Centered‑dot separator, no `|` pipes. + +### 2.8 Risk chips + +`safe` / `confirm` / `reject` render as fixed‑width 8‑char ALL‑CAPS chips: + +``` +[ SAFE ] PRIMARY_STRONG +[ CONFIRM] DANGER (muted via PRIMARY when only a soft warning) +[ REJECT ] DANGER +``` + +The current `! reason — suggestion` and `X reason — alternative` lines drop the leading glyph entirely; the chip carries the semantic, the reason line is plain prose under it. + +## 3. Anti‑patterns — what NOT to build + +These exist in the current code and must not survive Phase 2. + +1. **ASCII chrome boxes**: `+---+ | +---+` corner art for `Workspace map`, `Ask anything`, `Input box`. Remove. Use single‑rule `────` only when separation is necessary. +2. **Decorative graphs**: the `[nova] — [coven] — [cody]` workspace map. Cast does not have nova/cody nodes as first‑class concepts at the launcher level; this is gimmicky flourish DESIGN.md §10 forbids. +3. **Fake task inboxes**: `[ ] inspect repo [ ] launch harness` — these are not real tasks and they imply state we do not track. Delete. +4. **Multiple stacked section headers** ("Status", "Task inbox", "Slash commands", "Selected command") on the same frame. Collapse to the §2.5 hierarchy. +5. **Repeated brand voice**: `Cast — your Coven familiar` header *and* `Cast, your Coven familiar, is ready…` salute on the same screen. Choose one identity line. +6. **Emojis or pictographs in UI text**: per memory rule (2026‑05‑17). The current code does not use any, but Phase 2 must not introduce sigil glyphs, sparkles, or check‑mark glyphs. +7. **Glyph‑prefixed risk lines** (`!`, `X`): semantics belong in a typed chip, not a punctuation mark. +8. **`=>` and `|` separators** in copy: `/start => coven doctor`, `… | …`. Replace with column gap or a thin middle dot (`·`). +9. **Two visual languages in one product**: heavy ASCII boxes in the launcher next to plain key:value cards from Cast plan/outcome. They must share one chrome system. +10. **Filled surface tones inside the TUI** (`SURFACE_1`, `SURFACE_2`): unused in Phase 2; keep the canvas pure black. +11. **Saturated purple**: `PURPLE_3` (`#C5BDED`) currently maps to `PRIMARY` (the most common accent). Swap so `PRIMARY_STRONG` (`#9A8ECD`) carries weight and `PRIMARY` is hover/secondary only. +12. **`Store: ~/.coven` footer line**: implementation detail, not a user signal. Remove. + +## 4. Files Phase 2 will edit + +This list is the seam — Phase 2 should touch these and only these. + +- `crates/coven-cli/src/tui/shell.rs` — rewrites `render_magical_tui_frame_with_mode_and_width`, removes `magical_tui_graph_lines`, `magical_tui_status_lines`, `magical_tui_task_inbox_lines`, the `magical_tui_input_box_*` helpers, and the `Selected command` block. +- `crates/coven-cli/src/tui/cast/render.rs` — rewrites `render_cast_frame_with_mode`, `render_plan_intro_with_mode`, `render_outcome_with_mode` against the new field‑column rule; introduces a single `chip(…)` helper for risk badges. +- `crates/coven-cli/src/theme.rs` — adds `BORDER_SUBTLE` and `BORDER_STRONG` semantic tokens (mirroring `--oc-border-subtle` / `--oc-border-strong`). No existing tokens are renamed; `PRIMARY`/`PRIMARY_STRONG` *roles* shift via callsite changes, not by remapping the constants. +- Tests in `crates/coven-cli/src/tui/cast/render.rs` and `crates/coven-cli/src/tui/shell.rs` (the `render_magical_tui_frame_plain*` tests and the existing Cast render tests) — assertions update to match the new copy. Any test that asserts the presence of `+----+`, `[ ] inspect repo`, `Workspace map`, `Store:`, or `Cast — your Coven familiar` will be rewritten. + +No other crate is touched in Phase 2. `brand/ui/*.css` is canonical and stays as‑is; we adapt the TUI to it, not the other way around. + +## 5. Done‑when checklist (for the implementer in Phase 2) + +- [ ] Launcher renders ≤ 22 lines on an 80‑column terminal with default content. +- [ ] No `+`, `|`, `\`, `/` ASCII corner art remains in any rendered frame. +- [ ] Every accent color comes from a semantic theme token; `rg "Rgb \{" crates/coven-cli/src/tui` returns nothing. +- [ ] Cast plan, outcome, and launcher frames all use the 14‑char label column. +- [ ] Tests in `tui/cast/render.rs` and `tui/shell.rs` pass with assertions updated to the new copy. +- [ ] `coven` (interactive) and `coven | cat` (piped) both render frames matching §2.5 hierarchy; no surface‑specific divergence. From 6da49078579b9a2ca6d649c64a18661692a79d27 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 19 May 2026 07:36:54 -0500 Subject: [PATCH 06/14] docs(cast): add Phase 1 TUI visual contract Defines the sleek-minimalist Cast Codes target so Phase 2 has rules to implement against: surfaces in scope, the 14-char field column, hierarchy order, color roles re-anchored on PRIMARY_STRONG (#9A8ECD), the chip system for risk states, copy tone, and twelve explicit anti-patterns lifted from the current launcher (workspace map graph, fake task inbox, +---+ ASCII chrome, repeated brand voice, Store footer, etc.). Ends with the file seam Phase 2 will edit and a done-when checklist. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/cast-tui-contract.md | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/design/cast-tui-contract.md diff --git a/docs/design/cast-tui-contract.md b/docs/design/cast-tui-contract.md new file mode 100644 index 0000000..4846829 --- /dev/null +++ b/docs/design/cast-tui-contract.md @@ -0,0 +1,154 @@ +--- +summary: "Phase 1 visual contract for the Cast/Coven TUI: sleek minimalist Cast Codes target. Defines surfaces, spacing, typography, color roles, panel hierarchy, copy tone, and explicit anti-patterns." +title: "Cast TUI — Visual Contract (Phase 1)" +description: "Design contract the Cast TUI must implement against. Not for end users; the audience is OpenCoven contributors working on the Coven CLI." +--- + +# Cast TUI — Visual Contract (Phase 1) + +This document is the design target for the Coven CLI's interactive surfaces: the launcher ("magical TUI"), the Cast plan/outcome cards, and the non‑interactive Cast frame. It is intentionally short. Phase 2+ implements against it; do not deviate without amending this file. + +Anchors: [DESIGN.md](../../DESIGN.md), [BRAND.md](../BRAND.md), [`brand/ui/color-tokens.css`](../../brand/ui/color-tokens.css), [`brand/ui/typography.css`](../../brand/ui/typography.css). + +## 1. Surfaces in scope + +| Surface | Source | Notes | +| --- | --- | --- | +| Launcher frame ("Coven home") | `crates/coven-cli/src/tui/shell.rs::render_magical_tui_frame_with_mode_and_width` | The screen `coven` opens to. Today contains workspace map, status block, task inbox, input box, slash list, selected‑command panel, store footer. | +| Cast non‑interactive frame | `crates/coven-cli/src/tui/cast/render.rs::render_cast_frame_with_mode` | Printed when stdout is piped or stdin is not a TTY. | +| Cast plan intro card | `crates/coven-cli/src/tui/cast/render.rs::render_plan_intro_with_mode` | Shown before any side effect; describes the resolved intent and safety decision. | +| Cast outcome card | `crates/coven-cli/src/tui/cast/render.rs::render_outcome_with_mode` | Shown after the spell finishes; describes what landed and the next step. | +| Cast transcript banner | `crates/coven-cli/src/tui/shell.rs::dispatch_via_daemon` (`println!("Cast transcript — session …")`) | The "Press Enter at any time to send input." line printed before live output streams. | +| Cast exit summary line | `crates/coven-cli/src/tui/shell.rs::TranscriptObserver::on_exit` | `[Cast: session (exit code N)]` line that closes a transcript. | + +Out of scope (Phase 1): +- The ratatui‑based chat TUI surfaces (`crates/coven-cli/src/tui/chat/`) — separate visual track. +- Session browser (`tui/sessions.rs`) — touched in a later phase, not now. +- Doctor, patch, daemon‑status output — these are CLI prose, not surfaces. + +## 2. Design contract + +The whole product is one frame: black surface, monospace, sparse. Cast Codes should feel like reading a quiet status board, not a dashboard. + +### 2.1 Surfaces and panels + +- One surface tone: `--oc-surface-0` (`#000000`). No nested filled boxes. If a panel needs separation, separate it with a single line of `--oc-border-subtle` (`rgba(255,255,255,0.08)`) drawn as a single thin rule, never with `+---+` corner art. +- At most **two** visual panels per frame: + 1. A header band (3–4 lines max: brand name, one‑line context, one‑line status). + 2. A body region (plan/outcome fields *or* slash list, never both stacked deep). +- Replace the current `+--- Workspace map ---+`, `+-- Ask anything --+`, and `Selected command` blocks with a single bordered prompt area + an unbordered list. Heavy ASCII boxes are removed; if a border is needed at all, use a single horizontal rule above and below the content (`────`), no corners. + +### 2.2 Spacing and density + +- One blank line between sections. Never two. Never zero. +- Field rows: `label value` with a fixed label column of 14 chars (the longest field label, e.g. `Default harness`). All callsites use the same column so the eye locks onto value text. +- Wrap at the smaller of terminal width − 2 and 96 columns (`MAGICAL_TUI_MAX_INNER_WIDTH`). Truncate with `…` only at the end of a logical row; never mid‑label. +- Maximum 6 visible items in the slash list before scrolling indicator. The current frame shows 13 items always — that violates the "generous negative space" rule. + +### 2.3 Typography (within a monospace TUI) + +The DESIGN.md type stacks (Inter / Satoshi) do not apply inside a terminal — everything is `--oc-font-mono`. What we adapt: + +- **Headers**: rendered as a single line in `PRIMARY_STRONG` (`#9A8ECD`), Title Case, no decoration. *No* leading sigils, em‑dashes, or `>` glyphs. +- **Labels**: rendered in `FIELD_LABEL` (`TEXT_MUTED`), lowercase, no colon at the end of the rendered string — the column gap is the separator. Allowed exception: ALL‑CAPS short label chips like `SAFE`, `CONFIRM`, `REJECT`, `LIVE` (semantic only, two‑word max). +- **Body values**: `TEXT` (default white at 94% on black). +- **Hints / footer**: `DIM` (`TEXT_FAINT`), one line, never more than 80 chars. +- No mixed‑case slash commands; commands always render as `/`. +- Headlines never end in punctuation. Body sentences do. + +### 2.4 Color roles + +Honour the 90/10 rule. The renderer must use **only** these semantic tokens; raw `Rgb` literals or new hex strings are forbidden outside `theme.rs`. + +| Role | Token | When | +| --- | --- | --- | +| Default text | `TEXT` (`brand::TEXT`) | All body content and values. | +| Muted label | `FIELD_LABEL` (= `TEXT_MUTED`) | Field labels, hints inside a panel. | +| Faint hint | `DIM` (= `TEXT_FAINT`) | Footer hints, keyboard shortcuts, "Esc quits". | +| Strong accent | `PRIMARY_STRONG` (`#9A8ECD`) | Headlines, the selected slash row, the active risk chip if `safe`. | +| Soft accent | `PRIMARY` (`#C5BDED`) | Hover/secondary highlight; cursor underline; the `>` selection arrow. | +| Danger | `DANGER` (`#FF3B30`) | `REJECT` chip; destructive confirmation prompts. | +| Success | `SUCCESS` (`#30D158`) | `LIVE`, `OK`, `exit 0` chip. | +| Info | `ACCENT_BLUE` (`#0A84FF`) | Reserved for actionable links/keys only (e.g. inline `coven attach ` references); never used as a generic UI accent. | + +Surfaces stay `--oc-surface-0` (pure black). `SURFACE_1`/`SURFACE_2` are not used by the TUI in Phase 2; they exist for future ratatui panels. + +`USER_LABEL` (`#7A6DAA`) loses its current launcher role (it is currently used for everything from welcome to input‑box rows) — in the new contract it is reserved for differentiating the *user prompt line* from agent output inside the transcript only. + +### 2.5 Hierarchy + +Every Cast frame reads top‑to‑bottom in this order; sections may be omitted but never reordered: + +1. **Identity** — one line. `Cast` in `PRIMARY_STRONG`, nothing else. +2. **Context** — at most three field rows: `project`, `harness`, `daemon`. Single column. +3. **Body** — one of: + - Plan: `spell`, `harness`, `risk` (+ optional reason line), then a numbered step list (max 4 visible). + - Outcome: `spell`, `launched`, `session`, then optional `notes` (max 3), then `next`. + - Launcher: prompt input, then the slash list (max 6 visible). +4. **Footer hint** — one `DIM` line. Never two. + +The current `Welcome back to the Coven.` / `OpenCoven terminal home for local agent work.` / `Workspace map` / `Status` / `Task inbox` / `Slash commands` / `Selected command` / `Store: ~/.coven` stack collapses to: identity → context (3 rows) → prompt → slash list → footer. + +### 2.6 Copy tone + +- Confident, restrained, direct. Lifted from BRAND.md: "Arcane but precise, technical not gimmicky." +- One sentence per surface, max. Plan and outcome fields are noun phrases, not sentences. +- No second‑person address ("Welcome back"), no first‑person Cast ("I will…"). +- Forbidden tropes: "circle fades", "magical mode", "magical terminal mode", "the circle", "your Coven familiar is ready" repeated. The familiar's name does the work; the prose does not need to lampshade it. +- Risk reasons are noun‑first: `push to remote — pre‑flight required`, not `! push to remote — please confirm`. + +### 2.7 Selection and interaction + +- Slash list selection: the selected row is rendered in `PRIMARY_STRONG`; unselected rows in `TEXT_MUTED`. The leading marker is a single space for unselected and a thin `›` (U+203A) for selected — not `>`. +- Input area: single thin horizontal rule above and below (`────`), no corners, no label inside the rule. The placeholder is rendered in `DIM`. The cursor is implicit (terminal cursor); no synthetic `█` block. +- Focus glow (DESIGN.md §9) translates to: when the prompt has focus the underline rule below the input is `PRIMARY_STRONG`; otherwise it is `--oc-border-subtle`. +- Keys hint: a single `DIM` line at the bottom — `enter run · ↑↓ select · esc quit · ctrl+u clear`. Centered‑dot separator, no `|` pipes. + +### 2.8 Risk chips + +`safe` / `confirm` / `reject` render as fixed‑width 8‑char ALL‑CAPS chips: + +``` +[ SAFE ] PRIMARY_STRONG +[ CONFIRM] DANGER (muted via PRIMARY when only a soft warning) +[ REJECT ] DANGER +``` + +The current `! reason — suggestion` and `X reason — alternative` lines drop the leading glyph entirely; the chip carries the semantic, the reason line is plain prose under it. + +## 3. Anti‑patterns — what NOT to build + +These exist in the current code and must not survive Phase 2. + +1. **ASCII chrome boxes**: `+---+ | +---+` corner art for `Workspace map`, `Ask anything`, `Input box`. Remove. Use single‑rule `────` only when separation is necessary. +2. **Decorative graphs**: the `[nova] — [coven] — [cody]` workspace map. Cast does not have nova/cody nodes as first‑class concepts at the launcher level; this is gimmicky flourish DESIGN.md §10 forbids. +3. **Fake task inboxes**: `[ ] inspect repo [ ] launch harness` — these are not real tasks and they imply state we do not track. Delete. +4. **Multiple stacked section headers** ("Status", "Task inbox", "Slash commands", "Selected command") on the same frame. Collapse to the §2.5 hierarchy. +5. **Repeated brand voice**: `Cast — your Coven familiar` header *and* `Cast, your Coven familiar, is ready…` salute on the same screen. Choose one identity line. +6. **Emojis or pictographs in UI text**: per memory rule (2026‑05‑17). The current code does not use any, but Phase 2 must not introduce sigil glyphs, sparkles, or check‑mark glyphs. +7. **Glyph‑prefixed risk lines** (`!`, `X`): semantics belong in a typed chip, not a punctuation mark. +8. **`=>` and `|` separators** in copy: `/start => coven doctor`, `… | …`. Replace with column gap or a thin middle dot (`·`). +9. **Two visual languages in one product**: heavy ASCII boxes in the launcher next to plain key:value cards from Cast plan/outcome. They must share one chrome system. +10. **Filled surface tones inside the TUI** (`SURFACE_1`, `SURFACE_2`): unused in Phase 2; keep the canvas pure black. +11. **Saturated purple**: `PURPLE_3` (`#C5BDED`) currently maps to `PRIMARY` (the most common accent). Swap so `PRIMARY_STRONG` (`#9A8ECD`) carries weight and `PRIMARY` is hover/secondary only. +12. **`Store: ~/.coven` footer line**: implementation detail, not a user signal. Remove. + +## 4. Files Phase 2 will edit + +This list is the seam — Phase 2 should touch these and only these. + +- `crates/coven-cli/src/tui/shell.rs` — rewrites `render_magical_tui_frame_with_mode_and_width`, removes `magical_tui_graph_lines`, `magical_tui_status_lines`, `magical_tui_task_inbox_lines`, the `magical_tui_input_box_*` helpers, and the `Selected command` block. +- `crates/coven-cli/src/tui/cast/render.rs` — rewrites `render_cast_frame_with_mode`, `render_plan_intro_with_mode`, `render_outcome_with_mode` against the new field‑column rule; introduces a single `chip(…)` helper for risk badges. +- `crates/coven-cli/src/theme.rs` — adds `BORDER_SUBTLE` and `BORDER_STRONG` semantic tokens (mirroring `--oc-border-subtle` / `--oc-border-strong`). No existing tokens are renamed; `PRIMARY`/`PRIMARY_STRONG` *roles* shift via callsite changes, not by remapping the constants. +- Tests in `crates/coven-cli/src/tui/cast/render.rs` and `crates/coven-cli/src/tui/shell.rs` (the `render_magical_tui_frame_plain*` tests and the existing Cast render tests) — assertions update to match the new copy. Any test that asserts the presence of `+----+`, `[ ] inspect repo`, `Workspace map`, `Store:`, or `Cast — your Coven familiar` will be rewritten. + +No other crate is touched in Phase 2. `brand/ui/*.css` is canonical and stays as‑is; we adapt the TUI to it, not the other way around. + +## 5. Done‑when checklist (for the implementer in Phase 2) + +- [ ] Launcher renders ≤ 22 lines on an 80‑column terminal with default content. +- [ ] No `+`, `|`, `\`, `/` ASCII corner art remains in any rendered frame. +- [ ] Every accent color comes from a semantic theme token; `rg "Rgb \{" crates/coven-cli/src/tui` returns nothing. +- [ ] Cast plan, outcome, and launcher frames all use the 14‑char label column. +- [ ] Tests in `tui/cast/render.rs` and `tui/shell.rs` pass with assertions updated to the new copy. +- [ ] `coven` (interactive) and `coven | cat` (piped) both render frames matching §2.5 hierarchy; no surface‑specific divergence. From 73f85a196436ba73eddd8de7e9e0937f146f41cd Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 19 May 2026 11:13:12 -0500 Subject: [PATCH 07/14] TUI: rebrand launcher to 'Cast' and refactor theming Rework the launcher and theming across the TUI: replace the old "CovenCLI" identity with "Cast" and update tests to match the new wording and layout. Add several new theme tokens and helpers (SURFACE_0, SURFACE_3, BACKDROP, TEXT, TEXT_DIM, BORDER_DIM, SCROLL_TRACK), a Status semantic with status_token/status_style, a Palette helper, and a reusable fit_chars utility with tests. Remove duplicated fit_chars implementations and import theme::fit_chars where needed. Refactor cast renderer to use a LauncherSnapshot model and a two-column command+snapshot layout, introduce new rendering helpers (push_line, push_snapshot_row, render_paired_line, launcher_command_window, RowCell), use the U+203A selection marker, and simplify prompt/footer/preview rendering. Update chat renderers to use the new theme tokens (text, dim, border, scroll track, status_style) and replace ad-hoc color choices with semantic tokens. Adjust sessions and shell code to consume the shared utilities. --- crates/coven-cli/src/main.rs | 150 +++++++++----- crates/coven-cli/src/theme.rs | 251 +++++++++++++++++++++++- crates/coven-cli/src/tui/cast/render.rs | 18 +- crates/coven-cli/src/tui/chat/render.rs | 30 +-- crates/coven-cli/src/tui/sessions.rs | 18 +- 5 files changed, 366 insertions(+), 101 deletions(-) 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..bbb6802 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,13 @@ 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, + }; } // ── Semantic tokens (what callsites import) ── @@ -87,19 +98,58 @@ 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; + +// ── 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 +297,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 +395,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 +563,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 +578,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, @@ -477,6 +615,107 @@ 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); + } + + #[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/render.rs b/crates/coven-cli/src/tui/cast/render.rs index 7eeab22..9132e49 100644 --- a/crates/coven-cli/src/tui/cast/render.rs +++ b/crates/coven-cli/src/tui/cast/render.rs @@ -7,7 +7,7 @@ use std::path::Path; -use crate::theme::{self, TerminalMode}; +use crate::theme::{self, fit_chars, TerminalMode}; use super::outcome::CastOutcome; use super::plan::{CastHarnessSource, CastPlan, CastStepKind}; @@ -253,22 +253,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; 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], From 94463ff64abbbcf3a6b71684e211a4b4bd9a49e3 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 19 May 2026 11:42:38 -0500 Subject: [PATCH 08/14] cast(phase 4): redesign plan + outcome cards against visual contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan/outcome cards now follow the Phase 1 TUI visual contract: a 14-char label column, fixed-width ALL-CAPS risk chips ([ SAFE ] / [CONFIRM ] / [ REJECT ]) colored by severity, noun-first risk reasons as continuation rows (no `!`/`X` glyphs), numbered step list capped at four, and a risk-aware footer hint. Sacrifice plans now ask for the typed `sacrifice` word in the footer; rejected plans steer the user to reframe. Execution semantics are unchanged: planner output, safety classification, and gate behavior are untouched — only the renderer (and the theme tokens the renderer leans on) moved. Also adds `BORDER_SUBTLE` / `BORDER_STRONG` semantic tokens so future single-rule separators have a brand-aligned color, mirroring the `--oc-border-subtle` / `--oc-border-strong` CSS variables. Tests assert key labels, every risk chip, harness-source copy (`Cast default` / `user-chosen`), the typed-confirm footer for sacrifice, the intent-row fallback for system actions, step/note caps, and that plain output never leaks an ANSI escape across any risk state. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/coven-cli/src/theme.rs | 33 ++ crates/coven-cli/src/tui/cast/render.rs | 493 +++++++++++++++++++----- 2 files changed, 436 insertions(+), 90 deletions(-) diff --git a/crates/coven-cli/src/theme.rs b/crates/coven-cli/src/theme.rs index bbb6802..688d753 100644 --- a/crates/coven-cli/src/theme.rs +++ b/crates/coven-cli/src/theme.rs @@ -89,6 +89,20 @@ pub mod brand { g: 0x24, b: 0x38, }; + /// `--oc-border-subtle` flattened on black: rgba(255,255,255,0.08) → + /// 0x14 per channel. Use for unfocused rules and divider lines. + pub const BORDER_SUBTLE: Rgb = Rgb { + r: 0x14, + g: 0x14, + b: 0x14, + }; + /// `--oc-border-strong` flattened on black: rgba(255,255,255,0.14) → + /// 0x24 per channel. Use for focused rules and emphasized dividers. + pub const BORDER_STRONG: Rgb = Rgb { + r: 0x24, + g: 0x24, + b: 0x24, + }; } // ── Semantic tokens (what callsites import) ── @@ -118,6 +132,12 @@ pub const BORDER_DIM: Rgb = brand::TEXT_FAINT; pub const SCROLL_TRACK: Rgb = brand::SURFACE_3; /// Bottom-most canvas color behind every TUI screen. pub const BACKDROP: Rgb = brand::SURFACE_0; +/// Quiet divider line (e.g. unfocused single-rule input area). Mirrors +/// `--oc-border-subtle` from `brand/ui/color-tokens.css`. +pub const BORDER_SUBTLE: Rgb = brand::BORDER_SUBTLE; +/// Emphasized divider line (e.g. focused input area, active rule). +/// Mirrors `--oc-border-strong`. +pub const BORDER_STRONG: Rgb = brand::BORDER_STRONG; // ── Status semantics ── @@ -599,6 +619,17 @@ mod tests { flatten_on_black(&vars["--oc-text-faint"]), "--oc-text-faint" ); + + assert_eq!( + brand::BORDER_SUBTLE, + flatten_on_black(&vars["--oc-border-subtle"]), + "--oc-border-subtle" + ); + assert_eq!( + brand::BORDER_STRONG, + flatten_on_black(&vars["--oc-border-strong"]), + "--oc-border-strong" + ); } #[test] @@ -620,6 +651,8 @@ mod tests { assert_eq!(BORDER_DIM, brand::TEXT_FAINT); assert_eq!(SCROLL_TRACK, brand::SURFACE_3); assert_eq!(BACKDROP, brand::SURFACE_0); + assert_eq!(BORDER_SUBTLE, brand::BORDER_SUBTLE); + assert_eq!(BORDER_STRONG, brand::BORDER_STRONG); } #[test] diff --git a/crates/coven-cli/src/tui/cast/render.rs b/crates/coven-cli/src/tui/cast/render.rs index 9132e49..3d7411b 100644 --- a/crates/coven-cli/src/tui/cast/render.rs +++ b/crates/coven-cli/src/tui/cast/render.rs @@ -7,7 +7,7 @@ use std::path::Path; -use crate::theme::{self, fit_chars, TerminalMode}; +use crate::theme::{self, fit_chars, palette_for, Fg, Palette, TerminalMode}; use super::outcome::CastOutcome; use super::plan::{CastHarnessSource, CastPlan, CastStepKind}; @@ -15,6 +15,17 @@ use super::safety::{CastRisk, SafetyDecision}; const CAST_INTRO_INNER_WIDTH: usize = 76; +/// Width of the field-label column shared by every Cast card. Matches the +/// 14-char rule from `docs/design/cast-tui-contract.md` so the eye locks +/// onto the value column across plan, outcome, and launcher frames. +const LABEL_COLUMN_WIDTH: usize = 14; + +/// Fixed-width risk chip. The text-only form is what gets asserted in tests; +/// the colored form is wrapped by `risk_chip_fg`. +const CHIP_SAFE: &str = "[ SAFE ]"; +const CHIP_CONFIRM: &str = "[CONFIRM ]"; +const CHIP_REJECT: &str = "[ REJECT ]"; + /// One-line salute at the top of every Cast frame. Used by both the /// interactive launcher (woven into the shell frame) and the non-interactive /// fallback so logs and pipes always show the familiar's name. @@ -133,63 +144,48 @@ pub(crate) fn render_plan_intro_plain(plan: &CastPlan) -> String { } fn render_plan_intro_with_mode(plan: &CastPlan, mode: TerminalMode) -> String { - let primary_strong = theme::Fg::with_mode(theme::PRIMARY_STRONG, mode); - let primary = theme::Fg::with_mode(theme::PRIMARY, mode); - let field_label = theme::Fg::with_mode(theme::FIELD_LABEL, mode); - let user_label = theme::Fg::with_mode(theme::USER_LABEL, mode); - let reset = theme::Reset::with_mode(mode); + let p = palette_for(mode); let mut frame = String::new(); - frame.push_str(&format!("{primary_strong}Cast plan{reset}\n")); - frame.push_str(&format!("{field_label}Spell:{reset} {}\n", plan.headline)); + push_section_header(&mut frame, &p, "Cast plan"); + + push_label_row(&mut frame, &p, "spell", &plan_spell_value(plan)); if let Some(plan_harness) = plan.harness { let source = match plan_harness.source { CastHarnessSource::UserChose => "user-chosen", CastHarnessSource::SafeDefault => "Cast default", }; - frame.push_str(&format!( - "{field_label}Harness:{reset} {} ({})\n", - plan_harness.harness.label(), - source - )); + let value = format!("{} · {}", plan_harness.harness.label(), source); + push_label_row(&mut frame, &p, "harness", &value); + } else if let Some(session_id) = &plan.session_id { + push_label_row(&mut frame, &p, "session", session_id); + } else { + // System actions (sessions, doctor, daemon, help, start, tui, patch, + // quit) have no harness or session id — surface what Cast understood + // so the card still answers "what did you pick?". + push_label_row(&mut frame, &p, "intent", &plan.headline); } - if let Some(title) = &plan.title { - frame.push_str(&format!("{field_label}Session title:{reset} {}\n", title)); + push_risk_row(&mut frame, &p, mode, plan.risk()); + if let SafetyDecision::Confirm { reason, .. } = &plan.decision { + push_continuation_row(&mut frame, &p, reason); } - - frame.push_str(&format!( - "{field_label}Risk:{reset} {}\n", - risk_label(plan.risk()) - )); - if let SafetyDecision::Confirm { reason, suggestion } = &plan.decision { - frame.push_str(&format!( - "{user_label} ! {} — {}{reset}\n", - reason, suggestion - )); - } - if let SafetyDecision::Reject { - reason, - alternative, - } = &plan.decision - { - frame.push_str(&format!( - "{user_label} X {} — {}{reset}\n", - reason, alternative - )); + if let SafetyDecision::Reject { reason, .. } = &plan.decision { + push_continuation_row(&mut frame, &p, reason); } if !plan.steps.is_empty() { - frame.push_str(&format!("{primary_strong}Steps{reset}\n")); - for step in &plan.steps { - frame.push_str(&format!( - "{primary} - [{}] {}{reset}\n", - step_kind_label(step.kind), - step.note - )); + frame.push('\n'); + push_section_header(&mut frame, &p, "Steps"); + for (idx, step) in plan.steps.iter().take(4).enumerate() { + push_step_row(&mut frame, &p, idx + 1, step.kind, &step.note); } } + + frame.push('\n'); + push_footer_hint(&mut frame, &p, plan_footer_hint(plan)); + frame } @@ -206,37 +202,132 @@ pub(crate) fn render_outcome_plain(outcome: &CastOutcome) -> String { } fn render_outcome_with_mode(outcome: &CastOutcome, mode: TerminalMode) -> String { - let primary_strong = theme::Fg::with_mode(theme::PRIMARY_STRONG, mode); - let primary = theme::Fg::with_mode(theme::PRIMARY, mode); - let field_label = theme::Fg::with_mode(theme::FIELD_LABEL, mode); - let reset = theme::Reset::with_mode(mode); + let p = palette_for(mode); let mut frame = String::new(); - frame.push_str(&format!("{primary_strong}Cast outcome{reset}\n")); - frame.push_str(&format!("{field_label}Spell:{reset} {}\n", outcome.request)); + push_section_header(&mut frame, &p, "Cast outcome"); + push_label_row(&mut frame, &p, "spell", &outcome.request); if let Some(launched) = &outcome.launched { - frame.push_str(&format!("{field_label}Launched:{reset} {}\n", launched)); + push_label_row(&mut frame, &p, "launched", launched); } if let Some(session_id) = &outcome.session_id { - frame.push_str(&format!("{field_label}Session id:{reset} {}\n", session_id)); + push_label_row(&mut frame, &p, "session", session_id); } + if !outcome.notes.is_empty() { - frame.push_str(&format!("{primary_strong}Notes{reset}\n")); - for note in &outcome.notes { - frame.push_str(&format!("{primary} - {}{reset}\n", note)); + frame.push('\n'); + push_section_header(&mut frame, &p, "Notes"); + for note in outcome.notes.iter().take(3) { + push_note_row(&mut frame, &p, note); } } + if let Some(next) = &outcome.next_step { - frame.push_str(&format!("{field_label}Next:{reset} {}\n", next)); + frame.push('\n'); + push_label_row(&mut frame, &p, "next", next); } + frame } -fn risk_label(risk: CastRisk) -> &'static str { +/// What the user typed (or, if Cast built the plan without raw input, the +/// most descriptive fallback). The visual contract calls this `spell`. +fn plan_spell_value(plan: &CastPlan) -> String { + if !plan.raw_spell.is_empty() { + plan.raw_spell.clone() + } else if let Some(title) = &plan.title { + title.clone() + } else { + plan.headline.clone() + } +} + +/// One-line, DIM footer that tells the user how to leave or continue. The +/// risk level changes the verb so the message tracks what the gate will +/// actually ask for. +fn plan_footer_hint(plan: &CastPlan) -> &'static str { + use crate::tui::cast::intent::CastIntent; + + if matches!(plan.intent, CastIntent::SacrificeSession { .. }) { + return "type sacrifice to confirm · esc cancels"; + } + match plan.risk() { + CastRisk::Safe => "press enter to cast · esc cancels", + CastRisk::Confirm => "review the risk note · y/N to confirm · esc cancels", + CastRisk::Reject => "Cast will not run this · type to reframe", + } +} + +/// Section heading rendered in `PRIMARY_STRONG`, Title Case, no decoration. +fn push_section_header(frame: &mut String, p: &Palette, title: &str) { + frame.push_str(&format!("{}{}{}\n", p.primary_strong, title, p.reset)); +} + +/// `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), } } @@ -286,69 +377,291 @@ mod tests { assert!(frame.contains("coven doctor")); } - #[test] - fn intro_card_shows_safe_default_source_and_session_title() { - let plan = build_plan( + fn natural_plan(prompt: &str) -> CastPlan { + build_plan( CastIntent::NaturalSpell { - prompt: "fix the failing tests".to_string(), + prompt: prompt.to_string(), }, codex, ) - .unwrap(); + .unwrap() + .with_raw_spell(prompt) + } + fn slash_plan(raw: &str) -> CastPlan { + let intent = crate::tui::cast::intent::parse_spell(raw).unwrap(); + build_plan(intent, codex).unwrap().with_raw_spell(raw) + } + + fn assert_no_ansi_leakage(frame: &str) { + assert!( + !frame.contains('\x1b'), + "plain-mode frame leaked an ANSI escape sequence:\n{frame}" + ); + } + + fn assert_label_column(frame: &str, label: &str) { + let expected = format!("{: build_plan(intent, codex).unwrap().with_raw_spell(raw), + Err(_) => continue, + }; + assert_no_ansi_leakage(&render_plan_intro_plain(&plan)); + } + } + + #[test] + fn outcome_card_uses_section_headers_and_field_columns() { let outcome = CastOutcome { request: "fix the failing tests".to_string(), launched: Some("Codex session (project-scoped)".to_string()), session_id: Some("abcdef-1234".to_string()), - next_step: Some("`coven attach abcdef-1234` to follow live output".to_string()), - notes: vec!["risk: safe".to_string()], + next_step: Some("Run `coven attach abcdef-1234` to revisit".to_string()), + notes: vec!["Session finished: status `clean`, exit code 0".to_string()], }; let frame = render_outcome_plain(&outcome); assert!(frame.contains("Cast outcome")); - assert!(frame.contains("Launched: Codex session")); - assert!(frame.contains("Session id: abcdef-1234")); + assert!(frame.contains("Notes")); + assert_label_column(&frame, "spell"); + assert_label_column(&frame, "launched"); + assert_label_column(&frame, "session"); + assert_label_column(&frame, "next"); + // Old colon-suffixed labels are gone. + assert!(!frame.contains("Launched:")); + assert!(!frame.contains("Session id:")); + // The next-step value remains copy-pastable. assert!(frame.contains("coven attach abcdef-1234")); - assert!(frame.contains("risk: safe")); + // Note prefix is a thin middle dot per the contract — no hyphen bullets. + assert!( + frame.contains("· Session finished"), + "notes should use a `·` bullet, frame:\n{frame}" + ); + assert!( + !frame.contains("- Session finished"), + "notes must not use hyphen bullets, frame:\n{frame}" + ); + assert_no_ansi_leakage(&frame); + } + + #[test] + fn outcome_card_omits_optional_rows_when_unset() { + let outcome = CastOutcome::for_request("/sessions"); + let frame = render_outcome_plain(&outcome); + assert!(frame.contains("Cast outcome")); + assert_label_column(&frame, "spell"); + assert!(!frame.contains("launched")); + assert!(!frame.contains("session ")); + assert!(!frame.contains("Notes")); + assert!(!frame.contains("next")); + } + + #[test] + fn outcome_card_caps_notes_to_three_visible() { + let outcome = CastOutcome { + request: "fix tests".to_string(), + launched: None, + session_id: None, + next_step: None, + notes: (0..6).map(|i| format!("note {i}")).collect(), + }; + let frame = render_outcome_plain(&outcome); + assert!(frame.contains("note 0")); + assert!(frame.contains("note 2")); + assert!( + !frame.contains("note 4"), + "outcome should clip to 3 notes, frame:\n{frame}" + ); + } + + #[test] + fn risk_chip_colors_change_with_severity_in_true_color_mode() { + // The chip text stays the same; the foreground escape changes by + // severity. Spot-check the escapes by rendering against a TrueColor + // palette directly so we don't depend on the cached `mode()` value. + let safe_plan = natural_plan("fix the failing tests"); + let confirm_plan = natural_plan("git push to main"); + let reject_plan = natural_plan("rm -rf / now"); + + let safe = render_plan_intro_with_mode(&safe_plan, TerminalMode::TrueColor); + let confirm = render_plan_intro_with_mode(&confirm_plan, TerminalMode::TrueColor); + let reject = render_plan_intro_with_mode(&reject_plan, TerminalMode::TrueColor); + + // PRIMARY_STRONG (0x9A, 0x8E, 0xCD) for SAFE. + assert!(safe.contains("\x1b[38;2;154;142;205m")); + // PRIMARY (0xC5, 0xBD, 0xED) for CONFIRM. + assert!(confirm.contains("\x1b[38;2;197;189;237m")); + // DANGER (0xFF, 0x3B, 0x30) for REJECT. + assert!(reject.contains("\x1b[38;2;255;59;48m")); } } From abdf3a3b9f2b48ff161f685037b6f137f6a0c70e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 20 May 2026 03:48:44 -0500 Subject: [PATCH 09/14] cast(phase 5): sequential goal flow with deterministic sub-prompting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cast::quest, a pure module that decomposes a high-level user goal into an ordered Quest of phases (design → implement → verify by default). Each phase carries a concrete sub_prompt the harness will receive; advance() attaches a structured QuestHandoff from the prior phase and recomposes the next sub_prompt deterministically, while preserving any user-authored override. render_quest_handoff() turns the handoff into a visible card — source phase, prior status, carried context, target harness, and the verbatim sub-prompt — so every delegation is inspectable before it lands. No LLM planner is introduced; sub-prompts are assembled from structured templates plus the recorded prior outcome. The cast shell wiring for a `/quest ` intent is left as the next-phase seam and documented in docs/design/cast-quest-flow.md. Covered by 15 new unit tests (11 in cast::quest, 4 in cast::render) for the composer, advancer, edits, skip, failure framing, long sub-prompt clipping, and quest exhaustion. Full cast suite: 105/105 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/coven-cli/src/tui/cast/mod.rs | 8 + crates/coven-cli/src/tui/cast/quest.rs | 534 ++++++++++++++++++++++++ crates/coven-cli/src/tui/cast/render.rs | 248 +++++++++++ docs/design/cast-quest-flow.md | 143 +++++++ 4 files changed, 933 insertions(+) create mode 100644 crates/coven-cli/src/tui/cast/quest.rs create mode 100644 docs/design/cast-quest-flow.md diff --git a/crates/coven-cli/src/tui/cast/mod.rs b/crates/coven-cli/src/tui/cast/mod.rs index 3d56db7..b2e201c 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,7 +30,14 @@ 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, +}; pub(crate) use render::{render_cast_frame_for_terminal, render_outcome, render_plan_intro}; +#[allow(unused_imports)] +pub(crate) use render::render_quest_handoff; pub(crate) use safety::SafetyDecision; // Re-exports used only by tests in `crate::tests` (main.rs). Bundled here 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..c9a4ba4 --- /dev/null +++ b/crates/coven-cli/src/tui/cast/quest.rs @@ -0,0 +1,534 @@ +//! 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 3d7411b..3cb1c5b 100644 --- a/crates/coven-cli/src/tui/cast/render.rs +++ b/crates/coven-cli/src/tui/cast/render.rs @@ -11,6 +11,7 @@ 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; @@ -230,6 +231,129 @@ fn render_outcome_with_mode(outcome: &CastOutcome, mode: TerminalMode) -> String 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 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 { @@ -644,6 +768,130 @@ mod tests { ); } + #[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 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 +