From a3f5e7dda2f5d14c2bf0b56fca1837c94d2c8309 Mon Sep 17 00:00:00 2001 From: spytensor <17600413737@163.com> Date: Sun, 24 May 2026 17:05:55 +0400 Subject: [PATCH] Add non-default live room bridge --- src/console_room.rs | 320 +++++++++++++++++++++++++++++++++++++ src/console_tui.rs | 245 +++++++++++++++++++++++++++- src/lib.rs | 1 + src/main.rs | 24 ++- tests/console_room_test.rs | 138 ++++++++++++++++ 5 files changed, 721 insertions(+), 7 deletions(-) create mode 100644 src/console_room.rs create mode 100644 tests/console_room_test.rs diff --git a/src/console_room.rs b/src/console_room.rs new file mode 100644 index 0000000..0d80673 --- /dev/null +++ b/src/console_room.rs @@ -0,0 +1,320 @@ +//! Non-default live room bridge for the unified console path. +//! +//! This module owns the bridge between the renderer-independent composer and +//! existing REPL routing semantics. It does not spawn role engines itself and +//! does not treat rendered conversation prose as evidence. + +use anyhow::Result; + +use crate::console_actions::ConsolePermissionOverlay; +use crate::console_composer::ComposerCommandSpec; +use crate::console_snapshot::{ + ConversationTurn, ConversationVisibility, CoreRoomSnapshot, RoleLaneState, +}; +use crate::repl::{parse_line, Command, PermissionCommand}; + +/// Stable composer command specs for the non-default live room path. +#[must_use] +pub fn live_room_command_specs() -> Vec { + vec![ + ComposerCommandSpec::new("allow", "allow a tool for the session", true), + ComposerCommandSpec::new("deny", "deny a tool for the session", true), + ComposerCommandSpec::new("exit", "leave the live room", false), + ComposerCommandSpec::new("fresh", "restart roles cleanly", false), + ComposerCommandSpec::new("halt", "interrupt current turn", false), + ComposerCommandSpec::new("help", "show help", false), + ComposerCommandSpec::new("host", "swap host role for this session", true), + ComposerCommandSpec::new("permissions", "show session tool approvals", false), + ComposerCommandSpec::new("quit", "leave the live room", false), + ComposerCommandSpec::new("refresh", "refresh a role", true), + ] +} + +/// State for one non-default live room bridge session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveRoomBridge { + host_role: String, + roles: Vec, + last_action: Option, + permission_overlay: Option, +} + +impl LiveRoomBridge { + /// Build a bridge from the current snapshot facts. + #[must_use] + pub fn from_snapshot(snapshot: &CoreRoomSnapshot) -> Self { + let mut roles = snapshot + .runtime + .roles + .iter() + .filter(|role| role.enabled) + .map(|role| role.role.clone()) + .collect::>(); + roles.sort(); + Self { + host_role: snapshot.runtime.host_role.clone(), + roles, + last_action: None, + permission_overlay: None, + } + } + + /// Configured host role. + #[must_use] + pub fn host_role(&self) -> &str { + &self.host_role + } + + /// Enabled role names. + #[must_use] + pub fn roles(&self) -> &[String] { + &self.roles + } + + /// Last routed action. + #[must_use] + pub const fn last_action(&self) -> Option<&LiveRoomAction> { + self.last_action.as_ref() + } + + /// Pending permission/action overlay. + #[must_use] + pub const fn permission_overlay(&self) -> Option<&ConsolePermissionOverlay> { + self.permission_overlay.as_ref() + } + + /// Surface a permission/action overlay without mutating snapshot facts. + pub fn set_permission_overlay(&mut self, overlay: ConsolePermissionOverlay) { + self.permission_overlay = Some(overlay); + } + + /// Clear a pending permission/action overlay. + pub fn clear_permission_overlay(&mut self) { + self.permission_overlay = None; + } + + /// Submit one composer buffer through existing REPL parse semantics. + pub fn submit( + &mut self, + snapshot: &mut CoreRoomSnapshot, + input: &str, + ) -> Result { + let action = self.route(input); + apply_action_to_snapshot(snapshot, &action); + Self::update_role_lanes(snapshot, &action); + self.last_action = Some(action.clone()); + Ok(action) + } + + fn route(&self, input: &str) -> LiveRoomAction { + match parse_line(input) { + Command::Empty => LiveRoomAction::Noop, + Command::SendToHost(text) => LiveRoomAction::Dispatch { + target_role: self.host_role.clone(), + text, + origin: DispatchOrigin::BareUserText, + }, + Command::SendTo { role, text } => LiveRoomAction::Dispatch { + target_role: role, + text, + origin: DispatchOrigin::ExplicitRoleMention, + }, + Command::Broadcast(text) => LiveRoomAction::Broadcast { text }, + Command::Exit => LiveRoomAction::Exit, + Command::Help => LiveRoomAction::SupportedSlash { + command: "help".to_owned(), + message: live_room_help_message(), + }, + Command::Halt(target) => LiveRoomAction::SupportedSlash { + command: "halt".to_owned(), + message: match target { + Some(role) => format!("live room bridge would halt @{role}"), + None => "live room bridge would halt current turns".to_owned(), + }, + }, + Command::Fresh => LiveRoomAction::SupportedSlash { + command: "fresh".to_owned(), + message: "live room bridge would request fresh role sessions".to_owned(), + }, + Command::Refresh(role) => LiveRoomAction::SupportedSlash { + command: "refresh".to_owned(), + message: format!("live room bridge would refresh @{role}"), + }, + Command::Permissions(command) => LiveRoomAction::SupportedSlash { + command: "permissions".to_owned(), + message: match command { + PermissionCommand::Show => { + "live room bridge would show session permission policy".to_owned() + } + PermissionCommand::Clear => { + "live room bridge would clear session permission policy".to_owned() + } + }, + }, + Command::Allow(tool) => LiveRoomAction::SupportedSlash { + command: "allow".to_owned(), + message: format!("live room bridge would allow `{tool}` for this session"), + }, + Command::Deny(tool) => LiveRoomAction::SupportedSlash { + command: "deny".to_owned(), + message: format!("live room bridge would deny `{tool}` for this session"), + }, + Command::Host(role) => LiveRoomAction::SupportedSlash { + command: "host".to_owned(), + message: format!("live room bridge would make @{role} the session host"), + }, + Command::Patch { .. } + | Command::Compact(_) + | Command::Stop(_) + | Command::Resume(_) + | Command::Transcript(_) + | Command::Journal(_) + | Command::Welcome => LiveRoomAction::UnsupportedSlash { + command: slash_name(input), + message: "not yet available in `cr console --live-room`; use `cr start` until the v0.9.4 dogfood bridge closes parity gaps".to_owned(), + }, + } + } + + fn update_role_lanes(snapshot: &mut CoreRoomSnapshot, action: &LiveRoomAction) { + let LiveRoomAction::Dispatch { target_role, .. } = action else { + return; + }; + snapshot.runtime.active_role = Some(target_role.clone()); + for role in &mut snapshot.runtime.roles { + role.state = if role.role == *target_role { + RoleLaneState::Working + } else { + RoleLaneState::Idle + }; + if role.role == *target_role { + role.last_activity = Some("queued by live room composer".to_owned()); + } + } + } +} + +/// One action produced by the live room bridge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LiveRoomAction { + /// Nothing submitted. + Noop, + /// Dispatch prompt text to one role. + Dispatch { + /// Target role. + target_role: String, + /// Prompt text. + text: String, + /// Dispatch origin. + origin: DispatchOrigin, + }, + /// Broadcast prompt text to all roles. + Broadcast { + /// Prompt text. + text: String, + }, + /// Supported slash command handled or staged by the bridge. + SupportedSlash { + /// Command name without slash. + command: String, + /// Clear user-facing message. + message: String, + }, + /// Slash command that still belongs to the old REPL path. + UnsupportedSlash { + /// Command name without slash. + command: String, + /// Clear user-facing message. + message: String, + }, + /// Exit the live room. + Exit, +} + +impl LiveRoomAction { + /// User-facing status line. + #[must_use] + pub fn status_line(&self) -> String { + match self { + Self::Noop => "no input submitted".to_owned(), + Self::Dispatch { + target_role, + origin, + .. + } => format!("queued for @{target_role} via {}", origin.label()), + Self::Broadcast { .. } => "broadcast queued for all roles".to_owned(), + Self::SupportedSlash { message, .. } | Self::UnsupportedSlash { message, .. } => { + message.clone() + } + Self::Exit => "exit requested".to_owned(), + } + } +} + +/// Origin of a role dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DispatchOrigin { + /// Bare text routes to configured host. + BareUserText, + /// User explicitly addressed a role. + ExplicitRoleMention, +} + +impl DispatchOrigin { + const fn label(self) -> &'static str { + match self { + Self::BareUserText => "bare user text", + Self::ExplicitRoleMention => "explicit @role mention", + } + } +} + +fn apply_action_to_snapshot(snapshot: &mut CoreRoomSnapshot, action: &LiveRoomAction) { + match action { + LiveRoomAction::Dispatch { + target_role, + text, + origin, + } => { + let body = match origin { + DispatchOrigin::BareUserText => text.clone(), + DispatchOrigin::ExplicitRoleMention => format!("@{target_role} {text}"), + }; + snapshot.conversation.public_turns.push(ConversationTurn { + speaker: "user".to_owned(), + body, + visibility: ConversationVisibility::PublicTranscript, + }); + } + LiveRoomAction::Broadcast { text } => { + snapshot.conversation.public_turns.push(ConversationTurn { + speaker: "user".to_owned(), + body: format!("@all {text}"), + visibility: ConversationVisibility::PublicTranscript, + }); + } + LiveRoomAction::SupportedSlash { message, .. } + | LiveRoomAction::UnsupportedSlash { message, .. } => { + snapshot.conversation.public_turns.push(ConversationTurn { + speaker: snapshot.runtime.host_role.clone(), + body: message.clone(), + visibility: ConversationVisibility::PublicTranscript, + }); + } + LiveRoomAction::Noop | LiveRoomAction::Exit => {} + } +} + +fn live_room_help_message() -> String { + "live room bridge supports bare text, explicit @role tasks, @all, /help, /exit, /halt, /fresh, /refresh, /permissions, /allow, /deny, and /host; runtime-only commands still use `cr start` until dogfood closes parity gaps".to_owned() +} + +fn slash_name(input: &str) -> String { + input + .trim_start() + .strip_prefix('/') + .and_then(|rest| rest.split_whitespace().next()) + .filter(|name| !name.is_empty()) + .unwrap_or("unknown") + .to_owned() +} diff --git a/src/console_tui.rs b/src/console_tui.rs index 89d95be..f76a71d 100644 --- a/src/console_tui.rs +++ b/src/console_tui.rs @@ -11,7 +11,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use crossterm::cursor::{Hide, Show}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::backend::{CrosstermBackend, TestBackend}; @@ -23,6 +23,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use crate::console_actions::ConsolePermissionOverlay; +use crate::console_composer::{ComposerState, ComposerSubmitOutcome}; use crate::console_conversation::{ build_live_room_conversation, InternalTaskCard, LiveRoomTurnKind, }; @@ -30,6 +31,7 @@ use crate::console_health::overview_health_signals; use crate::console_layout::{compute_console_layout, RightRailSection}; use crate::console_navigation::{visible_rows, ConsoleNavigator, ConsoleView, ConsoleVisibleRow}; use crate::console_overview::{build_console_overview, ConsoleOverview, OverviewPulse}; +use crate::console_room::{live_room_command_specs, LiveRoomAction, LiveRoomBridge}; use crate::console_snapshot::{ CoreRoomSnapshot, DirtyState, HealthSeverity, InternalDelegationState, StatusState, WorkLifecycle, @@ -60,6 +62,12 @@ pub fn run_snapshot_console(snapshot_path: &Path) -> Result<()> { run_console(&snapshot) } +/// Run the non-default live room bridge console for a local project. +pub fn run_live_room_console(project_root: &Path) -> Result<()> { + let snapshot = crate::console_live::snapshot_from_project(project_root)?; + run_live_room_console_with_snapshot(snapshot) +} + /// Run the interactive read-only full-screen console for a validated snapshot. pub fn run_console(snapshot: &CoreRoomSnapshot) -> Result<()> { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { @@ -100,6 +108,116 @@ pub fn run_console(snapshot: &CoreRoomSnapshot) -> Result<()> { Ok(()) } +fn run_live_room_console_with_snapshot(mut snapshot: CoreRoomSnapshot) -> Result<()> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + anyhow::bail!("cr console --live-room requires an interactive TTY"); + } + let _guard = ConsoleTerminalGuard::enter()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend).context("create live room terminal")?; + terminal.clear().context("clear live room terminal")?; + let navigator = ConsoleNavigator::default(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + let mut composer = ComposerState::new( + bridge.roles().to_vec(), + live_room_command_specs(), + "type a task - @role - /help - /exit", + ); + loop { + terminal + .draw(|frame| { + render_live_room_frame_with_nav_and_avatar( + frame, + &snapshot, + &navigator, + RoleAvatarPack::from_env(), + &composer, + &bridge, + ); + }) + .context("render live room frame")?; + if event::poll(Duration::from_millis(120)).context("poll live room input")? { + match event::read().context("read live room input")? { + Event::Paste(text) => composer.paste_str(&text), + Event::Key(key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + break; + } + KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) + && composer.input().is_empty() => + { + break; + } + KeyCode::Enter + if key + .modifiers + .intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + composer.insert_newline(); + } + KeyCode::Enter => match composer.submit() { + ComposerSubmitOutcome::Submitted(input) => { + let action = bridge.submit(&mut snapshot, &input)?; + composer.set_submission_state( + crate::console_composer::ComposerSubmissionState::Idle, + ); + if action == LiveRoomAction::Exit { + break; + } + } + ComposerSubmitOutcome::CompletionAccepted + | ComposerSubmitOutcome::Noop => {} + }, + KeyCode::Tab | KeyCode::Down if composer.cycle_completion() => {} + KeyCode::BackTab | KeyCode::Up if composer.cycle_completion_back() => {} + KeyCode::Esc if composer.dismiss_completion() => {} + KeyCode::Esc if composer.input().is_empty() => break, + KeyCode::Backspace => { + let _ = composer.backspace(); + } + KeyCode::Delete => { + let _ = composer.delete(); + } + KeyCode::Left => { + let _ = composer.move_left(); + } + KeyCode::Right if composer.view_model().ghost_suffix.is_some() => { + let _ = composer.accept_completion(); + } + KeyCode::Right => { + let _ = composer.move_right(); + } + KeyCode::Home => { + let _ = composer.move_home(); + } + KeyCode::End => { + let _ = composer.move_end(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + composer.clear(); + } + KeyCode::Char(ch) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + composer.insert_char(ch); + } + _ => {} + } + } + _ => {} + } + } + } + terminal.show_cursor().context("restore live room cursor")?; + Ok(()) +} + /// Render a snapshot into plain text using ratatui's test backend. pub fn render_snapshot_to_text( snapshot: &CoreRoomSnapshot, @@ -183,6 +301,31 @@ pub fn render_snapshot_to_text_with_action_overlay( Ok(buffer_to_string(terminal.backend().buffer())) } +/// Render the non-default live room frame into plain text for tests. +pub fn render_live_room_to_text( + snapshot: &CoreRoomSnapshot, + width: u16, + height: u16, + composer: &ComposerState, + bridge: &LiveRoomBridge, +) -> Result { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).context("create test live room terminal")?; + terminal + .draw(|frame| { + render_live_room_frame_with_nav_and_avatar( + frame, + snapshot, + &ConsoleNavigator::default(), + RoleAvatarPack::from_env(), + composer, + bridge, + ); + }) + .context("draw test live room frame")?; + Ok(buffer_to_string(terminal.backend().buffer())) +} + fn render_console_frame_with_nav_and_avatar( frame: &mut Frame<'_>, snapshot: &CoreRoomSnapshot, @@ -212,6 +355,39 @@ fn render_console_frame_with_nav_and_avatar( render_footer(frame, root[2], snapshot, navigator); } +fn render_live_room_frame_with_nav_and_avatar( + frame: &mut Frame<'_>, + snapshot: &CoreRoomSnapshot, + navigator: &ConsoleNavigator, + avatar_pack: RoleAvatarPack, + composer: &ComposerState, + bridge: &LiveRoomBridge, +) { + let area = frame.size(); + let layout_model = compute_console_layout(snapshot, area.width); + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(8), + Constraint::Length(4), + Constraint::Length(2), + ]) + .split(area); + + render_header(frame, root[0], snapshot); + render_body( + frame, + root[1], + snapshot, + navigator, + layout_model.right_rail.as_ref(), + avatar_pack, + ); + render_composer(frame, root[2], snapshot, composer, bridge); + render_footer(frame, root[3], snapshot, navigator); +} + fn render_header(frame: &mut Frame<'_>, area: Rect, snapshot: &CoreRoomSnapshot) { let project = &snapshot.project; let github = &snapshot.github; @@ -637,6 +813,73 @@ fn section_to_items( items } +fn render_composer( + frame: &mut Frame<'_>, + area: Rect, + snapshot: &CoreRoomSnapshot, + composer: &ComposerState, + bridge: &LiveRoomBridge, +) { + let view = composer.view_model(); + let input = if view.input.is_empty() { + Span::styled(view.prompt_hint, Style::default().fg(Color::DarkGray)) + } else { + Span::raw(view.input) + }; + let mut lines = vec![Line::from(vec![ + Span::styled("target ", label_style()), + Span::raw(format!("@{} ", snapshot.runtime.host_role)), + Span::styled("state ", label_style()), + status_value_span(&format!("{:?}", view.submission_state).to_lowercase(), None), + Span::raw(" "), + Span::styled("cursor ", label_style()), + Span::raw(view.cursor.to_string()), + ])]; + if let Some(action) = bridge.last_action() { + lines.push(Line::from(vec![ + Span::styled("bridge ", label_style()), + Span::raw(action.status_line()), + ])); + } + lines.push(Line::from(vec![ + Span::styled("cr > ", Style::default().fg(Color::Green)), + input, + view.ghost_suffix.map_or_else( + || Span::raw(""), + |suffix| Span::styled(suffix, Style::default().fg(Color::DarkGray)), + ), + ])); + if !view.candidates.is_empty() { + let labels = view + .candidates + .iter() + .take(4) + .map(|candidate| { + if candidate.selected { + format!(">{}", candidate.label) + } else { + candidate.label.clone() + } + }) + .collect::>() + .join(" "); + lines.push(Line::from(vec![ + Span::styled("complete ", label_style()), + Span::raw(labels), + ])); + } + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Composer · live room bridge"), + ) + .wrap(Wrap { trim: true }), + area, + ); +} + fn render_footer( frame: &mut Frame<'_>, area: Rect, diff --git a/src/lib.rs b/src/lib.rs index dd945a4..285e239 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod console_live; pub mod console_navigation; pub mod console_overview; pub mod console_projection; +pub mod console_room; pub mod console_snapshot; pub mod console_state; pub mod console_tui; diff --git a/src/main.rs b/src/main.rs index c901109..6f34bd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ //! - `cr role rm ` — remove a role (refuses for the host) //! - `cr` — enter the console-first room, then continue into the REPL //! - `cr start [--project PATH] [--allow-large-priors]` — enter the REPL directly -//! - `cr console [--project PATH] [--snapshot PATH]` — enter the v0.9 read-only full-screen console +//! - `cr console [--project PATH] [--snapshot PATH] [--live-room]` — enter the v0.9 full-screen console //! - `cr prompt show ` — print a role's effective prompt //! - `cr lock` — regenerate `.coreroom/priors.lock` //! - `cr verify` — verify priors lock content @@ -110,7 +110,7 @@ enum Cmd { #[arg(long)] allow_large_priors: bool, }, - /// Enter the v0.9 read-only full-screen console. + /// Enter the v0.9 full-screen console. Console { /// Project root containing `.coreroom/`. Defaults to the current /// working directory when `--snapshot` is not supplied. @@ -118,8 +118,12 @@ enum Cmd { project: Option, /// TOML CoreRoomSnapshot file to render. When omitted, CoreRoom /// derives a live local snapshot from project config and git state. - #[arg(long)] + #[arg(long, conflicts_with = "live_room")] snapshot: Option, + /// Experimental non-default unified room path with a live composer + /// bridge. Keeps `cr console --snapshot` read-only. + #[arg(long)] + live_room: bool, }, /// Replay `.coreroom/messages.jsonl` through the live renderer. Show { @@ -807,7 +811,11 @@ fn main() -> Result<()> { fresh, allow_large_priors, }) => run_start(project, yolo, fresh, allow_large_priors), - Some(Cmd::Console { project, snapshot }) => run_console(project, snapshot), + Some(Cmd::Console { + project, + snapshot, + live_room, + }) => run_console(project, snapshot, live_room), Some(Cmd::Show { project, role, @@ -1369,12 +1377,16 @@ fn run_console_first_default() -> Result<()> { run_start(Some(project_root), false, false, false) } -fn run_console(project: Option, snapshot: Option) -> Result<()> { +fn run_console(project: Option, snapshot: Option, live_room: bool) -> Result<()> { if let Some(snapshot) = snapshot { return coreroom::console_tui::run_snapshot_console(&snapshot); } let root = project_root_or_cwd(project)?; - coreroom::console_tui::run_live_console(&root) + if live_room { + coreroom::console_tui::run_live_room_console(&root) + } else { + coreroom::console_tui::run_live_console(&root) + } } fn run_lock(project: Option) -> Result<()> { diff --git a/tests/console_room_test.rs b/tests/console_room_test.rs new file mode 100644 index 0000000..9aba00e --- /dev/null +++ b/tests/console_room_test.rs @@ -0,0 +1,138 @@ +//! Non-default live room bridge fixtures. + +use coreroom::console_actions::route_console_action; +use coreroom::console_composer::ComposerState; +use coreroom::console_room::{ + live_room_command_specs, DispatchOrigin, LiveRoomAction, LiveRoomBridge, +}; +use coreroom::console_snapshot::{ConversationVisibility, CoreRoomSnapshot, RoleLaneState}; +use coreroom::console_tui::render_live_room_to_text; +use coreroom::host_action::{ActionIntent, HostActionKind, HostActionRequest}; + +fn snapshot() -> CoreRoomSnapshot { + toml::from_str(include_str!("fixtures/console_snapshot_v08.toml")).expect("snapshot") +} + +#[test] +fn live_room_routes_bare_text_to_host_and_preserves_public_user_turn() { + let mut snapshot = snapshot(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + + let action = bridge + .submit(&mut snapshot, "review the unified room bridge") + .expect("submit"); + + assert_eq!( + action, + LiveRoomAction::Dispatch { + target_role: "host".to_owned(), + text: "review the unified room bridge".to_owned(), + origin: DispatchOrigin::BareUserText, + } + ); + assert_eq!(snapshot.runtime.active_role.as_deref(), Some("host")); + assert!(snapshot.conversation.public_turns.iter().any(|turn| { + turn.speaker == "user" + && turn.body == "review the unified room bridge" + && turn.visibility == ConversationVisibility::PublicTranscript + })); +} + +#[test] +fn live_room_routes_explicit_role_mentions_with_existing_repl_semantics() { + let mut snapshot = snapshot(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + + let action = bridge + .submit(&mut snapshot, "@reviewer check the visibility contract") + .expect("submit"); + + assert_eq!( + action, + LiveRoomAction::Dispatch { + target_role: "reviewer".to_owned(), + text: "check the visibility contract".to_owned(), + origin: DispatchOrigin::ExplicitRoleMention, + } + ); + assert!(snapshot.runtime.roles.iter().any(|role| { + role.role == "reviewer" + && role.state == RoleLaneState::Working + && role.last_activity.as_deref() == Some("queued by live room composer") + })); + assert!(snapshot + .conversation + .public_turns + .iter() + .any(|turn| turn.body == "@reviewer check the visibility contract")); +} + +#[test] +fn live_room_blocks_runtime_only_slash_commands_with_clear_message() { + let mut snapshot = snapshot(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + + let action = bridge + .submit(&mut snapshot, "/journal reviewer") + .expect("submit"); + + match action { + LiveRoomAction::UnsupportedSlash { command, message } => { + assert_eq!(command, "journal"); + assert!(message.contains("cr console --live-room")); + assert!(message.contains("cr start")); + } + other => panic!("unexpected action: {other:?}"), + } + assert!(snapshot + .conversation + .public_turns + .iter() + .any(|turn| turn.speaker == "host" && turn.body.contains("not yet available"))); +} + +#[test] +fn permission_overlay_stays_out_of_snapshot_conversation() { + let snapshot = snapshot(); + let before = snapshot.conversation.clone(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + let overlay = route_console_action(HostActionRequest::new( + "HA-live-room", + "host", + "host", + ActionIntent::Execute, + HostActionKind::UpdateTracker, + "WO-0305", + "Live room bridge asks for tracker update.", + )) + .expect("overlay"); + + bridge.set_permission_overlay(overlay); + + assert!(bridge.permission_overlay().is_some()); + assert_eq!(snapshot.conversation, before); +} + +#[test] +fn live_room_frame_renders_dashboard_conversation_and_composer_together() { + let mut snapshot = snapshot(); + let mut bridge = LiveRoomBridge::from_snapshot(&snapshot); + let mut composer = ComposerState::new( + bridge.roles().to_vec(), + live_room_command_specs(), + "type a task - @role - /help - /exit", + ); + composer.paste_str("@reviewer check the bridge"); + let _ = bridge + .submit(&mut snapshot, "@reviewer check the bridge") + .expect("submit"); + + let rendered = + render_live_room_to_text(&snapshot, 180, 52, &composer, &bridge).expect("render"); + + assert!(rendered.contains("Conversation")); + assert!(rendered.contains("Host-managed task cards")); + assert!(rendered.contains("Composer")); + assert!(rendered.contains("bridge queued for @reviewer")); + assert!(rendered.contains("@reviewer check the bridge")); +}