diff --git a/Cargo.lock b/Cargo.lock index 01b9a0d..d27c32e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3307,6 +3307,7 @@ dependencies = [ "dirs", "futures", "ratatui", + "serde_json", "spec-forest", "spec-forest-db", "tempfile", @@ -3315,6 +3316,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/spec-forest-app/src/main.rs b/crates/spec-forest-app/src/main.rs index 3e18ed3..c1d2f56 100644 --- a/crates/spec-forest-app/src/main.rs +++ b/crates/spec-forest-app/src/main.rs @@ -28,14 +28,14 @@ async fn static_handler(uri: Uri) -> Response { fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".spec_forest"); + .join(".spec-forest"); std::fs::create_dir_all(&dir).expect("could not create ~/.spec_forest directory"); - dir.join("spec_forest.db").to_string_lossy().into_owned() + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] #[command( - name = "spec_forest-app", + name = "spec-forest-app", about = "Spec Forest server with embedded web UI" )] struct Cli { diff --git a/crates/spec-forest-db/src/op_apply.rs b/crates/spec-forest-db/src/op_apply.rs index 387f8ea..96d3745 100644 --- a/crates/spec-forest-db/src/op_apply.rs +++ b/crates/spec-forest-db/src/op_apply.rs @@ -134,6 +134,16 @@ pub fn apply_op_to_conn(conn: &Connection, op: &SpecOp) -> Result<()> { Ok(()) } + SpecOp::UpdateFeature { node_id, content, embedding: emb } => { + let now = crate::util::now_iso8601(); + conn.execute( + "UPDATE nodes SET question = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, now, node_id], + )?; + embedding::upsert_embedding(conn, node_id, emb)?; + Ok(()) + } + SpecOp::CreateContextNode { spec_id, node_id, question, embedding } => { let now = crate::util::now_iso8601(); conn.execute( @@ -343,6 +353,15 @@ pub fn apply_op_to_conn_no_embed(conn: &Connection, op: &SpecOp) -> Result<()> { Ok(()) } + SpecOp::UpdateFeature { node_id, content, .. } => { + let now = crate::util::now_iso8601(); + conn.execute( + "UPDATE nodes SET question = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, now, node_id], + )?; + Ok(()) + } + SpecOp::CreateContextNode { spec_id, node_id, question, .. } => { let now = crate::util::now_iso8601(); conn.execute( diff --git a/crates/spec-forest-protocol/src/lib.rs b/crates/spec-forest-protocol/src/lib.rs index b3f6736..2496319 100644 --- a/crates/spec-forest-protocol/src/lib.rs +++ b/crates/spec-forest-protocol/src/lib.rs @@ -70,6 +70,11 @@ pub enum SpecOp { RemoveFeature { node_id: String, }, + UpdateFeature { + node_id: String, + content: String, + embedding: Vec, + }, CreateContextNode { spec_id: String, node_id: String, @@ -170,6 +175,7 @@ impl SpecOp { | SpecOp::UpdateQuestion { node_id, .. } | SpecOp::AddFeature { node_id, .. } | SpecOp::RemoveFeature { node_id, .. } + | SpecOp::UpdateFeature { node_id, .. } | SpecOp::CreateContextNode { node_id, .. } | SpecOp::UpdateResidualEntropy { node_id, .. } | SpecOp::ClearCandidates { node_id, .. } diff --git a/crates/spec-forest-sync/src/main.rs b/crates/spec-forest-sync/src/main.rs index 02d65a7..8772b01 100644 --- a/crates/spec-forest-sync/src/main.rs +++ b/crates/spec-forest-sync/src/main.rs @@ -25,7 +25,7 @@ struct Cli { fn default_db_path() -> String { dirs::home_dir() - .map(|p| p.join(".spec_forest").join("sync.db")) + .map(|p| p.join(".spec-forest").join("sync.db")) .unwrap_or_else(|| std::path::PathBuf::from("sync.db")) .to_string_lossy() .to_string() diff --git a/crates/spec-forest-tui/Cargo.toml b/crates/spec-forest-tui/Cargo.toml index 19b5852..8bbc73b 100644 --- a/crates/spec-forest-tui/Cargo.toml +++ b/crates/spec-forest-tui/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [dependencies] spec-forest = { path = "../spec-forest" } -spec-forest-db = { path = "../spec-forest-db" } ratatui = "0.29" crossterm = { version = "0.28", features = ["event-stream"] } futures = "0.3" @@ -18,6 +17,9 @@ tempfile = "3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-appender = "0.2" +uuid = { version = "1", features = ["v4"] } +serde_json = "1" [dev-dependencies] +spec-forest-db = { path = "../spec-forest-db" } tempfile = "3" diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 942d802..a869d2e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -10,11 +10,12 @@ pub enum Action { // Spec list OpenCreateSpec, - OpenSeedFromFile, + OpenSeedFromDir, OpenSyncConfig, OpenModelConfig, + DeleteSpec, - // Text input (shared across InputName, InputFile, SyncPasswordInput) + // Text input (shared across InputName, SyncPasswordInput) TypeChar(char), DeleteChar, Cancel, @@ -29,6 +30,10 @@ pub enum Action { TogglePause, CancelExplore, EditNextQuestion, + AddFeature, + RegenerateFeature, + AddQuestion, + DeleteNode, // Tree navigation ExpandOrCollapseTreeNode, @@ -36,6 +41,29 @@ pub enum Action { TreeUp, TreeDown, EditTreeNode, + SiblingUp, + SiblingDown, + + // Directory browser + DirBrowserUp, + DirBrowserDown, + DirBrowserExpand, + DirBrowserCollapse, + DirBrowserGoToParent, + DirBrowserSelect, + DirBrowserCancel, + + // Depth picker + DepthPickerUp, + DepthPickerDown, + DepthPickerConfirm, + DepthPickerCancel, + + // Spec options picker (mode + locality) + SpecOptionsUp, + SpecOptionsDown, + SpecOptionsConfirm, + SpecOptionsCancel, // Sync SyncLogin, @@ -44,5 +72,135 @@ pub enum Action { // Model SelectModel, + // Config + OpenConfig, + SetUsername, + ToggleAutoExplore, + + // Spec settings + OpenSpecSettings, + SetSpecDirectory, + ClearSpecDirectory, + + // Candidates + CandidateNext, + CandidatePrev, + AcceptCandidate, + EditCandidate, + + // Log panel + ToggleLog, + LogScrollUp, + LogScrollDown, + LogScrollLineUp, + LogScrollLineDown, + + // Shadow answers + GenerateShadow, + RegenerateShadow, + + // Simulation - launch + LaunchSimulation, + + // Simulation - channel picker + SimChannelUp, + SimChannelDown, + SimChannelToggle, + SimChannelToggleWholeSpec, + SimChannelToggleExploreCode, + SimChannelToggleGameMode, + SimChannelToggleLeanMode, + SimChannelConfirm, + SimChannelCancel, + + // Simulation - scenario input + SimScenarioChar(char), + SimScenarioBackspace, + SimScenarioNewline, + SimScenarioConfirm, + SimScenarioCancel, + + // Simulation - screen + SimEnterInsert, + SimExitToNormal, + SimBackgroundSimulation, + SimEndSimulation, + SimCaptureKey(crate::simulation::CapturedKey), + SimDeleteChar, + SimSubmitInput, + SimCycleChannel, + SimCycleLayout, + SimEnterReport, + SimEditScenario, + SimOpenRef(String), + SimRefDigit(char), + SimSelectInteraction(usize), + SimInteractionUp, + SimInteractionDown, + SimConfirmInteraction, + SimNavigateBack, + SimBreadcrumbFocus, + SimBreadcrumbLeft, + SimBreadcrumbRight, + SimBreadcrumbSelect, + SimBreadcrumbCancel, + SimCloseOverlay, + SimMouseClick { column: u16, row: u16 }, + + // Game mode + GameGroupUp, + GameGroupDown, + GameOutcomeLeft, + GameOutcomeRight, + GameConfirmChoice, + GameRejectOutcome, + GameRejectChar(char), + GameRejectBackspace, + GameRejectSubmit, + GameRejectCancel, + GameToggleUpdateLog, + + // Lean game mode + LeanSelectUp, + LeanSelectDown, + LeanConfirm, + LeanGoBack, + LeanEnterQuery, + LeanEnterModify, + LeanEnterSendActions, + LeanEnterWarmupRespond, + LeanToggleUpdateLog, + LeanScrollUp, + LeanScrollDown, + LeanInputChar(char), + LeanInputBackspace, + LeanInputSubmit, + LeanInputCancel, + LeanInputNewline, + LeanBackground, + LeanEnd, + + // Notification / session picker + OpenSessionPicker, + SessionPickerUp, + SessionPickerDown, + SessionPickerSelect, + SessionPickerDismiss, + DismissNotification, + + // Members screen + OpenMembers, + MembersActivateInput, + MembersDeactivateInput, + MembersRemoveMember, + MembersInputChar(char), + MembersInputBackspace, + MembersInputSubmit, + MembersUp, + MembersDown, + + // Help + ToggleHelp, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index ab1bde1..0f8716f 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -7,31 +7,52 @@ use futures::StreamExt; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use spec_forest::explore::{ExploreStatus, ExploreStatusResponse}; +use spec_forest::ingest::IngestState; +use spec_forest::api; use spec_forest::state::{AppState, GenerationStatus}; +use spec_forest::CandidateAnswer; use crate::action::Action; use crate::commands; +use crate::dir_browser::DirBrowserState; use crate::editor; use crate::error::handle_result; use crate::input; +use crate::log_buffer::SharedLogBuffer; use crate::tree_state::TreeState; use crate::ui; pub const MODELS: &[&str] = &["opus", "sonnet", "haiku"]; +pub const DEPTH_OPTIONS: &[(&str, &str)] = &[ + ("Depth 1", "Features only"), + ("Depth 2", "Features + questions + answers"), + ("Depth 3", "Three levels of Q&A"), + ("Depth 4", "Four levels of Q&A"), + ("Depth 5", "Full deep exploration (slow)"), +]; + +/// (display_label, mode_str, locality_str) +pub const SPEC_OPTIONS: &[(&str, &str, &str)] = &[ + ("Local Development", "development", "local"), + ("Local Exploration", "exploration", "local"), + ("Remote Development", "development", "remote"), + ("Remote Exploration", "exploration", "remote"), +]; + pub struct App { pub state: Arc, pub screen: Screen, - pub specs: Vec, + pub specs: Vec, pub selected: usize, - pub nodes: Vec, - pub node_selected: usize, + pub nodes: Vec, pub input: String, pub message: Option, pub should_quit: bool, pub tree_state: TreeState, pub tree_visible: bool, pub tree_focused: bool, + pub log_focused: bool, pub sync_register: bool, pub sync_connected: bool, pub needs_redraw: bool, @@ -41,38 +62,113 @@ pub struct App { pub explore_session_id: Option, pub generation_statuses: HashMap, pub explore_status: Option, + pub dir_browser: Option, + pub depth_selected: usize, + pub depth_picker_dir: Option<(String, String)>, + pub ingest_session_id: Option, + pub candidates: Vec, + pub candidate_selected: usize, + pub candidate_node_id: Option, + pub spec_options_selected: usize, + pub spec_options_source: SpecOptionsSource, + pub config_selected: usize, + pub dir_browser_source: DirBrowserSource, + pub spec_settings_selected: usize, + pub log_buffer: SharedLogBuffer, + pub log_visible: bool, + pub log_scroll_offset: usize, + pub pending_delete: Option, + pub auto_explore: bool, + // Shadow answers + pub shadow_session_id: Option, + pub shadow_status: Option, + pub implementation_statuses: HashMap)>, + pub shadow_answers: Vec, + pub shadow_node_id: Option, + // Simulation + pub sim_content_area: std::cell::Cell>, + pub sim_state: Option, + pub sim_channel_selected: usize, + pub sim_channel_selection: std::collections::HashSet, + pub sim_consume_whole_spec: bool, + pub sim_explore_code: bool, + pub sim_game_mode: bool, + pub sim_lean_mode: bool, + pub sim_scenario_input: String, + // Lean game + pub lean_state: Option, + // Background simulation notifications + pub background_sims: Vec, + pub sim_notifications: Vec, + pub session_picker: Option, + pub show_help: bool, + // Members screen + pub members: Vec, + pub members_creator: String, + pub members_selected: usize, + pub members_input: String, + pub members_input_active: bool, } #[derive(Clone)] pub enum Screen { SpecList, InputName, - InputFile, + DirBrowser, + DepthPicker, + SpecOptionsPicker, SpecView { spec_id: String }, + SpecSettings { spec_id: String }, + SpecMembers { spec_id: String }, SyncConfig, SyncPasswordInput, ModelConfig, + Config, + UsernameInput, + SimChannelPicker { spec_id: String, node_id: String }, + SimScenario { spec_id: String, node_id: String }, + Simulation { spec_id: String, session_id: String }, + ExploreDepthPicker { spec_id: String }, + LeanGame { spec_id: String, session_id: String }, +} + +#[derive(Clone)] +pub enum SpecOptionsSource { + None, + CreateSpec, + SeedFromDir, +} + +#[derive(Clone)] +pub enum DirBrowserSource { + SeedFromDir, + SpecSettings { spec_id: String }, } impl App { - pub fn new(state: Arc) -> Self { + pub fn new(state: Arc, log_buffer: SharedLogBuffer) -> Self { let specs = commands::refresh_spec_list(&state).unwrap_or_else(|e| { tracing::warn!("Failed to load specs on startup: {e}"); Vec::new() }); + let auto_explore = api::get_setting(&state, "auto_explore") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false); Self { state, screen: Screen::SpecList, specs, selected: 0, nodes: Vec::new(), - node_selected: 0, input: String::new(), message: None, should_quit: false, tree_state: TreeState::new(), tree_visible: false, tree_focused: false, + log_focused: false, sync_register: false, sync_connected: false, needs_redraw: false, @@ -82,6 +178,47 @@ impl App { explore_session_id: None, generation_statuses: HashMap::new(), explore_status: None, + dir_browser: None, + depth_selected: 0, + depth_picker_dir: None, + ingest_session_id: None, + candidates: Vec::new(), + candidate_selected: 0, + candidate_node_id: None, + spec_options_selected: 0, + spec_options_source: SpecOptionsSource::None, + config_selected: 0, + dir_browser_source: DirBrowserSource::SeedFromDir, + spec_settings_selected: 0, + log_buffer, + log_visible: false, + log_scroll_offset: 0, + pending_delete: None, + auto_explore, + shadow_session_id: None, + shadow_status: None, + implementation_statuses: HashMap::new(), + shadow_answers: Vec::new(), + shadow_node_id: None, + sim_content_area: std::cell::Cell::new(None), + sim_state: None, + sim_channel_selected: 0, + sim_channel_selection: std::collections::HashSet::new(), + sim_consume_whole_spec: false, + sim_explore_code: false, + sim_game_mode: false, + sim_lean_mode: false, + sim_scenario_input: String::new(), + lean_state: None, + background_sims: Vec::new(), + sim_notifications: Vec::new(), + session_picker: None, + show_help: false, + members: Vec::new(), + members_creator: String::new(), + members_selected: 0, + members_input: String::new(), + members_input_active: false, } } @@ -90,40 +227,200 @@ impl App { terminal: &mut Terminal>, ) -> std::io::Result<()> { let mut reader = EventStream::new(); + let mut op_notify_rx = self.state.subscribe_op_notifications(); + let mut tick_interval = tokio::time::interval(Duration::from_millis(250)); + while !self.should_quit { if self.needs_redraw { terminal.clear()?; self.needs_redraw = false; } + self.refresh_candidates_if_needed(); + self.refresh_shadow_answers_if_needed(); terminal.draw(|frame| ui::render(self, frame))?; - let event = tokio::time::timeout(Duration::from_millis(250), reader.next()).await; - if let Ok(Some(Ok(Event::Key(key)))) = event { - if key.kind != KeyEventKind::Press { - continue; + tokio::select! { + event = reader.next() => { + if let Some(Ok(event)) = event { + match event { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + self.message = None; + self.handle_key(key.code, key.modifiers).await; + } + Event::Mouse(mouse) => { + self.handle_mouse(mouse).await; + } + _ => {} + } + } + } + notification = op_notify_rx.recv() => { + self.handle_op_notification(notification); } - self.message = None; - self.handle_key(key.code).await; + _ = tick_interval.tick() => {} } + self.tick += 1; + if let Some(ref mut sim) = self.sim_state { + sim.tick = self.tick; + } + // Flush buffered digit input for multi-digit ref lookup + if let Some(ref sim) = self.sim_state { + if let Some(start_tick) = sim.ref_digit_start_tick { + if self.tick.saturating_sub(start_tick) >= 2 + && !sim.ref_digit_buffer.is_empty() + { + let marker = format!("[^{}]", sim.ref_digit_buffer); + // Clear buffer before dispatching (need mutable access) + if let Some(ref mut sim) = self.sim_state { + sim.ref_digit_buffer.clear(); + sim.ref_digit_start_tick = None; + } + self.execute_action(Action::SimOpenRef(marker)).await; + } + } + } self.poll_background_status(); } Ok(()) } - pub async fn handle_key(&mut self, key: KeyCode) { + pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { + use crossterm::event::KeyModifiers; + + // Help overlay: dismiss on any key + if self.show_help { + self.show_help = false; + return; + } + + // Session picker overlay takes priority when open + if self.session_picker.is_some() { + let action = match key { + KeyCode::Up | KeyCode::Char('k') => Action::SessionPickerUp, + KeyCode::Down | KeyCode::Char('j') => Action::SessionPickerDown, + KeyCode::Enter => Action::SessionPickerSelect, + KeyCode::Esc => Action::SessionPickerDismiss, + _ => Action::Noop, + }; + self.execute_action(action).await; + return; + } + + // Global Ctrl+s opens session picker (except in SimScenario and Simulation insert mode) + if key == KeyCode::Char('s') && modifiers.contains(KeyModifiers::CONTROL) { + let is_sim_scenario = matches!(self.screen, Screen::SimScenario { .. }); + let is_sim_insert = matches!(self.screen, Screen::Simulation { .. }) + && self + .sim_state + .as_ref() + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert); + if !is_sim_scenario && !is_sim_insert && !self.background_sims.is_empty() { + self.execute_action(Action::OpenSessionPicker).await; + return; + } + } + + // Global ? opens help (skip text-input screens) + if key == KeyCode::Char('?') { + let is_text_input = matches!( + self.screen, + Screen::InputName + | Screen::SyncPasswordInput + | Screen::UsernameInput + | Screen::SimScenario { .. } + ) || (matches!(self.screen, Screen::Simulation { .. }) + && self + .sim_state + .as_ref() + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)) + || (matches!(self.screen, Screen::SpecMembers { .. }) && self.members_input_active); + if !is_text_input { + self.show_help = true; + return; + } + } + + // Members screen has dual-mode input (normal vs text) + if matches!(self.screen, Screen::SpecMembers { .. }) { + let is_creator = self.state.user_name() == self.members_creator; + let action = input::map_spec_members_key(key, self.members_input_active, is_creator); + self.execute_action(action).await; + return; + } + + // Scenario input screen needs modifiers for Shift+Enter + if matches!(self.screen, Screen::SimScenario { .. }) { + let action = input::map_sim_scenario_key(key, modifiers); + self.execute_action(action).await; + return; + } + // Lean game screen needs modifiers for Shift+Enter + if matches!(self.screen, Screen::LeanGame { .. }) { + let in_input_mode = self + .lean_state + .as_ref() + .map(|s| s.in_input_mode()) + .unwrap_or(false); + let action = input::map_lean_game_key(key, modifiers, in_input_mode); + self.execute_action(action).await; + return; + } + // Simulation screen needs modifiers for Shift+Enter + if matches!(self.screen, Screen::Simulation { .. }) { + let (mode, game_mode, reject_mode, breadcrumb_focused) = self + .sim_state + .as_ref() + .map(|s| (s.mode, s.game_mode, s.reject_mode, s.breadcrumb_selected.is_some())) + .unwrap_or((crate::simulation::SimInputMode::Normal, false, false, false)); + let action = input::map_sim_key(key, modifiers, mode, game_mode, reject_mode, breadcrumb_focused); + self.execute_action(action).await; + return; + } let has_sync_url = self.state.sync_url().is_some(); let action = input::map_key( &self.screen, key, + modifiers, self.tree_visible, self.tree_focused, has_sync_url, + self.log_visible, + self.log_focused, + self.config_selected, ); self.execute_action(action).await; } + pub async fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) { + use crossterm::event::{MouseButton, MouseEventKind}; + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if !matches!(self.screen, Screen::Simulation { .. }) { + return; + } + self.message = None; + self.execute_action(Action::SimMouseClick { + column: mouse.column, + row: mouse.row, + }) + .await; + } + async fn execute_action(&mut self, action: Action) { + if action != Action::DeleteNode && action != Action::DeleteSpec && action != Action::Noop { + self.pending_delete = None; + } + // Reset lean quit_pending on any action that isn't LeanEnd. + if action != Action::LeanEnd { + if let Some(ref mut lean) = self.lean_state { + lean.quit_pending = false; + } + } match action { Action::Noop => {} Action::Quit => self.should_quit = true, @@ -139,9 +436,10 @@ impl App { self.input.clear(); self.screen = Screen::InputName; } - Action::OpenSeedFromFile => { - self.input.clear(); - self.screen = Screen::InputFile; + Action::OpenSeedFromDir => { + self.dir_browser_source = DirBrowserSource::SeedFromDir; + self.dir_browser = Some(DirBrowserState::new()); + self.screen = Screen::DirBrowser; } Action::OpenSyncConfig => { let status = spec_forest::api::sync_status(&self.state).await; @@ -156,6 +454,8 @@ impl App { self.screen = Screen::ModelConfig; } + Action::DeleteSpec => self.delete_spec().await, + // Text input Action::TypeChar(c) => self.input.push(c), Action::DeleteChar => { self.input.pop(); } @@ -164,21 +464,173 @@ impl App { // Spec view Action::ToggleTree => self.toggle_tree(), - Action::SwitchFocus => self.tree_focused = !self.tree_focused, + Action::SwitchFocus => { + // Cycle focus: main → tree (if visible) → log (if visible) → main + if self.log_focused { + // log → main + self.log_focused = false; + self.tree_focused = false; + } else if self.tree_focused { + if self.log_visible { + // tree → log + self.tree_focused = false; + self.log_focused = true; + } else { + // tree → main + self.tree_focused = false; + } + } else if self.tree_visible { + // main → tree + self.tree_focused = true; + } else if self.log_visible { + // main → log (no tree) + self.log_focused = true; + } + } Action::AiAnswer => self.trigger_ai_answer(), Action::ExploreNode => self.trigger_explore_node(), - Action::FullExplore => self.trigger_full_explore(), + Action::FullExplore => { + if let Screen::SpecView { spec_id } = &self.screen { + self.depth_selected = 2; // default to depth 3 + self.screen = Screen::ExploreDepthPicker { spec_id: spec_id.clone() }; + } + } + Action::GenerateShadow => self.trigger_shadow_generation(), + Action::RegenerateShadow => self.trigger_shadow_regeneration(), Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), - Action::EditNextQuestion => self.edit_next_question().await, + Action::EditNextQuestion => self.edit_tree_node().await, + Action::AddFeature => self.add_feature().await, + Action::RegenerateFeature => self.regenerate_feature(), + Action::AddQuestion => self.add_question().await, + Action::DeleteNode => self.delete_node().await, + + // Log panel + Action::ToggleLog => { + self.log_visible = !self.log_visible; + self.log_scroll_offset = 0; + if !self.log_visible { + self.log_focused = false; + } + } + Action::LogScrollUp => { + let max = self.log_buffer.lock().unwrap().len().saturating_sub(1); + self.log_scroll_offset = (self.log_scroll_offset + 5).min(max); + } + Action::LogScrollDown => { + self.log_scroll_offset = self.log_scroll_offset.saturating_sub(5); + } + Action::LogScrollLineUp => { + let max = self.log_buffer.lock().unwrap().len().saturating_sub(1); + self.log_scroll_offset = (self.log_scroll_offset + 1).min(max); + } + Action::LogScrollLineDown => { + self.log_scroll_offset = self.log_scroll_offset.saturating_sub(1); + } // Tree Action::TreeUp => self.tree_state.select_up(), Action::TreeDown => self.tree_state.select_down(), + Action::SiblingUp => self.tree_state.select_prev_sibling(), + Action::SiblingDown => self.tree_state.select_next_sibling(), Action::ExpandOrCollapseTreeNode => self.expand_or_collapse_tree_node(), Action::CollapseTreeNode => self.collapse_tree_node(), Action::EditTreeNode => self.edit_tree_node().await, + // Directory browser + Action::DirBrowserUp => { + if let Some(ref mut db) = self.dir_browser { db.select_up(); } + } + Action::DirBrowserDown => { + if let Some(ref mut db) = self.dir_browser { db.select_down(); } + } + Action::DirBrowserExpand => { + if let Some(ref mut db) = self.dir_browser { db.expand_selected(); } + } + Action::DirBrowserCollapse => { + if let Some(ref mut db) = self.dir_browser { db.collapse_selected(); } + } + Action::DirBrowserGoToParent => { + if let Some(ref mut db) = self.dir_browser { db.go_to_parent(); } + } + Action::DirBrowserSelect => { + if let Some(ref browser) = self.dir_browser { + if let Some(p) = browser.selected_path() { + let dir_path = p.to_string_lossy().to_string(); + match &self.dir_browser_source { + DirBrowserSource::SeedFromDir => { + let name = browser.selected_dir_name(); + self.depth_picker_dir = Some((dir_path, name)); + self.depth_selected = 0; + self.screen = Screen::DepthPicker; + } + DirBrowserSource::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.dir_browser = None; + match commands::update_directory( + &self.state, + &spec_id, + Some(dir_path.clone()), + ) { + Ok(_) => { + self.message = + Some(format!("Directory set to: {dir_path}")); + self.refresh_specs(); + } + Err(e) => { + self.message = + Some(format!("Failed to set directory: {e}")); + } + } + self.screen = Screen::SpecSettings { spec_id }; + } + } + } + } + } + Action::DirBrowserCancel => { + self.dir_browser = None; + match &self.dir_browser_source { + DirBrowserSource::SeedFromDir => { + self.screen = Screen::SpecList; + } + DirBrowserSource::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecSettings { spec_id }; + } + } + } + + // Depth picker + Action::DepthPickerUp => { + self.depth_selected = self.depth_selected.saturating_sub(1); + } + Action::DepthPickerDown => { + if self.depth_selected < DEPTH_OPTIONS.len() - 1 { + self.depth_selected += 1; + } + } + Action::DepthPickerConfirm => { + if let Screen::ExploreDepthPicker { spec_id } = &self.screen { + let spec_id = spec_id.clone(); + let depth = (self.depth_selected + 1) as u32; + self.screen = Screen::SpecView { spec_id }; + self.trigger_full_explore(depth); + } else { + self.spec_options_selected = 0; + self.spec_options_source = SpecOptionsSource::SeedFromDir; + self.screen = Screen::SpecOptionsPicker; + } + } + Action::DepthPickerCancel => { + if let Screen::ExploreDepthPicker { spec_id } = &self.screen { + self.screen = Screen::SpecView { spec_id: spec_id.clone() }; + } else { + self.depth_picker_dir = None; + self.screen = Screen::DirBrowser; + } + } + // Sync Action::SyncLogin => { self.sync_register = false; @@ -197,154 +649,1431 @@ impl App { self.message = Some(format!("Model set to: {}", self.model)); self.screen = Screen::SpecList; } - } - } - // ── Navigation helpers ────────────────────────────────────── + // Spec options picker + Action::SpecOptionsUp => { + self.spec_options_selected = self.spec_options_selected.saturating_sub(1); + } + Action::SpecOptionsDown => { + if self.spec_options_selected < SPEC_OPTIONS.len() - 1 { + self.spec_options_selected += 1; + } + } + Action::SpecOptionsConfirm => self.confirm_spec_options().await, + Action::SpecOptionsCancel => self.cancel_spec_options(), - fn navigate_up(&mut self) { - match &self.screen { - Screen::SpecList => { - self.selected = self.selected.saturating_sub(1); + // Config + Action::OpenConfig => { + self.config_selected = 0; + self.screen = Screen::Config; } - Screen::ModelConfig => { - self.model_selected = self.model_selected.saturating_sub(1); + Action::SetUsername => { + self.input = self.state.user_name(); + self.screen = Screen::UsernameInput; } - Screen::SpecView { .. } => { - if self.node_selected > 0 { - self.node_selected -= 1; - } + Action::ToggleAutoExplore => { + self.auto_explore = !self.auto_explore; + let _ = api::set_setting( + &self.state, + "auto_explore", + if self.auto_explore { "true" } else { "false" }, + ); + self.message = Some(format!( + "Auto explore: {}", + if self.auto_explore { "ON" } else { "OFF" } + )); } - _ => {} - } - } - fn navigate_down(&mut self) { - match &self.screen { - Screen::SpecList => { - if !self.specs.is_empty() && self.selected < self.specs.len() - 1 { - self.selected += 1; + // Spec settings + Action::OpenSpecSettings => { + if let Screen::SpecView { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.spec_settings_selected = 0; + self.screen = Screen::SpecSettings { spec_id }; } } - Screen::ModelConfig => { - if self.model_selected < MODELS.len() - 1 { - self.model_selected += 1; + Action::SetSpecDirectory => { + if let Screen::SpecSettings { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.dir_browser_source = DirBrowserSource::SpecSettings { spec_id }; + self.dir_browser = Some(DirBrowserState::new()); + self.screen = Screen::DirBrowser; } } - Screen::SpecView { .. } => { - if !self.nodes.is_empty() && self.node_selected < self.nodes.len() - 1 { - self.node_selected += 1; + Action::ClearSpecDirectory => { + if let Screen::SpecSettings { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + match commands::update_directory(&self.state, &spec_id, None) { + Ok(_) => { + self.message = Some("Directory cleared".to_string()); + self.refresh_specs(); + } + Err(e) => { + self.message = Some(format!("Failed to clear directory: {e}")); + } + } } } - _ => {} - } - } - - fn select_current(&mut self) { - if let Screen::SpecList = &self.screen - && let Some(spec) = self.specs.get(self.selected) - { - let spec_id = spec.id.clone(); - self.open_spec(&spec_id); - } - } - fn go_back(&mut self) { - match &self.screen { - Screen::SpecView { .. } => { - self.screen = Screen::SpecList; - self.refresh_specs(); + // Members + Action::OpenMembers => { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } | Screen::SpecSettings { spec_id } => spec_id.clone(), + _ => return, + }; + let spec_name = self.specs.iter() + .find(|s| s.id == spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::list_members(&self.state, &spec_name).await { + Ok((members, creator)) => { + self.members = members; + self.members_creator = creator; + self.members_selected = 0; + self.members_input.clear(); + self.members_input_active = false; + self.screen = Screen::SpecMembers { spec_id }; + } + Err(e) => { + self.message = Some(format!("Failed to load members: {e}")); + } + } } - Screen::SyncConfig => { - self.screen = Screen::SpecList; + Action::MembersUp => { + self.members_selected = self.members_selected.saturating_sub(1); } - _ => {} - } - } - - fn cancel_input(&mut self) { - match &self.screen { - Screen::InputName | Screen::InputFile => { - self.screen = Screen::SpecList; + Action::MembersDown => { + if !self.members.is_empty() { + self.members_selected = (self.members_selected + 1).min(self.members.len() - 1); + } } - Screen::SyncPasswordInput => { - self.input.clear(); - self.screen = Screen::SyncConfig; + Action::MembersActivateInput => { + self.members_input_active = true; + self.members_input.clear(); } - Screen::ModelConfig => { - self.screen = Screen::SpecList; + Action::MembersDeactivateInput => { + self.members_input_active = false; + self.members_input.clear(); } - _ => {} - } - } - - // ── Submit dispatcher ─────────────────────────────────────── - - async fn submit_input(&mut self) { - if self.input.is_empty() { - return; - } - match self.screen.clone() { - Screen::InputName => self.create_spec_with_seed().await, - Screen::InputFile => self.seed_from_file().await, - Screen::SyncPasswordInput => self.do_sync_connect().await, - _ => {} - } - } - - // ── Spec operations ───────────────────────────────────────── - - fn refresh_specs(&mut self) { - match commands::refresh_spec_list(&self.state) { - Ok(specs) => self.specs = specs, - Err(e) => { - tracing::error!("Failed to refresh specs: {e}"); - self.message = Some(e.to_string()); + Action::MembersInputChar(c) => { + self.members_input.push(c); + } + Action::MembersInputBackspace => { + self.members_input.pop(); + } + Action::MembersInputSubmit => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + let username = self.members_input.trim().to_string(); + if !username.is_empty() { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::grant_access(&self.state, &spec_name, &username).await { + Ok(_) => { + self.members.push(username); + self.members_input.clear(); + self.members_input_active = false; + self.message = Some("Member added".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to add member: {e}")); + } + } + } + } + } + Action::MembersRemoveMember => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + if let Some(member) = self.members.get(self.members_selected).cloned() { + if member == self.members_creator { + self.message = Some("Cannot remove the creator".to_string()); + } else { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::revoke_access(&self.state, &spec_name, &member).await { + Ok(_) => { + self.members.remove(self.members_selected); + if self.members_selected >= self.members.len() && self.members_selected > 0 { + self.members_selected -= 1; + } + self.message = Some("Member removed".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to remove member: {e}")); + } + } + } + } + } } - } - self.clamp_selected(); - } - - fn clamp_selected(&mut self) { - if !self.specs.is_empty() && self.selected >= self.specs.len() { - self.selected = self.specs.len() - 1; - } - } - fn open_spec(&mut self, spec_id: &str) { - match commands::load_spec_nodes(&self.state, spec_id) { - Ok(nodes) => { - self.nodes = nodes; - self.node_selected = 0; - self.tree_state = TreeState::new(); - self.tree_visible = false; - self.tree_focused = false; - self.screen = Screen::SpecView { - spec_id: spec_id.to_string(), - }; + // Candidates + Action::CandidateNext => { + if !self.candidates.is_empty() { + self.candidate_selected = + (self.candidate_selected + 1).min(self.candidates.len() - 1); + } } - Err(e) => { - tracing::error!("Failed to open spec: {e}"); - self.message = Some(e.to_string()); + Action::CandidatePrev => { + self.candidate_selected = self.candidate_selected.saturating_sub(1); } - } - } + Action::AcceptCandidate => self.accept_candidate().await, + Action::EditCandidate => self.edit_candidate().await, - async fn create_spec_with_seed(&mut self) { - let name = std::mem::take(&mut self.input); + // Simulation - launch + Action::LaunchSimulation => { + if let Screen::SpecView { ref spec_id } = self.screen { + if let Some(node_id) = self.tree_state.selected_node_id().map(|s| s.to_string()) { + let spec_id = spec_id.clone(); + self.sim_channel_selected = 0; + self.sim_channel_selection = [0].into(); // UI channel selected by default + self.sim_consume_whole_spec = false; + self.sim_explore_code = false; + self.sim_game_mode = false; + self.screen = Screen::SimChannelPicker { spec_id, node_id }; + } else { + self.message = Some("Select a node to simulate".to_string()); + } + } + } - self.needs_redraw = true; - let seed_content = match editor::edit_seed() { - Ok(content) => content, - Err(e) => { - self.message = Some(format!("Editor error: {e}")); - self.screen = Screen::SpecList; - return; + // Simulation - channel picker + Action::SimChannelUp => { + self.sim_channel_selected = self.sim_channel_selected.saturating_sub(1); + } + Action::SimChannelDown => { + let max = spec_forest::simulation::SimChannel::ALL.len().saturating_sub(1); + self.sim_channel_selected = (self.sim_channel_selected + 1).min(max); + } + Action::SimChannelToggle => { + let idx = self.sim_channel_selected; + if self.sim_channel_selection.contains(&idx) { + self.sim_channel_selection.remove(&idx); + } else { + self.sim_channel_selection.insert(idx); + } + } + Action::SimChannelToggleWholeSpec => { + self.sim_consume_whole_spec = !self.sim_consume_whole_spec; + } + Action::SimChannelToggleGameMode => { + self.sim_game_mode = !self.sim_game_mode; + if self.sim_game_mode { + self.sim_lean_mode = false; // mutually exclusive + } + } + Action::SimChannelToggleLeanMode => { + self.sim_lean_mode = !self.sim_lean_mode; + if self.sim_lean_mode { + self.sim_game_mode = false; // mutually exclusive + } + } + Action::SimChannelToggleExploreCode => { + if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { + let has_dir = self + .specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_ref()) + .is_some(); + if has_dir { + self.sim_explore_code = !self.sim_explore_code; + } + } + } + Action::SimChannelConfirm => { + if self.sim_channel_selection.is_empty() { + self.message = Some("Select at least one channel".to_string()); + } else if let Screen::SimChannelPicker { ref spec_id, ref node_id } = self.screen { + let spec_id = spec_id.clone(); + let node_id = node_id.clone(); + self.sim_scenario_input.clear(); + self.screen = Screen::SimScenario { spec_id, node_id }; + } + } + Action::SimChannelCancel => { + if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + } } - }; - match commands::create_spec(&self.state, name).await { - Ok(spec) => { + // Simulation - scenario input + Action::SimScenarioChar(c) => { + self.sim_scenario_input.push(c); + } + Action::SimScenarioBackspace => { + self.sim_scenario_input.pop(); + } + Action::SimScenarioNewline => { + self.sim_scenario_input.push('\n'); + } + Action::SimScenarioConfirm => { + if let Screen::SimScenario { ref spec_id, ref node_id } = self.screen { + let spec_id = spec_id.clone(); + let node_id = node_id.clone(); + let scenario = if self.sim_scenario_input.trim().is_empty() { + None + } else { + Some(self.sim_scenario_input.clone()) + }; + self.start_simulation(spec_id, node_id, scenario).await; + } + } + Action::SimScenarioCancel => { + if let Screen::SimScenario { ref spec_id, ref node_id } = self.screen { + let spec_id = spec_id.clone(); + let node_id = node_id.clone(); + self.screen = Screen::SimChannelPicker { spec_id, node_id }; + } + } + + // Simulation - screen + Action::SimEnterInsert => { + if let Some(ref mut sim) = self.sim_state { + sim.mode = crate::simulation::SimInputMode::Insert; + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::EnableMouseCapture + ); + } + } + Action::SimExitToNormal => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + sim.report_mode = false; + sim.report_input.clear(); + } else if sim.scenario_mode { + sim.scenario_mode = false; + sim.scenario_input.clear(); + } else { + sim.mode = crate::simulation::SimInputMode::Normal; + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } + } + } + Action::SimBackgroundSimulation => { + if let Some(ref mut sim) = self.sim_state { + // Close overlays first if open + if sim.report_overlay.is_some() { + sim.report_overlay = None; + return; + } + if sim.overlay.is_some() { + sim.overlay = None; + return; + } + if sim.mode == crate::simulation::SimInputMode::Insert { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } + } + if let Screen::Simulation { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + let label = self.state.get_sim_session(&session_id) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| session_id[..8.min(session_id.len())].to_string()); + let was_processing = self.sim_state.as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims.push(crate::notification::BackgroundSimEntry { + session_id, + spec_id: spec_id.clone(), + label, + was_processing, + }); + self.sim_state = None; + let sid = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + } + } + Action::SimEndSimulation => { + if let Some(ref mut sim) = self.sim_state { + // Close overlays first if open + if sim.report_overlay.is_some() { + sim.report_overlay = None; + return; + } + if sim.overlay.is_some() { + sim.overlay = None; + return; + } + if sim.mode == crate::simulation::SimInputMode::Insert { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } + } + if let Screen::Simulation { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + self.state.remove_sim_session(&session_id); + self.sim_state = None; + let sid = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + } + } + Action::SimCaptureKey(key) => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + if let crate::simulation::CapturedKey::Char(c) = key { + sim.report_input.push(c); + } + } else if sim.scenario_mode { + if let crate::simulation::CapturedKey::Char(c) = key { + sim.scenario_input.push(c); + } + } else { + sim.captured_keys.push(key); + } + } + } + Action::SimDeleteChar => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + sim.report_input.pop(); + } else if sim.scenario_mode { + sim.scenario_input.pop(); + } else { + sim.captured_keys.pop(); + } + } + } + Action::SimSubmitInput => { + self.submit_sim_input().await; + } + Action::SimSelectInteraction(idx) => { + self.select_sim_interaction(idx).await; + } + Action::SimCycleChannel => { + if let Some(ref mut sim) = self.sim_state { + sim.cycle_channel(); + } + } + Action::SimCycleLayout => { + if let Some(ref mut sim) = self.sim_state { + sim.cycle_layout(); + } + } + Action::SimEnterReport => { + if let Some(ref mut sim) = self.sim_state { + sim.report_mode = true; + sim.report_input.clear(); + sim.mode = crate::simulation::SimInputMode::Insert; + } + } + Action::SimEditScenario => { + if let Some(ref mut sim) = self.sim_state { + // Pre-populate with current scenario from the session + if let Screen::Simulation { ref session_id, .. } = self.screen { + let mut current = String::new(); + self.state.update_sim_session(session_id, |s| { + current = s.scenario.clone().unwrap_or_default(); + }); + sim.scenario_input = current; + } + sim.scenario_mode = true; + sim.mode = crate::simulation::SimInputMode::Insert; + } + } + Action::SimOpenRef(marker) => { + if let Some(ref mut sim) = self.sim_state { + // Find the node_id for this marker in channel contents or decisions + let node_id = sim + .channel_contents + .values() + .flat_map(|c| c.refs.iter()) + .chain(sim.decisions.iter().flat_map(|d| d.refs.iter())) + .find(|r| r.marker == marker) + .map(|r| r.node_id.clone()); + + if let Some(node_id) = node_id { + // Fetch node details + match spec_forest::api::get_node(&self.state, &node_id) { + Ok(node) => { + sim.overlay = Some(crate::simulation::RefOverlay { + node_id: node.id, + question: node.question, + answer: node.answer, + }); + } + Err(e) => { + self.message = + Some(format!("Failed to load ref node: {e}")); + } + } + } + } + } + Action::SimInteractionUp => { + if let Some(ref mut sim) = self.sim_state { + if !sim.interactions.is_empty() && sim.selected_interaction > 0 { + sim.selected_interaction -= 1; + } + } + } + Action::SimInteractionDown => { + if let Some(ref mut sim) = self.sim_state { + if !sim.interactions.is_empty() + && sim.selected_interaction < sim.interactions.len() - 1 + { + sim.selected_interaction += 1; + } + } + } + Action::SimConfirmInteraction => { + let idx = self + .sim_state + .as_ref() + .map(|s| s.selected_interaction) + .unwrap_or(0); + self.select_sim_interaction(idx).await; + } + Action::SimNavigateBack => { + if let Some(ref mut sim) = self.sim_state { + if sim.can_go_back && !sim.processing { + if let Some((_channels, _decisions)) = + self.state.sim_navigate_back(&sim.session_id) + { + // Refresh TUI state from the updated session + if let Some(contents) = + self.state.get_sim_channel_contents(&sim.session_id) + { + sim.channel_contents = contents; + } + sim.decisions = + self.state.get_sim_decisions(&sim.session_id); + sim.interactions = + self.state.get_sim_interactions(&sim.session_id); + sim.selected_interaction = 0; + sim.can_go_back = + self.state.get_sim_nav_depth(&sim.session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&sim.session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + } + } + } + Action::SimBreadcrumbFocus => { + if let Some(ref mut sim) = self.sim_state { + if sim.breadcrumbs.len() > 1 && !sim.processing { + sim.breadcrumb_selected = Some(sim.breadcrumbs.len() - 1); + } + } + } + Action::SimBreadcrumbLeft => { + if let Some(ref mut sim) = self.sim_state { + if let Some(ref mut idx) = sim.breadcrumb_selected { + if *idx > 0 { + *idx -= 1; + } + } + } + } + Action::SimBreadcrumbRight => { + if let Some(ref mut sim) = self.sim_state { + if let Some(ref mut idx) = sim.breadcrumb_selected { + if *idx + 1 < sim.breadcrumbs.len() { + *idx += 1; + } + } + } + } + Action::SimBreadcrumbSelect => { + if let Some(ref mut sim) = self.sim_state { + if let Some(idx) = sim.breadcrumb_selected { + if let Some((node_id, _)) = sim.breadcrumbs.get(idx).cloned() { + if let Some((_channels, _decisions)) = + self.state.sim_navigate_to(&sim.session_id, &node_id) + { + if let Some(contents) = + self.state.get_sim_channel_contents(&sim.session_id) + { + sim.channel_contents = contents; + } + sim.decisions = + self.state.get_sim_decisions(&sim.session_id); + sim.interactions = + self.state.get_sim_interactions(&sim.session_id); + sim.selected_interaction = 0; + sim.can_go_back = + self.state.get_sim_nav_depth(&sim.session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&sim.session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + } + sim.breadcrumb_selected = None; + } + } + } + Action::SimBreadcrumbCancel => { + if let Some(ref mut sim) = self.sim_state { + sim.breadcrumb_selected = None; + } + } + Action::SimRefDigit(c) => { + if let Some(ref mut sim) = self.sim_state { + sim.ref_digit_buffer.push(c); + if sim.ref_digit_start_tick.is_none() { + sim.ref_digit_start_tick = Some(sim.tick); + } + } + } + Action::SimCloseOverlay => { + if let Some(ref mut sim) = self.sim_state { + sim.report_overlay = None; + sim.overlay = None; + } + } + Action::SimMouseClick { column, row } => { + if let Some(ref mut sim) = self.sim_state { + if sim.mode != crate::simulation::SimInputMode::Insert { + return; + } + if let Some(content_area) = self.sim_content_area.get() { + // Only handle clicks within the channel content area + if column >= content_area.x + && column < content_area.x + content_area.width + && row >= content_area.y + && row < content_area.y + content_area.height + { + let rel_col = column - content_area.x; + let rel_row = row - content_area.y; + sim.captured_keys.push( + crate::simulation::CapturedKey::MouseClick { + column: rel_col, + row: rel_row, + }, + ); + } + } + } + } + + // ── Notification / session picker ── + Action::OpenSessionPicker => { + let entries: Vec = self + .background_sims + .iter() + .map(|bg| { + let ready = self + .state + .get_sim_session_status(&bg.session_id) + .map(|s| matches!(s, spec_forest::simulation::SimStatus::Idle)) + .unwrap_or(false); + crate::notification::SessionPickerEntry { + session_id: bg.session_id.clone(), + spec_id: bg.spec_id.clone(), + label: bg.label.clone(), + ready, + } + }) + .collect(); + if !entries.is_empty() { + self.session_picker = Some(crate::notification::SessionPickerState { + selected: 0, + entries, + }); + } + } + Action::SessionPickerUp => { + if let Some(ref mut picker) = self.session_picker { + picker.selected = picker.selected.saturating_sub(1); + } + } + Action::SessionPickerDown => { + if let Some(ref mut picker) = self.session_picker { + if picker.selected + 1 < picker.entries.len() { + picker.selected += 1; + } + } + } + Action::SessionPickerSelect => { + if let Some(picker) = self.session_picker.take() { + if let Some(entry) = picker.entries.get(picker.selected) { + let session_id = entry.session_id.clone(); + self.enter_sim_session(&session_id); + } + } + } + Action::SessionPickerDismiss => { + self.session_picker = None; + } + Action::DismissNotification => { + if !self.sim_notifications.is_empty() { + self.sim_notifications.remove(0); + } + } + // Game mode actions + Action::GameGroupUp => { + if let Some(ref mut sim) = self.sim_state { + if !sim.game_choice_groups.is_empty() && sim.selected_group > 0 { + sim.selected_group -= 1; + sim.selected_outcome = 0; + } + } + } + Action::GameGroupDown => { + if let Some(ref mut sim) = self.sim_state { + if !sim.game_choice_groups.is_empty() + && sim.selected_group < sim.game_choice_groups.len() - 1 + { + sim.selected_group += 1; + sim.selected_outcome = 0; + } + } + } + Action::GameOutcomeLeft => { + if let Some(ref mut sim) = self.sim_state { + if sim.selected_outcome > 0 { + sim.selected_outcome -= 1; + } + } + } + Action::GameOutcomeRight => { + if let Some(ref mut sim) = self.sim_state { + if let Some(group) = sim.game_choice_groups.get(sim.selected_group) { + if sim.selected_outcome < group.outcomes.len().saturating_sub(1) { + sim.selected_outcome += 1; + } + } + } + } + Action::GameConfirmChoice => { + if let Some(ref mut sim) = self.sim_state { + if !sim.processing && !sim.game_choice_groups.is_empty() { + let gi = sim.selected_group; + let oi = sim.selected_outcome; + sim.processing = true; + let state = self.state.clone(); + let sid = sim.session_id.clone(); + tokio::spawn(async move { + crate::commands::run_game_select_outcome(state, sid, gi, oi).await; + }); + } + } + } + Action::GameRejectOutcome => { + if let Some(ref mut sim) = self.sim_state { + if !sim.processing && !sim.game_choice_groups.is_empty() { + sim.reject_mode = true; + sim.reject_input.clear(); + } + } + } + Action::GameRejectChar(c) => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_input.push(c); + } + } + Action::GameRejectBackspace => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_input.pop(); + } + } + Action::GameRejectSubmit => { + if let Some(ref mut sim) = self.sim_state { + if !sim.reject_input.is_empty() { + let gi = sim.selected_group; + let oi = sim.selected_outcome; + let correction = sim.reject_input.clone(); + sim.reject_mode = false; + sim.reject_input.clear(); + sim.processing = true; + let state = self.state.clone(); + let sid = sim.session_id.clone(); + tokio::spawn(async move { + crate::commands::run_game_reject_outcome( + state, sid, gi, oi, correction, + ) + .await; + }); + } + } + } + Action::GameRejectCancel => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_mode = false; + sim.reject_input.clear(); + } + } + Action::GameToggleUpdateLog => { + if let Some(ref mut sim) = self.sim_state { + sim.show_update_log = !sim.show_update_log; + } + } + + // ── Lean game mode actions ──────────────────────────────── + Action::LeanSelectUp => { + if let Some(ref mut lean) = self.lean_state { + if lean.selected_interaction > 0 { + lean.selected_interaction -= 1; + } + } + } + Action::LeanSelectDown => { + if let Some(ref mut lean) = self.lean_state { + if !lean.interactions.is_empty() + && lean.selected_interaction < lean.interactions.len() - 1 + { + lean.selected_interaction += 1; + } + } + } + Action::LeanConfirm => { + if let Some(ref lean) = self.lean_state { + if !lean.interactions.is_empty() && !lean.processing { + let session_id = lean.session_id.clone(); + let edge_index = lean.selected_interaction; + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_navigate( + state, + session_id, + edge_index, + ) + .await; + }); + } + } + } + Action::LeanGoBack => { + if let Some(ref lean) = self.lean_state { + if lean.can_go_back { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_go_back( + self.state.clone(), + &lean.session_id, + ); + } + } + } + Action::LeanEnterQuery => { + if let Some(ref mut lean) = self.lean_state { + lean.query_mode = true; + lean.query_input.clear(); + } + } + Action::LeanEnterModify => { + if let Some(ref mut lean) = self.lean_state { + lean.modify_mode = true; + lean.modify_input.clear(); + } + } + Action::LeanEnterWarmupRespond => { + if let Some(ref mut lean) = self.lean_state { + if lean.warmup_active && lean.warmup_scenario_text.is_some() { + lean.warmup_mode = true; + lean.warmup_input.clear(); + } + } + } + Action::LeanEnterSendActions => { + if let Some(ref mut lean) = self.lean_state { + if !lean.spec_updating && lean.unsent_action_count > 0 { + lean.send_actions_mode = true; + lean.send_actions_input.clear(); + } + } + } + Action::LeanToggleUpdateLog => { + if let Some(ref mut lean) = self.lean_state { + lean.show_update_log = !lean.show_update_log; + } + } + Action::LeanScrollUp => { + if let Some(ref mut lean) = self.lean_state { + lean.scroll_offset = lean.scroll_offset.saturating_sub(5); + } + } + Action::LeanScrollDown => { + if let Some(ref mut lean) = self.lean_state { + lean.scroll_offset += 5; + } + } + Action::LeanInputChar(c) => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.push(c); + } else if lean.modify_mode { + lean.modify_input.push(c); + } else if lean.send_actions_mode { + lean.send_actions_input.push(c); + } else if lean.warmup_mode { + lean.warmup_input.push(c); + } + } + } + Action::LeanInputBackspace => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.pop(); + } else if lean.modify_mode { + lean.modify_input.pop(); + } else if lean.send_actions_mode { + lean.send_actions_input.pop(); + } else if lean.warmup_mode { + lean.warmup_input.pop(); + } + } + } + Action::LeanInputNewline => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + lean.query_input.push('\n'); + } else if lean.modify_mode { + lean.modify_input.push('\n'); + } else if lean.send_actions_mode { + lean.send_actions_input.push('\n'); + } else if lean.warmup_mode { + lean.warmup_input.push('\n'); + } + } + } + Action::LeanInputSubmit => { + if let Some(ref mut lean) = self.lean_state { + if lean.query_mode { + let question = lean.query_input.clone(); + lean.query_mode = false; + lean.query_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_query( + state, + session_id, + question, + ) + .await; + }); + } else if lean.modify_mode { + let modification = lean.modify_input.clone(); + lean.modify_mode = false; + lean.modify_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_modify( + state, + session_id, + modification, + ) + .await; + }); + } else if lean.send_actions_mode { + let notes = lean.send_actions_input.clone(); + lean.send_actions_mode = false; + lean.send_actions_input.clear(); + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + // If pregen is running, queue the send for after it finishes. + if lean.pregenerating { + state.update_sim_session(&session_id, |s| { + s.lean_queued_send = Some(notes); + }); + } else { + lean.spec_updating = true; + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_send_actions( + state, + session_id, + notes, + ) + .await; + }); + } + } else if lean.warmup_mode { + let response = lean.warmup_input.clone(); + lean.warmup_mode = false; + lean.warmup_input.clear(); + if !response.trim().is_empty() { + let session_id = lean.session_id.clone(); + let state = self.state.clone(); + tokio::spawn(async move { + spec_forest::simulation::warmup_orchestrate::handle_warmup_response( + state, + session_id, + response, + ) + .await; + }); + } + } + } + } + Action::LeanInputCancel => { + if let Some(ref mut lean) = self.lean_state { + lean.query_mode = false; + lean.modify_mode = false; + lean.send_actions_mode = false; + lean.warmup_mode = false; + lean.query_input.clear(); + lean.modify_input.clear(); + lean.send_actions_input.clear(); + lean.warmup_input.clear(); + } + } + Action::LeanBackground => { + // Close overlays first if open. + if let Some(ref mut lean) = self.lean_state { + if lean.report_overlay.is_some() { + lean.report_overlay = None; + return; + } + } + if let Screen::LeanGame { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + let label = self + .state + .get_sim_session(&session_id) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| { + format!("lean:{}", &session_id[..8.min(session_id.len())]) + }); + let was_processing = self + .lean_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id, + spec_id: spec_id.clone(), + label, + was_processing, + }); + self.lean_state = None; + let sid = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + } + } + Action::LeanEnd => { + // Warn if there are unsent actions. + if let Some(ref mut lean) = self.lean_state { + if lean.unsent_action_count > 0 && !lean.quit_pending { + lean.quit_pending = true; + return; + } + } + if let Screen::LeanGame { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + self.state.remove_sim_session(&session_id); + self.lean_state = None; + let sid = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + } + } + + Action::ToggleHelp => { + self.show_help = !self.show_help; + } + } + } + + // ── Simulation session management ─────────────────────────── + + /// Switch to a backgrounded simulation session, restoring it as the active sim. + fn enter_sim_session(&mut self, session_id: &str) { + // Background the current active sim if we're viewing one + if let Screen::Simulation { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let sid = session_id.clone(); + let label = self + .sim_state + .as_ref() + .and_then(|_| { + self.state + .get_sim_session(&sid) + .and_then(|s| s.scenario.clone()) + }) + .unwrap_or_else(|| sid[..8.min(sid.len())].to_string()); + let was_processing = self + .sim_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id: sid, + spec_id, + label, + was_processing, + }); + self.sim_state = None; + } + + // Also background the current lean game if we're viewing one + if let Screen::LeanGame { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let sid = session_id.clone(); + let label = self + .state + .get_sim_session(&sid) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| format!("lean:{}", &sid[..8.min(sid.len())])); + let was_processing = self + .lean_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id: sid, + spec_id, + label, + was_processing, + }); + self.lean_state = None; + } + + // Remove from background list + self.background_sims + .retain(|bg| bg.session_id != session_id); + + // Remove matching notifications + self.sim_notifications + .retain(|n| n.session_id != session_id); + + // Reconstruct state from the AppState session data + if let Some(session) = self.state.get_sim_session(session_id) { + if session.lean_mode { + // Restore as lean game. + let mut lean_state = crate::lean_state::LeanGameState::new( + session.id.clone(), + session.spec_id.clone(), + session.channels.clone(), + ); + lean_state.processing = matches!( + session.status, + spec_forest::simulation::SimStatus::Processing + ); + lean_state.game_spec_updates = session.game_spec_updates.clone(); + self.screen = Screen::LeanGame { + spec_id: session.spec_id.clone(), + session_id: session.id.clone(), + }; + self.lean_state = Some(lean_state); + } else { + let mut sim_state = crate::simulation::SimulationState::new( + session.id.clone(), + session.spec_id.clone(), + session.channels.clone(), + ); + sim_state.channel_contents = session.channel_contents.clone(); + sim_state.decisions = session.decisions.clone(); + sim_state.interactions = + self.state.get_sim_interactions(&session.id); + sim_state.processing = matches!( + session.status, + spec_forest::simulation::SimStatus::Processing + ); + sim_state.scenario_input = session.scenario.clone().unwrap_or_default(); + self.screen = Screen::Simulation { + spec_id: session.spec_id.clone(), + session_id: session.id.clone(), + }; + self.sim_state = Some(sim_state); + } + } + } + + /// Poll all backgrounded simulation sessions for status transitions. + fn poll_background_sims(&mut self) { + let mut new_notifications = Vec::new(); + self.background_sims.retain_mut(|bg| { + let status = self.state.get_sim_session_status(&bg.session_id); + match status { + Some(spec_forest::simulation::SimStatus::Idle) => { + if bg.was_processing { + // Transitioned from Processing → Idle: response is ready + new_notifications.push(crate::notification::SimNotification { + session_id: bg.session_id.clone(), + spec_id: bg.spec_id.clone(), + label: bg.label.clone(), + created_at: std::time::Instant::now(), + }); + bg.was_processing = false; + } + true + } + Some(spec_forest::simulation::SimStatus::Processing) => { + bg.was_processing = true; + true + } + Some(spec_forest::simulation::SimStatus::Error(_)) => false, + Some(spec_forest::simulation::SimStatus::Ended) => false, + None => false, // session was removed externally + } + }); + // Avoid duplicate notifications for the same session + for notif in new_notifications { + if !self + .sim_notifications + .iter() + .any(|n| n.session_id == notif.session_id) + { + self.sim_notifications.push(notif); + } + } + } + + // ── Navigation helpers ────────────────────────────────────── + + fn navigate_up(&mut self) { + match &self.screen { + Screen::SpecList => { + self.selected = self.selected.saturating_sub(1); + } + Screen::ModelConfig => { + self.model_selected = self.model_selected.saturating_sub(1); + } + Screen::Config => { + self.config_selected = self.config_selected.saturating_sub(1); + } + Screen::SpecSettings { .. } => { + self.spec_settings_selected = self.spec_settings_selected.saturating_sub(1); + } + Screen::SpecView { .. } => {} + _ => {} + } + } + + fn navigate_down(&mut self) { + match &self.screen { + Screen::SpecList => { + if !self.specs.is_empty() && self.selected < self.specs.len() - 1 { + self.selected += 1; + } + } + Screen::ModelConfig => { + if self.model_selected < MODELS.len() - 1 { + self.model_selected += 1; + } + } + Screen::Config => { + if self.config_selected < 1 { + self.config_selected += 1; + } + } + Screen::SpecSettings { .. } => { + // Currently only one item, but ready for more + } + Screen::SpecView { .. } => {} + _ => {} + } + } + + fn select_current(&mut self) { + if let Screen::SpecList = &self.screen + && let Some(spec) = self.specs.get(self.selected) + { + let spec_id = spec.id.clone(); + self.open_spec(&spec_id); + } + } + + fn go_back(&mut self) { + match &self.screen { + Screen::SpecView { .. } => { + self.screen = Screen::SpecList; + self.refresh_specs(); + } + Screen::SyncConfig => { + self.screen = Screen::SpecList; + } + _ => {} + } + } + + fn cancel_input(&mut self) { + match &self.screen { + Screen::InputName => { + self.screen = Screen::SpecList; + } + Screen::SyncPasswordInput => { + self.input.clear(); + self.screen = Screen::SyncConfig; + } + Screen::ModelConfig => { + self.screen = Screen::SpecList; + } + Screen::UsernameInput => { + self.input.clear(); + self.screen = Screen::Config; + } + Screen::Config => { + self.screen = Screen::SpecList; + } + Screen::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + } + Screen::SpecMembers { spec_id } => { + let spec_id = spec_id.clone(); + self.members_input_active = false; + self.members_input.clear(); + self.screen = Screen::SpecView { spec_id }; + } + _ => {} + } + } + + // ── Submit dispatcher ─────────────────────────────────────── + + async fn submit_input(&mut self) { + if self.input.is_empty() { + return; + } + match self.screen.clone() { + Screen::InputName => { + self.spec_options_selected = 0; + self.spec_options_source = SpecOptionsSource::CreateSpec; + self.screen = Screen::SpecOptionsPicker; + } + Screen::SyncPasswordInput => self.do_sync_connect().await, + Screen::UsernameInput => { + let name = std::mem::take(&mut self.input); + match api::set_user_name(&self.state, &name) { + Ok(()) => { + self.message = Some(format!("Username set to: {name}")); + } + Err(e) => { + self.message = Some(format!("Failed to save username: {e}")); + } + } + self.screen = Screen::Config; + } + _ => {} + } + } + + // ── Spec operations ───────────────────────────────────────── + + fn refresh_specs(&mut self) { + match commands::refresh_spec_list(&self.state) { + Ok(specs) => self.specs = specs, + Err(e) => { + tracing::error!("Failed to refresh specs: {e}"); + self.message = Some(e.to_string()); + } + } + self.clamp_selected(); + } + + fn clamp_selected(&mut self) { + if !self.specs.is_empty() && self.selected >= self.specs.len() { + self.selected = self.specs.len() - 1; + } + } + + fn open_spec(&mut self, spec_id: &str) { + match commands::load_spec_nodes(&self.state, spec_id) { + Ok(nodes) => { + self.nodes = nodes; + self.tree_state = TreeState::new(); + self.tree_visible = true; + self.tree_focused = true; + self.log_focused = false; + if let Err(e) = self.tree_state.rebuild(&self.state, spec_id) { + tracing::error!("Tree rebuild failed: {e}"); + self.message = Some(e.to_string()); + } + self.implementation_statuses = + commands::load_implementation_statuses(&self.state, spec_id) + .unwrap_or_default(); + self.shadow_node_id = None; + self.screen = Screen::SpecView { + spec_id: spec_id.to_string(), + }; + } + Err(e) => { + tracing::error!("Failed to open spec: {e}"); + self.message = Some(e.to_string()); + } + } + } + + async fn confirm_spec_options(&mut self) { + let (_, mode, locality) = SPEC_OPTIONS[self.spec_options_selected]; + match self.spec_options_source.clone() { + SpecOptionsSource::CreateSpec => { + self.create_spec_with_seed(mode, locality).await; + } + SpecOptionsSource::SeedFromDir => { + self.seed_from_directory_with_depth(mode, locality).await; + } + SpecOptionsSource::None => { + self.screen = Screen::SpecList; + } + } + } + + fn cancel_spec_options(&mut self) { + match self.spec_options_source { + SpecOptionsSource::CreateSpec => { + self.screen = Screen::InputName; + } + SpecOptionsSource::SeedFromDir => { + self.screen = Screen::DepthPicker; + } + SpecOptionsSource::None => { + self.screen = Screen::SpecList; + } + } + } + + async fn create_spec_with_seed(&mut self, mode: &str, locality: &str) { + let name = std::mem::take(&mut self.input); + + self.needs_redraw = true; + let seed_content = match editor::edit_seed() { + Ok(content) => content, + Err(e) => { + self.message = Some(format!("Editor error: {e}")); + self.screen = Screen::SpecList; + return; + } + }; + + match commands::create_spec(&self.state, name, Some(mode), Some(locality)).await { + Ok(spec) => { if let Some(content) = seed_content { match commands::seed_spec(&self.state, &spec.id, content, self.model.clone()) .await @@ -372,41 +2101,40 @@ impl App { self.message = Some(e.to_string()); self.screen = Screen::SpecList; } - } - } - - async fn seed_from_file(&mut self) { - let path = std::mem::take(&mut self.input); - let content = match commands::read_file_content(&path) { - Ok(c) => c, - Err(e) => { - self.message = Some(e.to_string()); - self.screen = Screen::SpecList; - return; - } + } + } + + async fn seed_from_directory_with_depth(&mut self, mode: &str, locality: &str) { + let (dir_path, name) = match self.depth_picker_dir.take() { + Some(d) => d, + None => return, }; + let depth = self.depth_selected + 1; - let name = commands::file_stem(&path); - match commands::create_spec(&self.state, name).await { - Ok(spec) => { - match commands::seed_spec(&self.state, &spec.id, content, self.model.clone()).await - { - Ok(()) => { - self.message = Some(format!("Seeded: {}", spec.name)); - self.refresh_specs(); - self.open_spec(&spec.id); - } - Err(e) => { - tracing::error!("Seed from file failed: {e}"); - self.message = Some(e.to_string()); - self.refresh_specs(); - self.screen = Screen::SpecList; - } - } + match commands::ingest_recursive( + &self.state, + name.clone(), + None, + Some(mode), + Some(locality), + dir_path, + depth, + self.model.clone(), + ) + .await + { + Ok(resp) => { + let spec_id = resp.spec_id.clone(); + self.ingest_session_id = Some(resp.session_id); + self.message = Some(format!("Ingesting '{}' at depth {}...", name, depth)); + self.refresh_specs(); + self.dir_browser = None; + self.open_spec(&spec_id); } Err(e) => { - tracing::error!("Create spec from file failed: {e}"); + tracing::error!("Ingest recursive failed: {e}"); self.message = Some(e.to_string()); + self.dir_browser = None; self.screen = Screen::SpecList; } } @@ -436,12 +2164,12 @@ impl App { }; self.tree_visible = !self.tree_visible; if self.tree_visible { - let db = self.state.db(); - if let Err(e) = self.tree_state.rebuild(&db, &spec_id) { + if let Err(e) = self.tree_state.rebuild(&self.state, &spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } self.tree_focused = true; + self.log_focused = false; } else { self.tree_focused = false; } @@ -452,13 +2180,10 @@ impl App { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let db = self.state.db(); - if let Err(e) = self.tree_state.expand_selected(&db, &spec_id) { + if let Err(e) = self.tree_state.expand_selected(&self.state, &spec_id) { tracing::error!("Tree expand failed: {e}"); self.message = Some(e.to_string()); } - drop(db); - self.sync_flat_list_to_tree(); } fn collapse_tree_node(&mut self) { @@ -466,8 +2191,7 @@ impl App { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let db = self.state.db(); - if let Err(e) = self.tree_state.collapse_selected(&db, &spec_id) { + if let Err(e) = self.tree_state.collapse_selected(&self.state, &spec_id) { tracing::error!("Tree collapse failed: {e}"); self.message = Some(e.to_string()); } @@ -475,144 +2199,518 @@ impl App { fn rebuild_tree_if_visible(&mut self, spec_id: &str) { if self.tree_visible { - let db = self.state.db(); - if let Err(e) = self.tree_state.rebuild(&db, spec_id) { + if let Err(e) = self.tree_state.rebuild(&self.state, spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } } } - fn sync_flat_list_to_tree(&mut self) { - if let Some(node_id) = self.tree_state.selected_node_id() - && let Some(idx) = self.nodes.iter().position(|n| n.id == node_id) - { - self.node_selected = idx; + + // ── Node operations ───────────────────────────────────────── + + pub fn get_selected_node(&self) -> Option<&spec_forest::Node> { + let node_id = self.tree_state.selected_node_id()?; + self.nodes.iter().find(|n| n.id == node_id) + } + + fn trigger_ai_answer(&mut self) { + let Some(node) = self.get_selected_node().cloned() else { + return; + }; + commands::spawn_ai_answer(self.state.clone(), node.id.clone(), self.model.clone(), self.auto_explore); + let q = truncate_str(&node.question, 40); + self.message = Some(format!("AI answering: {q}...")); + } + + fn trigger_explore_node(&mut self) { + let Some(node) = self.get_selected_node().cloned() else { + return; + }; + match commands::explore_node(&self.state, &node.id, self.model.clone()) { + Ok(()) => { + let q = truncate_str(&node.question, 40); + self.message = Some(format!("Exploring: {q}...")); + } + Err(e) => { + tracing::error!("Explore node failed: {e}"); + self.message = Some(e.to_string()); + } + } + } + + fn trigger_full_explore(&mut self, depth: u32) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + if self.explore_session_id.is_some() { + self.message = Some("Explore already running".to_string()); + return; + } + let root_node_id = self.get_selected_node().map(|n| n.id.clone()); + match commands::start_full_explore(&self.state, &spec_id, root_node_id, depth, self.model.clone(), true) + { + Ok(status) => { + self.explore_session_id = Some(status.session_id.clone()); + self.explore_status = Some(status); + self.message = Some("Explore session started".to_string()); + } + Err(e) => { + tracing::error!("Full explore failed: {e}"); + self.message = Some(e.to_string()); + } + } + } + + fn trigger_shadow_generation(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let spec = match self.specs.get(self.selected) { + Some(s) => s, + None => return, + }; + let dir_path = match &spec.directory { + Some(d) => d.clone(), + None => { + self.message = Some("No directory set. Use [g] Settings to set one.".to_string()); + return; + } + }; + if self.shadow_session_id.is_some() { + self.message = Some("Shadow generation already running".to_string()); + return; + } + let focus_node_id = self.get_selected_node().map(|n| n.id.clone()); + match commands::start_shadow( + &self.state, + spec_id, + dir_path, + self.model.clone(), + focus_node_id, + ) { + Ok(resp) => { + self.shadow_session_id = Some(resp.session_id.clone()); + self.shadow_status = Some(resp); + self.message = Some("Shadow generation started...".to_string()); + } + Err(e) => { + tracing::error!("Shadow generation failed: {e}"); + self.message = Some(format!("Shadow generation failed: {e}")); + } + } + } + + fn trigger_shadow_regeneration(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let spec = match self.specs.get(self.selected) { + Some(s) => s, + None => return, + }; + let dir_path = match &spec.directory { + Some(d) => d.clone(), + None => { + self.message = Some("No directory set. Use [g] Settings to set one.".to_string()); + return; + } + }; + if self.shadow_session_id.is_some() { + self.message = Some("Shadow regeneration already running".to_string()); + return; + } + let focus_node_id = self.get_selected_node().map(|n| n.id.clone()); + match commands::start_shadow_regenerate( + &self.state, + spec_id, + dir_path, + self.model.clone(), + focus_node_id, + ) { + Ok(resp) => { + self.shadow_session_id = Some(resp.session_id.clone()); + self.shadow_status = Some(resp); + self.message = Some("Shadow regeneration started...".to_string()); + } + Err(e) => { + tracing::error!("Shadow regeneration failed: {e}"); + self.message = Some(format!("Shadow regeneration failed: {e}")); + } + } + } + + fn toggle_explore_pause(&mut self) { + if let Some(ref sid) = self.explore_session_id + && let Some(ref status) = self.explore_status + { + match status.status { + ExploreStatus::Running => { + spec_forest::api::pause_explore(&self.state, sid); + self.message = Some("Explore paused".to_string()); + } + ExploreStatus::Paused => { + spec_forest::api::resume_explore(&self.state, sid); + self.message = Some("Explore resumed".to_string()); + } + _ => {} + } + } + } + + fn cancel_explore_session(&mut self) { + if let Some(ref sid) = self.explore_session_id.clone() { + spec_forest::api::cancel_explore(&self.state, sid); + self.explore_session_id = None; + self.explore_status = None; + self.message = Some("Explore cancelled".to_string()); + } + } + + // ── Editor operations ─────────────────────────────────────── + + async fn edit_tree_node(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let node = { + let Some(node_id) = self.tree_state.selected_node_id() else { + return; + }; + match api::get_node(&self.state, node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to get node for edit: {e}"); + self.message = Some(format!("Error: {e}")); + return; + } + } + }; + + self.needs_redraw = true; + match editor::edit_node(&node.question, node.answer.as_deref()) { + Ok(Some(result)) => { + let mut updated = false; + if let Some(new_question) = result.question { + match commands::update_question( + &self.state, + &node.id, + new_question, + self.model.clone(), + ) + .await + { + Ok(()) => updated = true, + Err(e) => { + tracing::error!("Update question failed: {e}"); + self.message = Some(e.to_string()); + } + } + } + if let Some(new_answer) = result.answer { + match commands::submit_answer( + &self.state, + &node.id, + new_answer, + self.model.clone(), + self.auto_explore, + ) + .await + { + Ok(()) => updated = true, + Err(e) => { + tracing::error!("Submit answer failed: {e}"); + self.message = Some(e.to_string()); + } + } + } + if updated { + self.message = Some("Node updated".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + } + Ok(None) => self.message = Some("No changes".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + + // ── Add feature / question ─────────────────────────────── + + async fn add_feature(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + self.needs_redraw = true; + match editor::edit_new_feature() { + Ok(Some(content)) => { + match commands::create_feature( + &self.state, + &spec_id, + content, + self.model.clone(), + ) + .await + { + Ok(node) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Feature added: {label}")); + } + Err(e) => { + tracing::error!("Create feature failed: {e}"); + self.message = Some(e.to_string()); + } + } + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Ok(None) => self.message = Some("Cancelled".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + + fn regenerate_feature(&mut self) { + let Some(node) = self.get_selected_node().cloned() else { + self.message = Some("No node selected".to_string()); + return; + }; + + match commands::regenerate_feature(&self.state, &node.id, self.model.clone()) { + Ok(()) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Regenerating feature: {label}...")); + } + Err(e) => { + self.message = Some(e.to_string()); + } + } + } + + async fn add_question(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + let parent_id = match self.get_selected_node() { + Some(node) => node.id.clone(), + None => { + self.message = Some("No node selected".to_string()); + return; + } + }; + + self.needs_redraw = true; + match editor::edit_new_question() { + Ok(Some(question)) => { + match commands::add_child( + &self.state, + &parent_id, + question, + self.model.clone(), + ) + .await + { + Ok(node) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Question added: {label}")); + } + Err(e) => { + tracing::error!("Add child failed: {e}"); + self.message = Some(e.to_string()); + } + } + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Ok(None) => self.message = Some("Cancelled".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + + // ── Delete node ────────────────────────────────────────── + + async fn delete_node(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + let node = match self.get_selected_node() { + Some(node) => node.clone(), + None => { + self.message = Some("No node selected".to_string()); + return; + } + }; + + if self.pending_delete.as_deref() == Some(&node.id) { + self.pending_delete = None; + match commands::delete_node(&self.state, &node.id).await { + Ok(_) => { + self.message = Some("Node deleted".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Err(e) => { + tracing::error!("Delete node failed: {e}"); + self.message = Some(e.to_string()); + } + } + } else { + let label = truncate_str(&node.question, 40); + self.pending_delete = Some(node.id.clone()); + self.message = Some(format!("Press d again to delete '{label}'")); + } + } + + // ── Delete spec ────────────────────────────────────────── + + async fn delete_spec(&mut self) { + if !matches!(self.screen, Screen::SpecList) { + return; } - } - // ── Node operations ───────────────────────────────────────── + let spec = match self.specs.get(self.selected) { + Some(spec) => spec.clone(), + None => { + self.message = Some("No spec selected".to_string()); + return; + } + }; - pub fn get_selected_node(&self) -> Option<&spec_forest_db::Node> { - if self.tree_focused && self.tree_visible { - let node_id = self.tree_state.selected_node_id()?; - self.nodes.iter().find(|n| n.id == node_id) + if self.pending_delete.as_deref() == Some(&spec.id) { + self.pending_delete = None; + match commands::delete_spec(&self.state, &spec.id).await { + Ok(_) => { + self.message = Some("Spec deleted".to_string()); + if let Some(specs) = + handle_result(commands::refresh_spec_list(&self.state), &mut self.message) + { + self.specs = specs; + } + if self.selected > 0 && self.selected >= self.specs.len() { + self.selected = self.specs.len().saturating_sub(1); + } + } + Err(e) => { + tracing::error!("Delete spec failed: {e}"); + self.message = Some(e.to_string()); + } + } } else { - self.nodes.get(self.node_selected) + let label = truncate_str(&spec.name, 40); + self.pending_delete = Some(spec.id.clone()); + self.message = Some(format!("Press d again to delete '{label}'")); } } - fn trigger_ai_answer(&mut self) { - let Some(node) = self.get_selected_node().cloned() else { + // ── Candidate operations ─────────────────────────────────── + + pub fn refresh_candidates_if_needed(&mut self) { + let current_node_id = self.get_selected_node().map(|n| n.id.clone()); + if current_node_id == self.candidate_node_id { return; - }; - commands::spawn_ai_answer(self.state.clone(), node.id.clone(), self.model.clone()); - let q = truncate_str(&node.question, 40); - self.message = Some(format!("AI answering: {q}...")); + } + self.candidate_node_id = current_node_id.clone(); + self.candidate_selected = 0; + if let Some(node_id) = current_node_id { + match api::get_candidates(&self.state, &node_id) { + Ok(c) => self.candidates = c, + Err(e) => { + tracing::warn!("Failed to load candidates: {e}"); + self.candidates = Vec::new(); + } + } + } else { + self.candidates = Vec::new(); + } } - fn trigger_explore_node(&mut self) { - let Some(node) = self.get_selected_node().cloned() else { + fn refresh_shadow_answers_if_needed(&mut self) { + let current_node_id = self.get_selected_node().map(|n| n.id.clone()); + if current_node_id == self.shadow_node_id { return; - }; - match commands::explore_node(&self.state, &node.id, self.model.clone()) { - Ok(()) => { - let q = truncate_str(&node.question, 40); - self.message = Some(format!("Exploring: {q}...")); - } - Err(e) => { - tracing::error!("Explore node failed: {e}"); - self.message = Some(e.to_string()); - } } + self.shadow_node_id = current_node_id.clone(); + self.shadow_answers = match current_node_id { + Some(nid) => commands::get_shadow_answers(&self.state, &nid).unwrap_or_default(), + None => Vec::new(), + }; } - fn trigger_full_explore(&mut self) { + async fn accept_candidate(&mut self) { let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - if self.explore_session_id.is_some() { - self.message = Some("Explore already running".to_string()); + let Some(candidate) = self.candidates.get(self.candidate_selected).cloned() else { return; - } - let root_node_id = self.get_selected_node().map(|n| n.id.clone()); - match commands::start_full_explore(&self.state, &spec_id, root_node_id, self.model.clone()) + }; + let node_id = candidate.node_id.clone(); + let answer_text = candidate.answer.clone(); + + match commands::submit_answer(&self.state, &node_id, answer_text, self.model.clone(), self.auto_explore).await { - Ok(status) => { - self.explore_session_id = Some(status.session_id.clone()); - self.explore_status = Some(status); - self.message = Some("Explore session started".to_string()); + Ok(()) => { + self.message = Some("Candidate accepted".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); } Err(e) => { - tracing::error!("Full explore failed: {e}"); + tracing::error!("Accept candidate failed: {e}"); self.message = Some(e.to_string()); } } } - fn toggle_explore_pause(&mut self) { - if let Some(ref sid) = self.explore_session_id - && let Some(ref status) = self.explore_status - { - match status.status { - ExploreStatus::Running => { - spec_forest::api::pause_explore(&self.state, sid); - self.message = Some("Explore paused".to_string()); - } - ExploreStatus::Paused => { - spec_forest::api::resume_explore(&self.state, sid); - self.message = Some("Explore resumed".to_string()); - } - _ => {} - } - } - } - - fn cancel_explore_session(&mut self) { - if let Some(ref sid) = self.explore_session_id.clone() { - spec_forest::api::cancel_explore(&self.state, sid); - self.explore_session_id = None; - self.explore_status = None; - self.message = Some("Explore cancelled".to_string()); - } - } - - // ── Editor operations ─────────────────────────────────────── - - async fn edit_tree_node(&mut self) { + async fn edit_candidate(&mut self) { let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let node = { - let Some(node_id) = self.tree_state.selected_node_id() else { + let Some(candidate) = self.candidates.get(self.candidate_selected).cloned() else { + return; + }; + let node = match api::get_node(&self.state, &candidate.node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to get node for candidate edit: {e}"); + self.message = Some(format!("Error: {e}")); return; - }; - match self.state.db().get_node(node_id) { - Ok(node) => node, - Err(e) => { - tracing::error!("Failed to get node for edit: {e}"); - self.message = Some(format!("Error: {e}")); - return; - } } }; self.needs_redraw = true; - match editor::edit_question(&node.question, node.answer.as_deref()) { + match editor::edit_answer(&node.question, Some(&candidate.answer)) { Ok(Some(new_answer)) => { match commands::submit_answer( &self.state, &node.id, new_answer, self.model.clone(), + self.auto_explore, ) .await { - Ok(()) => self.message = Some("Answer submitted".to_string()), + Ok(()) => self.message = Some("Edited candidate submitted".to_string()), Err(e) => { - tracing::error!("Submit answer failed: {e}"); + tracing::error!("Submit edited candidate failed: {e}"); self.message = Some(e.to_string()); } } @@ -627,51 +2725,35 @@ impl App { } } - async fn edit_next_question(&mut self) { - let spec_id = match &self.screen { - Screen::SpecView { spec_id } => spec_id.clone(), - _ => return, - }; - let next = self.state.db().get_next_question(&spec_id); - match next { - Ok(Some(node)) => { - self.needs_redraw = true; - match editor::edit_question(&node.question, node.answer.as_deref()) { - Ok(Some(new_answer)) => { - match commands::submit_answer( - &self.state, - &node.id, - new_answer, - self.model.clone(), - ) - .await - { - Ok(()) => self.message = Some("Answer submitted".to_string()), - Err(e) => { - tracing::error!("Submit answer failed: {e}"); - self.message = Some(e.to_string()); - } - } - self.refresh_nodes(&spec_id); - } - Ok(None) => self.message = Some("No changes".to_string()), - Err(e) => { - tracing::error!("Editor failed: {e}"); - self.message = Some(format!("Editor error: {e}")); - } - } - } - Ok(None) => self.message = Some("No unanswered questions".to_string()), - Err(e) => { - tracing::error!("Failed to get next question: {e}"); - self.message = Some(format!("Error: {e}")); - } + // ── Background polling ────────────────────────────────────── + + pub fn sync_disconnect_indicator(&self) -> Option<&'static str> { + if self.state.sync_url().is_some() && !self.sync_connected { + Some("Sync: not connected") + } else { + None } } - // ── Background polling ────────────────────────────────────── - fn poll_background_status(&mut self) { + if self.state.sync_url().is_some() { + if let Some(connected) = self.state.try_check_sync_connected() { + self.sync_connected = connected; + } + } + + // Poll simulation session if on simulation screen + if let Screen::Simulation { ref session_id, .. } = self.screen { + self.poll_sim_status(session_id.clone()); + } + // Poll lean game session + if let Screen::LeanGame { ref session_id, .. } = self.screen { + self.poll_lean_status(session_id.clone()); + } + + // Always poll background simulation sessions for notifications + self.poll_background_sims(); + let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, @@ -700,10 +2782,57 @@ impl App { } } - if prev_busy || self.is_busy() { + if let Some(sid) = self.ingest_session_id.clone() { + match commands::poll_ingest_status(&self.state, &sid) { + Ok(status) => { + let done = matches!( + status.status, + IngestState::Done | IngestState::Cancelled + ); + if done { + self.ingest_session_id = None; + self.message = Some("Ingest complete".to_string()); + } + } + Err(_) => { + self.ingest_session_id = None; + } + } + } + + if let Some(sid) = self.shadow_session_id.clone() { + match commands::poll_ingest_status(&self.state, &sid) { + Ok(status) => { + let done = matches!( + status.status, + IngestState::Done | IngestState::Cancelled + ); + self.shadow_status = Some(status); + if done { + self.shadow_session_id = None; + self.message = Some("Shadow generation complete".to_string()); + self.implementation_statuses = + commands::load_implementation_statuses(&self.state, &spec_id) + .unwrap_or_default(); + self.shadow_node_id = None; // force shadow answer reload + } + } + Err(_) => { + self.shadow_session_id = None; + self.shadow_status = None; + } + } + } + + let now_busy = self.is_busy(); + if prev_busy || now_busy { self.refresh_nodes(&spec_id); self.rebuild_tree_if_visible(&spec_id); } + // Only force candidate reload when transitioning from busy to idle + if prev_busy && !now_busy { + self.candidate_node_id = None; + } } fn is_busy(&self) -> bool { @@ -712,6 +2841,76 @@ impl App { .explore_status .as_ref() .is_some_and(|s| s.status == ExploreStatus::Running) + || self.ingest_session_id.is_some() + || self.shadow_session_id.is_some() + } + + fn handle_op_notification( + &mut self, + result: Result< + spec_forest::op_channel::OpNotification, + tokio::sync::broadcast::error::RecvError, + >, + ) { + match result { + Ok(notification) => { + if matches!(self.screen, Screen::SpecList) { + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + return; + } + if let Screen::SpecView { ref spec_id } = self.screen { + if notification.spec_list_changed && notification.spec_id == *spec_id { + // Deleted the spec we're viewing — go back to list. + if notification.op_type == "DeleteSpec" { + self.screen = Screen::SpecList; + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + return; + } + } + if notification.spec_id == *spec_id { + let sid = spec_id.clone(); + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + self.candidate_node_id = None; + self.shadow_node_id = None; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("TUI missed {n} op notifications, doing full refresh"); + match &self.screen { + Screen::SpecView { spec_id } => { + let sid = spec_id.clone(); + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + self.candidate_node_id = None; + self.shadow_node_id = None; + } + Screen::SpecList => { + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + } + _ => {} + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::warn!("Op notification channel closed"); + } + } } fn refresh_nodes(&mut self, spec_id: &str) { @@ -722,6 +2921,518 @@ impl App { self.nodes = nodes; } } + + // ── Simulation ───────────────────────────────────────────── + + fn poll_sim_status(&mut self, session_id: String) { + if let Some(ref mut sim) = self.sim_state { + if let Some(status) = self.state.get_sim_session_status(&session_id) { + match status { + spec_forest::simulation::SimStatus::Idle => { + // Always sync pregenerating status (it's a background process) + let was_pregenerating = sim.pregenerating; + sim.pregenerating = + self.state.get_sim_pregenerating(&session_id); + + // If pregeneration just finished, refresh interactions + // (new branches may have been grafted onto the tree) + if was_pregenerating && !sim.pregenerating { + sim.interactions = + self.state.get_sim_interactions(&session_id); + } + + if sim.processing { + // Transition from processing to idle means turn completed + sim.processing = false; + // Check for pending report first (reports don't replace channels) + if let Some(report) = + self.state.take_sim_pending_report(&session_id) + { + sim.report_overlay = + Some(crate::simulation::ReportOverlay { + explanation: report.explanation, + refs: report.refs, + }); + } else { + // Normal turn: pull latest channel contents, decisions, and interactions + if let Some(contents) = + self.state.get_sim_channel_contents(&session_id) + { + sim.channel_contents = contents; + } + sim.decisions = + self.state.get_sim_decisions(&session_id); + if sim.game_mode { + sim.game_choice_groups = + self.state.get_sim_game_choice_groups(&session_id); + sim.selected_group = 0; + sim.selected_outcome = 0; + sim.game_spec_updates = + self.state.get_sim_game_spec_updates(&session_id); + } else { + sim.interactions = + self.state.get_sim_interactions(&session_id); + sim.selected_interaction = 0; + } + sim.can_go_back = + self.state.get_sim_nav_depth(&session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + } + } + spec_forest::simulation::SimStatus::Processing => { + sim.processing = true; + } + spec_forest::simulation::SimStatus::Error(ref e) => { + sim.processing = false; + self.message = Some(format!("Simulation error: {e}")); + } + spec_forest::simulation::SimStatus::Ended => { + sim.processing = false; + } + } + } + } + } + + fn poll_lean_status(&mut self, session_id: String) { + if let Some(ref mut lean) = self.lean_state { + lean.tick += 1; + if let Some(status) = self.state.get_sim_session_status(&session_id) { + match status { + spec_forest::simulation::SimStatus::Idle => { + if lean.processing { + lean.processing = false; + // Clear warmup state. + lean.warmup_active = false; + lean.warmup_scenario_text = None; + lean.warmup_node_question = None; + lean.warmup_generating = false; + lean.warmup_game_ready = false; + lean.warmup_mode = false; + lean.warmup_input.clear(); + // Check for pending report. + if let Some(report) = + self.state.take_sim_pending_report(&session_id) + { + lean.report_overlay = + Some(crate::simulation::ReportOverlay { + explanation: report.explanation, + refs: report.refs, + }); + } + } + // Always sync from session state. + if let Some(session) = self.state.get_sim_session(&session_id) { + if let Some(ref graph) = session.lean_graph { + if let Some(ref current_id) = session.lean_current_node_id { + // Update channel contents. + if let Some(node) = graph.get_node(current_id) { + lean.channel_contents = node.channels.clone(); + } + // Update interactions from edges. + lean.interactions = graph + .get_edges(current_id) + .iter() + .map(|edge| { + let entropy = if edge.edge_kind + != spec_forest::simulation::LeanEdgeKind::Leaf + { + graph + .get_node(&edge.target_node_id) + .map(|n| n.entropy_hint) + .unwrap_or(0.0) + } else { + 0.5 + }; + let at_frontier = edge.edge_kind + == spec_forest::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&edge.target_node_id); + crate::lean_state::LeanInteractionView { + label: edge.label.clone(), + edge_kind: edge.edge_kind, + entropy_hint: entropy, + at_frontier, + } + }) + .collect(); + // Clamp selected interaction. + if lean.selected_interaction >= lean.interactions.len() + && !lean.interactions.is_empty() + { + lean.selected_interaction = 0; + } + } + // Breadcrumbs. + let crumbs = graph + .collect_breadcrumbs(&session.lean_navigation_path); + lean.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + lean.can_go_back = session.lean_navigation_path.len() > 1; + } + lean.pregenerating = session.lean_generating; + lean.game_spec_updates = + session.game_spec_updates.clone(); + let unsent_count = session + .lean_action_history + .len() + .saturating_sub(session.lean_sent_history_len); + lean.unsent_action_count = unsent_count; + lean.unsent_action_labels = session.lean_action_history + [session.lean_sent_history_len..] + .iter() + .map(|e| e.label.clone()) + .collect(); + lean.spec_updating = session.lean_spec_updating; + } + } + spec_forest::simulation::SimStatus::Processing => { + lean.processing = true; + if let Some(session) = self.state.get_sim_session(&session_id) { + // Sync warmup state. + lean.warmup_active = session.warmup_active; + lean.warmup_generating = session.warmup_generating; + lean.warmup_game_ready = session.warmup_game_ready; + lean.warmup_scenario_text = session + .warmup_scenario + .as_ref() + .map(|s| s.scenario_text.clone()); + lean.warmup_node_question = session + .warmup_scenario + .as_ref() + .map(|s| s.node_question.clone()); + lean.can_go_back = session.lean_navigation_path.len() > 1; + let unsent_count = session + .lean_action_history + .len() + .saturating_sub(session.lean_sent_history_len); + lean.unsent_action_count = unsent_count; + lean.spec_updating = session.lean_spec_updating; + if let Some(ref graph) = session.lean_graph { + if let Some(ref current_id) = session.lean_current_node_id { + // Sync interactions even during processing so + // navigating to an existing node always shows edges. + lean.interactions = graph + .get_edges(current_id) + .iter() + .map(|edge| { + let entropy = if edge.edge_kind + != spec_forest::simulation::LeanEdgeKind::Leaf + { + graph + .get_node(&edge.target_node_id) + .map(|n| n.entropy_hint) + .unwrap_or(0.0) + } else { + 0.5 + }; + let at_frontier = edge.edge_kind + == spec_forest::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&edge.target_node_id); + crate::lean_state::LeanInteractionView { + label: edge.label.clone(), + edge_kind: edge.edge_kind, + entropy_hint: entropy, + at_frontier, + } + }) + .collect(); + if lean.selected_interaction >= lean.interactions.len() + && !lean.interactions.is_empty() + { + lean.selected_interaction = 0; + } + if let Some(node) = graph.get_node(current_id) { + lean.channel_contents = node.channels.clone(); + } + } + let crumbs = + graph.collect_breadcrumbs(&session.lean_navigation_path); + lean.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + lean.pregenerating = session.lean_generating; + } + } + spec_forest::simulation::SimStatus::Error(ref e) => { + lean.processing = false; + self.message = Some(format!("Lean game error: {e}")); + } + spec_forest::simulation::SimStatus::Ended => { + lean.processing = false; + } + } + } + } + } + + async fn start_simulation( + &mut self, + spec_id: String, + focus_node_id: String, + scenario: Option, + ) { + use spec_forest::simulation::{SimChannel, SimSession}; + + let channels: Vec = self + .sim_channel_selection + .iter() + .filter_map(|&i| SimChannel::ALL.get(i).copied()) + .collect(); + + let session_id = uuid::Uuid::new_v4().to_string(); + let session = SimSession::new( + session_id.clone(), + spec_id.clone(), + Some(focus_node_id.clone()), + self.model.clone(), + channels.clone(), + scenario.clone(), + ); + self.state.set_sim_session(session); + self.state.update_sim_session(&session_id, |s| { + s.game_mode = self.sim_game_mode; + s.lean_mode = self.sim_lean_mode; + }); + + if self.sim_lean_mode { + // Lean game mode: use dedicated state and screen. + let lean_state = crate::lean_state::LeanGameState::new( + session_id.clone(), + spec_id.clone(), + channels.clone(), + ); + self.lean_state = Some(lean_state); + self.screen = Screen::LeanGame { + spec_id: spec_id.clone(), + session_id: session_id.clone(), + }; + } else { + let mut sim_state = crate::simulation::SimulationState::new( + session_id.clone(), + spec_id.clone(), + channels.clone(), + ); + sim_state.game_mode = self.sim_game_mode; + self.sim_state = Some(sim_state); + self.screen = Screen::Simulation { + spec_id: spec_id.clone(), + session_id: session_id.clone(), + }; + } + + // Spawn the initial simulation turn in background + let state = self.state.clone(); + let model = self.model.clone(); + let sid = session_id.clone(); + let spec_id_for_task = spec_id.clone(); + let channels_for_task = channels; + let focus_node_for_task = focus_node_id; + let consume_whole_spec = self.sim_consume_whole_spec; + let directory = if self.sim_explore_code { + self.specs + .iter() + .find(|s| s.id == spec_id) + .and_then(|s| s.directory.clone()) + } else { + None + }; + + // Mark session as processing + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + if let Some(ref mut sim) = self.sim_state { + sim.processing = true; + } + if let Some(ref mut lean) = self.lean_state { + lean.processing = true; + } + + if self.sim_lean_mode { + // Lean game: use dedicated orchestration. + let state = self.state.clone(); + let sid = session_id.clone(); + tokio::spawn(async move { + spec_forest::simulation::lean_orchestrate::orchestrate_lean_initial_turn( + state, sid, + ) + .await; + }); + // Spawn warmup interactions in parallel. + let warmup_state = self.state.clone(); + let warmup_sid = session_id.clone(); + tokio::spawn(async move { + spec_forest::simulation::warmup_orchestrate::start_warmup( + warmup_state, warmup_sid, + ) + .await; + }); + } else { + tokio::spawn(async move { + commands::run_sim_initial_turn( + state, + sid, + spec_id_for_task, + model, + channels_for_task, + focus_node_for_task, + scenario, + consume_whole_spec, + directory, + ) + .await; + }); + } + } + + async fn submit_sim_input(&mut self) { + #[derive(PartialEq)] + enum SimSubmitKind { + Report, + Scenario, + Input, + } + + let (session_id, input_text, kind) = match self.sim_state.as_mut() { + Some(sim) if !sim.processing => { + let kind = if sim.report_mode { + SimSubmitKind::Report + } else if sim.scenario_mode { + SimSubmitKind::Scenario + } else { + SimSubmitKind::Input + }; + let text = match kind { + SimSubmitKind::Report => { + let report = spec_forest::simulation::SimReport { + description: sim.report_input.clone(), + }; + sim.report_mode = false; + sim.report_input.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&report).unwrap_or_default() + } + SimSubmitKind::Scenario => { + let scenario_text = sim.scenario_input.clone(); + sim.scenario_mode = false; + sim.scenario_input.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + scenario_text + } + SimSubmitKind::Input => { + let raw_text: String = sim + .captured_keys + .iter() + .map(|k| match k { + crate::simulation::CapturedKey::Char(c) => c.to_string(), + crate::simulation::CapturedKey::Enter => "\n".into(), + crate::simulation::CapturedKey::Tab => "\t".into(), + _ => k.to_key_string(), + }) + .collect(); + let input = spec_forest::simulation::SimInput { + keys: sim + .captured_keys + .iter() + .map(|k| k.to_key_string()) + .collect(), + raw_text, + }; + sim.captured_keys.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&input).unwrap_or_default() + } + }; + sim.processing = true; + (sim.session_id.clone(), text, kind) + } + _ => return, + }; + + // Mark backend session as processing + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + + // For scenario updates, also persist the new scenario on the session + if kind == SimSubmitKind::Scenario { + let scenario = if input_text.trim().is_empty() { + None + } else { + Some(input_text.clone()) + }; + let scenario_for_session = scenario.clone(); + self.state.update_sim_session(&session_id, |s| { + s.scenario = scenario_for_session; + }); + // Build the resume message that tells the LLM the scenario changed + let resume_text = match scenario { + Some(desc) => format!( + "SCENARIO UPDATE: The simulation scenario has changed. \ + The new scenario is:\n\n{desc}\n\n\ + Update all channels to reflect this new scenario." + ), + None => "SCENARIO UPDATE: The scenario has been cleared. \ + Continue with no specific scenario context." + .to_string(), + }; + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + commands::run_sim_resume_turn(state, sid, resume_text).await; + }); + } else if kind == SimSubmitKind::Report { + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + commands::run_sim_report_turn(state, sid, input_text).await; + }); + } else { + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + commands::run_sim_resume_turn(state, sid, input_text).await; + }); + } + } + + async fn select_sim_interaction(&mut self, idx: usize) { + let (session_id, input_json) = match self.sim_state.as_mut() { + Some(sim) if !sim.processing => { + if idx >= sim.interactions.len() { + return; + } + let interaction = &sim.interactions[idx]; + let input = spec_forest::simulation::SimInput { + keys: interaction.input.keys.clone(), + raw_text: interaction.input.raw_text.clone(), + }; + let json = serde_json::to_string(&input).unwrap_or_default(); + sim.processing = true; + (sim.session_id.clone(), json) + } + _ => return, + }; + + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + crate::commands::run_sim_resume_turn(state, sid, input_json).await; + }); + } } pub fn truncate_str(s: &str, max: usize) -> &str { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 5fb9e44..d01735c 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -1,26 +1,29 @@ use std::sync::Arc; use spec_forest::explore::ExploreStatusResponse; +use spec_forest::simulation::{self, SimChannel}; use spec_forest::state::AppState; use crate::error::TuiError; -pub fn refresh_spec_list(state: &AppState) -> Result, TuiError> { +pub fn refresh_spec_list(state: &AppState) -> Result, TuiError> { spec_forest::api::list_specs(state).map_err(|e| TuiError::Api(e.to_string())) } pub fn load_spec_nodes( state: &AppState, spec_id: &str, -) -> Result, TuiError> { +) -> Result, TuiError> { spec_forest::api::get_spec_nodes(state, spec_id).map_err(|e| TuiError::Api(e.to_string())) } pub async fn create_spec( state: &Arc, name: String, -) -> Result { - spec_forest::api::create_spec(state, name, None, None, None, None) + mode: Option<&str>, + locality: Option<&str>, +) -> Result { + spec_forest::api::create_spec(state, name, None, mode, locality, None) .await .map_err(|e| TuiError::Api(e.to_string())) } @@ -42,16 +45,29 @@ pub async fn submit_answer( node_id: &str, answer: String, model: String, + generate: bool, ) -> Result<(), TuiError> { - spec_forest::api::answer_node(state, node_id, answer, model, true) + spec_forest::api::answer_node(state, node_id, answer, model, generate, None, None) .await .map(|_| ()) .map_err(|e| TuiError::Api(e.to_string())) } -pub fn spawn_ai_answer(state: Arc, node_id: String, model: String) { +pub async fn update_question( + state: &Arc, + node_id: &str, + question: String, + model: String, +) -> Result<(), TuiError> { + spec_forest::api::update_question(state, node_id, question, model) + .await + .map(|_| ()) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn spawn_ai_answer(state: Arc, node_id: String, model: String, generate: bool) { tokio::spawn(async move { - if let Err(e) = spec_forest::api::ai_answer(&state, &node_id, model).await { + if let Err(e) = spec_forest::api::ai_answer(&state, &node_id, model, generate).await { tracing::error!("AI answer failed for node {node_id}: {e}"); } }); @@ -71,9 +87,11 @@ pub fn start_full_explore( state: &Arc, spec_id: &str, root_node_id: Option, + depth: u32, model: String, + end_on_answer: bool, ) -> Result { - spec_forest::api::start_explore(state, spec_id, root_node_id, 3, model, false) + spec_forest::api::start_explore(state, spec_id, root_node_id, depth, model, end_on_answer) .map_err(|e| TuiError::Api(e.to_string())) } @@ -85,6 +103,96 @@ pub fn poll_explore_status( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn ingest_recursive( + state: &Arc, + spec_name: String, + spec_description: Option, + mode: Option<&str>, + locality: Option<&str>, + dir_path: String, + depth: usize, + model: String, +) -> Result { + spec_forest::api::ingest_recursive( + state, + spec_name, + spec_description, + mode, + locality, + dir_path, + depth, + Some(model), + ) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn poll_ingest_status( + state: &AppState, + session_id: &str, +) -> Result { + spec_forest::api::ingest_status(state, session_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn create_feature( + state: &Arc, + spec_id: &str, + content: String, + model: String, +) -> Result { + spec_forest::api::create_feature(state, spec_id, content, model) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn add_child( + state: &Arc, + parent_id: &str, + question: String, + model: String, +) -> Result { + spec_forest::api::add_child(state, parent_id, question, model) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn update_directory( + state: &AppState, + spec_id: &str, + directory: Option, +) -> Result { + spec_forest::api::update_directory(state, spec_id, directory) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn regenerate_feature( + state: &Arc, + node_id: &str, + model: String, +) -> Result<(), TuiError> { + spec_forest::api::regenerate_feature(state, node_id, model) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn delete_spec( + state: &Arc, + spec_id: &str, +) -> Result<(), TuiError> { + spec_forest::api::delete_spec(state, spec_id) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn delete_node( + state: &Arc, + node_id: &str, +) -> Result { + spec_forest::api::delete_node(state, node_id) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn connect_sync( state: &Arc, password: String, @@ -95,13 +203,145 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } -pub fn read_file_content(path: &str) -> Result { - std::fs::read_to_string(path).map_err(TuiError::Io) +// ── Members ─────────────────────────────────────────────── + +pub async fn list_members( + state: &Arc, + spec_name: &str, +) -> Result<(Vec, String), TuiError> { + spec_forest::api::sync_list_members(state, spec_name) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn grant_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_grant_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn revoke_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_revoke_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +// ── Shadow answers ──────────────────────────────────────── + +pub fn start_shadow( + state: &Arc, + spec_id: String, + dir_path: String, + model: String, + focus_node_id: Option, +) -> Result { + spec_forest::api::ingest_shadow(state, spec_id, dir_path, Some(model), focus_node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn start_shadow_regenerate( + state: &Arc, + spec_id: String, + dir_path: String, + model: String, + focus_node_id: Option, +) -> Result { + spec_forest::api::ingest_shadow_regenerate(state, spec_id, dir_path, Some(model), focus_node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn get_shadow_answers( + state: &AppState, + node_id: &str, +) -> Result, TuiError> { + spec_forest::api::get_shadow_answers(state, node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn load_implementation_statuses( + state: &AppState, + spec_id: &str, +) -> Result)>, TuiError> { + let db = state.db(); + db.get_aggregated_implementation_status(spec_id) + .map_err(|e| TuiError::Api(e.to_string())) } -pub fn file_stem(path: &str) -> String { - std::path::Path::new(path) - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "seeded-spec".to_string()) +// ── Simulation ───────────────────────────────────────────── + +/// Run the initial simulation turn in a background task. +/// Updates the sim session in AppState when complete. +pub async fn run_sim_initial_turn( + state: Arc, + session_id: String, + _spec_id: String, + _model: String, + _channels: Vec, + _focus_node_id: String, + _scenario: Option, + consume_whole_spec: bool, + directory: Option, +) { + simulation::orchestrate::orchestrate_initial_turn( + state, + session_id, + consume_whole_spec, + directory, + ) + .await; } + +/// Resume a simulation turn with user input. +/// Updates the sim session in AppState when complete. +pub async fn run_sim_resume_turn(state: Arc, session_id: String, input: String) { + simulation::orchestrate::orchestrate_resume_turn(state, session_id, input).await; +} + +/// Resume a simulation turn with a user report. +/// Stores the report explanation without replacing channel contents. +pub async fn run_sim_report_turn(state: Arc, session_id: String, input: String) { + simulation::orchestrate::orchestrate_report_turn(state, session_id, input).await; +} + +/// Select a game mode outcome. +pub async fn run_game_select_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, +) { + simulation::orchestrate::orchestrate_game_select_outcome( + state, + session_id, + group_index, + outcome_index, + ) + .await; +} + +/// Reject a game mode outcome with a correction. +pub async fn run_game_reject_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, + correction: String, +) { + simulation::orchestrate::orchestrate_game_reject_outcome( + state, + session_id, + group_index, + outcome_index, + correction, + ) + .await; +} + diff --git a/crates/spec-forest-tui/src/dir_browser.rs b/crates/spec-forest-tui/src/dir_browser.rs new file mode 100644 index 0000000..6577751 --- /dev/null +++ b/crates/spec-forest-tui/src/dir_browser.rs @@ -0,0 +1,166 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug)] +pub struct DirEntry { + pub path: PathBuf, + pub name: String, + pub depth: usize, +} + +pub struct DirBrowserState { + pub root: PathBuf, + pub entries: Vec, + pub selected: usize, + expanded: HashSet, +} + +impl DirBrowserState { + pub fn new() -> Self { + let root = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let mut state = Self { + root, + entries: Vec::new(), + selected: 0, + expanded: HashSet::new(), + }; + state.expanded.insert(state.root.clone()); + state.rebuild(); + state + } + + pub fn rebuild(&mut self) { + self.entries.clear(); + self.entries.push(DirEntry { + path: self.root.clone(), + name: self + .root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.root.to_string_lossy().to_string()), + depth: 0, + }); + if self.expanded.contains(&self.root) { + self.push_children(&self.root.clone(), 1); + } + self.clamp_selection(); + } + + fn push_children(&mut self, dir: &Path, depth: usize) { + let children = read_dir_sorted(dir); + for child in children { + let name = child + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let is_expanded = self.expanded.contains(&child); + self.entries.push(DirEntry { + path: child.clone(), + name, + depth, + }); + if is_expanded { + self.push_children(&child, depth + 1); + } + } + } + + pub fn select_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn select_down(&mut self) { + if !self.entries.is_empty() && self.selected < self.entries.len() - 1 { + self.selected += 1; + } + } + + pub fn expand_selected(&mut self) { + let Some(entry) = self.entries.get(self.selected) else { + return; + }; + let path = entry.path.clone(); + if self.expanded.contains(&path) { + self.expanded.remove(&path); + } else { + self.expanded.insert(path); + } + self.rebuild(); + } + + pub fn collapse_selected(&mut self) { + let Some(entry) = self.entries.get(self.selected) else { + return; + }; + let path = entry.path.clone(); + let depth = entry.depth; + + if self.expanded.contains(&path) { + self.expanded.remove(&path); + self.rebuild(); + } else if depth > 0 { + for i in (0..self.selected).rev() { + if self.entries[i].depth == depth - 1 { + self.selected = i; + break; + } + } + } + } + + pub fn go_to_parent(&mut self) { + if let Some(parent) = self.root.parent() { + let parent = parent.to_path_buf(); + if parent != self.root { + let old_root = self.root.clone(); + self.root = parent; + self.expanded.clear(); + self.expanded.insert(self.root.clone()); + self.expanded.insert(old_root.clone()); + self.rebuild(); + // Select the old root in the new listing + if let Some(idx) = self.entries.iter().position(|e| e.path == old_root) { + self.selected = idx; + } + } + } + } + + pub fn selected_path(&self) -> Option<&Path> { + self.entries.get(self.selected).map(|e| e.path.as_path()) + } + + pub fn selected_dir_name(&self) -> String { + self.entries + .get(self.selected) + .map(|e| e.name.clone()) + .unwrap_or_else(|| "seeded-spec".to_string()) + } + + pub fn is_expanded(&self, path: &Path) -> bool { + self.expanded.contains(path) + } + + fn clamp_selection(&mut self) { + if !self.entries.is_empty() && self.selected >= self.entries.len() { + self.selected = self.entries.len() - 1; + } + } +} + +fn read_dir_sorted(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut dirs: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + dirs.sort_by(|a, b| { + let a_name = a.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); + let b_name = b.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); + a_name.cmp(&b_name) + }); + dirs +} diff --git a/crates/spec-forest-tui/src/editor.rs b/crates/spec-forest-tui/src/editor.rs index 56361e1..a3f5b6c 100644 --- a/crates/spec-forest-tui/src/editor.rs +++ b/crates/spec-forest-tui/src/editor.rs @@ -50,7 +50,10 @@ fn run_editor(initial_content: &str) -> io::Result> { let content = std::fs::read_to_string(&path)?; let stripped: String = content .lines() - .filter(|line| !line.starts_with('#')) + .filter(|line| { + let trimmed = line.trim_start(); + !(trimmed.starts_with('#') && !trimmed.starts_with("##")) + }) .collect::>() .join("\n") .trim() @@ -59,10 +62,68 @@ fn run_editor(initial_content: &str) -> io::Result> { Ok(Some(stripped)) } -/// Opens the user's default editor with a temp file containing the question -/// and current answer. Returns `Some(new_answer)` if the answer was changed, +/// Result of editing a node — contains only the fields that changed. +pub struct EditResult { + pub question: Option, + pub answer: Option, +} + +/// Opens the user's default editor with both the question and answer editable. +/// Returns `Some(EditResult)` with only the changed fields set, or `None` if +/// nothing changed or the editor was aborted. +pub fn edit_node(question: &str, current_answer: Option<&str>) -> io::Result> { + let answer_text = current_answer.unwrap_or(""); + let initial = format!( + "# Lines starting with # are ignored.\n# Save and quit to submit.\n\n## Question\n{question}\n\n## Answer\n{answer_text}" + ); + + let content = match run_editor(&initial)? { + Some(c) => c, + None => return Ok(None), + }; + + let (new_question, new_answer) = parse_question_answer(&content); + + let q_changed = new_question.trim() != question.trim(); + let a_changed = new_answer.trim() != answer_text.trim(); + + if !q_changed && !a_changed { + return Ok(None); + } + + Ok(Some(EditResult { + question: if q_changed { Some(new_question) } else { None }, + answer: if a_changed { Some(new_answer) } else { None }, + })) +} + +/// Parse editor content into (question, answer) by splitting on section markers. +fn parse_question_answer(content: &str) -> (String, String) { + // Look for ## Answer marker to split + if let Some(answer_pos) = content.find("## Answer") { + let question_part = &content[..answer_pos]; + let answer_part = &content[answer_pos + "## Answer".len()..]; + + // Strip ## Question marker from question part + let question = question_part + .strip_prefix("## Question") + .unwrap_or(question_part) + .trim() + .to_string(); + let answer = answer_part.trim().to_string(); + (question, answer) + } else if let Some(q) = content.strip_prefix("## Question") { + (q.trim().to_string(), String::new()) + } else { + // No markers found — treat entire content as question + (content.trim().to_string(), String::new()) + } +} + +/// Opens the user's default editor with the question as context (non-editable comment) +/// and the answer as editable text. Returns `Some(new_answer)` if the answer was changed, /// `None` if unchanged or the editor was aborted. -pub fn edit_question(question: &str, current_answer: Option<&str>) -> io::Result> { +pub fn edit_answer(question: &str, current_answer: Option<&str>) -> io::Result> { let answer_text = current_answer.unwrap_or(""); let initial = format!( "# Question: {question}\n# Lines starting with # are ignored.\n# Save and quit to submit.\n\n{answer_text}" @@ -74,6 +135,24 @@ pub fn edit_question(question: &str, current_answer: Option<&str>) -> io::Result } } +/// Opens the editor for entering a new feature description. +pub fn edit_new_feature() -> io::Result> { + let initial = "# Enter a description for the new feature.\n# Lines starting with # are ignored.\n# Save and quit to submit. Leave empty to cancel.\n\n"; + match run_editor(initial)? { + Some(s) if s.is_empty() => Ok(None), + other => Ok(other), + } +} + +/// Opens the editor for entering a new child question. +pub fn edit_new_question() -> io::Result> { + let initial = "# Enter a question to add as a child of the selected node.\n# Lines starting with # are ignored.\n# Save and quit to submit. Leave empty to cancel.\n\n"; + match run_editor(initial)? { + Some(s) if s.is_empty() => Ok(None), + other => Ok(other), + } +} + /// Opens the user's default editor with a blank temp file for entering seed content. /// Returns `Some(content)` if the user wrote something, `None` if left empty or aborted. pub fn edit_seed() -> io::Result> { diff --git a/crates/spec-forest-tui/src/error.rs b/crates/spec-forest-tui/src/error.rs index 4cd9414..8bcccc7 100644 --- a/crates/spec-forest-tui/src/error.rs +++ b/crates/spec-forest-tui/src/error.rs @@ -2,7 +2,7 @@ use std::fmt; #[derive(Debug)] pub enum TuiError { - Db(spec_forest_db::Error), + Db(spec_forest::DbError), Api(String), Io(std::io::Error), Editor(String), @@ -21,12 +21,18 @@ impl fmt::Display for TuiError { impl std::error::Error for TuiError {} -impl From for TuiError { - fn from(e: spec_forest_db::Error) -> Self { +impl From for TuiError { + fn from(e: spec_forest::DbError) -> Self { TuiError::Db(e) } } +impl From for TuiError { + fn from(e: spec_forest::ApiError) -> Self { + TuiError::Api(e.to_string()) + } +} + impl From for TuiError { fn from(e: std::io::Error) -> Self { TuiError::Io(e) diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 8511677..ee08cf2 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -1,23 +1,243 @@ -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyModifiers}; use crate::action::Action; use crate::app::Screen; +use crate::simulation::SimInputMode; /// Maps a key event to a semantic action based on the current screen and UI state. /// This is a pure function with no side effects. pub fn map_key( screen: &Screen, key: KeyCode, + modifiers: KeyModifiers, tree_visible: bool, tree_focused: bool, has_sync_url: bool, + log_visible: bool, + log_focused: bool, + config_selected: usize, ) -> Action { match screen { Screen::SpecList => map_spec_list_key(key), - Screen::InputName | Screen::InputFile | Screen::SyncPasswordInput => map_input_key(key), - Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), + Screen::InputName | Screen::SyncPasswordInput => map_input_key(key), + Screen::DirBrowser => map_dir_browser_key(key), + Screen::DepthPicker => map_depth_picker_key(key), + Screen::SpecOptionsPicker => map_spec_options_key(key), + Screen::SpecView { .. } => map_spec_view_key(key, modifiers, tree_visible, tree_focused, log_visible, log_focused), + Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), + Screen::Config => map_config_key(key, config_selected), + Screen::UsernameInput => map_input_key(key), + Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), + Screen::ExploreDepthPicker { .. } => map_depth_picker_key(key), + Screen::SpecMembers { .. } => Action::Noop, // handled by map_spec_members_key + Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key + Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key + Screen::LeanGame { .. } => Action::Noop, // handled by map_lean_game_key + } +} + +/// Maps keys for the lean game screen. Needs modifiers for Shift+Enter. +pub fn map_lean_game_key( + key: KeyCode, + modifiers: KeyModifiers, + in_input_mode: bool, +) -> Action { + if in_input_mode { + return map_lean_input_key(key, modifiers); + } + map_lean_normal_key(key) +} + +fn map_lean_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Up | KeyCode::Char('k') => Action::LeanSelectUp, + KeyCode::Down | KeyCode::Char('j') => Action::LeanSelectDown, + KeyCode::Char('1') => Action::LeanSelectUp, // Select first + KeyCode::Char('2') => Action::LeanSelectDown, // Select second + KeyCode::Enter => Action::LeanConfirm, + KeyCode::Backspace => Action::LeanGoBack, + KeyCode::Char('i') => Action::LeanEnterQuery, + KeyCode::Char('m') => Action::LeanEnterModify, + KeyCode::Char('r') => Action::LeanEnterWarmupRespond, + KeyCode::Char('s') => Action::LeanEnterSendActions, + KeyCode::Char('u') => Action::LeanToggleUpdateLog, + KeyCode::Char('Q') => Action::LeanEnd, + KeyCode::Esc => Action::LeanBackground, + KeyCode::PageUp => Action::LeanScrollUp, + KeyCode::PageDown => Action::LeanScrollDown, + _ => Action::Noop, + } +} + +fn map_lean_input_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::LeanInputCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::LeanInputSubmit, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::LeanInputSubmit, + KeyCode::Backspace => Action::LeanInputBackspace, + KeyCode::Char(c) => Action::LeanInputChar(c), + KeyCode::Enter => Action::LeanInputNewline, + _ => Action::Noop, + } +} + +/// Maps keys for the simulation scenario input screen. Needs modifiers for Shift+Enter. +pub fn map_sim_scenario_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::SimScenarioCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimScenarioConfirm, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => { + Action::SimScenarioConfirm + } + KeyCode::Backspace => Action::SimScenarioBackspace, + KeyCode::Char(c) => Action::SimScenarioChar(c), + KeyCode::Enter => Action::SimScenarioNewline, + _ => Action::Noop, + } +} + +/// Maps keys for the simulation screen. Needs modifiers for Shift+Enter. +pub fn map_sim_key( + key: KeyCode, + modifiers: KeyModifiers, + mode: SimInputMode, + game_mode: bool, + reject_mode: bool, + breadcrumb_focused: bool, +) -> Action { + if reject_mode { + return map_game_reject_key(key, modifiers); + } + if breadcrumb_focused { + return map_breadcrumb_key(key); + } + match mode { + SimInputMode::Normal if game_mode => map_game_normal_key(key), + SimInputMode::Normal => map_sim_normal_key(key), + SimInputMode::Insert => map_sim_insert_key(key, modifiers), + } +} + +fn map_breadcrumb_key(key: KeyCode) -> Action { + match key { + KeyCode::Left | KeyCode::Char('h') => Action::SimBreadcrumbLeft, + KeyCode::Right | KeyCode::Char('l') => Action::SimBreadcrumbRight, + KeyCode::Enter => Action::SimBreadcrumbSelect, + KeyCode::Esc | KeyCode::Char('b') => Action::SimBreadcrumbCancel, + _ => Action::Noop, + } +} + +fn map_sim_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('i') => Action::SimEnterInsert, + KeyCode::Esc => Action::SimBackgroundSimulation, + KeyCode::Char('Q') => Action::SimEndSimulation, + KeyCode::Tab => Action::SimCycleChannel, + KeyCode::F(5) => Action::SimCycleLayout, + KeyCode::Char('r') => Action::SimEnterReport, + KeyCode::Char('S') => Action::SimEditScenario, + // Number keys buffer for multi-digit ref lookup (e.g. "11" for [^11]) + KeyCode::Char(c @ '0'..='9') => Action::SimRefDigit(c), + // Arrow keys navigate predicted interactions, Enter selects + KeyCode::Up => Action::SimInteractionUp, + KeyCode::Down => Action::SimInteractionDown, + KeyCode::Enter => Action::SimConfirmInteraction, + KeyCode::Backspace => Action::SimNavigateBack, + KeyCode::Char('b') => Action::SimBreadcrumbFocus, + _ => Action::Noop, + } +} + +fn map_game_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('i') => Action::SimEnterInsert, + KeyCode::Esc => Action::SimBackgroundSimulation, + KeyCode::Char('Q') => Action::SimEndSimulation, + KeyCode::Tab => Action::SimCycleChannel, + KeyCode::F(5) => Action::SimCycleLayout, + KeyCode::Char('r') => Action::SimEnterReport, + KeyCode::Char('S') => Action::SimEditScenario, + KeyCode::Char(c @ '0'..='9') => Action::SimRefDigit(c), + // Game mode: Up/Down navigate groups, Left/Right navigate outcomes + KeyCode::Up => Action::GameGroupUp, + KeyCode::Down => Action::GameGroupDown, + KeyCode::Left => Action::GameOutcomeLeft, + KeyCode::Right => Action::GameOutcomeRight, + KeyCode::Enter => Action::GameConfirmChoice, + KeyCode::Char('x') => Action::GameRejectOutcome, + KeyCode::Char('u') => Action::GameToggleUpdateLog, + KeyCode::Backspace => Action::SimNavigateBack, + _ => Action::Noop, + } +} + +fn map_game_reject_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::GameRejectCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::GameRejectSubmit, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => { + Action::GameRejectSubmit + } + KeyCode::Backspace => Action::GameRejectBackspace, + KeyCode::Char(c) => Action::GameRejectChar(c), + _ => Action::Noop, + } +} + +fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + use crate::simulation::CapturedKey; + match key { + KeyCode::Esc => Action::SimExitToNormal, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimSubmitInput, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, + KeyCode::Backspace => Action::SimDeleteChar, + KeyCode::Char(c) => Action::SimCaptureKey(CapturedKey::Char(c)), + KeyCode::Enter => Action::SimCaptureKey(CapturedKey::Enter), + KeyCode::Tab => Action::SimCaptureKey(CapturedKey::Tab), + KeyCode::Up => Action::SimCaptureKey(CapturedKey::Up), + KeyCode::Down => Action::SimCaptureKey(CapturedKey::Down), + KeyCode::Left => Action::SimCaptureKey(CapturedKey::Left), + KeyCode::Right => Action::SimCaptureKey(CapturedKey::Right), + KeyCode::Home => Action::SimCaptureKey(CapturedKey::Home), + KeyCode::End => Action::SimCaptureKey(CapturedKey::End), + KeyCode::Delete => Action::SimCaptureKey(CapturedKey::Delete), + KeyCode::Insert => Action::SimCaptureKey(CapturedKey::Insert), + KeyCode::PageUp => Action::SimCaptureKey(CapturedKey::PageUp), + KeyCode::PageDown => Action::SimCaptureKey(CapturedKey::PageDown), + KeyCode::F(n) => Action::SimCaptureKey(CapturedKey::F(n)), + _ => Action::Noop, + } +} + +fn map_sim_channel_picker_key(key: KeyCode) -> Action { + match key { + KeyCode::Up => Action::SimChannelUp, + KeyCode::Down => Action::SimChannelDown, + KeyCode::Char(' ') => Action::SimChannelToggle, + KeyCode::Tab => Action::SimChannelToggleWholeSpec, + KeyCode::BackTab => Action::SimChannelToggleExploreCode, + KeyCode::Char('g') => Action::SimChannelToggleGameMode, + KeyCode::Char('l') => Action::SimChannelToggleLeanMode, + KeyCode::Enter => Action::SimChannelConfirm, + KeyCode::Esc => Action::SimChannelCancel, + _ => Action::Noop, + } +} + +fn map_config_key(key: KeyCode, selected: usize) -> Action { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::NavigateUp, + KeyCode::Down => Action::NavigateDown, + KeyCode::Enter => match selected { + 0 => Action::SetUsername, + 1 => Action::ToggleAutoExplore, + _ => Action::Noop, + }, + _ => Action::Noop, } } @@ -25,9 +245,11 @@ fn map_spec_list_key(key: KeyCode) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Char('c') => Action::OpenCreateSpec, - KeyCode::Char('s') => Action::OpenSeedFromFile, + KeyCode::Char('s') => Action::OpenSeedFromDir, KeyCode::Char('y') => Action::OpenSyncConfig, KeyCode::Char('m') => Action::OpenModelConfig, + KeyCode::Char('g') => Action::OpenConfig, + KeyCode::Char('d') => Action::DeleteSpec, KeyCode::Up => Action::NavigateUp, KeyCode::Down => Action::NavigateDown, KeyCode::Enter => Action::Select, @@ -45,12 +267,56 @@ fn map_input_key(key: KeyCode) -> Action { } } -fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Action { +fn map_dir_browser_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::DirBrowserUp, + KeyCode::Down => Action::DirBrowserDown, + KeyCode::Right | KeyCode::Enter => Action::DirBrowserExpand, + KeyCode::Left => Action::DirBrowserCollapse, + KeyCode::Backspace => Action::DirBrowserGoToParent, + KeyCode::Char('s') => Action::DirBrowserSelect, + KeyCode::Esc => Action::DirBrowserCancel, + _ => Action::Noop, + } +} + +fn map_depth_picker_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::DepthPickerUp, + KeyCode::Down => Action::DepthPickerDown, + KeyCode::Enter => Action::DepthPickerConfirm, + KeyCode::Esc => Action::DepthPickerCancel, + _ => Action::Noop, + } +} + +fn map_spec_options_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::SpecOptionsUp, + KeyCode::Down => Action::SpecOptionsDown, + KeyCode::Enter => Action::SpecOptionsConfirm, + KeyCode::Esc => Action::SpecOptionsCancel, + _ => Action::Noop, + } +} + +fn map_spec_view_key(key: KeyCode, modifiers: KeyModifiers, tree_visible: bool, tree_focused: bool, log_visible: bool, log_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Backspace => Action::GoBack, KeyCode::Char('t') => Action::ToggleTree, - KeyCode::Tab if tree_visible => Action::SwitchFocus, + KeyCode::Char('l') => Action::ToggleLog, + KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::Char('M') => Action::OpenMembers, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::ALT) => Action::RegenerateShadow, + KeyCode::Up if log_focused && log_visible => Action::LogScrollLineUp, + KeyCode::Down if log_focused && log_visible => Action::LogScrollLineDown, + KeyCode::PageUp if log_visible => Action::LogScrollUp, + KeyCode::PageDown if log_visible => Action::LogScrollDown, + KeyCode::Tab if tree_visible || log_visible => Action::SwitchFocus, _ if tree_focused && tree_visible => map_tree_key(key), _ => map_flat_list_key(key), } @@ -66,22 +332,42 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('a') => Action::AiAnswer, KeyCode::Char('x') => Action::ExploreNode, KeyCode::Char('X') => Action::FullExplore, + KeyCode::Char('s') => Action::LaunchSimulation, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, + KeyCode::Char(']') => Action::CandidateNext, + KeyCode::Char('[') => Action::CandidatePrev, + KeyCode::Char('y') => Action::AcceptCandidate, + KeyCode::Char('E') => Action::EditCandidate, + KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('R') => Action::RegenerateFeature, + KeyCode::Char('n') => Action::AddQuestion, + KeyCode::Char('d') => Action::DeleteNode, + KeyCode::Char('S') => Action::GenerateShadow, _ => Action::Noop, } } fn map_flat_list_key(key: KeyCode) -> Action { match key { - KeyCode::Up => Action::NavigateUp, - KeyCode::Down => Action::NavigateDown, + KeyCode::Up => Action::SiblingUp, + KeyCode::Down => Action::SiblingDown, KeyCode::Char('e') => Action::EditNextQuestion, KeyCode::Char('a') => Action::AiAnswer, KeyCode::Char('x') => Action::ExploreNode, KeyCode::Char('X') => Action::FullExplore, + KeyCode::Char('s') => Action::LaunchSimulation, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, + KeyCode::Char(']') => Action::CandidateNext, + KeyCode::Char('[') => Action::CandidatePrev, + KeyCode::Char('E') => Action::EditCandidate, + KeyCode::Char('y') => Action::AcceptCandidate, + KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('R') => Action::RegenerateFeature, + KeyCode::Char('n') => Action::AddQuestion, + KeyCode::Char('d') => Action::DeleteNode, + KeyCode::Char('S') => Action::GenerateShadow, _ => Action::Noop, } } @@ -95,6 +381,39 @@ fn map_sync_config_key(key: KeyCode, has_url: bool) -> Action { } } +fn map_spec_settings_key(key: KeyCode) -> Action { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::NavigateUp, + KeyCode::Down => Action::NavigateDown, + KeyCode::Enter => Action::SetSpecDirectory, + KeyCode::Char('d') => Action::ClearSpecDirectory, + KeyCode::Char('m') => Action::OpenMembers, + _ => Action::Noop, + } +} + +pub fn map_spec_members_key(key: KeyCode, input_active: bool, is_creator: bool) -> Action { + if input_active { + match key { + KeyCode::Esc => Action::MembersDeactivateInput, + KeyCode::Enter => Action::MembersInputSubmit, + KeyCode::Backspace => Action::MembersInputBackspace, + KeyCode::Char(c) => Action::MembersInputChar(c), + _ => Action::Noop, + } + } else { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::MembersUp, + KeyCode::Down => Action::MembersDown, + KeyCode::Char('a') if is_creator => Action::MembersActivateInput, + KeyCode::Char('d') if is_creator => Action::MembersRemoveMember, + _ => Action::Noop, + } + } +} + fn map_model_config_key(key: KeyCode) -> Action { match key { KeyCode::Esc => Action::Cancel, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs new file mode 100644 index 0000000..c169aa2 --- /dev/null +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -0,0 +1,96 @@ +use spec_forest::simulation::{ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel}; +use std::collections::HashMap; + +use crate::simulation::ReportOverlay; + +/// TUI-side state for the lean game screen. +pub struct LeanGameState { + pub session_id: String, + pub spec_id: String, + pub channels: Vec, + pub channel_contents: HashMap, + pub interactions: Vec, + pub selected_interaction: usize, + pub breadcrumbs: Vec<(String, String)>, + pub can_go_back: bool, + pub processing: bool, + pub pregenerating: bool, + pub tick: u64, + // Input modes + pub query_mode: bool, + pub query_input: String, + pub modify_mode: bool, + pub modify_input: String, + pub report_overlay: Option, + pub show_update_log: bool, + pub game_spec_updates: Vec, + pub scroll_offset: usize, + // Send actions + pub send_actions_mode: bool, + pub send_actions_input: String, + pub spec_updating: bool, + pub unsent_action_count: usize, + pub unsent_action_labels: Vec, + pub quit_pending: bool, + // Warmup + pub warmup_active: bool, + pub warmup_scenario_text: Option, + pub warmup_node_question: Option, + pub warmup_generating: bool, + pub warmup_game_ready: bool, + pub warmup_mode: bool, + pub warmup_input: String, +} + +/// View model for a single interaction in the lean game panel. +pub struct LeanInteractionView { + pub label: String, + pub edge_kind: LeanEdgeKind, + pub entropy_hint: f64, + /// True if this edge's target node has only leaf (ungenerated) children. + pub at_frontier: bool, +} + +impl LeanGameState { + pub fn new(session_id: String, spec_id: String, channels: Vec) -> Self { + Self { + session_id, + spec_id, + channels, + channel_contents: HashMap::new(), + interactions: Vec::new(), + selected_interaction: 0, + breadcrumbs: Vec::new(), + can_go_back: false, + processing: false, + pregenerating: false, + tick: 0, + query_mode: false, + query_input: String::new(), + modify_mode: false, + modify_input: String::new(), + report_overlay: None, + show_update_log: false, + game_spec_updates: Vec::new(), + scroll_offset: 0, + send_actions_mode: false, + send_actions_input: String::new(), + spec_updating: false, + unsent_action_count: 0, + unsent_action_labels: Vec::new(), + quit_pending: false, + warmup_active: false, + warmup_scenario_text: None, + warmup_node_question: None, + warmup_generating: false, + warmup_game_ready: false, + warmup_mode: false, + warmup_input: String::new(), + } + } + + /// Whether we're in any text input mode. + pub fn in_input_mode(&self) -> bool { + self.query_mode || self.modify_mode || self.send_actions_mode || self.warmup_mode + } +} diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index 72d457c..751fb71 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -1,8 +1,13 @@ pub mod action; pub mod app; pub mod commands; +pub mod dir_browser; pub mod editor; pub mod error; pub mod input; +pub mod lean_state; +pub mod log_buffer; +pub mod notification; +pub mod simulation; pub mod tree_state; pub mod ui; diff --git a/crates/spec-forest-tui/src/log_buffer.rs b/crates/spec-forest-tui/src/log_buffer.rs new file mode 100644 index 0000000..84e880f --- /dev/null +++ b/crates/spec-forest-tui/src/log_buffer.rs @@ -0,0 +1,132 @@ +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +pub struct LogEntry { + pub timestamp: String, + pub level: Level, + pub message: String, +} + +pub struct LogBuffer { + entries: VecDeque, + capacity: usize, +} + +pub type SharedLogBuffer = Arc>; + +impl LogBuffer { + pub fn new_shared(capacity: usize) -> SharedLogBuffer { + Arc::new(Mutex::new(LogBuffer { + entries: VecDeque::with_capacity(capacity), + capacity, + })) + } + + pub fn push(&mut self, entry: LogEntry) { + if self.entries.len() >= self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn entries(&self) -> &VecDeque { + &self.entries + } + + pub fn len(&self) -> usize { + self.entries.len() + } +} + +pub struct TuiLogLayer { + buffer: SharedLogBuffer, +} + +impl TuiLogLayer { + pub fn new(buffer: SharedLogBuffer) -> Self { + Self { buffer } + } +} + +struct MessageVisitor { + message: String, + fields: Vec<(String, String)>, +} + +impl MessageVisitor { + fn into_message(self) -> String { + if self.fields.is_empty() { + return self.message; + } + let mut out = self.message; + for (k, v) in self.fields { + out.push(' '); + out.push_str(&k); + out.push('='); + out.push_str(&v); + } + out + } +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } else { + self.fields + .push((field.name().to_string(), format!("{:?}", value))); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields + .push((field.name().to_string(), value.to_string())); + } + } +} + +fn format_timestamp() -> String { + let now = SystemTime::now(); + let duration = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let total_secs = duration.as_secs(); + // Get local time offset by using libc localtime + // For simplicity, just format as UTC HH:MM:SS + let secs_of_day = total_secs % 86400; + let hours = secs_of_day / 3600; + let minutes = (secs_of_day % 3600) / 60; + let seconds = secs_of_day % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} + +impl Layer for TuiLogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = MessageVisitor { + message: String::new(), + fields: Vec::new(), + }; + event.record(&mut visitor); + + let entry = LogEntry { + timestamp: format_timestamp(), + level: *event.metadata().level(), + message: visitor.into_message(), + }; + + if let Ok(mut buf) = self.buffer.lock() { + buf.push(entry); + } + } +} diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index db87363..307e9ef 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -9,14 +9,16 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use spec_forest::ServerConfig; use spec_forest_tui::app; +use spec_forest_tui::log_buffer::{LogBuffer, TuiLogLayer}; use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".specdag"); - std::fs::create_dir_all(&dir).expect("could not create ~/.specdag directory"); - dir.join("specdag.db").to_string_lossy().into_owned() + .join(".spec-forest"); + std::fs::create_dir_all(&dir).expect("could not create ~/.spec-forest directory"); + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] @@ -57,12 +59,24 @@ async fn main() -> Result<(), Box> { // Initialize file-based logging (stdout is the TUI) let log_dir = dirs::home_dir() .expect("could not determine home directory") - .join(".specdag"); + .join(".spec-forest"); let file_appender = tracing_appender::rolling::daily(&log_dir, "tui.log"); - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive("spec_forest_tui=info".parse().unwrap())) + + let log_buffer = LogBuffer::new_shared(500); + + let file_layer = tracing_subscriber::fmt::layer() .with_writer(file_appender) - .with_ansi(false) + .with_ansi(false); + + let tui_layer = TuiLogLayer::new(log_buffer.clone()); + + let filter = EnvFilter::from_default_env() + .add_directive("spec_forest_tui=info".parse().unwrap()); + + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(tui_layer) .init(); let host = cli.host.clone(); @@ -72,14 +86,18 @@ async fn main() -> Result<(), Box> { db_path: cli.db_path, sync_url: cli.sync_url, log_prompts: cli.log_prompts, - user_name: cli.user_name, }; let (router, ct, state) = spec_forest::build_server(config).await?; + if let Some(ref name) = cli.user_name { + state.set_user_name(name.clone()); + } + // Spawn HTTP server (REST API + MCP) in background let addr = format!("{host}:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; + state.set_mcp_url(format!("http://{addr}/mcp")); eprintln!("MCP server: http://{addr}/mcp"); eprintln!("REST API: http://{addr}/api/"); @@ -106,7 +124,7 @@ async fn main() -> Result<(), Box> { let mut terminal = Terminal::new(backend)?; // Run TUI - let mut app = app::App::new(state); + let mut app = app::App::new(state, log_buffer); let result = app.run(&mut terminal).await; // Restore terminal diff --git a/crates/spec-forest-tui/src/notification.rs b/crates/spec-forest-tui/src/notification.rs new file mode 100644 index 0000000..f341d73 --- /dev/null +++ b/crates/spec-forest-tui/src/notification.rs @@ -0,0 +1,30 @@ +/// A persistent notification for a background simulation session that has a response ready. +pub struct SimNotification { + pub session_id: String, + pub spec_id: String, + /// Human-readable label (spec name or truncated scenario). + pub label: String, + pub created_at: std::time::Instant, +} + +/// Lightweight tracker for a simulation session running in the background. +pub struct BackgroundSimEntry { + pub session_id: String, + pub spec_id: String, + pub label: String, + /// Whether the session was processing on the last poll (used to detect Idle transitions). + pub was_processing: bool, +} + +/// State for the Ctrl+s session picker overlay. +pub struct SessionPickerState { + pub selected: usize, + pub entries: Vec, +} + +pub struct SessionPickerEntry { + pub session_id: String, + pub spec_id: String, + pub label: String, + pub ready: bool, +} diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs new file mode 100644 index 0000000..dbdf635 --- /dev/null +++ b/crates/spec-forest-tui/src/simulation.rs @@ -0,0 +1,188 @@ +use spec_forest::simulation::{ + ChannelContent, GameChoiceGroup, GameSpecUpdate, PredictedInteraction, SimChannel, +}; +use std::collections::HashMap; + +/// A captured keystroke in simulation insert mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapturedKey { + Char(char), + Enter, + Tab, + Up, + Down, + Left, + Right, + Home, + End, + Delete, + Insert, + PageUp, + PageDown, + F(u8), + MouseClick { column: u16, row: u16 }, +} + +impl CapturedKey { + /// Machine-readable key string for SimInput.keys. + pub fn to_key_string(&self) -> String { + match self { + Self::Char(c) => c.to_string(), + Self::Enter => "{enter}".into(), + Self::Tab => "{tab}".into(), + Self::Up => "{up}".into(), + Self::Down => "{down}".into(), + Self::Left => "{left}".into(), + Self::Right => "{right}".into(), + Self::Home => "{home}".into(), + Self::End => "{end}".into(), + Self::Delete => "{delete}".into(), + Self::Insert => "{insert}".into(), + Self::PageUp => "{pageup}".into(), + Self::PageDown => "{pagedown}".into(), + Self::F(n) => format!("{{f{n}}}"), + Self::MouseClick { column, row } => format!("{{left-click:{column},{row}}}"), + } + } +} + +/// TUI-side state for the simulation screen. +pub struct SimulationState { + pub session_id: String, + pub spec_id: String, + pub channels: Vec, + pub active_channel: usize, + pub layout: SimLayout, + pub captured_keys: Vec, + pub mode: SimInputMode, + pub overlay: Option, + pub report_overlay: Option, + pub report_mode: bool, + pub report_input: String, + pub scenario_mode: bool, + pub scenario_input: String, + pub channel_contents: HashMap, + pub decisions: Vec, + /// Predicted interactions available at the current tree position. + pub interactions: Vec, + pub selected_interaction: usize, + /// Whether the user can navigate back (not at root). + pub can_go_back: bool, + /// Breadcrumb trail: (node_id, label) pairs from root to current position. + pub breadcrumbs: Vec<(String, String)>, + /// When Some, the breadcrumb bar is focused and this is the selected index. + pub breadcrumb_selected: Option, + /// Whether background pregeneration is in progress. + pub pregenerating: bool, + pub ref_digit_buffer: String, + pub ref_digit_start_tick: Option, + pub processing: bool, + pub tick: u64, + // ── Game mode fields ───────────────────────────────────────────── + /// Whether this simulation is in game mode (spec-updating play-through). + pub game_mode: bool, + /// Grouped interaction choices with multiple outcomes each. + pub game_choice_groups: Vec, + /// Index of the currently selected interaction group. + pub selected_group: usize, + /// Index of the currently selected outcome within the group. + pub selected_outcome: usize, + /// Whether the user is typing a rejection correction. + pub reject_mode: bool, + /// Text input for rejection correction. + pub reject_input: String, + /// Log of spec updates triggered during this game session. + pub game_spec_updates: Vec, + /// Whether the spec update log overlay is visible. + pub show_update_log: bool, +} + +impl SimulationState { + pub fn new(session_id: String, spec_id: String, channels: Vec) -> Self { + Self { + session_id, + spec_id, + channels, + active_channel: 0, + layout: SimLayout::Tabs, + captured_keys: Vec::new(), + mode: SimInputMode::Normal, + overlay: None, + report_overlay: None, + report_mode: false, + report_input: String::new(), + scenario_mode: false, + scenario_input: String::new(), + channel_contents: HashMap::new(), + decisions: Vec::new(), + interactions: Vec::new(), + selected_interaction: 0, + can_go_back: false, + breadcrumbs: Vec::new(), + breadcrumb_selected: None, + pregenerating: false, + ref_digit_buffer: String::new(), + ref_digit_start_tick: None, + processing: false, + tick: 0, + game_mode: false, + game_choice_groups: Vec::new(), + selected_group: 0, + selected_outcome: 0, + reject_mode: false, + reject_input: String::new(), + game_spec_updates: Vec::new(), + show_update_log: false, + } + } + + /// Display string for the captured key sequence. + pub fn display_captured_input(&self) -> String { + self.captured_keys + .iter() + .map(|k| k.to_key_string()) + .collect::() + } + + pub fn active_channel_key(&self) -> Option<&str> { + self.channels.get(self.active_channel).map(|c| c.key()) + } + + pub fn cycle_channel(&mut self) { + if !self.channels.is_empty() { + self.active_channel = (self.active_channel + 1) % self.channels.len(); + } + } + + pub fn cycle_layout(&mut self) { + self.layout = match self.layout { + SimLayout::Tabs => SimLayout::SplitH, + SimLayout::SplitH => SimLayout::SplitV, + SimLayout::SplitV => SimLayout::Tabs, + }; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimLayout { + Tabs, + SplitH, + SplitV, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimInputMode { + Normal, + Insert, +} + +pub struct RefOverlay { + pub node_id: String, + pub question: String, + pub answer: Option, +} + +pub struct ReportOverlay { + pub explanation: String, + pub refs: Vec, +} diff --git a/crates/spec-forest-tui/src/tree_state.rs b/crates/spec-forest-tui/src/tree_state.rs index 8c40672..cb24131 100644 --- a/crates/spec-forest-tui/src/tree_state.rs +++ b/crates/spec-forest-tui/src/tree_state.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; -use spec_forest_db::{Database, NodeState}; +use spec_forest::state::AppState; +use spec_forest::{Node, NodeState}; use crate::error::TuiError; @@ -50,9 +51,41 @@ impl TreeState { } } + pub fn select_prev_sibling(&mut self) { + let Some(current) = self.entries.get(self.selected) else { + return; + }; + let depth = current.depth; + for i in (0..self.selected).rev() { + if self.entries[i].depth < depth { + break; + } + if self.entries[i].depth == depth { + self.selected = i; + return; + } + } + } + + pub fn select_next_sibling(&mut self) { + let Some(current) = self.entries.get(self.selected) else { + return; + }; + let depth = current.depth; + for i in (self.selected + 1)..self.entries.len() { + if self.entries[i].depth < depth { + break; + } + if self.entries[i].depth == depth { + self.selected = i; + return; + } + } + } + pub fn expand_selected( &mut self, - db: &Database, + state: &AppState, spec_id: &str, ) -> Result<(), TuiError> { let Some(entry) = self.entries.get(self.selected) else { @@ -65,20 +98,20 @@ impl TreeState { if self.expanded.contains(&node_id) { self.expanded.remove(&node_id); } else { - let children = db.get_children(&node_id)?; + let children = spec_forest::api::get_children(state, &node_id)?; if children.is_empty() { self.known_leaves.insert(node_id); - self.rebuild(db, spec_id)?; + self.rebuild(state, spec_id)?; return Ok(()); } self.expanded.insert(node_id); } - self.rebuild(db, spec_id) + self.rebuild(state, spec_id) } pub fn collapse_selected( &mut self, - db: &Database, + state: &AppState, spec_id: &str, ) -> Result<(), TuiError> { let Some(entry) = self.entries.get(self.selected) else { @@ -89,7 +122,7 @@ impl TreeState { if self.expanded.contains(&node_id) { self.expanded.remove(&node_id); - self.rebuild(db, spec_id)?; + self.rebuild(state, spec_id)?; } else if depth > 0 { // Jump to parent: find the nearest entry above with depth - 1 for i in (0..self.selected).rev() { @@ -102,12 +135,12 @@ impl TreeState { Ok(()) } - pub fn rebuild(&mut self, db: &Database, spec_id: &str) -> Result<(), TuiError> { + pub fn rebuild(&mut self, state: &AppState, spec_id: &str) -> Result<(), TuiError> { self.known_leaves.clear(); self.entries.clear(); - let roots = db.get_roots(spec_id)?; + let roots = spec_forest::api::get_spec_roots(state, spec_id)?; for root in &roots { - self.push_node(db, root, 0)?; + self.push_node(state, root, 0)?; } self.clamp_selection(); Ok(()) @@ -121,8 +154,8 @@ impl TreeState { fn push_node( &mut self, - db: &Database, - node: &spec_forest_db::Node, + state: &AppState, + node: &Node, depth: usize, ) -> Result<(), TuiError> { let has_children = !self.known_leaves.contains(&node.id); @@ -137,7 +170,7 @@ impl TreeState { }); if self.expanded.contains(&node.id) { - let children = db.get_children(&node.id)?; + let children = spec_forest::api::get_children(state, &node.id)?; if children.is_empty() { self.known_leaves.insert(node.id.clone()); if let Some(entry) = self.entries.last_mut() { @@ -145,7 +178,7 @@ impl TreeState { } } for child in &children { - self.push_node(db, child, depth + 1)?; + self.push_node(state, child, depth + 1)?; } } Ok(()) diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 048e797..3cf04d5 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -1,7 +1,21 @@ mod common; +mod config; +mod help_popup; +mod depth_picker; +mod dir_browser; mod input_screen; +mod lean_game; +pub(crate) mod log_panel; mod model_config; +mod notification_bar; +mod session_picker; +mod sim_channel_picker; +mod sim_scenario; +mod simulation; mod spec_list; +mod spec_members; +mod spec_options_picker; +mod spec_settings; mod spec_view; mod sync_config; @@ -13,10 +27,32 @@ pub fn render(app: &App, frame: &mut Frame) { match &app.screen { Screen::SpecList => spec_list::render(app, frame), Screen::InputName => input_screen::render_input(app, frame, "Spec name:"), - Screen::InputFile => input_screen::render_input(app, frame, "File path:"), + Screen::DirBrowser => dir_browser::render(app, frame), + Screen::DepthPicker => depth_picker::render(app, frame), + Screen::SpecOptionsPicker => spec_options_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), + Screen::SpecSettings { .. } => spec_settings::render(app, frame), + Screen::SpecMembers { .. } => spec_members::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), Screen::ModelConfig => model_config::render(app, frame), + Screen::Config => config::render(app, frame), + Screen::UsernameInput => input_screen::render_input(app, frame, "Username:"), + Screen::SimChannelPicker { .. } => sim_channel_picker::render(app, frame), + Screen::SimScenario { .. } => sim_scenario::render(app, frame), + Screen::Simulation { .. } => simulation::render(app, frame), + Screen::ExploreDepthPicker { .. } => depth_picker::render(app, frame), + Screen::LeanGame { .. } => lean_game::render(app, frame), + } + + // Global overlays (drawn last = on top via painter's order) + if notification_bar::should_render(app) { + notification_bar::render(app, frame); + } + if app.session_picker.is_some() { + session_picker::render(app, frame); + } + if app.show_help { + help_popup::render(app, frame); } } diff --git a/crates/spec-forest-tui/src/ui/common.rs b/crates/spec-forest-tui/src/ui/common.rs index a2f1796..5e7b186 100644 --- a/crates/spec-forest-tui/src/ui/common.rs +++ b/crates/spec-forest-tui/src/ui/common.rs @@ -1,5 +1,56 @@ -use ratatui::style::Color; -use spec_forest_db::NodeState; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use spec_forest::NodeState; + +/// Render a footer line with badge-styled keys: ` key ` in dark-gray bg, then description. +pub fn render_footer_line<'a>(items: &[(&str, &str)], sync_label: Option<&str>) -> Line<'a> { + let mut spans: Vec> = Vec::new(); + if let Some(label) = sync_label { + spans.push(Span::styled( + format!(" {label} "), + Style::default().fg(Color::Red), + )); + } + for (i, (key, desc)) in items.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!(" {key} "), + Style::default() + .bg(Color::DarkGray) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(format!(" {desc}"))); + } + Line::from(spans) +} + +/// Build a centered rectangle within `area`. +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vertical[1]); + + horizontal[1] +} pub fn spinner_char(tick: u64) -> char { const FRAMES: &[char] = &[ @@ -9,6 +60,16 @@ pub fn spinner_char(tick: u64) -> char { FRAMES[(tick % FRAMES.len() as u64) as usize] } +pub fn impl_status_icon(status: &str) -> (&'static str, Color) { + match status { + "unimplemented" => ("\u{25CB}", Color::DarkGray), // ○ + "partially_implemented" => ("\u{25D0}", Color::Yellow), // ◐ + "implemented" => ("\u{25CF}", Color::Green), // ● + "diverged" => ("\u{26A1}", Color::Red), // ⚡ + _ => ("?", Color::DarkGray), + } +} + pub fn state_color(state: NodeState) -> Color { match state { NodeState::Unanswered => Color::Red, diff --git a/crates/spec-forest-tui/src/ui/config.rs b/crates/spec-forest-tui/src/ui/config.rs new file mode 100644 index 0000000..e1dab73 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/config.rs @@ -0,0 +1,57 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let username = app.state.user_name(); + let (auto_explore_label, auto_explore_color) = if app.auto_explore { + ("ON", Color::Green) + } else { + ("OFF", Color::Red) + }; + let items: Vec = vec![ + ListItem::new(Line::from(vec![ + Span::raw(" Username: "), + Span::styled(username, Style::default().fg(Color::Cyan)), + ])), + ListItem::new(Line::from(vec![ + Span::raw(" Auto Explore: "), + Span::styled(auto_explore_label, Style::default().fg(auto_explore_color)), + ])), + ]; + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Config ")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.config_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Edit"), ("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/depth_picker.rs b/crates/spec-forest-tui/src/ui/depth_picker.rs new file mode 100644 index 0000000..fe0b3a8 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/depth_picker.rs @@ -0,0 +1,69 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen, DEPTH_OPTIONS}; + +pub fn render(app: &App, frame: &mut Frame) { + let title = if matches!(&app.screen, Screen::ExploreDepthPicker { .. }) { + " Full exploration depth ".to_string() + } else { + let dir_name = app + .depth_picker_dir + .as_ref() + .map(|(_, name)| name.as_str()) + .unwrap_or("directory"); + format!(" Exploration depth for: {dir_name} ") + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let items: Vec = DEPTH_OPTIONS + .iter() + .enumerate() + .map(|(i, (label, desc))| { + let style = if i == app.depth_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled( + format!(" {label} \u{2014} {desc}"), + style, + ))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(title), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.depth_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_line = super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Back"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/dir_browser.rs b/crates/spec-forest-tui/src/ui/dir_browser.rs new file mode 100644 index 0000000..84b9fd0 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/dir_browser.rs @@ -0,0 +1,72 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let Some(ref browser) = app.dir_browser else { + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .split(frame.area()); + + let items: Vec = browser + .entries + .iter() + .map(|entry| { + let indent = " ".repeat(entry.depth); + let icon = if browser.is_expanded(&entry.path) { + "v " + } else { + "> " + }; + ListItem::new(Line::from(format!("{indent}{icon}{}", entry.name))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Seed from directory "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if !browser.entries.is_empty() { + state.select(Some(browser.selected)); + } + frame.render_stateful_widget(list, chunks[0], &mut state); + + let path_text = browser + .selected_path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let path_display = + Paragraph::new(path_text).block(Block::default().borders(Borders::ALL)); + frame.render_widget(path_display, chunks[1]); + + let footer_line = super::common::render_footer_line( + &[("S", "Select"), ("Enter", "Expand"), ("Bksp", "Parent"), ("Esc", "Cancel"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[2]); +} diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs new file mode 100644 index 0000000..3d0aee3 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -0,0 +1,401 @@ +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use crate::app::{App, Screen}; + +pub struct HelpSection { + pub title: &'static str, + pub bindings: Vec<(&'static str, &'static str)>, +} + +fn help_sections(app: &App) -> Vec { + match &app.screen { + Screen::SpecList => vec![ + HelpSection { + title: "Navigation", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Open spec"), + ], + }, + HelpSection { + title: "Actions", + bindings: vec![ + ("c", "Create spec"), + ("d", "Delete spec"), + ("s", "Seed from directory"), + ("m", "Model config"), + ("y", "Sync config"), + ("g", "Global config"), + ], + }, + HelpSection { + title: "General", + bindings: vec![("q", "Quit")], + }, + ], + Screen::SpecView { .. } => { + let mut sections = vec![]; + + sections.push(HelpSection { + title: "Navigation", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Tab", "Switch focus"), + ("Bksp", "Back to spec list"), + ], + }); + + sections.push(HelpSection { + title: "AI Actions", + bindings: vec![ + ("a", "AI answer"), + ("x", "Explore node"), + ("X", "Full explore"), + ("S", "Shadow answers"), + ("Alt+S", "Regen shadow"), + ("s", "Simulate"), + ], + }); + + sections.push(HelpSection { + title: "Edit", + bindings: vec![ + ("e", "Edit node"), + ("f", "Add feature"), + ("R", "Regenerate"), + ("n", "Add question"), + ("d", "Delete node"), + ], + }); + + sections.push(HelpSection { + title: "Panels", + bindings: vec![ + ("t", "Toggle tree"), + ("l", "Toggle log"), + ("g", "Spec settings"), + ("M", "Members"), + ], + }); + + if app.tree_focused && app.tree_visible { + sections.push(HelpSection { + title: "Tree", + bindings: vec![ + ("Left", "Collapse node"), + ("Right/Enter", "Expand node"), + ("K/J", "Sibling up/down"), + ], + }); + } + + if app.log_focused && app.log_visible { + sections.push(HelpSection { + title: "Log", + bindings: vec![ + ("Up/Down", "Scroll line"), + ("PgUp/PgDn", "Scroll page"), + ], + }); + } + + if !app.candidates.is_empty() { + sections.push(HelpSection { + title: "Candidates", + bindings: vec![ + ("[", "Previous candidate"), + ("]", "Next candidate"), + ("y", "Accept candidate"), + ("E", "Edit candidate"), + ], + }); + } + + if app + .explore_status + .as_ref() + .is_some_and(|s| { + matches!( + s.status, + spec_forest::explore::ExploreStatus::Running + | spec_forest::explore::ExploreStatus::Paused + ) + }) + { + sections.push(HelpSection { + title: "Explore", + bindings: vec![("p", "Pause/Resume"), ("c", "Cancel")], + }); + } + + sections.push(HelpSection { + title: "General", + bindings: vec![("q", "Quit")], + }); + + sections + } + Screen::Simulation { .. } => { + let mode = app + .sim_state + .as_ref() + .map(|s| s.mode) + .unwrap_or(crate::simulation::SimInputMode::Normal); + match mode { + crate::simulation::SimInputMode::Normal => vec![ + HelpSection { + title: "Interactions", + bindings: vec![ + ("↑/↓", "Select interaction"), + ("Enter", "Confirm interaction"), + ("Backspace", "Go back"), + ("i", "Custom input"), + ], + }, + HelpSection { + title: "Channels & Layout", + bindings: vec![ + ("Tab", "Cycle channel"), + ("F5", "Cycle layout"), + ], + }, + HelpSection { + title: "Actions", + bindings: vec![ + ("r", "Show report"), + ("S", "Edit scenario"), + ("1-99", "View ref"), + ], + }, + HelpSection { + title: "Session", + bindings: vec![ + ("Esc", "Background simulation"), + ("Q", "End simulation"), + ], + }, + ], + crate::simulation::SimInputMode::Insert => vec![HelpSection { + title: "Insert Mode", + bindings: vec![ + ("Left/Right", "Move cursor"), + ("Home/End", "Jump to start/end"), + ("Ctrl+S", "Send message"), + ("Shift+Enter", "Send message"), + ("Esc", "Back to normal mode"), + ], + }], + } + } + Screen::SimChannelPicker { .. } => vec![HelpSection { + title: "Channel Picker", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Space", "Toggle channel"), + ("Tab", "Toggle whole spec"), + ("Shift+Tab", "Toggle explore code"), + ("Enter", "Start simulation"), + ("Esc", "Cancel"), + ], + }], + Screen::SimScenario { .. } => vec![HelpSection { + title: "Scenario Input", + bindings: vec![ + ("Enter", "New line"), + ("Shift+Enter", "Start simulation"), + ("Ctrl+S", "Start simulation"), + ("Esc", "Back"), + ], + }], + Screen::Config => vec![HelpSection { + title: "Config", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Edit selected"), + ("Esc", "Back"), + ], + }], + Screen::ModelConfig => vec![HelpSection { + title: "Model Config", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm model"), + ("Esc", "Cancel"), + ], + }], + Screen::SpecSettings { .. } => vec![HelpSection { + title: "Spec Settings", + bindings: vec![ + ("Enter", "Change directory"), + ("d", "Clear directory"), + ("m", "Members"), + ("Esc", "Back"), + ], + }], + Screen::SpecMembers { .. } => { + if app.members_input_active { + vec![HelpSection { + title: "Add Member", + bindings: vec![ + ("Enter", "Submit"), + ("Esc", "Cancel"), + ], + }] + } else { + let is_creator = app.state.user_name() == app.members_creator; + let mut bindings = vec![ + ("Up/Down", "Navigate members"), + ]; + if is_creator { + bindings.push(("a", "Add member")); + bindings.push(("d", "Remove member")); + } + bindings.push(("Esc", "Back")); + vec![HelpSection { + title: "Members", + bindings, + }] + } + } + Screen::SyncConfig => { + let has_url = app.state.sync_url().is_some(); + if has_url { + vec![HelpSection { + title: "Sync Config", + bindings: vec![ + ("l", "Login"), + ("r", "Register"), + ("Esc", "Back"), + ], + }] + } else { + vec![HelpSection { + title: "Sync Config", + bindings: vec![("Esc", "Back")], + }] + } + } + Screen::DirBrowser => vec![HelpSection { + title: "Directory Browser", + bindings: vec![ + ("Up/Down", "Navigate"), + ("Enter/Right", "Expand directory"), + ("Left", "Collapse"), + ("Bksp", "Go to parent"), + ("S", "Select directory"), + ("Esc", "Cancel"), + ], + }], + Screen::DepthPicker | Screen::ExploreDepthPicker { .. } => vec![HelpSection { + title: "Depth Picker", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm"), + ("Esc", "Back"), + ], + }], + Screen::SpecOptionsPicker => vec![HelpSection { + title: "Spec Options", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm"), + ("Esc", "Cancel"), + ], + }], + Screen::InputName | Screen::SyncPasswordInput | Screen::UsernameInput => { + vec![HelpSection { + title: "Text Input", + bindings: vec![ + ("Enter", "Submit"), + ("Esc", "Cancel"), + ], + }] + } + Screen::LeanGame { .. } => vec![HelpSection { + title: "Lean Game", + bindings: vec![ + ("Up/Down", "Select interaction"), + ("Enter", "Confirm interaction"), + ("Backspace", "Go back"), + ("i", "Query (ask why)"), + ("m", "Modify output"), + ("u", "Toggle update log"), + ("PgUp/PgDn", "Scroll output"), + ("Q/Esc", "End session"), + ], + }], + } +} + +pub fn render(app: &App, frame: &mut Frame) { + if !app.show_help { + return; + } + + let area = frame.area(); + let sections = help_sections(app); + + // Calculate content height: section titles + bindings + spacing + let content_lines: u16 = sections + .iter() + .map(|s| 1 + s.bindings.len() as u16 + 1) // title + bindings + blank line + .sum::() + + 1; // footer hint + + let popup_width = (area.width * 3 / 5).max(40).min(area.width); + let popup_height = (content_lines + 2) // +2 for border + .max(6) + .min(area.height.saturating_sub(4)); + + let popup_area = super::common::centered_rect(popup_width, popup_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let mut lines: Vec = Vec::new(); + + for section in §ions { + lines.push(Line::from(Span::styled( + format!(" {}", section.title), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + + for (key, desc) in §ion.bindings { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!(" {key} "), + Style::default() + .bg(Color::DarkGray) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {desc}")), + ])); + } + + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + " Press any key to close", + Style::default().fg(Color::DarkGray), + ))); + + let content = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(content, inner); +} diff --git a/crates/spec-forest-tui/src/ui/input_screen.rs b/crates/spec-forest-tui/src/ui/input_screen.rs index 31c7a3f..67eb359 100644 --- a/crates/spec-forest-tui/src/ui/input_screen.rs +++ b/crates/spec-forest-tui/src/ui/input_screen.rs @@ -23,8 +23,11 @@ pub fn render_input(app: &App, frame: &mut Frame, prompt: &str) { let input = Paragraph::new(input_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(input, chunks[1]); - let footer = - Paragraph::new("[Enter] Submit [Esc] Cancel").block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Submit"), ("Esc", "Cancel")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } @@ -46,7 +49,10 @@ pub fn render_password(app: &App, frame: &mut Frame) { let input = Paragraph::new(input_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(input, chunks[1]); - let footer = - Paragraph::new("[Enter] Submit [Esc] Cancel").block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Submit"), ("Esc", "Cancel")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs new file mode 100644 index 0000000..98ffe4b --- /dev/null +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -0,0 +1,499 @@ +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; +use spec_forest::simulation::LeanEdgeKind; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let lean = match &app.lean_state { + Some(s) => s, + None => { + let msg = Paragraph::new("No lean game session active.") + .block(Block::default().borders(Borders::ALL).title(" Lean Game ")); + frame.render_widget(msg, frame.area()); + return; + } + }; + + let area = frame.area(); + + // Calculate interaction panel height based on number of interactions. + let interaction_lines = lean.interactions.len().max(2) as u16 + 2; // +2 for borders + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Breadcrumbs + Constraint::Min(5), // Output area + Constraint::Length(interaction_lines), // Interactions + Constraint::Length(1), // Status bar + ]) + .split(area); + + // ── Breadcrumbs ───────────────────────────────────────────────── + render_breadcrumbs(app, frame, chunks[0]); + + // ── Output area ───────────────────────────────────────────────── + render_output(app, frame, chunks[1]); + + // ── Interactions ──────────────────────────────────────────────── + render_interactions(app, frame, chunks[2]); + + // ── Status bar ────────────────────────────────────────────────── + render_status_bar(app, frame, chunks[3]); + + // ── Overlays ──────────────────────────────────────────────────── + if lean.query_mode || lean.modify_mode || lean.send_actions_mode || lean.warmup_mode { + render_input_overlay(app, frame); + } + if lean.report_overlay.is_some() { + render_report_overlay(app, frame); + } + if lean.show_update_log { + render_update_log_overlay(app, frame); + } +} + +fn render_breadcrumbs(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + let mut spans = vec![Span::styled( + " Lean Game ", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )]; + + for (i, (_, label)) in lean.breadcrumbs.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" > ", Style::default().fg(Color::DarkGray))); + } + let style = if i == lean.breadcrumbs.len() - 1 { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(label.clone(), style)); + } + + let paragraph = Paragraph::new(Line::from(spans)) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(paragraph, area); +} + +fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + // Build combined output from all channels. + let mut lines: Vec = Vec::new(); + + // If warmup is active, show warmup content instead of channels. + if lean.warmup_active { + if let Some(ref scenario) = lean.warmup_scenario_text { + lines.push(Line::from("")); + for line in scenario.lines() { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(Color::White), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Press 'r' to respond", + Style::default().fg(Color::Yellow), + ))); + if lean.warmup_game_ready { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Game ready! Respond or wait for auto-transition.", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))); + } + } else if lean.warmup_generating { + lines.push(Line::from(Span::styled( + " Preparing warmup scenario...", + Style::default().fg(Color::Yellow), + ))); + } else { + lines.push(Line::from(Span::styled( + " Starting warmup...", + Style::default().fg(Color::DarkGray), + ))); + } + } else { + // UI channel gets primary display. + if let Some(content) = lean.channel_contents.get("ui") { + for line in content.text.lines() { + lines.push(Line::from(line.to_string())); + } + } + + // Other channels rendered below with prefixes. + for (key, content) in &lean.channel_contents { + if key == "ui" || content.text.is_empty() { + continue; + } + lines.push(Line::from("")); + for line in content.text.lines() { + let prefix = match key.as_str() { + "network" => "[NET] ", + "audio" => "[AUD] ", + "errors" => "[ERR] ", + "logs" => "[LOG] ", + _ => "", + }; + let style = match key.as_str() { + "errors" => Style::default().fg(Color::Red), + "network" => Style::default().fg(Color::Blue), + "audio" => Style::default().fg(Color::Magenta), + "logs" => Style::default().fg(Color::DarkGray), + _ => Style::default(), + }; + lines.push(Line::from(Span::styled( + format!("{prefix}{line}"), + style, + ))); + } + } + } + + let title = if lean.warmup_active { + " Warmup (game loading...) " + } else if lean.processing { + " Output (generating...) " + } else if lean.spec_updating { + " Output (updating spec...) " + } else { + " Output " + }; + + let border_color = if lean.warmup_active { + Color::Green + } else if lean.processing { + Color::Yellow + } else if lean.spec_updating { + Color::Magenta + } else { + Color::Cyan + }; + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)), + ) + .wrap(Wrap { trim: false }) + .scroll((lean.scroll_offset as u16, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + let mut lines: Vec = Vec::new(); + + if lean.interactions.is_empty() { + if lean.warmup_active { + lines.push(Line::from(Span::styled( + " Game loading... explore warmup scenarios above", + Style::default().fg(Color::Green), + ))); + } else if lean.processing { + lines.push(Line::from(Span::styled( + " Generating interactions...", + Style::default().fg(Color::Yellow), + ))); + } else { + lines.push(Line::from(Span::styled( + " No interactions available", + Style::default().fg(Color::DarkGray), + ))); + } + } else { + for (i, interaction) in lean.interactions.iter().enumerate() { + let is_selected = i == lean.selected_interaction; + + let marker = if is_selected { "► " } else { " " }; + + // Edge kind indicator. + let (kind_symbol, kind_color) = match interaction.edge_kind { + LeanEdgeKind::Generative if interaction.at_frontier => ("◐", Color::Yellow), + LeanEdgeKind::Generative => ("●", Color::Green), + LeanEdgeKind::Leaf => ("○", Color::Yellow), + LeanEdgeKind::Shortcut => ("↩", Color::DarkGray), + }; + + // Entropy-based label coloring. + let label_color = if interaction.entropy_hint > 0.7 { + Color::Yellow // High entropy = interesting + } else if is_selected { + Color::Cyan + } else { + Color::White + }; + + let label_style = if is_selected { + Style::default().fg(label_color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(label_color) + }; + + lines.push(Line::from(vec![ + Span::styled( + format!("{marker}{}. ", i + 1), + if is_selected { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }, + ), + Span::styled(interaction.label.clone(), label_style), + Span::raw(" "), + Span::styled(kind_symbol, Style::default().fg(kind_color)), + ])); + } + } + + let pregen_indicator = if lean.pregenerating { " ⟳" } else { "" }; + let title = format!(" Interactions{pregen_indicator} "); + + let paragraph = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(paragraph, area); +} + +fn render_status_bar(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { + let lean = app.lean_state.as_ref().unwrap(); + + // Show quit warning if pending. + if lean.quit_pending { + let warning = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" Q again to quit ({} unsent actions) ", lean.unsent_action_count), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(warning, area); + return; + } + + let mut items: Vec<(&str, String)> = if lean.warmup_active { + let mut v = vec![("r", "respond".into())]; + if lean.warmup_game_ready { + v.push(("", "game ready!".into())); + } else { + v.push(("", "game loading...".into())); + } + v + } else { + vec![ + ("↑↓", "select".into()), + ("Enter", "go".into()), + ("Bksp", "back".into()), + ("i", "query".into()), + ("m", "modify".into()), + ] + }; + + if lean.unsent_action_count > 0 { + items.push(("s", format!("send({})", lean.unsent_action_count))); + } + + if lean.spec_updating { + items.push(("", "updating spec...".into())); + } + + items.push(("u", "updates".into())); + items.push(("Q", "quit".into())); + + let spans: Vec = items + .iter() + .enumerate() + .flat_map(|(i, (key, desc))| { + let mut v = Vec::new(); + if !key.is_empty() { + v.push(Span::styled( + format!(" {key}"), + Style::default().fg(Color::Yellow), + )); + } + v.push(Span::styled( + format!(" {desc}"), + if *key == "" { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }, + )); + if i < items.len() - 1 { + v.push(Span::styled(" │", Style::default().fg(Color::DarkGray))); + } + v + }) + .collect(); + + let paragraph = Paragraph::new(Line::from(spans)); + frame.render_widget(paragraph, area); +} + +fn render_input_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + let area = frame.area(); + + if lean.send_actions_mode { + // Send actions overlay: show action list + notes input. + let action_lines = lean.unsent_action_labels.len() as u16; + // 2 for border + 1 header + actions + 1 blank + 3 for notes input area + let overlay_height = (4 + action_lines + 3).min(area.height.saturating_sub(2)); + let overlay_area = ratatui::layout::Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(overlay_height + 1), + width: area.width.saturating_sub(2), + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + format!("Actions to send ({}):", lean.unsent_action_labels.len()), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ))); + for (i, label) in lean.unsent_action_labels.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!(" {}. ", i + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(label, Style::default().fg(Color::White)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Notes (optional):", + Style::default().fg(Color::Gray), + ))); + lines.push(Line::from(if lean.send_actions_input.is_empty() { + Span::styled("(type to add notes)", Style::default().fg(Color::DarkGray)) + } else { + Span::raw(&lean.send_actions_input) + })); + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Send Actions (Ctrl+S to submit, Esc to cancel) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); + } else { + // Query, modify, or warmup respond overlay. + let overlay_height = 5; + let overlay_area = ratatui::layout::Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(overlay_height + 1), + width: area.width.saturating_sub(2), + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let (title, input) = if lean.warmup_mode { + (" Warmup Response (Ctrl+S to submit, Esc to cancel) ", &lean.warmup_input) + } else if lean.query_mode { + (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) + } else { + (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + }; + + let paragraph = Paragraph::new(input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); + } +} + +fn render_report_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + let report = match &lean.report_overlay { + Some(r) => r, + None => return, + }; + + let area = frame.area(); + let overlay = super::common::centered_rect( + area.width.saturating_sub(4).min(80), + area.height.saturating_sub(4).min(20), + area, + ); + + frame.render_widget(Clear, overlay); + + let paragraph = Paragraph::new(report.explanation.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Report (any key to close) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay); +} + +fn render_update_log_overlay(app: &App, frame: &mut Frame) { + let lean = app.lean_state.as_ref().unwrap(); + + let area = frame.area(); + let overlay = super::common::centered_rect( + area.width.saturating_sub(4).min(80), + area.height.saturating_sub(4).min(20), + area, + ); + + frame.render_widget(Clear, overlay); + + let mut lines: Vec = Vec::new(); + if lean.game_spec_updates.is_empty() { + lines.push(Line::from(Span::styled( + "No spec updates yet.", + Style::default().fg(Color::DarkGray), + ))); + } else { + for (i, update) in lean.game_spec_updates.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(&update.description, Style::default().fg(Color::White)), + ])); + if !update.node_id.is_empty() { + lines.push(Line::from(Span::styled( + format!(" Node: {}", update.node_id), + Style::default().fg(Color::DarkGray), + ))); + } + } + } + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Spec Updates (u to close) ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay); +} diff --git a/crates/spec-forest-tui/src/ui/log_panel.rs b/crates/spec-forest-tui/src/ui/log_panel.rs new file mode 100644 index 0000000..dd013cc --- /dev/null +++ b/crates/spec-forest-tui/src/ui/log_panel.rs @@ -0,0 +1,59 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use tracing::Level; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame, area: Rect) { + let border_style = if app.log_focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let block = Block::default().borders(Borders::ALL).title(" Logs ").border_style(border_style); + let inner_height = area.height.saturating_sub(2) as usize; + + let buf = app.log_buffer.lock().unwrap(); + let entries = buf.entries(); + let total = entries.len(); + + // log_scroll_offset 0 = show most recent at bottom + let end = total.saturating_sub(app.log_scroll_offset); + let start = end.saturating_sub(inner_height); + + let lines: Vec = entries + .range(start..end) + .map(|entry| { + let level_color = match entry.level { + Level::ERROR => Color::Red, + Level::WARN => Color::Yellow, + Level::INFO => Color::Green, + Level::DEBUG => Color::Cyan, + Level::TRACE => Color::DarkGray, + }; + let level_str = match entry.level { + Level::ERROR => "ERROR", + Level::WARN => " WARN", + Level::INFO => " INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + }; + Line::from(vec![ + Span::styled( + format!("{} ", entry.timestamp), + Style::default().fg(Color::DarkGray), + ), + Span::styled(format!("{level_str} "), Style::default().fg(level_color)), + Span::raw(&entry.message), + ]) + }) + .collect(); + + let content = Paragraph::new(lines).block(block); + frame.render_widget(content, area); +} diff --git a/crates/spec-forest-tui/src/ui/model_config.rs b/crates/spec-forest-tui/src/ui/model_config.rs index 3dce837..eda1333 100644 --- a/crates/spec-forest-tui/src/ui/model_config.rs +++ b/crates/spec-forest-tui/src/ui/model_config.rs @@ -50,10 +50,14 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.model_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer_text = app - .message - .as_deref() - .unwrap_or("[Up/Down] Select [Enter] Confirm [Esc] Cancel"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Cancel"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/notification_bar.rs b/crates/spec-forest-tui/src/ui/notification_bar.rs new file mode 100644 index 0000000..8fe3df6 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/notification_bar.rs @@ -0,0 +1,100 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::App; + +/// Returns true when the notification bar should be rendered. +pub fn should_render(app: &App) -> bool { + !app.background_sims.is_empty() || !app.sim_notifications.is_empty() +} + +/// Render a 1-line notification bar at the bottom of the screen. +/// Shows background sim count + any ready notifications. Drawn last (painter's order). +pub fn render(app: &App, frame: &mut Frame) { + let area = frame.area(); + let bar_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(1), + width: area.width, + height: 1, + }; + + let mut spans = Vec::new(); + + let ready_count = app.sim_notifications.len(); + let total_bg = app.background_sims.len(); + + if ready_count > 0 { + // Highlighted notification for ready sessions + let notif_style = Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD); + + let first = &app.sim_notifications[0]; + let label = truncate_label(&first.label, 30); + + if ready_count == 1 { + spans.push(Span::styled( + format!(" [SIM] \"{label}\" ready "), + notif_style, + )); + } else { + spans.push(Span::styled( + format!(" [SIM] \"{label}\" ready [+{} more] ", ready_count - 1), + notif_style, + )); + } + + spans.push(Span::raw(" ")); + } + + // Always show background sim count with Ctrl+s hint + let info_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let hint_style = Style::default().fg(Color::DarkGray); + + let processing = total_bg.saturating_sub(ready_count); + if total_bg > 0 { + let mut parts = Vec::new(); + if ready_count > 0 { + parts.push(format!("{ready_count} ready")); + } + if processing > 0 { + parts.push(format!("{processing} running")); + } + let summary = parts.join(", "); + spans.push(Span::styled( + format!("[{total_bg} sim{}] ", if total_bg == 1 { "" } else { "s" }), + info_style, + )); + spans.push(Span::styled(summary, hint_style)); + spans.push(Span::styled(" │ Ctrl+s switch", hint_style)); + } + + let bg_style = if ready_count > 0 { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + let line = Line::from(spans); + let bar = Paragraph::new(line).style(bg_style); + frame.render_widget(bar, bar_area); +} + +fn truncate_label(label: &str, max_len: usize) -> String { + if label.len() <= max_len { + label.to_string() + } else if max_len > 3 { + format!("{}...", &label[..max_len - 3]) + } else { + label[..max_len].to_string() + } +} diff --git a/crates/spec-forest-tui/src/ui/session_picker.rs b/crates/spec-forest-tui/src/ui/session_picker.rs new file mode 100644 index 0000000..24bb781 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/session_picker.rs @@ -0,0 +1,81 @@ +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem}, + Frame, +}; + +use crate::app::App; + +/// Render a centered popup overlay listing backgrounded simulation sessions. +pub fn render(app: &App, frame: &mut Frame) { + let picker = match &app.session_picker { + Some(p) => p, + None => return, + }; + + let area = frame.area(); + + // Size the popup: ~60% width, height fits entries + 2 (border) + let popup_width = (area.width * 3 / 5).max(30).min(area.width); + let popup_height = ((picker.entries.len() as u16) + 2) + .max(4) + .min(area.height); + + let popup_area = super::common::centered_rect(popup_width, popup_height, area); + + // Clear the background + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Sessions (Ctrl+s) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let items: Vec = picker + .entries + .iter() + .enumerate() + .map(|(i, entry)| { + let (icon, icon_color) = if entry.ready { + ("✓", Color::Green) + } else { + ("●", Color::Yellow) + }; + let status_text = if entry.ready { "ready" } else { "processing" }; + + let style = if i == picker.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let line = Line::from(vec![ + Span::styled(format!(" {icon} "), Style::default().fg(icon_color)), + Span::styled( + truncate_label(&entry.label, popup_area.width.saturating_sub(20) as usize), + style, + ), + Span::styled(format!(" ({status_text})"), style.fg(Color::DarkGray)), + ]); + ListItem::new(line).style(style) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, popup_area); +} + +fn truncate_label(label: &str, max_len: usize) -> String { + if label.len() <= max_len { + label.to_string() + } else if max_len > 3 { + format!("{}...", &label[..max_len - 3]) + } else { + label[..max_len].to_string() + } +} + diff --git a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs new file mode 100644 index 0000000..a3d90e6 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -0,0 +1,155 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; + +use spec_forest::simulation::SimChannel; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // channel list + Constraint::Length(3), // whole spec toggle + Constraint::Length(3), // explore code toggle + Constraint::Length(3), // game mode toggle + Constraint::Length(3), // lean mode toggle + Constraint::Length(3), // footer + ]) + .split(frame.area()); + + let items: Vec = SimChannel::ALL + .iter() + .enumerate() + .map(|(i, ch)| { + let selected = app.sim_channel_selection.contains(&i); + let checkbox = if selected { "[x]" } else { "[ ]" }; + let style = if i == app.sim_channel_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if selected { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled( + format!(" {checkbox} {ch}"), + style, + ))) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Select Simulation Channels (Space to toggle, Enter to start) "); + + let list = List::new(items).block(block); + frame.render_widget(list, chunks[0]); + + // Whole spec toggle + let whole_spec_checkbox = if app.sim_consume_whole_spec { + "[x]" + } else { + "[ ]" + }; + let whole_spec_style = if app.sim_consume_whole_spec { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let whole_spec = Paragraph::new(Line::from(Span::styled( + format!(" {whole_spec_checkbox} Consume Whole Spec — include all spec nodes in system prompt"), + whole_spec_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(whole_spec, chunks[1]); + + // Explore code toggle + let has_directory = if let Screen::SimChannelPicker { ref spec_id, .. } = app.screen { + app.specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_ref()) + .is_some() + } else { + false + }; + + let (explore_checkbox, explore_label, explore_style) = if !has_directory { + ( + "[ ]", + "Explore Code — requires directory to be set", + Style::default().fg(Color::DarkGray), + ) + } else if app.sim_explore_code { + ( + "[x]", + "Explore Code — let agent read the actual codebase", + Style::default().fg(Color::Green), + ) + } else { + ( + "[ ]", + "Explore Code — let agent read the actual codebase", + Style::default(), + ) + }; + let explore_code = Paragraph::new(Line::from(Span::styled( + format!(" {explore_checkbox} {explore_label}"), + explore_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(explore_code, chunks[2]); + + // Game mode toggle + let game_checkbox = if app.sim_game_mode { "[x]" } else { "[ ]" }; + let game_style = if app.sim_game_mode { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let game_mode = Paragraph::new(Line::from(Span::styled( + format!(" {game_checkbox} Game Mode — choices update the spec as you play"), + game_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(game_mode, chunks[3]); + + // Lean mode toggle + let lean_checkbox = if app.sim_lean_mode { "[x]" } else { "[ ]" }; + let lean_style = if app.sim_lean_mode { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let lean_mode = Paragraph::new(Line::from(Span::styled( + format!(" {lean_checkbox} Lean Game — binary choices, batch generation, low latency"), + lean_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(lean_mode, chunks[4]); + + let selected_count = app.sim_channel_selection.len(); + let mut footer_spans = vec![ + Span::styled( + format!(" {selected_count} selected "), + Style::default().fg(Color::Cyan), + ), + ]; + let badge_line = super::common::render_footer_line( + &[("Space", "Toggle"), ("g", "Game"), ("l", "Lean"), ("Enter", "Start"), ("Esc", "Cancel")], + None, + ); + footer_spans.extend(badge_line.spans); + let footer = Paragraph::new(Line::from(footer_spans)) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(footer, chunks[5]); +} diff --git a/crates/spec-forest-tui/src/ui/sim_scenario.rs b/crates/spec-forest-tui/src/ui/sim_scenario.rs new file mode 100644 index 0000000..728b7b4 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/sim_scenario.rs @@ -0,0 +1,47 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // title + Constraint::Min(5), // text area + Constraint::Length(3), // footer + ]) + .split(frame.area()); + + let title = Paragraph::new(Line::from(vec![Span::styled( + " Describe the simulation scenario (optional) ", + Style::default().fg(Color::Cyan), + )])) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(title, chunks[0]); + + let text = if app.sim_scenario_input.is_empty() { + Paragraph::new(Line::from(Span::styled( + "e.g. \"User is logged in, other nodes are sending ACK messages every 2s\"", + Style::default().fg(Color::DarkGray), + ))) + } else { + Paragraph::new(app.sim_scenario_input.as_str()) + }; + let text = text + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(" Scenario ")); + frame.render_widget(text, chunks[1]); + + let footer_line = super::common::render_footer_line( + &[("Shift+Enter", "Start"), ("Esc", "Back")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[2]); +} diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs new file mode 100644 index 0000000..4b2f1a3 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -0,0 +1,860 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +use crate::app::App; +use crate::simulation::{SimInputMode, SimLayout}; +use super::common::spinner_char; + +pub fn render(app: &App, frame: &mut Frame) { + let sim = match &app.sim_state { + Some(s) => s, + None => return, + }; + + let has_decisions = !sim.decisions.is_empty(); + let decisions_height = if has_decisions { + // 1 line per decision + extra for spec_gaps, capped at 8 content lines + 2 border + let content_lines: usize = sim + .decisions + .iter() + .map(|d| 1 + d.spec_gaps.len()) + .sum(); + (content_lines.min(8) + 2) as u16 + } else { + 0 + }; + + let has_interactions = if sim.game_mode { + !sim.game_choice_groups.is_empty() + } else { + !sim.interactions.is_empty() + }; + let interactions_height = if !has_interactions { + 0 + } else if sim.game_mode { + // Game mode: each group has 1 label line + outcomes, capped + let content_lines: usize = sim + .game_choice_groups + .iter() + .map(|g| 1 + g.outcomes.len()) + .sum(); + (content_lines.min(12) + 2) as u16 + } else { + // 1 line per interaction + 2 for border, capped at 6 + 2 + (sim.interactions.len().min(6) + 2) as u16 + }; + + let has_breadcrumbs = sim.breadcrumbs.len() > 1; + + let mut constraints = vec![ + Constraint::Length(1), // tab bar + ]; + if has_breadcrumbs { + constraints.push(Constraint::Length(1)); // breadcrumb bar + } + constraints.push(Constraint::Min(3)); // channel content + if has_decisions { + constraints.push(Constraint::Length(decisions_height)); // decisions panel + } + if has_interactions { + constraints.push(Constraint::Length(interactions_height)); // interactions panel + } + constraints.push(Constraint::Length(3)); // input area + constraints.push(Constraint::Length(1)); // status bar + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.area()); + + let mut idx = 0; + render_tab_bar(app, frame, chunks[idx]); + idx += 1; + if has_breadcrumbs { + render_breadcrumb_bar(sim, frame, chunks[idx]); + idx += 1; + } + // Store channel content area for mouse click coordinate mapping + app.sim_content_area.set(Some(chunks[idx])); + render_channel_content(app, frame, chunks[idx]); + idx += 1; + if has_decisions { + render_decisions_panel(sim, frame, chunks[idx]); + idx += 1; + } + if has_interactions { + if sim.game_mode { + render_game_choices_panel(sim, frame, chunks[idx]); + } else { + render_interactions_panel(sim, frame, chunks[idx]); + } + idx += 1; + } + render_input_area(app, frame, chunks[idx]); + idx += 1; + render_status_bar(app, frame, chunks[idx]); + + // Render overlay on top if present + if let Some(ref overlay) = sim.overlay { + render_ref_overlay(overlay, frame, frame.area()); + } + + // Render report overlay on top if present + if let Some(ref report) = sim.report_overlay { + render_report_overlay(report, frame, frame.area()); + } + + // Game mode overlays + if sim.reject_mode { + render_reject_overlay(sim, frame, frame.area()); + } + if sim.show_update_log { + render_update_log_overlay(sim, frame, frame.area()); + } +} + +fn render_tab_bar(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + let tabs: Vec = sim + .channels + .iter() + .enumerate() + .flat_map(|(i, ch)| { + let style = if i == sim.active_channel { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + let num = format!(" {}:{} ", i + 1, ch); + vec![Span::styled(num, style), Span::raw(" ")] + }) + .collect(); + + let layout_indicator = match sim.layout { + SimLayout::Tabs => "[Tabs]", + SimLayout::SplitH => "[Split-H]", + SimLayout::SplitV => "[Split-V]", + }; + + let mut line_spans = tabs; + line_spans.push(Span::styled( + format!(" {layout_indicator}"), + Style::default().fg(Color::DarkGray), + )); + + frame.render_widget(Paragraph::new(Line::from(line_spans)), area); +} + +fn render_breadcrumb_bar( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let focused = sim.breadcrumb_selected.is_some(); + let width = area.width as usize; + let sep = " > "; + + // Build the full breadcrumb string to check if it fits. + let full_len: usize = sim + .breadcrumbs + .iter() + .map(|(_, l)| l.len()) + .sum::() + + sep.len() * sim.breadcrumbs.len().saturating_sub(1); + + // Determine which breadcrumbs to show. If the full trail fits, show all. + // Otherwise, show "Start", ellipsis, and the last entries that fit. + let (entries, offset): (Vec<(usize, &str)>, usize) = if full_len <= width || sim.breadcrumbs.len() <= 3 { + ( + sim.breadcrumbs.iter().enumerate().map(|(i, (_, l))| (i, l.as_str())).collect(), + 0, + ) + } else { + // Always show first ("Start") + " > … > " + as many trailing as fit + let prefix_cost = sim.breadcrumbs[0].1.len() + " > … > ".len(); + let remaining = width.saturating_sub(prefix_cost); + let mut count = 0; + let mut used = 0; + for (_, label) in sim.breadcrumbs.iter().rev() { + let cost = label.len() + if count > 0 { sep.len() } else { 0 }; + if used + cost > remaining && count > 0 { + break; + } + used += cost; + count += 1; + } + let start = sim.breadcrumbs.len() - count; + let mut entries: Vec<(usize, &str)> = Vec::with_capacity(count + 2); + entries.push((0, sim.breadcrumbs[0].1.as_str())); + entries.push((usize::MAX, "…")); // sentinel for ellipsis + for i in start..sim.breadcrumbs.len() { + entries.push((i, sim.breadcrumbs[i].1.as_str())); + } + (entries, 0) + }; + let _ = offset; + + let dim = Style::default().fg(Color::DarkGray); + let normal = Style::default().fg(Color::Gray); + let selected_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let sep_style = if focused { normal } else { dim }; + + let mut spans = Vec::new(); + for (i, (real_idx, label)) in entries.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(sep, sep_style)); + } + let style = if *real_idx == usize::MAX { + // Ellipsis marker + dim + } else if focused && sim.breadcrumb_selected == Some(*real_idx) { + selected_style + } else if focused { + normal + } else { + dim + }; + spans.push(Span::styled(label.to_string(), style)); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_channel_content(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + match sim.layout { + SimLayout::Tabs => { + // Show only the active channel + if let Some(key) = sim.active_channel_key() { + render_single_channel(sim, key, frame, area); + } + } + SimLayout::SplitH => { + if sim.channels.is_empty() { + return; + } + let constraints: Vec = sim + .channels + .iter() + .map(|_| Constraint::Ratio(1, sim.channels.len() as u32)) + .collect(); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(area); + for (i, ch) in sim.channels.iter().enumerate() { + render_single_channel(sim, ch.key(), frame, chunks[i]); + } + } + SimLayout::SplitV => { + if sim.channels.is_empty() { + return; + } + let constraints: Vec = sim + .channels + .iter() + .map(|_| Constraint::Ratio(1, sim.channels.len() as u32)) + .collect(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + for (i, ch) in sim.channels.iter().enumerate() { + render_single_channel(sim, ch.key(), frame, chunks[i]); + } + } + } +} + +fn render_single_channel( + sim: &crate::simulation::SimulationState, + channel_key: &str, + frame: &mut Frame, + area: Rect, +) { + let channel_content = sim.channel_contents.get(channel_key); + let content = channel_content.map(|c| c.text.as_str()).unwrap_or(""); + + // Show loading state when processing and no content yet + let mut lines = if content.is_empty() && sim.processing { + vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {} Initializing simulation...", spinner_char(sim.tick)), + Style::default().fg(Color::Cyan), + )), + ] + } else { + render_text_with_refs(content) + }; + + // Show spec gap warnings if present + if let Some(content) = channel_content { + for gap in &content.spec_gaps { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" SPEC GAP: {gap}"), + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", channel_key)); + + let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +/// Parse text content and highlight [^N] reference markers. +fn render_text_with_refs(text: &str) -> Vec> { + let mut lines = Vec::new(); + + for line_str in text.lines() { + let mut spans = Vec::new(); + let mut remaining = line_str; + + while let Some(start) = remaining.find("[^") { + // Add text before the marker + if start > 0 { + spans.push(Span::raw(remaining[..start].to_string())); + } + + // Find the end of the marker + if let Some(end) = remaining[start..].find(']') { + let marker = &remaining[start..start + end + 1]; + spans.push(Span::styled( + marker.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + remaining = &remaining[start + end + 1..]; + } else { + spans.push(Span::raw(remaining[start..].to_string())); + remaining = ""; + break; + } + } + + if !remaining.is_empty() { + spans.push(Span::raw(remaining.to_string())); + } + + lines.push(Line::from(spans)); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "(no content yet)", + Style::default().fg(Color::DarkGray), + ))); + } + + lines +} + +fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + let (border_color, title) = match sim.mode { + SimInputMode::Normal => (Color::Gray, " Input [i to type] "), + SimInputMode::Insert => (Color::Green, " INSERT (Ctrl+S or Shift+Enter to send, Esc to exit) "), + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(title); + + let display_text = if sim.report_mode { + format!("[Report] {}", sim.report_input) + } else if sim.scenario_mode { + format!("[Scenario] {}", sim.scenario_input) + } else if sim.captured_keys.is_empty() && sim.mode == SimInputMode::Normal { + String::new() + } else { + sim.display_captured_input() + }; + + let paragraph = Paragraph::new(display_text.clone()) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); + + // Show cursor in insert mode (always at end) + if sim.mode == SimInputMode::Insert && !sim.report_mode && !sim.scenario_mode { + let inner = area.inner(ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + let width = inner.width as usize; + let mut row: u16 = 0; + let mut col: u16 = 0; + if width > 0 { + for _ch in display_text.chars() { + col += 1; + if col >= inner.width { + row += 1; + col = 0; + } + } + } + frame.set_cursor_position((inner.x + col, inner.y + row)); + } +} + +fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + let mut spans = Vec::new(); + + if sim.processing { + spans.push(Span::styled( + format!(" {} thinking... ", spinner_char(sim.tick)), + Style::default().fg(Color::Cyan), + )); + spans.push(Span::raw(" ")); + } + + let mode_str = match sim.mode { + SimInputMode::Normal => "NORMAL", + SimInputMode::Insert => "INSERT", + }; + spans.push(Span::styled( + format!(" {mode_str} "), + Style::default().fg(Color::Black).bg(match sim.mode { + SimInputMode::Normal => Color::Blue, + SimInputMode::Insert => Color::Green, + }), + )); + + spans.push(Span::raw(" ")); + + if sim.pregenerating { + spans.push(Span::styled( + format!(" {} Expanding tree... ", spinner_char(sim.tick)), + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(" ")); + } + + if let Some(ref msg) = app.message { + spans.push(Span::styled( + format!(" {msg} "), + Style::default().fg(Color::Red), + )); + } else if !sim.processing { + let hint_items: Vec<(&str, &str)> = match sim.mode { + SimInputMode::Normal => { + let mut items = Vec::new(); + if sim.game_mode && !sim.game_choice_groups.is_empty() { + items.extend_from_slice(&[ + ("↑↓", "Group"), + ("←→", "Outcome"), + ("Enter", "Confirm"), + ("x", "Reject"), + ("u", "Updates"), + ]); + } else if !sim.interactions.is_empty() { + items.extend_from_slice(&[("↑↓", "Select"), ("Enter", "Confirm"), ("i", "Custom")]); + } else { + items.push(("i", "Insert")); + } + if sim.can_go_back { + items.push(("Bksp", "Back")); + } + if sim.breadcrumbs.len() > 1 { + items.push(("b", "Trail")); + } + items.extend_from_slice(&[("Tab", "Channel"), ("Esc", "Background")]); + if sim.interactions.is_empty() && sim.game_choice_groups.is_empty() { + items.push(("?", "Help")); + } + items + } + SimInputMode::Insert => vec![("Ctrl+S", "Send"), ("Esc", "Normal")], + }; + let badge_line = super::common::render_footer_line(&hint_items, None); + spans.extend(badge_line.spans); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_decisions_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + for (i, decision) in sim.decisions.iter().enumerate() { + let mut spans = vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(decision.description.clone()), + ]; + + // Show ref markers inline in magenta + if !decision.refs.is_empty() { + let markers: Vec = decision.refs.iter().map(|r| r.marker.clone()).collect(); + spans.push(Span::styled( + format!(" {}", markers.join(" ")), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )); + } + + lines.push(Line::from(spans)); + + // Show spec gaps for this decision in red + for gap in &decision.spec_gaps { + lines.push(Line::from(Span::styled( + format!(" SPEC GAP: {gap}"), + Style::default().fg(Color::Red), + ))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Decisions "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_interactions_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + for (i, interaction) in sim.interactions.iter().enumerate() { + let is_selected = i == sim.selected_interaction; + let is_shallow = interaction.result.is_none(); + let prefix = if is_selected { "▸ " } else { " " }; + + let mut spans_line = vec![]; + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if is_shallow { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }; + + spans_line.push(Span::styled( + format!("{prefix}{}: {}", i + 1, interaction.label), + style, + )); + + if is_shallow && !is_selected { + spans_line.push(Span::styled( + " (generates)", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM), + )); + } + + lines.push(Line::from(spans_line)); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Interactions (↑↓ select, Enter confirm, i for custom input) "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_ref_overlay( + overlay: &crate::simulation::RefOverlay, + frame: &mut Frame, + area: Rect, +) { + // Centered overlay, 60% width, 40% height + let overlay_width = (area.width as f32 * 0.6) as u16; + let overlay_height = (area.height as f32 * 0.4) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = vec![ + Line::from(Span::styled( + format!("Node: {}", &overlay.node_id), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled("Q: ", Style::default().fg(Color::Yellow))), + Line::from(overlay.question.clone()), + Line::from(""), + ]; + + if let Some(ref answer) = overlay.answer { + lines.push(Line::from(Span::styled( + "A: ", + Style::default().fg(Color::Green), + ))); + lines.push(Line::from(answer.clone())); + } else { + lines.push(Line::from(Span::styled( + "(unanswered)", + Style::default().fg(Color::DarkGray), + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[Esc] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Spec Reference "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} + +fn render_report_overlay( + report: &crate::simulation::ReportOverlay, + frame: &mut Frame, + area: Rect, +) { + // Centered overlay, 70% width, 50% height + let overlay_width = (area.width as f32 * 0.7) as u16; + let overlay_height = (area.height as f32 * 0.5) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = render_text_with_refs(&report.explanation); + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[Esc] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Report Explanation "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} + +// ── Game Mode Rendering ────────────────────────────────────────────── + +fn render_game_choices_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + + for (gi, group) in sim.game_choice_groups.iter().enumerate() { + let is_selected_group = gi == sim.selected_group; + let group_prefix = if is_selected_group { "▸ " } else { " " }; + let group_style = if is_selected_group { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::from(Span::styled( + format!("{group_prefix}{}", group.interaction_label), + group_style, + ))); + + for (oi, outcome) in group.outcomes.iter().enumerate() { + let is_selected_outcome = is_selected_group && oi == sim.selected_outcome; + let marker = if is_selected_outcome { " ◄" } else { "" }; + let outcome_style = if is_selected_outcome { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + lines.push(Line::from(Span::styled( + format!(" [{}] {}{}", oi + 1, outcome.summary, marker), + outcome_style, + ))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Game Choices (↑↓ group, ←→ outcome, Enter confirm, x reject) "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_reject_overlay( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let overlay_width = (area.width as f32 * 0.6) as u16; + let overlay_height = 8; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let group_label = sim + .game_choice_groups + .get(sim.selected_group) + .map(|g| g.interaction_label.as_str()) + .unwrap_or("?"); + let outcome_label = sim + .game_choice_groups + .get(sim.selected_group) + .and_then(|g| g.outcomes.get(sim.selected_outcome)) + .map(|o| o.summary.as_str()) + .unwrap_or("?"); + + let lines = vec![ + Line::from(Span::styled( + format!("Rejecting: {} → {}", group_label, outcome_label), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from("What should happen instead?"), + Line::from(format!("> {}_", sim.reject_input)), + Line::from(""), + Line::from(Span::styled( + "Ctrl+S submit | Esc cancel", + Style::default().fg(Color::DarkGray), + )), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Reject Outcome "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, overlay_area); +} + +fn render_update_log_overlay( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let overlay_width = (area.width as f32 * 0.7) as u16; + let overlay_height = (area.height as f32 * 0.5) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = Vec::new(); + + if sim.game_spec_updates.is_empty() { + lines.push(Line::from(Span::styled( + "No spec updates yet.", + Style::default().fg(Color::DarkGray), + ))); + } else { + for (i, update) in sim.game_spec_updates.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} → {}", update.interaction_label, update.outcome_summary), + Style::default().fg(Color::White), + ), + ])); + lines.push(Line::from(Span::styled( + format!(" {}", update.description), + Style::default().fg(Color::Green), + ))); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[u] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Spec Updates This Session "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 5c3234f..2b12c01 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -2,7 +2,7 @@ use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; @@ -11,7 +11,7 @@ use crate::app::App; pub fn render(app: &App, frame: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(3)]) + .constraints([Constraint::Min(3), Constraint::Length(1), Constraint::Length(3)]) .split(frame.area()); let items: Vec = app @@ -35,10 +35,25 @@ pub fn render(app: &App, frame: &mut Frame) { } frame.render_stateful_widget(list, chunks[0], &mut state); - let footer_text = app - .message - .as_deref() - .unwrap_or("[c] Create [s] Seed from file [m] Model [y] Sync [Enter] Open [q] Quit"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[1]); + let id_text = if !app.specs.is_empty() { + format!(" ID: {}", app.specs[app.selected].id) + } else { + String::new() + }; + let id_line = Paragraph::new(Span::styled( + id_text, + Style::default().fg(Color::DarkGray), + )); + frame.render_widget(id_line, chunks[1]); + + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Open"), ("c", "Create"), ("d", "Delete"), ("q", "Quit"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/spec_members.rs b/crates/spec-forest-tui/src/ui/spec_members.rs new file mode 100644 index 0000000..b4e2282 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_members.rs @@ -0,0 +1,113 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let spec_id = match &app.screen { + Screen::SpecMembers { spec_id } => spec_id, + _ => return, + }; + + let spec_name = app + .specs + .iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.as_str()) + .unwrap_or("?"); + + let is_creator = app.state.user_name() == app.members_creator; + + let constraints = if app.members_input_active { + vec![ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(3), + ] + } else { + vec![Constraint::Min(3), Constraint::Length(3)] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.area()); + + // Member list + let items: Vec = app + .members + .iter() + .map(|m| { + let mut spans = vec![Span::raw(format!(" {m}"))]; + if *m == app.members_creator { + spans.push(Span::styled(" (creator)", Style::default().fg(Color::Cyan))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let empty_msg = if items.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No members", + Style::default().fg(Color::DarkGray), + )))] + } else { + items + }; + + let list = List::new(empty_msg) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Members — {spec_name} ")), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if !app.members.is_empty() { + state.select(Some(app.members_selected)); + } + frame.render_stateful_widget(list, chunks[0], &mut state); + + // Input area (when active) + if app.members_input_active { + let input_display = format!(" Add member: {}█", app.members_input); + let input_widget = Paragraph::new(input_display) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)); + frame.render_widget(input_widget, chunks[1]); + } + + // Footer + let footer_idx = if app.members_input_active { 2 } else { 1 }; + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else if app.members_input_active { + super::common::render_footer_line( + &[("Enter", "Add"), ("Esc", "Cancel")], + None, + ) + } else if is_creator { + super::common::render_footer_line( + &[("a", "Add member"), ("d", "Remove"), ("Esc", "Back"), ("?", "Help")], + None, + ) + } else { + super::common::render_footer_line( + &[("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[footer_idx]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_options_picker.rs b/crates/spec-forest-tui/src/ui/spec_options_picker.rs new file mode 100644 index 0000000..202f0c2 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_options_picker.rs @@ -0,0 +1,55 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, SPEC_OPTIONS}; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let items: Vec = SPEC_OPTIONS + .iter() + .enumerate() + .map(|(i, (label, _, _))| { + let style = if i == app.spec_options_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(format!(" {label}"), style))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Select Mode & Locality "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.spec_options_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_line = super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Cancel"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_settings.rs b/crates/spec-forest-tui/src/ui/spec_settings.rs new file mode 100644 index 0000000..9b7f17d --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_settings.rs @@ -0,0 +1,61 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let spec_id = match &app.screen { + Screen::SpecSettings { spec_id } => spec_id, + _ => return, + }; + + let directory_display = app + .specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_deref()) + .unwrap_or("Not set"); + + let items: Vec = vec![ListItem::new(Line::from(vec![ + Span::raw(" Directory: "), + Span::styled(directory_display, Style::default().fg(Color::Cyan)), + ]))]; + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Spec Settings "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.spec_settings_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Set Dir"), ("d", "Clear Dir"), ("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index e692537..adfdec0 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -7,6 +7,7 @@ use ratatui::{ }; use spec_forest::explore::ExploreStatus; +use spec_forest::ingest::IngestState; use spec_forest::state::GenerationStatus; use crate::app::App; @@ -17,50 +18,78 @@ pub fn render(app: &App, frame: &mut Frame) { .explore_status .as_ref() .is_some_and(|s| matches!(s.status, ExploreStatus::Running | ExploreStatus::Paused)); + let has_shadow_bar = app.shadow_session_id.is_some(); - let constraints = if has_explore_bar { - vec![ - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(3), - ] - } else { - vec![Constraint::Min(3), Constraint::Length(3)] - }; + let mut constraints = vec![Constraint::Min(3)]; + + if app.log_visible { + constraints.push(Constraint::Percentage(30)); + } + if has_explore_bar { + constraints.push(Constraint::Length(1)); + } + if has_shadow_bar { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(3)); let chunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(frame.area()); + let mut idx = 0; + let main_area = if app.tree_visible { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(chunks[0]); + .split(chunks[idx]); render_tree_panel(app, frame, h_chunks[0]); h_chunks[1] } else { - chunks[0] + chunks[idx] }; + idx += 1; render_node_content(app, frame, main_area); + if app.log_visible { + super::log_panel::render(app, frame, chunks[idx]); + idx += 1; + } + if has_explore_bar { - render_explore_status_bar(app, frame, chunks[1]); + render_explore_status_bar(app, frame, chunks[idx]); + idx += 1; + } + + if has_shadow_bar { + render_shadow_status_bar(app, frame, chunks[idx]); + idx += 1; } - let footer_chunk = if has_explore_bar { chunks[2] } else { chunks[1] }; + let footer_chunk = chunks[idx]; - let footer_text = if let Some(ref msg) = app.message { - msg.clone() - } else if app.tree_visible { - "[a] AI answer [x] Explore [X] Full explore [e] Edit [t] Tree [Tab] Focus [Bksp] Back [q] Quit".to_string() + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else if !app.candidates.is_empty() { + super::common::render_footer_line( + &[("y", "Accept"), ("[]", "Navigate"), ("Bksp", "Back"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) + } else if app.log_focused { + super::common::render_footer_line( + &[("↑↓", "Scroll"), ("Tab", "Focus"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) } else { - "[a] AI answer [x] Explore [X] Full explore [e] Edit [t] Tree [Bksp] Back [q] Quit" - .to_string() + super::common::render_footer_line( + &[("a", "AI"), ("x", "Explore"), ("Bksp", "Back"), ("q", "Quit"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) }; - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, footer_chunk); } @@ -80,6 +109,12 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + format!("ID: {}", node.id), + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( "Question", Style::default() @@ -115,6 +150,122 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { } } + // Implementation status section + if let Some((status, review)) = app.implementation_statuses.get(&node.id) { + let (icon, color) = super::common::impl_status_icon(status); + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Implementation Status", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), Style::default().fg(color)), + Span::styled( + status.replace('_', " "), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ])); + if let Some(review_text) = review { + lines.push(Line::from("")); + lines.push(Line::from(review_text.clone())); + } + } + + // Shadow answers detail section + if !app.shadow_answers.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Shadow Answers ({})", app.shadow_answers.len()), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for (i, shadow) in app.shadow_answers.iter().enumerate() { + let (icon, color) = shadow + .implementation_status + .as_deref() + .map(|s| super::common::impl_status_icon(s)) + .unwrap_or(("?", Color::DarkGray)); + + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), Style::default().fg(color)), + Span::styled( + format!("Shadow {} - {}", i + 1, shadow.source_dir), + Style::default().fg(Color::DarkGray), + ), + ])); + + if let Some(ref review) = shadow.implementation_review { + lines.push(Line::from(Span::styled( + format!(" {review}"), + Style::default().fg(Color::DarkGray), + ))); + } + + let answer_preview: String = + shadow.answer.lines().take(3).collect::>().join("\n"); + for line in answer_preview.lines() { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(Color::DarkGray), + ))); + } + lines.push(Line::from("")); + } + } + + // Candidates section + if !app.candidates.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "Candidates ({}) ── [[] prev []] next [y] accept [E] edit", + app.candidates.len() + ), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for (i, candidate) in app.candidates.iter().enumerate() { + let is_selected = i == app.candidate_selected; + let marker = if is_selected { "▸ " } else { " " }; + let rank_label = format!("{}Candidate {}", marker, i + 1); + + let header_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + lines.push(Line::from(Span::styled(rank_label, header_style))); + + let text_style = if is_selected { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }; + lines.push(Line::from("")); + for text_line in candidate.answer.lines() { + lines.push(Line::from(Span::styled( + text_line.to_string(), + text_style, + ))); + } + lines.push(Line::from("")); + } + } + let content = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }); @@ -159,19 +310,26 @@ fn render_tree_panel(app: &App, frame: &mut Frame, area: Rect) { } None => { let s = match entry.node_state { - spec_forest_db::NodeState::Unanswered => "[?]", - spec_forest_db::NodeState::Answered => "[+]", - spec_forest_db::NodeState::NeedsReview => "[~]", - spec_forest_db::NodeState::Deleted => "[x]", + spec_forest::NodeState::Unanswered => "[?]", + spec_forest::NodeState::Answered => "[+]", + spec_forest::NodeState::NeedsReview => "[~]", + spec_forest::NodeState::Deleted => "[x]", }; (s.to_string(), state_color(entry.node_state)) } }; - ListItem::new(Line::from(vec![ + let impl_icon = app.implementation_statuses.get(&entry.node_id).map(|(status, _)| { + super::common::impl_status_icon(status) + }); + let mut spans = vec![ Span::raw(format!("{indent}{expand_icon}")), Span::styled(format!("{state_str} "), Style::default().fg(state_fg)), - Span::raw(&entry.label), - ])) + ]; + if let Some((icon, color)) = impl_icon { + spans.push(Span::styled(format!("{icon} "), Style::default().fg(color))); + } + spans.push(Span::raw(&entry.label)); + ListItem::new(Line::from(spans)) }) .collect(); @@ -228,3 +386,25 @@ fn render_explore_status_bar(app: &App, frame: &mut Frame, area: Rect) { let bar = Paragraph::new(Span::styled(text, Style::default().fg(color))); frame.render_widget(bar, area); } + +fn render_shadow_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let Some(ref status) = app.shadow_status else { + return; + }; + let spin = spinner_char(app.tick); + let state_label = match status.status { + IngestState::Running => "running", + IngestState::Paused => "paused", + _ => "done", + }; + let text = format!( + " {spin} Shadow {state_label}: {}/{} done | {} skipped | {} failed", + status.completed, status.total, status.skipped, status.failed, + ); + let color = match status.status { + IngestState::Running => Color::Magenta, + _ => Color::Green, + }; + let bar = Paragraph::new(Span::styled(text, Style::default().fg(color))); + frame.render_widget(bar, area); +} diff --git a/crates/spec-forest-tui/src/ui/sync_config.rs b/crates/spec-forest-tui/src/ui/sync_config.rs index 593d3b4..f424883 100644 --- a/crates/spec-forest-tui/src/ui/sync_config.rs +++ b/crates/spec-forest-tui/src/ui/sync_config.rs @@ -36,13 +36,19 @@ pub fn render(app: &App, frame: &mut Frame) { frame.render_widget(info, chunks[0]); let has_url = app.state.sync_url().is_some(); - let footer_text = if let Some(ref msg) = app.message { - msg.as_str().to_string() + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) } else if has_url { - "[l] Login [r] Register [Esc] Back".to_string() + super::common::render_footer_line( + &[("l", "Login"), ("r", "Register"), ("Esc", "Back"), ("?", "Help")], + None, + ) } else { - "Set --sync-url to enable sync [Esc] Back".to_string() + super::common::render_footer_line( + &[("Esc", "Back"), ("?", "Help")], + None, + ) }; - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 25aea35..e86442f 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -1,12 +1,13 @@ use std::sync::Arc; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend}; use spec_forest::state::AppState; use spec_forest_db::{ChildInput, CreateSpec, Locality, Node, NodeState, Spec, SpecMode}; use spec_forest_tui::action::Action; use spec_forest_tui::app::{App, Screen}; use spec_forest_tui::input; +use spec_forest_tui::tree_state::TreeEntry; use spec_forest_tui::ui; use tempfile::tempdir; @@ -18,7 +19,7 @@ fn make_app() -> App { let dir = Box::leak(Box::new(dir)); let _ = dir; let state = Arc::new(AppState::new(db_path.to_str().unwrap()).unwrap()); - App::new(state) + App::new(state, spec_forest_tui::log_buffer::LogBuffer::new_shared(100)) } fn make_spec(name: &str) -> Spec { @@ -88,10 +89,10 @@ fn render_to_string(app: &App) -> String { #[tokio::test] async fn test_spec_list_shows_footer() { let app = make_app(); - let output = render_to_string(&app); + let output = render_to_string_sized(&app, 120, 24); assert!(output.contains("Quit"), "footer should show Quit"); assert!(output.contains("Create"), "footer should show Create"); - assert!(output.contains("Seed"), "footer should show Seed"); + assert!(output.contains("Help"), "footer should show Help"); } #[tokio::test] @@ -113,11 +114,12 @@ async fn test_input_name_shows_prompt() { } #[tokio::test] -async fn test_input_file_shows_prompt() { +async fn test_dir_browser_shows_title() { let mut app = make_app(); - app.screen = Screen::InputFile; + app.dir_browser = Some(spec_forest_tui::dir_browser::DirBrowserState::new()); + app.screen = Screen::DirBrowser; let output = render_to_string(&app); - assert!(output.contains("File path:"), "should show file prompt"); + assert!(output.contains("Seed from directory"), "should show dir browser title"); } #[tokio::test] @@ -131,6 +133,17 @@ async fn test_spec_view_shows_selected_node_content() { app.screen = Screen::SpecView { spec_id: "spec-MySpec".to_string(), }; + app.tree_visible = true; + app.tree_focused = true; + for node in &app.nodes { + app.tree_state.entries.push(TreeEntry { + node_id: node.id.clone(), + depth: 0, + has_children: false, + label: node.question.clone(), + node_state: node.state, + }); + } // Default selection is 0, so the first node's question should appear let output = render_to_string(&app); assert!( @@ -145,9 +158,7 @@ async fn test_spec_view_shows_selected_node_content() { #[tokio::test] async fn test_spec_view_tree_shows_markers() { - let (mut app, _spec_id, _root_id) = make_app_with_tree(); - // Enable tree view - app.handle_key(KeyCode::Char('t')).await; + let (app, _spec_id, _root_id) = make_app_with_tree(); let output = render_to_string_sized(&app, 120, 30); assert!( output.contains("[?]") || output.contains("[+]"), @@ -160,14 +171,14 @@ async fn test_spec_view_tree_shows_markers() { #[tokio::test] async fn test_quit_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('q')).await; + app.handle_key(KeyCode::Char('q'), KeyModifiers::NONE).await; assert!(app.should_quit); } #[tokio::test] async fn test_create_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('c')).await; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE).await; assert!(matches!(app.screen, Screen::InputName)); assert!(app.input.is_empty()); } @@ -175,16 +186,17 @@ async fn test_create_key() { #[tokio::test] async fn test_seed_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('s')).await; - assert!(matches!(app.screen, Screen::InputFile)); + app.handle_key(KeyCode::Char('s'), KeyModifiers::NONE).await; + assert!(matches!(app.screen, Screen::DirBrowser)); + assert!(app.dir_browser.is_some()); } #[tokio::test] async fn test_input_typing() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Char('h')).await; - app.handle_key(KeyCode::Char('i')).await; + app.handle_key(KeyCode::Char('h'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Char('i'), KeyModifiers::NONE).await; assert_eq!(app.input, "hi"); } @@ -192,9 +204,9 @@ async fn test_input_typing() { async fn test_input_backspace() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Char('a')).await; - app.handle_key(KeyCode::Char('b')).await; - app.handle_key(KeyCode::Backspace).await; + app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Char('b'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Backspace, KeyModifiers::NONE).await; assert_eq!(app.input, "a"); } @@ -202,7 +214,7 @@ async fn test_input_backspace() { async fn test_input_escape_returns_to_spec_list() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Esc).await; + app.handle_key(KeyCode::Esc, KeyModifiers::NONE).await; assert!(matches!(app.screen, Screen::SpecList)); } @@ -211,11 +223,11 @@ async fn test_list_navigation() { let mut app = make_app(); app.specs = vec![make_spec("A"), make_spec("B"), make_spec("C")]; assert_eq!(app.selected, 0); - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 2); - app.handle_key(KeyCode::Up).await; + app.handle_key(KeyCode::Up, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); } @@ -224,11 +236,11 @@ async fn test_list_navigation_bounds() { let mut app = make_app(); app.specs = vec![make_spec("A"), make_spec("B")]; // Up at 0 stays at 0 - app.handle_key(KeyCode::Up).await; + app.handle_key(KeyCode::Up, KeyModifiers::NONE).await; assert_eq!(app.selected, 0); // Down to end, then one more stays at end - app.handle_key(KeyCode::Down).await; - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); } @@ -237,7 +249,7 @@ async fn test_list_navigation_bounds() { #[tokio::test] async fn test_create_then_render_shows_input_screen() { let mut app = make_app(); - app.handle_key(KeyCode::Char('c')).await; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE).await; let output = render_to_string(&app); assert!(output.contains("Spec name:"), "should show input prompt after pressing c"); assert!(output.contains("Cancel"), "should show cancel hint"); @@ -320,6 +332,9 @@ fn make_app_with_tree() -> (App, String, String) { app.screen = Screen::SpecView { spec_id: spec_id.clone(), }; + app.tree_visible = true; + app.tree_focused = true; + app.tree_state.rebuild(&app.state, &spec_id).unwrap(); (app, spec_id, root_id) } @@ -337,10 +352,8 @@ async fn test_flat_list_enter_does_not_change_state() { app.screen = Screen::SpecView { spec_id: "spec-S".to_string(), }; - app.node_selected = 0; - // Press Enter on an answered node in the flat list - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // BUG: Enter does nothing — no drill-down, no expand, no detail view. // The screen and selection should remain unchanged (no crash at least). @@ -348,7 +361,6 @@ async fn test_flat_list_enter_does_not_change_state() { matches!(app.screen, Screen::SpecView { .. }), "screen should still be SpecView after Enter" ); - assert_eq!(app.node_selected, 0, "selection should not change"); // TODO: Once fixed, Enter should do something useful (e.g. show detail or expand). } @@ -359,12 +371,6 @@ async fn test_flat_list_enter_does_not_change_state() { #[tokio::test] async fn test_tree_expand_shows_children() { let (mut app, _spec_id, _root_id) = make_app_with_tree(); - - // Press 't' to show tree — this rebuilds tree from DB - app.handle_key(KeyCode::Char('t')).await; - - assert!(app.tree_visible, "tree should be visible after pressing t"); - assert!(app.tree_focused, "tree should be focused after pressing t"); assert!( !app.tree_state.entries.is_empty(), "tree should have entries after rebuild" @@ -381,7 +387,7 @@ async fn test_tree_expand_shows_children() { let initial_count = app.tree_state.entries.len(); // Press Enter to expand the root node (tree is focused) - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // After expanding, children should appear in tree entries assert!( @@ -420,12 +426,10 @@ async fn test_tree_expand_shows_children() { #[tokio::test] async fn test_tree_expand_with_right_arrow() { let (mut app, _spec_id, _root_id) = make_app_with_tree(); - - app.handle_key(KeyCode::Char('t')).await; let initial_count = app.tree_state.entries.len(); // Right arrow should expand just like Enter - app.handle_key(KeyCode::Right).await; + app.handle_key(KeyCode::Right, KeyModifiers::NONE).await; assert!( app.tree_state.entries.len() > initial_count, @@ -487,8 +491,8 @@ async fn test_tree_shows_all_root_nodes() { app.nodes = db.get_nodes_by_spec(&spec_id).unwrap(); } - // Open tree - app.handle_key(KeyCode::Char('t')).await; + // Rebuild tree to pick up extra roots + app.tree_state.rebuild(&app.state, &spec_id).unwrap(); // The tree should contain all root nodes let root_entries: Vec<_> = app @@ -526,11 +530,8 @@ async fn test_flat_list_and_tree_node_count_consistency() { flat_count ); - // Open tree and expand all nodes - app.handle_key(KeyCode::Char('t')).await; - // Expand the root - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // Tree entries after full expansion should match flat node count let tree_count = app.tree_state.entries.len(); @@ -544,50 +545,50 @@ async fn test_flat_list_and_tree_node_count_consistency() { #[test] fn test_input_map_spec_list_quit() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), KeyModifiers::NONE, false, false, false, false, false, 0); assert_eq!(action, Action::Quit); } #[test] fn test_input_map_spec_list_create() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), KeyModifiers::NONE, false, false, false, false, false, 0); assert_eq!(action, Action::OpenCreateSpec); } #[test] fn test_input_map_spec_list_navigate() { assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Up, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Down, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateDown ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Select ); } #[test] fn test_input_map_shared_text_input() { - // InputName, InputFile, and SyncPasswordInput all share the same mapping - for screen in [Screen::InputName, Screen::InputFile, Screen::SyncPasswordInput] { + // InputName and SyncPasswordInput share the same mapping + for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( - input::map_key(&screen, KeyCode::Esc, false, false, false), + input::map_key(&screen, KeyCode::Esc, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Cancel ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, false, false, false), + input::map_key(&screen, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Submit ); assert_eq!( - input::map_key(&screen, KeyCode::Backspace, false, false, false), + input::map_key(&screen, KeyCode::Backspace, KeyModifiers::NONE, false, false, false, false, false, 0), Action::DeleteChar ); assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false), + input::map_key(&screen, KeyCode::Char('a'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::TypeChar('a') ); } @@ -600,15 +601,15 @@ fn test_input_map_spec_view_tree_focused() { }; // With tree visible and focused, keys go to tree handlers assert_eq!( - input::map_key(&screen, KeyCode::Up, true, true, false), + input::map_key(&screen, KeyCode::Up, KeyModifiers::NONE, true, true, false, false, false, 0), Action::TreeUp ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, true, true, false), + input::map_key(&screen, KeyCode::Enter, KeyModifiers::NONE, true, true, false, false, false, 0), Action::ExpandOrCollapseTreeNode ); assert_eq!( - input::map_key(&screen, KeyCode::Left, true, true, false), + input::map_key(&screen, KeyCode::Left, KeyModifiers::NONE, true, true, false, false, false, 0), Action::CollapseTreeNode ); } @@ -620,11 +621,11 @@ fn test_input_map_spec_view_flat_list() { }; // Without tree, keys go to flat list handlers assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false), + input::map_key(&screen, KeyCode::Char('a'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::AiAnswer ); assert_eq!( - input::map_key(&screen, KeyCode::Char('e'), false, false, false), + input::map_key(&screen, KeyCode::Char('e'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::EditNextQuestion ); } @@ -635,7 +636,7 @@ fn test_input_map_spec_view_toggle_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('t'), false, false, false), + input::map_key(&screen, KeyCode::Char('t'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::ToggleTree ); } @@ -647,12 +648,12 @@ fn test_input_map_spec_view_tab_focus() { }; // Tab only works when tree is visible assert_eq!( - input::map_key(&screen, KeyCode::Tab, true, true, false), + input::map_key(&screen, KeyCode::Tab, KeyModifiers::NONE, true, true, false, false, false, 0), Action::SwitchFocus ); // Tab when tree not visible goes to flat list (noop) assert_eq!( - input::map_key(&screen, KeyCode::Tab, false, false, false), + input::map_key(&screen, KeyCode::Tab, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Noop ); } @@ -660,11 +661,11 @@ fn test_input_map_spec_view_tab_focus() { #[test] fn test_input_map_sync_config_with_url() { assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), KeyModifiers::NONE, false, false, true, false, false, 0), Action::SyncLogin ); assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true), + input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), KeyModifiers::NONE, false, false, true, false, false, 0), Action::SyncRegister ); } @@ -673,7 +674,7 @@ fn test_input_map_sync_config_with_url() { fn test_input_map_sync_config_without_url() { // Without URL, login/register are noop assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::Noop ); } @@ -681,15 +682,15 @@ fn test_input_map_sync_config_without_url() { #[test] fn test_input_map_model_config() { assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Up, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::SelectModel ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Esc, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Cancel ); } @@ -714,3 +715,140 @@ fn test_truncate_str_multibyte_utf8() { let mixed = "ab🎉cd"; assert_eq!(truncate_str(mixed, 3), "ab🎉"); } + +// ── Candidate tests ───────────────────────────────────────── + +fn make_app_with_candidates() -> (App, String, String) { + let (mut app, spec_id, root_id) = make_app_with_tree(); + // Insert candidates for the root node + { + let db = app.state.db(); + db.upsert_candidates( + &root_id, + &[ + ("c1".to_string(), "First candidate answer".to_string(), 0), + ("c2".to_string(), "Second candidate answer".to_string(), 1), + ("c3".to_string(), "Third candidate answer".to_string(), 2), + ], + ) + .unwrap(); + } + // Select root node (tree_state.selected starts at 0 = root) and load candidates + app.tree_visible = true; + app.tree_focused = true; + app.refresh_candidates_if_needed(); + (app, spec_id, root_id) +} + +#[tokio::test] +async fn test_candidates_render_when_present() { + let (app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidates.len(), 3); + let output = render_to_string_sized(&app, 120, 40); + assert!( + output.contains("Candidates (3)"), + "should show candidates header" + ); + assert!( + output.contains("First candidate answer"), + "should show selected candidate text" + ); +} + +#[tokio::test] +async fn test_candidate_navigation() { + let (mut app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidate_selected, 0); + + // Navigate next + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 1); + + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 2); + + // Bounds: can't go past end + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 2); + + // Navigate prev + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 1); + + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 0); + + // Bounds: can't go below 0 + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; + assert_eq!(app.candidate_selected, 0); +} + +#[test] +fn test_accept_candidate_key_mapping() { + // Verify y maps to AcceptCandidate in both modes + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, true, true, false, false, false, 0), + Action::AcceptCandidate + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, false, false, false, false, false, 0), + Action::AcceptCandidate + ); +} + +#[tokio::test] +async fn test_candidates_clear_when_switching_nodes() { + let (mut app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidates.len(), 3, "should have candidates for root"); + + // Expand root to reveal children, then navigate to a child + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; + app.refresh_candidates_if_needed(); + assert!( + app.candidates.is_empty(), + "candidates should be empty for node without candidates" + ); + assert_eq!(app.candidate_selected, 0, "selection should reset"); +} + +#[test] +fn test_input_map_candidate_keys_tree() { + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char(']'), KeyModifiers::NONE, true, true, false, false, false, 0), + Action::CandidateNext + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('['), KeyModifiers::NONE, true, true, false, false, false, 0), + Action::CandidatePrev + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, true, true, false, false, false, 0), + Action::AcceptCandidate + ); +} + +#[test] +fn test_input_map_candidate_keys_flat_list() { + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char(']'), KeyModifiers::NONE, false, false, false, false, false, 0), + Action::CandidateNext + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('['), KeyModifiers::NONE, false, false, false, false, false, 0), + Action::CandidatePrev + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, false, false, false, false, false, 0), + Action::AcceptCandidate + ); +} diff --git a/crates/spec-forest/src/api/features.rs b/crates/spec-forest/src/api/features.rs index faf9a62..b2ad10a 100644 --- a/crates/spec-forest/src/api/features.rs +++ b/crates/spec-forest/src/api/features.rs @@ -31,6 +31,31 @@ pub async fn create_feature( Ok(node) } +pub fn regenerate_feature( + state: &Arc, + node_id: &str, + model: String, +) -> Result<(), ApiError> { + // Verify the node exists and is a root (feature) node + let db = state.db(); + let _node = db.get_node(node_id)?; + let is_root = db.get_ancestors(node_id)?.is_empty(); + drop(db); + + if !is_root { + return Err(ApiError::InvalidInput( + "Only root feature nodes can be regenerated".to_string(), + )); + } + + crate::generate::spawn_feature_regeneration( + state.clone(), + node_id.to_string(), + model, + ); + Ok(()) +} + pub async fn seed_spec( state: &Arc, spec_id: &str, diff --git a/crates/spec-forest/src/api/graph.rs b/crates/spec-forest/src/api/graph.rs index 06ac609..427c7e3 100644 --- a/crates/spec-forest/src/api/graph.rs +++ b/crates/spec-forest/src/api/graph.rs @@ -66,6 +66,11 @@ pub fn get_spec_graph( }) } +pub fn get_children(state: &AppState, node_id: &str) -> Result, ApiError> { + let children = state.db().get_children(node_id)?; + Ok(children) +} + pub fn get_ancestors(state: &AppState, node_id: &str) -> Result, ApiError> { let nodes = state.db().get_ancestors(node_id)?; Ok(nodes) diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index 4df2dca..b22e2e5 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -38,7 +38,10 @@ pub async fn answer_node( answer: String, model: String, generate: bool, + residual_entropy: Option, + residual_entropy_reasoning: Option, ) -> Result { + tracing::debug!(node_id = %id, "answer_node: start"); // Read node info with a short-lived lock, then release before embedding let (question, was_unanswered, old_answer, is_root, spec_id) = { let db = state.db(); @@ -64,16 +67,16 @@ pub async fn answer_node( node_id: id.to_string(), answer: answer.clone(), embedding, - residual_entropy: None, - residual_entropy_reasoning: None, + residual_entropy, + residual_entropy_reasoning, } } else { spec_forest_protocol::SpecOp::UpdateAnswer { node_id: id.to_string(), answer: answer.clone(), embedding, - residual_entropy: None, - residual_entropy_reasoning: None, + residual_entropy, + residual_entropy_reasoning, } }; let submit_result = state.submit_op(&spec_id, op).await; @@ -104,8 +107,10 @@ pub async fn answer_node( Err(e) => return Err(ApiError::Internal(e.to_string())), }; - // Evaluate residual entropy in the background - crate::generate::spawn_entropy_evaluation(state.clone(), node.id.clone(), model.clone()); + // Evaluate residual entropy in the background (skip if caller already provided it) + if residual_entropy.is_none() { + crate::generate::spawn_entropy_evaluation(state.clone(), node.id.clone(), model.clone()); + } if generate { if was_unanswered { @@ -226,6 +231,7 @@ pub async fn ai_answer( state: &Arc, id: &str, model: String, + generate: bool, ) -> Result { // Read pre-answer state let (was_unanswered, old_answer, is_root, spec_id) = { @@ -263,19 +269,21 @@ pub async fn ai_answer( // Trigger post-answer pipeline (same as answer_node) crate::generate::spawn_entropy_evaluation(state.clone(), id.to_string(), model.clone()); - if was_unanswered { - crate::generate::spawn_generation(state.clone(), id.to_string(), model.clone()); - } else { - let children = state.db().get_children(id)?; - if !children.is_empty() { - let old = old_answer.unwrap_or_default(); - crate::generate::spawn_review( - state.clone(), - id.to_string(), - old, - crate::generate::EditKind::Answer, - model.clone(), - ); + if generate { + if was_unanswered { + crate::generate::spawn_generation(state.clone(), id.to_string(), model.clone()); + } else { + let children = state.db().get_children(id)?; + if !children.is_empty() { + let old = old_answer.unwrap_or_default(); + crate::generate::spawn_review( + state.clone(), + id.to_string(), + old, + crate::generate::EditKind::Answer, + model.clone(), + ); + } } } @@ -361,3 +369,8 @@ pub async fn delete_node(state: &Arc, id: &str) -> Result Result, ApiError> { + Ok(state.db().get_next_question(spec_id)?) +} diff --git a/crates/spec-forest/src/api/server.rs b/crates/spec-forest/src/api/server.rs index 307952b..1f064c4 100644 --- a/crates/spec-forest/src/api/server.rs +++ b/crates/spec-forest/src/api/server.rs @@ -69,6 +69,15 @@ pub fn set_user_name(state: &AppState, name: &str) -> Result<(), ApiError> { Ok(()) } +pub fn get_setting(state: &AppState, key: &str) -> Result, ApiError> { + Ok(state.db().get_setting(key)?) +} + +pub fn set_setting(state: &AppState, key: &str, value: &str) -> Result<(), ApiError> { + state.db().set_setting(key, value)?; + Ok(()) +} + pub fn get_shadow_answers( state: &AppState, node_id: &str, @@ -86,7 +95,7 @@ pub fn get_candidates( node_id: &str, ) -> Result, ApiError> { let candidates = state.db().get_candidates(node_id)?; - eprintln!( + tracing::debug!( "[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len() diff --git a/crates/spec-forest/src/generate.rs b/crates/spec-forest/src/generate.rs index 7c417eb..784d424 100644 --- a/crates/spec-forest/src/generate.rs +++ b/crates/spec-forest/src/generate.rs @@ -18,7 +18,7 @@ pub use children::{run_generation_in_dir_public, run_generation_public, spawn_ge pub use claude_runner::{run_claude_in_dir_cached, run_claude_public}; pub use dir_context::{get_relevant_dir_context, CODEBASE_CONTEXT_OUTPUT_INSTRUCTION}; pub use entropy::spawn_entropy_evaluation; -pub use features::spawn_feature_extraction; +pub use features::{spawn_feature_extraction, spawn_feature_regeneration}; pub use output::{spawn_output_generation, spawn_spec_output_generation}; pub use review::spawn_review; pub use summary::spawn_summary_regeneration; diff --git a/crates/spec-forest/src/generate/claude_runner.rs b/crates/spec-forest/src/generate/claude_runner.rs index 1374a78..4a25536 100644 --- a/crates/spec-forest/src/generate/claude_runner.rs +++ b/crates/spec-forest/src/generate/claude_runner.rs @@ -82,6 +82,12 @@ pub(super) async fn run_claude_in_dir( Ok(result) => result?, Err(_) => { let err_msg = "claude CLI timed out after 600 seconds"; + tracing::warn!( + model, + caller, + prompt_chars = prompt.len(), + "Claude CLI timed out after 600s" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Err(err_msg), start.elapsed()); } @@ -94,6 +100,14 @@ pub(super) async fn run_claude_in_dir( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let err_msg = format!("claude CLI failed: {}", stderr); + tracing::warn!( + model, + caller, + prompt_chars = prompt.len(), + stderr_chars = stderr.len(), + ?elapsed, + "Claude CLI failed" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Err(&err_msg), elapsed); } @@ -101,6 +115,14 @@ pub(super) async fn run_claude_in_dir( } let response = String::from_utf8(output.stdout)?; + tracing::info!( + model, + caller, + prompt_chars = prompt.len(), + response_chars = response.len(), + ?elapsed, + "Claude CLI responded" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Ok(&response), elapsed); } diff --git a/crates/spec-forest/src/generate/features.rs b/crates/spec-forest/src/generate/features.rs index 40f135c..3c47ea7 100644 --- a/crates/spec-forest/src/generate/features.rs +++ b/crates/spec-forest/src/generate/features.rs @@ -139,6 +139,201 @@ async fn run_feature_extraction( Ok(()) } +// --- Feature regeneration --- + +pub fn spawn_feature_regeneration( + state: Arc, + node_id: String, + model: String, +) { + state.set_generation_status(&node_id, GenerationStatus::Generating); + + tokio::spawn(async move { + let result = run_feature_regeneration(&state, &node_id, &model).await; + match result { + Ok(regenerated) => { + if regenerated { + // Fetch the node to get spec_id for summary regeneration + if let Ok(node) = state.db().get_node(&node_id) { + spawn_summary_regeneration( + state.clone(), + node.spec_id, + model, + ); + } + } + state.clear_generation_status(&node_id); + } + Err(e) => { + tracing::error!(node_id = %node_id, "Feature regeneration failed: {e}"); + state.set_generation_status( + &node_id, + GenerationStatus::Failed { + error: e.to_string(), + }, + ); + } + } + }); +} + +async fn run_feature_regeneration( + state: &AppState, + node_id: &str, + model: &str, +) -> Result> { + let (node, spec, children) = { + let db = state.db(); + let node = db.get_node(node_id)?; + let spec = db.get_spec(&node.spec_id)?; + let children = db.get_children(node_id)?; + (node, spec, children) + }; + + let existing_description = &node.question; + + // Build child Q&A context + let mut child_context = String::new(); + for child in &children { + child_context.push_str(&format!("Q: {}\n", child.question)); + if let Some(ref answer) = child.answer { + child_context.push_str(&format!("A: {}\n", answer)); + } + child_context.push('\n'); + } + + // Get cached directory context if available + let dir_context = spec.directory.as_deref().and_then(|dir| { + super::dir_context::get_relevant_dir_context(state, &node.spec_id, dir, existing_description) + }); + + let prompt = build_feature_regeneration_prompt( + &spec, + existing_description, + &child_context, + dir_context.as_deref(), + spec.directory.is_some(), + ); + + let response = if let Some(ref dir) = spec.directory { + super::claude_runner::run_claude_in_dir_cached( + state, &node.spec_id, &prompt, model, dir, "feature_regeneration", + ).await? + } else { + let prompt_log = state.prompt_log().map(|l| l.as_ref()); + run_claude(&prompt, model, "feature_regeneration", prompt_log).await? + }; + let new_description = response.trim().to_string(); + + if new_description.is_empty() { + return Err("Regeneration produced empty description".into()); + } + + // Suppress no-op if description unchanged + if new_description == *existing_description { + tracing::info!(node_id, "Feature regeneration produced identical description, skipping update"); + return Ok(false); + } + + // Embed the new content + let embedding = state + .embed(&new_description) + .map_err(|e| -> Box { e.to_string().into() })?; + + // Submit UpdateFeature op + let op = spec_forest_protocol::SpecOp::UpdateFeature { + node_id: node_id.to_string(), + content: new_description, + embedding, + }; + state + .submit_op(&node.spec_id, op) + .await + .map_err(|e| -> Box { e.to_string().into() })?; + + // Trigger cascade review on children + if !children.is_empty() { + let review_op = spec_forest_protocol::SpecOp::TriggerReview { + node_id: node_id.to_string(), + }; + state + .submit_op(&node.spec_id, review_op) + .await + .map_err(|e| -> Box { e.to_string().into() })?; + } + + Ok(true) +} + +fn build_feature_regeneration_prompt( + spec: &spec_forest_db::Spec, + existing_description: &str, + child_qa_context: &str, + dir_context: Option<&str>, + has_directory: bool, +) -> String { + let mut prompt = String::new(); + + prompt.push_str( + "You are updating a feature description to reflect the current state of a codebase. \ + Your goal is to produce a revised description that accurately reflects what the code \ + currently implements while preserving the original spec intent.\n\n", + ); + + prompt.push_str(&format!( + "## Project: {}\n\n", + spec.name, + )); + + prompt.push_str(&format!( + "## Current Feature Description\n\n{}\n\n", + existing_description, + )); + + if !child_qa_context.is_empty() { + prompt.push_str(&format!( + "## Feature Specification Details\n\n\ + The following questions and answers further define this feature:\n\n{}\n", + child_qa_context, + )); + } + + if let Some(ctx) = dir_context { + prompt.push_str(ctx); + prompt.push('\n'); + } + + if has_directory { + if let Some(ref dir) = spec.directory { + prompt.push_str(&format!( + "## Project Directory\n\n\ + This specification is for a software project located at: {}\n\n\ + Before generating your response, use the Read, Glob, and Grep tools to explore the \ + project directory and gather relevant context about this feature. Look at source code, \ + configuration files, and any implementation related to the feature described above.\n\n", + dir + )); + } + } + + prompt.push_str( + "## Instructions\n\n\ + Based on the codebase context and the existing feature description, write an \ + updated feature description (2-4 sentences) that:\n\ + 1. Reflects what the code currently implements for this feature\n\ + 2. Preserves the original spec intent where applicable\n\ + 3. Notes any significant additions or changes from the original description\n\n\ + Respond with ONLY the updated description text, no JSON, no markdown headers, no \ + additional commentary.\n", + ); + + if has_directory { + prompt.push_str(super::dir_context::CODEBASE_CONTEXT_OUTPUT_INSTRUCTION); + } + + prompt +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/spec-forest/src/http.rs b/crates/spec-forest/src/http.rs index 59ac639..f8b21cb 100644 --- a/crates/spec-forest/src/http.rs +++ b/crates/spec-forest/src/http.rs @@ -68,7 +68,7 @@ impl IntoResponse for AppError { (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": msg}))).into_response() } AppError::Internal(msg) => { - eprintln!("HTTP 500: {}", msg); + tracing::error!("HTTP 500: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({"error": msg}))).into_response() } } @@ -444,7 +444,7 @@ async fn answer_node( ) -> Result, AppError> { let model = body.model.unwrap_or_else(|| "opus".to_string()); let generate = body.generate.unwrap_or(true); - Ok(Json(crate::api::answer_node(&state, &id, body.answer, model, generate).await?)) + Ok(Json(crate::api::answer_node(&state, &id, body.answer, model, generate, None, None).await?)) } async fn update_question_handler( @@ -471,7 +471,7 @@ async fn ai_answer_handler( axum::Json(body): axum::Json, ) -> Result, AppError> { let model = body.model.unwrap_or_else(|| "opus".to_string()); - Ok(Json(crate::api::ai_answer(&state, &id, model).await?)) + Ok(Json(crate::api::ai_answer(&state, &id, model, true).await?)) } async fn add_child_handler( @@ -862,7 +862,7 @@ async fn get_candidates_handler( Path(node_id): Path, ) -> Result>, AppError> { let candidates = crate::api::get_candidates(&state, &node_id)?; - eprintln!("[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len()); + tracing::debug!("[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len()); Ok(Json(candidates)) } diff --git a/crates/spec-forest/src/ingest.rs b/crates/spec-forest/src/ingest.rs index 67696fb..f45e17f 100644 --- a/crates/spec-forest/src/ingest.rs +++ b/crates/spec-forest/src/ingest.rs @@ -395,7 +395,7 @@ async fn run_answer_coordinator(state: SharedState, session_id: String) { } } Err(e) => { - eprintln!("[ingest] Failed to answer node {}: {}", node_id, e); + tracing::error!("[ingest] Failed to answer node {}: {}", node_id, e); let sessions = state.ingest_sessions_lock(); if let Some(session) = sessions.get(&session_id) { session.failed.fetch_add(1, Ordering::Relaxed); @@ -572,7 +572,7 @@ async fn run_shadow_coordinator(state: SharedState, session_id: String, regenera } } Err(e) => { - eprintln!("[ingest] Failed to shadow node {}: {}", node_id, e); + tracing::error!("[ingest] Failed to shadow node {}: {}", node_id, e); let sessions = state.ingest_sessions_lock(); if let Some(session) = sessions.get(&session_id) { session.failed.fetch_add(1, Ordering::Relaxed); @@ -748,7 +748,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { let response = match crate::generate::run_claude_in_dir_cached(&state, &spec_id, &prompt, &model, &dir_path, "ingest_feature_extraction").await { Ok(r) => r, Err(e) => { - eprintln!("[ingest] Feature extraction failed: {}", e); + tracing::error!("[ingest] Feature extraction failed: {}", e); state.update_ingest_session(&session_id, |s| { s.status = IngestState::Done; }); cleanup_session(state, &session_id).await; return; @@ -758,7 +758,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { let features = match parse_feature_response(&response) { Ok(f) => f, Err(e) => { - eprintln!("[ingest] Failed to parse features: {}", e); + tracing::error!("[ingest] Failed to parse features: {}", e); state.update_ingest_session(&session_id, |s| { s.status = IngestState::Done; }); cleanup_session(state, &session_id).await; return; @@ -793,11 +793,11 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { if applied { feature_node_ids.push(node_id); } else { - eprintln!("[ingest] Feature root {} not applied after extended wait", node_id); + tracing::error!("[ingest] Feature root {} not applied after extended wait", node_id); } } Err(e) => { - eprintln!("[ingest] Failed to create feature root: {}", e); + tracing::error!("[ingest] Failed to create feature root: {}", e); } } } @@ -894,7 +894,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { } } Err(e) => { - eprintln!("[ingest] recursive answer failed: {}", e); + tracing::error!("[ingest] recursive answer failed: {}", e); let sessions = state.ingest_sessions_lock(); if let Some(s) = sessions.get(&session_id_c) { s.failed.fetch_add(1, Ordering::Relaxed); diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 4a01f11..becc63c 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -3,10 +3,11 @@ mod dir_context; pub mod explore; mod generate; mod http; -mod ingest; +pub mod ingest; pub mod op_channel; mod op_loop; mod prompt_log; +pub mod simulation; pub mod state; pub mod sync; mod tool_types; @@ -14,6 +15,13 @@ mod tools; pub use api::ApiError; +// Re-export DB types for frontend crates +pub use spec_forest_db::{ + Error as DbError, Locality, Node, NodeState, Spec, SpecMode, SpecSummary, +}; +pub use spec_forest_db::candidate::CandidateAnswer; +pub use spec_forest_db::ShadowAnswer; + use op_channel::OpRequest; use prompt_log::PromptLog; use rmcp::transport::streamable_http_server::{ @@ -43,7 +51,7 @@ pub async fn build_app_state( let log = PromptLog::new(log_path) .map_err(|e| format!("Failed to open prompt log file '{}': {}", log_path, e))?; state.set_prompt_log(log); - eprintln!("Logging AI prompts to {log_path}"); + tracing::info!("Logging AI prompts to {log_path}"); } // Load persisted username from the database @@ -53,8 +61,8 @@ pub async fn build_app_state( if let Some(ref sync_url) = config.sync_url { state.set_sync_url(sync_url.clone()); - eprintln!("Sync server configured at {sync_url}"); - eprintln!("Enter sync password in the web UI to connect"); + tracing::info!("Sync server configured at {sync_url}"); + tracing::info!("Enter sync password in the web UI to connect"); } let (op_tx, op_rx) = tokio::sync::mpsc::channel::(256); @@ -92,7 +100,10 @@ pub async fn build_server( let mcp_service = StreamableHttpService::new( { let state = state.clone(); - move || Ok(SpecForestServer::new(state.clone())) + move || { + tracing::debug!("mcp: new handler"); + Ok(SpecForestServer::new(state.clone())) + } }, Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig { diff --git a/crates/spec-forest/src/main.rs b/crates/spec-forest/src/main.rs index 5321b7c..79ac86a 100644 --- a/crates/spec-forest/src/main.rs +++ b/crates/spec-forest/src/main.rs @@ -5,9 +5,9 @@ use tokio::net::TcpListener; fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".spec_forest"); - std::fs::create_dir_all(&dir).expect("could not create ~/.spec_forest directory"); - dir.join("spec_forest.db").to_string_lossy().into_owned() + .join(".spec-forest"); + std::fs::create_dir_all(&dir).expect("could not create ~/.spec-forest directory"); + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] diff --git a/crates/spec-forest/src/op_channel.rs b/crates/spec-forest/src/op_channel.rs index a6d30e0..062f3f7 100644 --- a/crates/spec-forest/src/op_channel.rs +++ b/crates/spec-forest/src/op_channel.rs @@ -53,3 +53,12 @@ impl std::fmt::Display for OpError { } impl std::error::Error for OpError {} + +/// Notification sent after an op is successfully committed. +#[derive(Clone, Debug)] +pub struct OpNotification { + pub spec_id: String, + pub op_type: &'static str, + /// True when the spec list changed (CreateSpec / DeleteSpec). + pub spec_list_changed: bool, +} diff --git a/crates/spec-forest/src/op_loop.rs b/crates/spec-forest/src/op_loop.rs index e1cb654..4a5d61b 100644 --- a/crates/spec-forest/src/op_loop.rs +++ b/crates/spec-forest/src/op_loop.rs @@ -7,7 +7,7 @@ mod side_effects; pub use op_name::spec_op_type_name; -use crate::op_channel::{OpError, OpRequest, OpSource}; +use crate::op_channel::{OpError, OpNotification, OpRequest, OpSource}; use crate::state::AppState; use spec_forest_protocol::SpecOp; use std::sync::Arc; @@ -15,7 +15,10 @@ use tokio::sync::mpsc; /// Run the unified op apply loop. All database mutations flow through here. pub async fn run_op_loop(state: Arc, mut op_rx: mpsc::Receiver) { + tracing::info!("op_loop: started"); while let Some(req) = op_rx.recv().await { + let op_type = spec_op_type_name(&req.op); + tracing::debug!(spec_id = %req.spec_id, op_type, "op_loop: applying"); let result = apply_and_log(&state, &req); let _ = req.response.send(result); } @@ -34,7 +37,15 @@ fn apply_and_log(state: &AppState, req: &OpRequest) -> Result { // DeleteSpec has its own internal transaction and removes all data — nothing to log. if matches!(req.op, SpecOp::DeleteSpec { .. }) { - return side_effects::apply_delete_spec(conn, &req.op); + let result = side_effects::apply_delete_spec(conn, &req.op); + if result.is_ok() { + state.notify_op(OpNotification { + spec_id: req.spec_id.clone(), + op_type: "DeleteSpec", + spec_list_changed: true, + }); + } + return result; } // Wrap all mutations in a transaction so last_seq stays in sync. @@ -84,6 +95,11 @@ fn apply_and_log(state: &AppState, req: &OpRequest) -> Result { )?; tx.commit().map_err(|e| OpError::Database(e.to_string()))?; + state.notify_op(OpNotification { + spec_id: req.spec_id.clone(), + op_type: op_name::spec_op_type_name(&req.op), + spec_list_changed: matches!(req.op, SpecOp::CreateSpec { .. }), + }); Ok(seq) } diff --git a/crates/spec-forest/src/op_loop/op_name.rs b/crates/spec-forest/src/op_loop/op_name.rs index e3c349d..7360a6c 100644 --- a/crates/spec-forest/src/op_loop/op_name.rs +++ b/crates/spec-forest/src/op_loop/op_name.rs @@ -15,6 +15,7 @@ pub fn spec_op_type_name(op: &SpecOp) -> &'static str { SpecOp::UpdateQuestion { .. } => "UpdateQuestion", SpecOp::AddFeature { .. } => "AddFeature", SpecOp::RemoveFeature { .. } => "RemoveFeature", + SpecOp::UpdateFeature { .. } => "UpdateFeature", SpecOp::CreateContextNode { .. } => "CreateContextNode", SpecOp::UpdateContextSummary { .. } => "UpdateContextSummary", SpecOp::DeleteSpec { .. } => "DeleteSpec", diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs new file mode 100644 index 0000000..5884c40 --- /dev/null +++ b/crates/spec-forest/src/simulation.rs @@ -0,0 +1,29 @@ +pub mod lean_graph; +pub mod lean_orchestrate; +pub mod lean_prompt; +pub mod lean_types; +mod prompt; +pub mod orchestrate; +pub mod runner; +pub mod session; +pub mod tree; +pub mod types; +pub mod warmup_orchestrate; +pub mod warmup_types; + +pub use prompt::{ + append_code_aware_section, build_game_resume_prompt, build_game_spec_update_prompt, + build_game_system_prompt_whole_spec_with_tree, build_game_system_prompt_with_tree, + build_game_tree_output_format, build_initial_prompt, build_system_prompt, + build_system_prompt_whole_spec, build_system_prompt_whole_spec_with_tree, + build_system_prompt_with_tree, build_tree_output_format, build_tree_resume_prompt, +}; +pub use session::{SimChannel, SimSession, SimStatus}; +pub use tree::BreadcrumbEntry; +pub use lean_graph::LeanGraph; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanHistoryEntry, LeanNode}; +pub use types::{ + ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, + GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, + SimResponse, SimTreeNode, SimTreeResponse, +}; diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs new file mode 100644 index 0000000..d43e01a --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -0,0 +1,523 @@ +use std::collections::HashMap; + +use super::lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanNode}; +use super::tree::BreadcrumbEntry; +use super::types::SimInput; +use uuid::Uuid; + +/// A directed acyclic graph of lean game nodes and edges. +/// +/// Each node has exactly 2 generative outgoing edges (plus any number of +/// shortcut edges to existing nodes). Leaf edges are generative edges whose +/// targets haven't been generated yet. +#[derive(Debug, Clone)] +pub struct LeanGraph { + /// All nodes keyed by node_id (UUID). + pub nodes: HashMap, + /// Adjacency list: outgoing edges keyed by source node_id. + pub edges: HashMap>, + /// The root node_id (initial output). + pub root_id: String, +} + +impl LeanGraph { + /// Create a new graph from an initial batch response. + /// + /// Assigns UUIDs to all new nodes and wires up edges. + pub fn from_batch(batch: LeanBatchResponse) -> Self { + let mut graph = LeanGraph { + nodes: HashMap::new(), + edges: HashMap::new(), + root_id: String::new(), + }; + + // Map AI-local IDs to UUIDs. + let mut id_map: HashMap = HashMap::new(); + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = Uuid::new_v4().to_string(); + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + if i == 0 { + graph.root_id = uuid.clone(); + } + graph.nodes.insert(uuid, node); + } + + // Wire up edges. + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + // Shortcut: `to` is already a UUID in the existing graph. + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if graph.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + graph.edges.entry(from_id).or_default().push(lean_edge); + } + + graph + } + + /// Merge a new batch into the existing graph. + /// + /// New nodes get UUIDs. Shortcut edges resolve against existing graph nodes. + /// The `anchor_node_id` is the graph node from which this batch was generated; + /// the batch's root node replaces the leaf edge target pointing to it. + pub fn merge_batch(&mut self, batch: LeanBatchResponse, anchor_node_id: &str) { + // Map AI-local IDs to UUIDs. + let mut id_map: HashMap = HashMap::new(); + let mut batch_root_uuid = String::new(); + + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = Uuid::new_v4().to_string(); + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + if i == 0 { + batch_root_uuid = uuid.clone(); + } + self.nodes.insert(uuid, node); + } + + // Wire up new edges. + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if self.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + self.edges.entry(from_id).or_default().push(lean_edge); + } + + // Update any leaf edges on the anchor node that now point to the batch root. + if let Some(edges) = self.edges.get_mut(anchor_node_id) { + for edge in edges.iter_mut() { + if edge.edge_kind == LeanEdgeKind::Leaf { + // Rewire the first leaf edge to point to the batch root. + edge.target_node_id = batch_root_uuid.clone(); + edge.edge_kind = LeanEdgeKind::Generative; + break; + } + } + } + } + + /// Replace a node's content and edges with a new batch. + /// + /// Used by modify: the batch root replaces the anchor node's channels and + /// edges, so the player sees the modified output at the same position. + pub fn replace_at(&mut self, batch: LeanBatchResponse, anchor_node_id: &str) { + let mut id_map: HashMap = HashMap::new(); + + for (i, mut node) in batch.nodes.into_iter().enumerate() { + let uuid = if i == 0 { + // Reuse the anchor node's ID for the batch root. + anchor_node_id.to_string() + } else { + Uuid::new_v4().to_string() + }; + id_map.insert(node.node_id.clone(), uuid.clone()); + node.node_id = uuid.clone(); + self.nodes.insert(uuid, node); + } + + // Replace the anchor node's edges entirely. + self.edges.remove(anchor_node_id); + + for edge in batch.edges { + let from_id = id_map.get(&edge.from).cloned().unwrap_or(edge.from.clone()); + let to_id = if edge.is_shortcut { + edge.to.clone() + } else { + id_map.get(&edge.to).cloned().unwrap_or(edge.to.clone()) + }; + + let edge_kind = if edge.is_shortcut { + LeanEdgeKind::Shortcut + } else if self.nodes.contains_key(&to_id) { + LeanEdgeKind::Generative + } else { + LeanEdgeKind::Leaf + }; + + let lean_edge = LeanEdge { + label: edge.label, + input: edge.input, + target_node_id: to_id, + edge_kind, + }; + + self.edges.entry(from_id).or_default().push(lean_edge); + } + } + + /// Get a node by ID. + pub fn get_node(&self, id: &str) -> Option<&LeanNode> { + self.nodes.get(id) + } + + /// Get all outgoing edges from a node. + pub fn get_edges(&self, node_id: &str) -> &[LeanEdge] { + self.edges.get(node_id).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// Get only the generative edges from a node. + pub fn generative_edges(&self, node_id: &str) -> Vec<&LeanEdge> { + self.get_edges(node_id) + .iter() + .filter(|e| e.edge_kind == LeanEdgeKind::Generative) + .collect() + } + + /// Whether any outgoing edge from this node is a leaf (ungenerated target). + pub fn has_leaf_edges(&self, node_id: &str) -> bool { + self.get_edges(node_id) + .iter() + .any(|e| e.edge_kind == LeanEdgeKind::Leaf) + } + + /// Find the nearest descendant (via generative edges) that has leaf edges. + /// Returns the node_id suitable as a pregen anchor, or `None` if no frontier found. + pub fn find_pregen_target(&self, node_id: &str) -> Option { + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + queue.push_back(node_id.to_string()); + visited.insert(node_id.to_string()); + + while let Some(current) = queue.pop_front() { + if self.has_leaf_edges(¤t) { + return Some(current); + } + for edge in self.get_edges(¤t) { + if edge.edge_kind == LeanEdgeKind::Generative + && !visited.contains(&edge.target_node_id) + { + visited.insert(edge.target_node_id.clone()); + queue.push_back(edge.target_node_id.clone()); + } + } + } + None + } + + /// BFS depth of generated nodes reachable via generative edges. + pub fn depth_remaining(&self, node_id: &str) -> u8 { + let mut max_depth: u8 = 0; + let mut queue: Vec<(&str, u8)> = vec![(node_id, 0)]; + let mut visited = std::collections::HashSet::new(); + visited.insert(node_id.to_string()); + + while let Some((current, depth)) = queue.pop() { + for edge in self.get_edges(current) { + if edge.edge_kind == LeanEdgeKind::Generative + && !visited.contains(&edge.target_node_id) + { + let next_depth = depth + 1; + if next_depth > max_depth { + max_depth = next_depth; + } + visited.insert(edge.target_node_id.clone()); + queue.push((&edge.target_node_id, next_depth)); + } + } + } + + max_depth + } + + /// Build breadcrumb entries from a navigation path. + pub fn collect_breadcrumbs(&self, path: &[String]) -> Vec { + let mut crumbs = Vec::new(); + + for (i, node_id) in path.iter().enumerate() { + let label = if i == 0 { + "Start".to_string() + } else { + // Find the edge from path[i-1] to path[i] to get the label. + let prev_id = &path[i - 1]; + self.get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *node_id) + .map(|e| e.label.clone()) + .unwrap_or_else(|| format!("Node {}", &node_id[..8.min(node_id.len())])) + }; + + crumbs.push(BreadcrumbEntry { + node_id: node_id.clone(), + label, + }); + } + + crumbs + } + + /// Collect path history as (input, node) pairs for AI replay. + pub fn collect_path_history(&self, path: &[String]) -> Vec<(&SimInput, &LeanNode)> { + let mut history = Vec::new(); + + for i in 1..path.len() { + let prev_id = &path[i - 1]; + let curr_id = &path[i]; + + // Find the edge that connects prev to curr. + let input = self + .get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *curr_id) + .map(|e| &e.input); + + let node = self.nodes.get(curr_id); + + if let (Some(input), Some(node)) = (input, node) { + history.push((input, node)); + } + } + + history + } + + /// Collect path history as (edge_label, node) pairs for display. + pub fn collect_labeled_path_history(&self, path: &[String]) -> Vec<(String, &LeanNode)> { + let mut history = Vec::new(); + + for i in 1..path.len() { + let prev_id = &path[i - 1]; + let curr_id = &path[i]; + + let label = self + .get_edges(prev_id) + .iter() + .find(|e| e.target_node_id == *curr_id) + .map(|e| e.label.clone()) + .unwrap_or_else(|| "???".to_string()); + + if let Some(node) = self.nodes.get(curr_id) { + history.push((label, node)); + } + } + + history + } + + /// All node IDs in the graph (for passing to AI as shortcut targets). + pub fn existing_node_ids(&self) -> Vec { + self.nodes.keys().cloned().collect() + } + + /// All node IDs with a brief summary (first 80 chars of UI channel text). + pub fn existing_node_summaries(&self) -> Vec<(String, String)> { + self.nodes + .iter() + .map(|(id, node)| { + let summary = node + .channels + .get("ui") + .map(|c| { + let text = &c.text; + if text.len() > 150 { + format!("{}...", &text[..text.floor_char_boundary(150)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + (id.clone(), summary) + }) + .collect() + } +} + +/// Parse a `LeanFlatTree` (AI wire format) into a `LeanBatchResponse`. +pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse { + let nodes = flat + .nodes + .into_iter() + .map(|n| LeanNode { + node_id: n.id, + channels: n.channels, + entropy_hint: n.entropy_hint, + }) + .collect(); + + let edges = flat + .edges + .into_iter() + .map(|e| LeanBatchEdge { + from: e.from, + to: e.to, + label: e.label, + input: e.input, + is_shortcut: e.shortcut, + }) + .collect(); + + LeanBatchResponse { nodes, edges } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::types::ChannelContent; + + fn make_channel(text: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert( + "ui".to_string(), + ChannelContent { + text: text.to_string(), + refs: vec![], + spec_gaps: vec![], + }, + ); + m + } + + fn make_input(label: &str) -> SimInput { + SimInput { + keys: vec![label.to_string()], + raw_text: label.to_string(), + } + } + + #[test] + fn test_from_batch_creates_graph() { + let batch = LeanBatchResponse { + nodes: vec![ + LeanNode { + node_id: "root".into(), + channels: make_channel("Root screen"), + entropy_hint: 0.5, + + }, + LeanNode { + node_id: "n1".into(), + channels: make_channel("Screen A"), + entropy_hint: 0.8, + + }, + LeanNode { + node_id: "n2".into(), + channels: make_channel("Screen B"), + entropy_hint: 0.3, + + }, + ], + edges: vec![ + LeanBatchEdge { + from: "root".into(), + to: "n1".into(), + label: "Click A".into(), + input: make_input("a"), + is_shortcut: false, + }, + LeanBatchEdge { + from: "root".into(), + to: "n2".into(), + label: "Click B".into(), + input: make_input("b"), + is_shortcut: false, + }, + ], + }; + + let graph = LeanGraph::from_batch(batch); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.get_edges(&graph.root_id).len(), 2); + assert_eq!(graph.depth_remaining(&graph.root_id), 1); + } + + #[test] + fn test_breadcrumbs() { + let batch = LeanBatchResponse { + nodes: vec![ + LeanNode { + node_id: "root".into(), + channels: make_channel("Root"), + entropy_hint: 0.0, + + }, + LeanNode { + node_id: "n1".into(), + channels: make_channel("Child"), + entropy_hint: 0.0, + + }, + ], + edges: vec![LeanBatchEdge { + from: "root".into(), + to: "n1".into(), + label: "Go to child".into(), + input: make_input("enter"), + is_shortcut: false, + }], + }; + + let graph = LeanGraph::from_batch(batch); + let child_id = graph + .get_edges(&graph.root_id) + .first() + .unwrap() + .target_node_id + .clone(); + + let path = vec![graph.root_id.clone(), child_id]; + let crumbs = graph.collect_breadcrumbs(&path); + assert_eq!(crumbs.len(), 2); + assert_eq!(crumbs[0].label, "Start"); + assert_eq!(crumbs[1].label, "Go to child"); + } + + #[test] + fn test_has_leaf_edges() { + let batch = LeanBatchResponse { + nodes: vec![LeanNode { + node_id: "root".into(), + channels: make_channel("Root"), + entropy_hint: 0.0, + }], + edges: vec![LeanBatchEdge { + from: "root".into(), + to: "nonexistent".into(), + label: "Go somewhere".into(), + input: make_input("enter"), + is_shortcut: false, + }], + }; + + let graph = LeanGraph::from_batch(batch); + assert!(graph.has_leaf_edges(&graph.root_id)); + assert_eq!(graph.depth_remaining(&graph.root_id), 0); + } +} diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs new file mode 100644 index 0000000..1df6267 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -0,0 +1,856 @@ +use std::sync::Arc; +use tracing::{error, info}; + +use super::lean_graph::LeanGraph; +use super::lean_types::LeanEdgeKind; +use super::runner::SimConfig; +use super::session::SimStatus; +use crate::state::AppState; + +/// Orchestrate the initial lean game turn. +/// +/// Loads spec context, builds the lean system prompt, generates the first +/// DAG batch, and stores it in the session. +pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: String) { + info!(session_id, "Starting lean game initial turn"); + + // Read session config. + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let spec_id = session.spec_id.clone(); + let model = session.model.clone(); + let channels = session.channels.clone(); + let scenario = session.scenario.clone(); + let batch_depth = session.lean_batch_depth; + let whole_spec = session.whole_spec; + let focus_node_id = match session.root_node_id { + Some(ref id) => id.clone(), + None => { + set_error(&state, &session_id, "No focus node set for lean game"); + return; + } + }; + drop(session); + + // Load spec context. + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + set_error(&state, &session_id, &format!("Failed to load focus node: {e}")); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + set_error(&state, &session_id, &format!("Spec summary error: {e}")); + return; + } + }; + + // Detect if focus is a spec root (e.g., "What are we building?"). + let is_root_focus = focus_node.question.starts_with("What are we building") + || focus_node.question.starts_with("What are we exploring"); + + // Collect high-entropy nodes, scoped to focus feature when applicable. + let high_entropy_nodes = collect_high_entropy_nodes( + &state, + &spec_id, + 10, + Some(&focus_node_id), + is_root_focus, + ); + + // Build system prompt — whole-spec or focused. + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let system_prompt = if whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + super::lean_prompt::build_lean_system_prompt_whole_spec( + &channels, + &focus_node, + &all_nodes, + &summary, + &high_entropy_nodes, + &spec_id, + is_root_focus, + ) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + super::lean_prompt::build_lean_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + &high_entropy_nodes, + &spec_id, + is_root_focus, + ) + }; + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &[], // No existing nodes yet. + ); + let full_system_prompt = format!("{system_prompt}\n\n{output_format}"); + + // Build initial prompt. + let initial_prompt = super::lean_prompt::build_lean_initial_prompt(&channels, scenario.as_deref()); + + // Build config — spec read-only tools only for batch generation. + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = SimConfig::spec_read_write(model, full_system_prompt, mcp_url); + + match super::runner::start_lean_batch_turn(&config, &initial_prompt).await { + Ok((claude_session_id, batch_response)) => { + let graph = LeanGraph::from_batch(batch_response); + let root_id = graph.root_id.clone(); + let root_id_for_pregen = root_id.clone(); + + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + // Populate channel_contents from root for the TUI. + if let Some(node) = graph.get_node(&root_id) { + s.channel_contents = node.channels.clone(); + } + s.lean_current_node_id = Some(root_id.clone()); + s.lean_navigation_path = vec![root_id]; + s.lean_graph = Some(graph); + s.lean_generation += 1; + s.status = SimStatus::Idle; + }); + info!(session_id, "Lean game initial turn complete"); + // Signal warmup that the real game is ready. + super::warmup_orchestrate::signal_game_ready(&state, &session_id); + // Auto-pregen if root has shallow depth. + maybe_trigger_pregen(&state, &session_id, &root_id_for_pregen); + } + Err(e) => { + set_error(&state, &session_id, &format!("AI generation failed: {e}")); + } + } +} + +/// Navigate to a specific edge from the current node. +/// +/// If the target exists (generative or shortcut), navigation is instant. +/// If the target is a leaf, sets Processing and spawns pregen. +pub async fn orchestrate_lean_navigate( + state: Arc, + session_id: String, + edge_index: usize, +) { + // Read edge info from the graph. + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let graph = match &session.lean_graph { + Some(g) => g, + None => return, + }; + let current_id = match &session.lean_current_node_id { + Some(id) => id.clone(), + None => return, + }; + + let edges = graph.get_edges(¤t_id); + let edge = match edges.get(edge_index) { + Some(e) => e, + None => return, + }; + + let target_node_id = edge.target_node_id.clone(); + let edge_kind = edge.edge_kind; + let edge_label = edge.label.clone(); + drop(session); + + match edge_kind { + LeanEdgeKind::Generative | LeanEdgeKind::Shortcut => { + // Instant navigation. + let from_id = current_id.clone(); + state.update_sim_session(&session_id, |s| { + s.lean_current_node_id = Some(target_node_id.clone()); + s.lean_navigation_path.push(target_node_id.clone()); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id.clone(), + to_node_id: target_node_id.clone(), + label: edge_label.clone(), + is_back: false, + }); + // Update channel_contents for TUI. + if let Some(ref graph) = s.lean_graph { + if let Some(node) = graph.get_node(&target_node_id) { + s.channel_contents = node.channels.clone(); + } + } + // Ensure status is Idle for instant navigation. + s.status = SimStatus::Idle; + }); + + // Spawn background pregen if needed. + maybe_trigger_pregen(&state, &session_id, &target_node_id); + } + LeanEdgeKind::Leaf => { + // If a spec update is running, queue this leaf navigation for later. + let spec_updating = state + .get_sim_session(&session_id) + .map(|s| s.lean_spec_updating) + .unwrap_or(false); + if spec_updating { + state.update_sim_session(&session_id, |s| { + s.lean_queued_leaf = Some((current_id.clone(), edge_index)); + }); + return; + } + + // Need to generate first. + let generation = state + .get_sim_session(&session_id) + .map(|s| s.lean_generation) + .unwrap_or(0); + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + s.lean_generating = true; + s.lean_generation_target = Some(current_id.clone()); + }); + + let state2 = state.clone(); + let sid2 = session_id.clone(); + let current = current_id.clone(); + tokio::spawn(async move { + orchestrate_lean_batch_pregen(state2.clone(), sid2.clone(), current).await; + + // Check if user navigated away during generation. + let current_gen = state2 + .get_sim_session(&sid2) + .map(|s| s.lean_generation); + if current_gen != Some(generation) { + return; + } + + // After generation, navigate to the newly generated target. + let session = state2.get_sim_session(&sid2); + if let Some(s) = session { + if let Some(ref graph) = s.lean_graph { + if let Some(ref curr) = s.lean_current_node_id { + let edges = graph.get_edges(curr); + if let Some(edge) = edges.get(edge_index) { + if edge.edge_kind != LeanEdgeKind::Leaf { + let target = edge.target_node_id.clone(); + let edge_label = edge.label.clone(); + let from_id = curr.clone(); + drop(s); + state2.update_sim_session(&sid2, |s| { + s.lean_current_node_id = Some(target.clone()); + s.lean_navigation_path.push(target.clone()); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id, + to_node_id: target.clone(), + label: edge_label, + is_back: false, + }); + if let Some(ref graph) = s.lean_graph { + if let Some(node) = graph.get_node(&target) { + s.channel_contents = node.channels.clone(); + } + } + s.status = SimStatus::Idle; + }); + // Trigger pregen on the new node so next level starts generating. + maybe_trigger_pregen(&state2, &sid2, &target); + return; + } + } + } + } + } + state2.update_sim_session(&sid2, |s| { + s.status = SimStatus::Idle; + }); + }); + } + } +} + +/// Navigate back one step in the breadcrumb trail. +pub fn orchestrate_lean_go_back(state: Arc, session_id: &str) { + state.update_sim_session(session_id, |s| { + if s.lean_navigation_path.len() > 1 { + let from_id = s.lean_current_node_id.clone().unwrap_or_default(); + s.lean_navigation_path.pop(); + let prev_id = s.lean_navigation_path.last().cloned(); + s.lean_current_node_id = prev_id.clone(); + let to_id = prev_id.clone().unwrap_or_default(); + s.lean_action_history.push(super::lean_types::LeanHistoryEntry { + from_node_id: from_id, + to_node_id: to_id, + label: "← Back".to_string(), + is_back: true, + }); + // Update channel_contents for TUI. + if let (Some(graph), Some(id)) = (&s.lean_graph, &prev_id) { + if let Some(node) = graph.get_node(id) { + s.channel_contents = node.channels.clone(); + } + } + // Cancel any in-flight leaf generation. + if s.status == SimStatus::Processing { + s.lean_generation += 1; + s.status = SimStatus::Idle; + s.lean_generating = false; + s.lean_generation_target = None; + } + } + }); + // After going back, the destination node might need pregen. + let current_id = state + .get_sim_session(session_id) + .and_then(|s| s.lean_current_node_id.clone()); + if let Some(id) = current_id { + maybe_trigger_pregen(&state, session_id, &id); + } +} + +/// Background batch pregeneration from a target node. +async fn orchestrate_lean_batch_pregen( + state: Arc, + session_id: String, + target_node_id: String, +) { + info!(session_id, target_node_id, "Starting lean batch pregen"); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + error!(session_id, "No claude session ID for resume"); + set_lean_generating_false(&state, &session_id); + return; + } + }; + + let generation = session.lean_generation; + let batch_depth = session.lean_batch_depth; + let channels = session.channels.clone(); + + let existing_summaries = session + .lean_graph + .as_ref() + .map(|g| g.existing_node_summaries()) + .unwrap_or_default(); + + // Collect path history for AI replay. + let path_history_data: Vec<(super::types::SimInput, super::lean_types::LeanNode)> = session + .lean_graph + .as_ref() + .map(|g| { + g.collect_path_history(&session.lean_navigation_path) + .into_iter() + .map(|(i, n)| (i.clone(), n.clone())) + .collect() + }) + .unwrap_or_default(); + drop(session); + + // Build resume prompt. + let history_refs: Vec<(&super::types::SimInput, &super::lean_types::LeanNode)> = + path_history_data.iter().map(|(i, n)| (i, n)).collect(); + let resume_prompt = super::lean_prompt::build_lean_resume_prompt(&history_refs, None); + + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &existing_summaries, + ); + let full_prompt = format!("{resume_prompt}\n\n{output_format}"); + + // Call AI to resume. + match super::runner::resume_lean_batch_turn(&claude_session_id, &full_prompt).await { + Ok(batch_response) => { + state.update_sim_session(&session_id, |s| { + // Check generation counter for staleness. + if s.lean_generation != generation { + info!(session_id, "Stale pregen, discarding"); + } else if let Some(ref mut graph) = s.lean_graph { + graph.merge_batch(batch_response, &target_node_id); + } + s.lean_generating = false; + s.lean_generation_target = None; + }); + info!(session_id, "Lean batch pregen complete"); + + // Check for queued work now that pregen is done. + spawn_queued_work(state, session_id); + } + Err(e) => { + error!(session_id, error = %e, "Lean batch pregen failed"); + set_lean_generating_false(&state, &session_id); + + // Check for queued work even on pregen failure. + spawn_queued_work(state, session_id); + } + } +} + +/// Send accumulated navigation actions to the main Claude session for spec updates. +/// +/// Builds a prompt with the navigation history since the last send, the user's +/// notes, and the current spec outline. Resumes the main session which then +/// uses write MCP tools to update the spec. +pub async fn orchestrate_lean_send_actions( + state: Arc, + session_id: String, + user_notes: String, +) { + info!(session_id, "Starting lean send actions"); + + // 1. Snapshot unsent history range and set spec_updating flag. + let (claude_sid, spec_id, unsent_history_text, new_sent_len) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_sid = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for send actions"); + return; + } + }; + let spec_id = session.spec_id.clone(); + let sent = session.lean_sent_history_len; + let new_sent_len = session.lean_action_history.len(); + + // Build history text from unsent action history entries. + let unsent_entries: Vec = + session.lean_action_history[sent..].to_vec(); + let history_text = + format_history_from_entries(session.lean_graph.as_ref(), &unsent_entries); + + drop(session); + (claude_sid, spec_id, history_text, new_sent_len) + }; + + state.update_sim_session(&session_id, |s| { + s.lean_spec_updating = true; + }); + + // 2. Build spec outline for context. + let roots = crate::api::get_spec_roots(&state, &spec_id).unwrap_or_default(); + let descendants_by_root: Vec> = roots + .iter() + .map(|root| crate::api::get_descendants(&state, &root.id).unwrap_or_default()) + .collect(); + let spec_outline = super::lean_prompt::build_spec_outline(&roots, &descendants_by_root); + + // 2b. Include any warmup captures as additional context. + let warmup_section = { + let captures = state + .get_sim_session(&session_id) + .map(|s| s.warmup_captures.clone()) + .unwrap_or_default(); + if captures.is_empty() { + String::new() + } else { + let mut section = String::from( + "\n## Pre-game Warmup Feedback\n\ + The player provided these responses during warmup (before the game started). \ + Consider these when updating the spec:\n\n", + ); + for cap in &captures { + section.push_str(&format!( + "- **Re: {}**\n Player said: \"{}\"\n", + cap.node_question, cap.player_response + )); + } + section.push('\n'); + section + } + }; + + // 3. Build prompt. + let full_notes = if warmup_section.is_empty() { + user_notes + } else { + format!("{user_notes}{warmup_section}") + }; + let prompt = super::lean_prompt::build_send_actions_prompt( + &unsent_history_text, + &full_notes, + &spec_id, + &spec_outline, + ); + + // 4. Resume main session with spec update prompt. + match super::runner::resume_lean_spec_update_turn(&claude_sid, &prompt).await { + Ok(response) => { + let sent = state + .get_sim_session(&session_id) + .map(|s| s.lean_sent_history_len) + .unwrap_or(0); + state.update_sim_session(&session_id, |s| { + s.lean_spec_updating = false; + s.lean_sent_history_len = new_sent_len; + s.game_spec_updates.push(super::types::GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: format!("{} actions sent", new_sent_len.saturating_sub(sent)), + description: response, + node_id: String::new(), + action: "send_actions".to_string(), + }); + }); + info!(session_id, "Lean send actions complete"); + + // Process any queued work. + spawn_queued_work(state, session_id); + } + Err(e) => { + error!(session_id, error = %e, "Lean send actions failed"); + state.update_sim_session(&session_id, |s| { + s.lean_spec_updating = false; + }); + } + } +} + +/// Spawn queued work after a spec update or pregen completes. +fn spawn_queued_work(state: Arc, session_id: String) { + // Check for queued leaf navigation. + let queued_leaf = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_queued_leaf.clone()); + if let Some((_node_id, edge_index)) = queued_leaf { + state.update_sim_session(&session_id, |s| { + s.lean_queued_leaf = None; + }); + tokio::spawn(async move { + orchestrate_lean_navigate(state, session_id, edge_index).await; + }); + return; + } + + // Check for queued send actions. + let queued_send = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_queued_send.clone()); + if let Some(notes) = queued_send { + state.update_sim_session(&session_id, |s| { + s.lean_queued_send = None; + }); + tokio::spawn(async move { + orchestrate_lean_send_actions(state, session_id, notes).await; + }); + return; + } + + // Re-check if current position needs pregen (user may have moved during prior pregen). + let current_id = state + .get_sim_session(&session_id) + .and_then(|s| s.lean_current_node_id.clone()); + if let Some(id) = current_id { + maybe_trigger_pregen(&state, &session_id, &id); + } +} + +/// Format action history entries for the send actions prompt. +fn format_history_from_entries( + graph: Option<&super::lean_graph::LeanGraph>, + entries: &[super::lean_types::LeanHistoryEntry], +) -> String { + let mut text = String::new(); + for (i, entry) in entries.iter().enumerate() { + text.push_str(&format!("### Step {}\n", i + 1)); + if entry.is_back { + text.push_str("**Action:** ← Back (returned to previous state)\n"); + } else { + text.push_str(&format!("**Action:** {}\n", entry.label)); + } + if let Some(graph) = graph { + if let Some(node) = graph.get_node(&entry.to_node_id) { + const CHANNEL_ORDER: &[&str] = &["ui", "audio", "network", "errors", "logs"]; + let mut has_output = false; + for &channel_name in CHANNEL_ORDER { + if let Some(content) = node.channels.get(channel_name) { + if content.text.is_empty() { + continue; + } + let limit = if channel_name == "ui" { 500 } else { 200 }; + let output = if content.text.len() > limit { + format!( + "{}...", + &content.text[..content.text.floor_char_boundary(limit)] + ) + } else { + content.text.clone() + }; + text.push_str(&format!("**[{}]:**\n{}\n\n", channel_name, output)); + if !content.spec_gaps.is_empty() { + text.push_str(&format!( + "**[{} assumptions]:** {}\n\n", + channel_name, + content.spec_gaps.join("; ") + )); + } + has_output = true; + } + } + if !has_output { + text.push('\n'); + } + } + } + } + text +} + +/// Handle a player query about the current state. +pub async fn orchestrate_lean_query( + state: Arc, + session_id: String, + question: String, +) { + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + }); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for query"); + return; + } + }; + drop(session); + + let prompt = super::lean_prompt::build_lean_query_prompt(&question); + + match super::runner::resume_sim_report_turn(&claude_session_id, &prompt).await { + Ok(report) => { + state.update_sim_session(&session_id, |s| { + s.pending_report = Some(report); + s.status = SimStatus::Idle; + }); + } + Err(e) => { + set_error(&state, &session_id, &format!("Query failed: {e}")); + } + } +} + +/// Handle a player modification request — regenerate batch from current node. +pub async fn orchestrate_lean_modify( + state: Arc, + session_id: String, + modification: String, +) { + state.update_sim_session(&session_id, |s| { + s.status = SimStatus::Processing; + s.lean_generation += 1; // Invalidate in-flight pregens. + }); + + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => { + set_error(&state, &session_id, "No claude session for modify"); + return; + } + }; + let batch_depth = session.lean_batch_depth; + let channels = session.channels.clone(); + let current_id = session.lean_current_node_id.clone(); + let existing_summaries = session + .lean_graph + .as_ref() + .map(|g| g.existing_node_summaries()) + .unwrap_or_default(); + drop(session); + + let modify_prompt = super::lean_prompt::build_lean_modify_prompt(&modification); + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let output_format = super::lean_prompt::build_lean_batch_output_format( + batch_depth, + &channel_list, + &existing_summaries, + ); + let full_prompt = format!("{modify_prompt}\n\n{output_format}"); + + match super::runner::resume_lean_batch_turn(&claude_session_id, &full_prompt).await { + Ok(batch_response) => { + state.update_sim_session(&session_id, |s| { + if let Some(ref mut graph) = s.lean_graph { + if let Some(ref cid) = current_id { + // Replace the current node's content and edges with the modified batch. + graph.replace_at(batch_response, cid); + // Update channel_contents so the TUI shows the modified output. + if let Some(node) = graph.get_node(cid) { + s.channel_contents = node.channels.clone(); + } + } + } + s.status = SimStatus::Idle; + }); + } + Err(e) => { + set_error(&state, &session_id, &format!("Modify failed: {e}")); + } + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +/// Check if the given node needs pregen and spawn it if so. +/// +/// Finds the nearest descendant with leaf edges to use as the actual pregen +/// anchor, so the generated batch attaches at the right frontier node. +fn maybe_trigger_pregen(state: &Arc, session_id: &str, node_id: &str) { + let pregen_target = { + let session = state.get_sim_session(session_id); + if let Some(ref s) = session { + if let Some(ref graph) = s.lean_graph { + let depth = graph.depth_remaining(node_id); + if !s.lean_generating && depth < 2 { + graph.find_pregen_target(node_id) + } else { + None + } + } else { + None + } + } else { + None + } + }; + + if let Some(target) = pregen_target { + let state2 = state.clone(); + let sid2 = session_id.to_string(); + state.update_sim_session(session_id, |s| { + s.lean_generating = true; + s.lean_generation_target = Some(target.clone()); + }); + tokio::spawn(async move { + orchestrate_lean_batch_pregen(state2, sid2, target).await; + }); + } +} + +fn set_error(state: &AppState, session_id: &str, msg: &str) { + error!(session_id, msg, "Lean game error"); + state.update_sim_session(session_id, |s| { + s.status = SimStatus::Error(msg.to_string()); + s.lean_generating = false; + }); +} + +fn set_lean_generating_false(state: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + s.lean_generating = false; + s.lean_generation_target = None; + }); +} + +/// Collect high-entropy nodes from the spec for prompt guidance. +/// +/// When `focus_node_id` is provided and the focus node is not a spec root, +/// candidates are scoped to descendants of the focus node. Falls back to +/// unscoped collection if no descendants match. +pub(crate) fn collect_high_entropy_nodes( + state: &AppState, + spec_id: &str, + limit: usize, + focus_node_id: Option<&str>, + is_root_focus: bool, +) -> Vec<(String, String)> { + let nodes = crate::api::get_spec_nodes(state, spec_id).unwrap_or_default(); + + // When focused on a non-root feature, scope to its descendants. + let scope_ids: Option> = + if !is_root_focus { + if let Some(fid) = focus_node_id { + let descendants = crate::api::get_descendants(state, fid).unwrap_or_default(); + if !descendants.is_empty() { + Some(descendants.into_iter().map(|n| n.id).collect()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let in_scope = |id: &str| -> bool { + match &scope_ids { + Some(ids) => ids.contains(id), + None => true, + } + }; + + let mut candidates: Vec<(String, String)> = Vec::new(); + + // Unanswered first. + for node in &nodes { + if node.answer.is_none() && in_scope(&node.id) { + candidates.push((node.id.clone(), node.question.clone())); + } + } + + // Then nodes needing review. + for node in &nodes { + if node.answer.is_some() && node.state == crate::NodeState::NeedsReview && in_scope(&node.id) { + candidates.push((node.id.clone(), node.question.clone())); + } + } + + // Fall back to unscoped if feature-scoped search found nothing. + if candidates.is_empty() && scope_ids.is_some() { + for node in &nodes { + if node.answer.is_none() { + candidates.push((node.id.clone(), node.question.clone())); + } + } + for node in &nodes { + if node.answer.is_some() && node.state == crate::NodeState::NeedsReview { + candidates.push((node.id.clone(), node.question.clone())); + } + } + } + + candidates.truncate(limit); + candidates +} diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs new file mode 100644 index 0000000..c853b25 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -0,0 +1,695 @@ +use super::session::SimChannel; +use super::types::SimInput; +use crate::Node; +use spec_forest_db::SpecSummary; + +/// Build the system prompt for a lean game simulation. +/// +/// Key differences from regular sim prompt: +/// - Ultra-lightweight node output (no decisions, no spec_gaps, no refs) +/// - Entropy guidance via high-entropy node IDs + questions +/// - MCP tools enabled for spec lookup +/// - DAG format with generative + shortcut edges +pub fn build_lean_system_prompt( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + high_entropy_nodes: &[(String, String)], // (node_id, question) + spec_id: &str, + is_root_focus: bool, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section (compact). + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Ancestor chain (compact). + let mut ancestor_section = String::new(); + for node in ancestors { + if node.id == focus_node.id { + continue; + } + ancestor_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + ancestor_section.push_str(&format!(" → {}", answer)); + } + ancestor_section.push('\n'); + } + + // Descendants (compact). + let mut descendant_section = String::new(); + for node in descendants { + if node.id == focus_node.id { + continue; + } + descendant_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + descendant_section.push_str(&format!(" → {}", answer)); + } + descendant_section.push('\n'); + } + + // Other roots (just questions). + let mut other_roots_section = String::new(); + for node in other_roots { + other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); + } + + // Scenario design guidance with high-entropy nodes. + let mut scenario_section = String::new(); + if is_root_focus { + scenario_section.push_str( + "## Scenario Focus\n\ + This simulation was launched from the spec root. You may design scenarios \ + exploring spec gaps from ANY area of the specification.\n\n", + ); + } else { + scenario_section.push_str(&format!( + "## Scenario Focus\n\ + This simulation was launched from a specific feature: **{}**. \ + Design scenarios that explore gaps WITHIN this feature's scope. \ + Only venture outside this feature if a gap naturally depends on \ + cross-cutting behavior.\n\n", + focus_node.question + )); + } + if !high_entropy_nodes.is_empty() { + scenario_section.push_str( + "### Known Spec Gaps\n\ + These spec areas have unresolved or uncertain answers. Design scenarios \ + that naturally lead the player through situations where these questions \ + matter:\n\n", + ); + for (id, question) in high_entropy_nodes { + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + scenario_section.push('\n'); + } + + format!( + r#"## SPEC FIDELITY — YOUR PRIMARY OBLIGATION +- When the spec provides an answer for a behavior, you MUST render output that + matches that answer exactly. Do not improvise, reinterpret, or simplify. +- Think of yourself as an implementer following a spec document. If the spec says + the login page has email and password fields with a "Sign In" button, that is + what you render — not a variation. +- Before generating output for ANY area, use the MCP tools (search_nodes, + get_node, get_descendants) to verify what the spec says. Do not rely solely + on the context provided below — search for related nodes proactively. +- Do not invent behavior that contradicts what the spec says. When in doubt, + look it up. + +## WHEN THE SPEC IS SILENT +Only when the spec is genuinely silent or ambiguous on a topic should you make +implementation choices. In that case, make choices as a thoughtful implementer +would — pick reasonable defaults and render them confidently. The channel text +must always read like a finished application. Use the spec_gaps array to log +each assumption you made, and entropy_hint to signal overall uncertainty. + +## CARDINAL RULE: YOU ARE A SCENARIO DESIGNER +You simulate the program that would be built from this spec. But your real job +is designing scenarios that EXPLORE SPEC GAPS. + +Before generating the DAG, mentally identify 3–5 high-entropy decisions you must +make where the spec is silent. Then design the DAG so that each generative path +is a mini-scenario that forces the player to experience one of these decisions. + +At each node, generate exactly 2 NEW child outputs via generative edges: +- At least one edge should present a scenario that explores a spec gap — a place + where you had to make an assumption the player needs to validate or reject. +- The other can present an alternative scenario for a different gap, or the + expected/obvious path. + +SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add +shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic +navigation: back buttons, shared destinations, menu returns, and loop-backs. +A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. + +## SPEC_GAPS: LOG YOUR ASSUMPTIONS +For every node, populate the `spec_gaps` array on each channel with short notes +about assumptions you made for that output. Examples: +- "Assumed password minimum is 8 chars — spec silent on validation rules" +- "Chose to show inline error — spec doesn't specify error display pattern" +- "Defaulted to email-only login — spec doesn't mention social auth" + +These notes are your implementer log. They are NOT shown to the player but are +used later to determine which assumptions were validated through play. The channel +text itself must remain clean — no uncertainty markers, no spec questions. + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, +explanation, or markdown before or after the JSON. Do NOT wrap in code fences. +The very first character must be `{{`. + +## Spec Context +Spec ID: {spec_id} +Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_review} review. + +{focus_section} + +### Ancestors +{ancestor_section} + +### Descendants +{descendant_section} + +### Other Areas +{other_roots_section} + +{scenario_section} + +## Tools (READ-ONLY) +You have read-only access to spec-forest MCP tools. Use them to look up spec details: +- **search_nodes**: Search by text (spec_id: {spec_id}) +- **get_node**: Get a node by ID +- **get_descendants**: Get a node's subtree +- **get_spec_summary**: Get spec overview + +These are the ONLY tools available. Do NOT attempt to use any other tools. +Do NOT try to modify the spec, create sessions, or call any sim_* or game_* tools. +Use these read-only tools when generating outputs that touch areas outside the loaded context. + +## Channel Semantics +Active channels: {channel_list} +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would \ + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout \ + to approximate any UI type (web, desktop, mobile, TUI). Keep concise. +- "audio": Timestamped audio events, e.g. '[AUDIO] Click sound' +- "network": Network events, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages from the simulated application +- "logs": Application log output + +Keep channel text concise — concrete simulation output as a real application would display it. \ +No spec questions or uncertainty markers in channel text. DO populate the spec_gaps array \ +with short implementer notes for each assumption you made."#, + spec_id = spec_id, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + focus_section = focus_section, + ancestor_section = if ancestor_section.is_empty() { + "_(root node)_\n".to_string() + } else { + ancestor_section + }, + descendant_section = if descendant_section.is_empty() { + "_(none)_\n".to_string() + } else { + descendant_section + }, + other_roots_section = if other_roots_section.is_empty() { + "_(none)_\n".to_string() + } else { + other_roots_section + }, + scenario_section = scenario_section, + channel_list = channel_list, + ) +} + +/// Build the system prompt for a lean game simulation with the ENTIRE spec loaded. +/// +/// Similar to `build_lean_system_prompt` but includes all spec nodes instead of +/// just ancestors/descendants/other roots. +pub fn build_lean_system_prompt_whole_spec( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + high_entropy_nodes: &[(String, String)], // (node_id, question) + spec_id: &str, + is_root_focus: bool, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section (compact). + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build complete spec section with all nodes. + let mut all_nodes_section = String::new(); + for node in all_nodes { + if node.id == focus_node.id { + continue; + } + all_nodes_section.push_str(&format!("- **{}**: {}", node.id, node.question)); + if let Some(ref answer) = node.answer { + all_nodes_section.push_str(&format!(" → {}", answer)); + } else { + all_nodes_section.push_str(" _(unanswered)_"); + } + all_nodes_section.push('\n'); + } + + // Scenario design guidance with high-entropy nodes. + let mut scenario_section = String::new(); + if is_root_focus { + scenario_section.push_str( + "## Scenario Focus\n\ + This simulation was launched from the spec root. You may design scenarios \ + exploring spec gaps from ANY area of the specification.\n\n", + ); + } else { + scenario_section.push_str(&format!( + "## Scenario Focus\n\ + This simulation was launched from a specific feature: **{}**. \ + Design scenarios that explore gaps WITHIN this feature's scope. \ + Only venture outside this feature if a gap naturally depends on \ + cross-cutting behavior.\n\n", + focus_node.question + )); + } + if !high_entropy_nodes.is_empty() { + scenario_section.push_str( + "### Known Spec Gaps\n\ + These spec areas have unresolved or uncertain answers. Design scenarios \ + that naturally lead the player through situations where these questions \ + matter:\n\n", + ); + for (id, question) in high_entropy_nodes { + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + scenario_section.push('\n'); + } + + format!( + r#"## SPEC FIDELITY — YOUR PRIMARY OBLIGATION +- When the spec provides an answer for a behavior, you MUST render output that + matches that answer exactly. Do not improvise, reinterpret, or simplify. +- Think of yourself as an implementer following a spec document. If the spec says + the login page has email and password fields with a "Sign In" button, that is + what you render — not a variation. +- Before generating output for ANY area, use the MCP tools (search_nodes, + get_node, get_descendants) to verify what the spec says. Do not rely solely + on the context provided below — search for related nodes proactively. +- Do not invent behavior that contradicts what the spec says. When in doubt, + look it up. + +## WHEN THE SPEC IS SILENT +Only when the spec is genuinely silent or ambiguous on a topic should you make +implementation choices. In that case, make choices as a thoughtful implementer +would — pick reasonable defaults and render them confidently. The channel text +must always read like a finished application. Use the spec_gaps array to log +each assumption you made, and entropy_hint to signal overall uncertainty. + +## CARDINAL RULE: YOU ARE A SCENARIO DESIGNER +You simulate the program that would be built from this spec. But your real job +is designing scenarios that EXPLORE SPEC GAPS. + +Before generating the DAG, mentally identify 3–5 high-entropy decisions you must +make where the spec is silent. Then design the DAG so that each generative path +is a mini-scenario that forces the player to experience one of these decisions. + +At each node, generate exactly 2 NEW child outputs via generative edges: +- At least one edge should present a scenario that explores a spec gap — a place + where you had to make an assumption the player needs to validate or reject. +- The other can present an alternative scenario for a different gap, or the + expected/obvious path. + +SHORTCUT EDGES ARE ESSENTIAL. Whenever existing nodes are listed, actively add +shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realistic +navigation: back buttons, shared destinations, menu returns, and loop-backs. +A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. + +## SPEC_GAPS: LOG YOUR ASSUMPTIONS +For every node, populate the `spec_gaps` array on each channel with short notes +about assumptions you made for that output. Examples: +- "Assumed password minimum is 8 chars — spec silent on validation rules" +- "Chose to show inline error — spec doesn't specify error display pattern" +- "Defaulted to email-only login — spec doesn't mention social auth" + +These notes are your implementer log. They are NOT shown to the player but are +used later to determine which assumptions were validated through play. The channel +text itself must remain clean — no uncertainty markers, no spec questions. + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, +explanation, or markdown before or after the JSON. Do NOT wrap in code fences. +The very first character must be `{{`. + +## Spec Context +Spec ID: {spec_id} +Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_review} review. + +{focus_section} + +## Complete Specification (All Nodes) +The entire spec has been loaded. All nodes are listed below: + +{all_nodes_section} + +{scenario_section} + +## Tools (READ-ONLY) +You have read-only access to spec-forest MCP tools. Use them to look up spec details: +- **search_nodes**: Search by text (spec_id: {spec_id}) +- **get_node**: Get a node by ID +- **get_descendants**: Get a node's subtree +- **get_spec_summary**: Get spec overview + +These are the ONLY tools available. Do NOT attempt to use any other tools. +Do NOT try to modify the spec, create sessions, or call any sim_* or game_* tools. +Use these read-only tools when generating outputs that touch areas outside the loaded context. + +## Channel Semantics +Active channels: {channel_list} +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would \ + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout \ + to approximate any UI type (web, desktop, mobile, TUI). Keep concise. +- "audio": Timestamped audio events, e.g. '[AUDIO] Click sound' +- "network": Network events, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages from the simulated application +- "logs": Application log output + +Keep channel text concise — concrete simulation output as a real application would display it. \ +No spec questions or uncertainty markers in channel text. DO populate the spec_gaps array \ +with short implementer notes for each assumption you made."#, + spec_id = spec_id, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + focus_section = focus_section, + all_nodes_section = if all_nodes_section.is_empty() { + "_(no other nodes)_\n".to_string() + } else { + all_nodes_section + }, + scenario_section = scenario_section, + channel_list = channel_list, + ) +} + +/// Build the lean batch output format section. +/// +/// Describes the DAG wire format: nodes + edges with generative/shortcut distinction. +pub fn build_lean_batch_output_format( + batch_depth: u8, + channel_list: &str, + existing_nodes: &[(String, String)], // (node_id, brief summary) +) -> String { + let mut existing_section = String::new(); + if !existing_nodes.is_empty() { + existing_section.push_str( + "## Existing DAG Nodes — ADD SHORTCUTS TO THESE\n\ + These nodes already exist in the DAG. Add shortcut edges (`\"shortcut\": true`) to \ + create realistic navigation paths (back buttons, shared screens, loop-backs). \ + Each non-leaf node should have at least 1 shortcut edge.\n\n", + ); + for (id, summary) in existing_nodes { + let truncated = if summary.len() > 120 { + format!("{}...", &summary[..summary.floor_char_boundary(120)]) + } else { + summary.clone() + }; + existing_section.push_str(&format!("- `{}`: {}\n", id, truncated)); + } + existing_section.push('\n'); + } + + format!( + r#"## Output Format — Lean DAG (nodes + edges) +Every response must be a JSON object with "nodes" and "edges" arrays. + +Schema: +{{{{ + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} + }}}}, + "entropy_hint": 0.7 + }}}}, + {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, + {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} + ], + "edges": [ + {{{{"from": "root", "to": "n1", "label": "Click Submit button", "input": {{{{"keys": ["Enter"], "raw_text": ""}}}}}}}}, + {{{{"from": "root", "to": "n2", "label": "Open Settings", "input": {{{{"keys": ["click"], "raw_text": ""}}}}}}}}, + {{{{"from": "root", "to": "existing-uuid", "label": "Navigate back", "input": {{{{"keys": ["back"], "raw_text": ""}}}}, "shortcut": true}}}} + ] +}}}} + +Active channels: {channel_list} + +## DAG Rules +1. Generate {depth} levels deep. Root is level 0, children are level 1, etc. +2. Each non-leaf node MUST have exactly 2 generative edges (creating NEW child nodes). +3. You SHOULD add shortcut edges (`"shortcut": true`) linking to existing nodes. + When existing nodes are listed, each non-leaf node SHOULD have at least 1 shortcut edge. + Good shortcut scenarios: "Go Back" / "Return to menu" / "Cancel" leading to a prior screen, + "Submit" leading to a shared confirmation state, navigation tabs leading to already-visited areas, + error-then-retry loops back to an input form. Shortcuts are free — use them generously. +4. Each generative edge should represent a distinct scenario path. At least one should + explore a spec gap — a place where you had to make an assumption. The edge label + should hint at the scenario without revealing spec internals (e.g., "Submit with + short password" not "Test spec gap: password validation unspecified"). +5. entropy_hint (0.0–1.0): how close this node's state is to unresolved spec decisions. + 0.0 = fully specified, 1.0 = highly ambiguous. +6. Leaf nodes at max depth: include edges but OMIT the target nodes from "nodes" array. +7. Node IDs must be short unique strings ("root", "n1", "n2", etc.). +8. Every node must include entries for ALL active channels. +9. Keep channel text concise — focus on the simulation output, not explanations. +10. Channel text must NEVER contain spec questions, uncertainty markers, or placeholders. + Render every output as if the application is fully built. +11. Populate spec_gaps on each channel with short notes about assumptions you made + for that output. These are your implementer log — they help track which decisions + need spec coverage. + +{existing_section}"#, + channel_list = channel_list, + depth = batch_depth, + existing_section = existing_section, + ) +} + +/// Build the initial prompt for the first lean game turn. +pub fn build_lean_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + match scenario { + Some(desc) if !desc.trim().is_empty() => format!( + "Initialize the lean game simulation with this scenario:\n\n\ + {desc}\n\n\ + Render the application state across channels: {channel_list}. \ + Generate the DAG batch from the starting state." + ), + _ => format!( + "Initialize the lean game simulation. Render the application's starting state \ + across channels: {channel_list}. Generate the DAG batch from the starting state." + ), + } +} + +/// Build a resume prompt that replays the player's path and requests the next batch. +pub fn build_lean_resume_prompt( + history: &[(&SimInput, &super::lean_types::LeanNode)], + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !history.is_empty() { + prompt.push_str("The player navigated through these interactions:\n\n"); + for (i, (input, node)) in history.iter().enumerate() { + let ui_summary = node + .channels + .get("ui") + .map(|c| { + let text = &c.text; + if text.len() > 200 { + format!("{}...", &text[..text.floor_char_boundary(200)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + + prompt.push_str(&format!( + "{}. Input: keys={:?}, raw_text={:?}\n UI: {}\n\n", + i + 1, + input.keys, + input.raw_text, + ui_summary, + )); + } + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "The player provided a custom input: {}\n\n", + input + )); + } + None => { + prompt.push_str("The player reached the end of the generated DAG.\n\n"); + } + } + + prompt.push_str( + "Generate the next DAG batch from the current state. \ + Continue designing scenario paths that explore spec gaps. Each new batch should \ + introduce scenarios for assumptions not yet explored. Populate spec_gaps on new nodes \ + with the assumptions you made.\n\n\ + IMPORTANT: The existing nodes listed in the output format section are available as \ + shortcut targets. Add shortcut edges generously — back-navigation, shared screens, \ + and loop-backs make the DAG realistic. Aim for at least 1 shortcut per non-leaf node.\n\n\ + JSON only, no text before or after. First character must be `{`.", + ); + + prompt +} + +/// Build a query prompt for when the player asks a question. +pub fn build_lean_query_prompt(question: &str) -> String { + format!( + "The player asks: \"{question}\"\n\n\ + Answer their question about the current simulation state. Reference spec nodes \ + where relevant. Respond with a JSON object:\n\ + {{\"explanation\": \"...\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ + JSON only, no text before or after." + ) +} + +/// Build a modify prompt for when the player wants to change the simulation. +pub fn build_lean_modify_prompt(modification: &str) -> String { + format!( + "The player wants to modify the simulation: \"{modification}\"\n\n\ + Apply this modification and regenerate the DAG batch from the current state. \ + The modification should be reflected in the root node's output and all subsequent nodes. \ + JSON only, no text before or after. First character must be `{{}}`." + ) +} + +/// Build the prompt for sending accumulated navigation actions to update the spec. +/// +/// Includes the navigation history, user notes, and the full spec outline. +/// The AI uses write tools to apply updates and responds with a plain text summary. +pub fn build_send_actions_prompt( + navigation_history: &str, + user_notes: &str, + spec_id: &str, + spec_outline: &str, +) -> String { + let mut prompt = String::new(); + + prompt.push_str("## Spec Update Request\n\n"); + prompt.push_str( + "The player has been navigating through the simulation. Their journey is a source of \ + truth for updating the spec. Below is their navigation history — each action they chose \ + and the resulting output.\n\n", + ); + + prompt.push_str( + "### How to interpret the journey\n\n\ + - **Silent navigation = acceptance.** If the player navigated to or past an output \ + without modifying or querying it, treat that action and its output as correct behavior. \ + The player is implicitly validating that the simulation behaved as expected.\n\ + - **Think beyond the UI.** Each interaction implies behavior across the full system. \ + If the player submits a form, that confirms not just the UI layout but also the API \ + endpoint, validation rules, data persistence, and any side effects. Update specs for \ + ALL relevant features — not just the screen the player was looking at.\n\ + - **New information fills spec gaps.** Where the spec is unspecified or underspecified \ + and the player's journey demonstrates concrete behavior for those areas, update the spec \ + to capture that new information. The journey is evidence of how the system should work.\n\ + - **Modifications and queries matter too.** The player may have asked you questions or \ + requested modifications during the session — those interactions (already in your session \ + context) should also inform what you update.\n\ + - **Implementer assumptions (spec_gaps) are evidence.** Each step includes the \ + assumptions the simulator made (listed as \"assumptions\" after the channel output). \ + If the player navigated past without modifying, those assumptions are validated — \ + capture them as new spec answers. If the player modified or queried, the assumption \ + was wrong — do NOT add it.\n\ + - **Don't duplicate existing coverage.** If the spec already clearly describes the \ + observed behavior, skip it. Only add or update where there is genuinely new information \ + from the journey.\n\n", + ); + + prompt.push_str("### Navigation History\n"); + prompt.push_str(navigation_history); + + if !user_notes.trim().is_empty() { + prompt.push_str(&format!("\n### Player Notes\n{}\n\n", user_notes)); + } + + prompt.push_str(&format!( + "### Current Spec Outline (spec_id: {})\n{}\n\n", + spec_id, spec_outline + )); + + prompt.push_str( + "Based on the navigation history, player notes, and any prior modifications or queries \ + from this session, use the spec tools to update the specification. You can:\n\ + - **search_nodes / search_features**: Find related spec areas\n\ + - **get_node / get_descendants**: Read details\n\ + - **answer_question**: Update an existing node's answer\n\ + - **add_children**: Add new Q&A under an existing node\n\ + - **add_feature**: Create a new feature root\n\n\ + Focus on capturing new information revealed by the player's journey — especially \ + behaviors that were previously unspecified. After making all updates, respond with a \ + brief summary of what you changed and why.", + ); + + prompt +} + +/// Build a compact text outline of the entire spec tree. +pub fn build_spec_outline(roots: &[Node], descendants_by_root: &[Vec]) -> String { + let mut outline = String::new(); + + for (root, descendants) in roots.iter().zip(descendants_by_root.iter()) { + outline.push_str(&format!("Feature: {} ({})\n", root.question, root.id)); + + // Build a simple indented list from descendants. + // Descendants are in depth-first order from get_descendants. + for node in descendants { + if node.id == root.id { + continue; + } + let answer_summary = match &node.answer { + Some(a) if a.len() > 80 => { + format!(" -> {}...", &a[..a.floor_char_boundary(80)]) + } + Some(a) => format!(" -> {a}"), + None => " (unanswered)".to_string(), + }; + outline.push_str(&format!(" Q: {} ({}){}\n", node.question, node.id, answer_summary)); + } + } + + if outline.is_empty() { + "(empty spec)".to_string() + } else { + outline + } +} diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs new file mode 100644 index 0000000..ffcfd3b --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::types::ChannelContent; +use super::types::SimInput; + +// ── Node ──────────────────────────────────────────────────────────────── + +/// A node in the lean game DAG. +/// +/// Ultra-lightweight: no decisions, no spec_gaps, no refs. +/// Grounding transparency is deferred to on-demand query mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanNode { + /// Unique identifier assigned server-side after parsing. + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the simulation (text only). + pub channels: HashMap, + /// How close this node is to high-entropy spec areas (0.0–1.0). + #[serde(default)] + pub entropy_hint: f64, +} + +// ── Edge ──────────────────────────────────────────────────────────────── + +/// An edge in the lean game DAG. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanEdge { + /// Human-readable label for the interaction (e.g., "Click Submit"). + pub label: String, + /// The input this interaction represents. + pub input: SimInput, + /// Node ID this edge leads to. + pub target_node_id: String, + /// What kind of edge this is. + pub edge_kind: LeanEdgeKind, +} + +/// Classifies how an edge was created and whether its target exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LeanEdgeKind { + /// Target was created as a new node by this batch. Exactly 2 per node. + Generative, + /// Links to an already-existing node in the DAG. Free, no generation cost. + Shortcut, + /// Generative edge whose target hasn't been generated yet. + /// Triggers batch pre-generation when the player is nearby. + Leaf, +} + +// ── Batch response (parsed from AI output) ────────────────────────────── + +/// Parsed batch of new nodes + edges from a single AI generation call. +#[derive(Debug, Clone)] +pub struct LeanBatchResponse { + pub nodes: Vec, + pub edges: Vec, +} + +/// An edge in a batch response, before being merged into the graph. +#[derive(Debug, Clone)] +pub struct LeanBatchEdge { + /// AI-local node ID (e.g., "root", "n1"). + pub from: String, + /// AI-local node ID or existing graph node UUID. + pub to: String, + pub label: String, + pub input: SimInput, + /// If true, `to` refers to an existing node ID in the DAG. + pub is_shortcut: bool, +} + +// ── Flat wire format (what the AI actually produces) ──────────────────── + +/// Flat adjacency-list format for lean game output. +/// Converted to `LeanBatchResponse` after parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatTree { + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatNode { + pub id: String, + pub channels: HashMap, + #[serde(default)] + pub entropy_hint: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanFlatEdge { + pub from: String, + pub to: String, + pub label: String, + pub input: SimInput, + /// If true, `to` refers to an existing node_id in the DAG (not a new node in this batch). + #[serde(default)] + pub shortcut: bool, +} + +// ── Action history ───────────────────────────────────────────────────── + +/// A single entry in the chronological action history for send-actions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanHistoryEntry { + pub from_node_id: String, + pub to_node_id: String, + pub label: String, + pub is_back: bool, +} diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs new file mode 100644 index 0000000..0561a11 --- /dev/null +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -0,0 +1,893 @@ +use std::sync::Arc; + +use crate::simulation; +use crate::simulation::tree; +use crate::simulation::types::SimInput; +use crate::state::AppState; + +/// Run the initial simulation turn. +/// Reads session config from AppState, builds prompts, calls the runner, +/// and updates the session with the interaction tree or error. +pub async fn orchestrate_initial_turn( + state: Arc, + session_id: String, + consume_whole_spec: bool, + directory: Option, +) { + let (spec_id, model, channels, focus_node_id, scenario, tree_depth, tree_branching) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.spec_id.clone(), + session.model.clone(), + session.channels.clone(), + session.root_node_id.clone().unwrap_or_default(), + session.scenario.clone(), + session.tree_depth, + session.tree_branching, + ) + }; + + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to load focus node for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); + }); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to load spec summary for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); + }); + return; + } + }; + + let system_prompt = if consume_whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + simulation::build_system_prompt_whole_spec_with_tree( + &channels, + &focus_node, + &all_nodes, + &summary, + tree_depth, + tree_branching, + ) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + simulation::build_system_prompt_with_tree( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + tree_depth, + tree_branching, + ) + }; + + let system_prompt = if directory.is_some() { + simulation::append_code_aware_section(&system_prompt) + } else { + system_prompt + }; + let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); + + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); + + match simulation::runner::start_sim_tree_turn(&config, &initial_prompt).await { + Ok((claude_session_id, mut response)) => { + tree::assign_node_ids(&mut response.root); + let root_id = response.root.node_id.clone(); + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + // Populate channel_contents/decisions from root for backward compat + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.interaction_tree = Some(response.root); + s.current_node_id = Some(root_id.clone()); + s.navigation_path = vec![root_id]; + s.tree_generation += 1; + s.status = simulation::SimStatus::Idle; + }); + + // Check if eager pregeneration is needed at the root + maybe_eager_pregen(&state, &session_id); + } + Err(e) => { + tracing::error!("Simulation initial tree turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Resume a simulation turn with user input. +/// +/// First checks the interaction tree for a matching predicted interaction. +/// If found, navigates instantly without calling the AI. +/// If not found, falls back to AI generation with path history replay. +pub async fn orchestrate_resume_turn(state: Arc, session_id: String, input: String) { + // Parse the input as SimInput + let sim_input: SimInput = match serde_json::from_str(&input) { + Ok(i) => i, + Err(_) => { + // If it's not valid SimInput JSON (e.g. scenario update), fall through to AI + orchestrate_ai_resume(state, session_id, input).await; + return; + } + }; + + // Check tree for matching interaction + let tree_match = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + if let (Some(interaction_tree), Some(current_id)) = + (&session.interaction_tree, &session.current_node_id) + { + if let Some(current_node) = tree::find_node(interaction_tree, current_id) { + if let Some(idx) = tree::find_matching_interaction(current_node, &sim_input) { + match ¤t_node.interactions[idx].result { + Some(child) => Some(( + child.node_id.clone(), + child.channels.clone(), + child.decisions.clone(), + tree::is_leaf(child), + )), + None => { + // Shallow interaction — matched label/input but no pre-computed result. + // Fall through to AI resume. + None + } + } + } else { + None + } + } else { + None + } + } else { + None + } + }; + + match tree_match { + Some((child_id, channels, decisions, _is_leaf)) => { + // Tree hit - navigate instantly + state.update_sim_session(&session_id, |s| { + s.channel_contents = channels; + s.decisions = decisions; + s.current_node_id = Some(child_id.clone()); + s.navigation_path.push(child_id); + s.status = simulation::SimStatus::Idle; + }); + + // Check if eager pregeneration is needed at the new position + maybe_eager_pregen(&state, &session_id); + } + None => { + // Tree miss - fall back to AI generation + orchestrate_ai_resume(state, session_id, input).await; + } + } +} + +/// Fall back to AI generation when input doesn't match the tree. +/// Replays path history and generates a new tree. +async fn orchestrate_ai_resume(state: Arc, session_id: String, input: String) { + let (claude_sid, tree_data) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.claude_session_id.clone().unwrap_or_default(), + session.interaction_tree.clone().zip(Some(session.navigation_path.clone())), + ) + }; + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + // Build resume prompt with path history + let prompt = if let Some((ref interaction_tree, ref path)) = tree_data { + let history = tree::collect_path_history(interaction_tree, path); + simulation::build_tree_resume_prompt(&history, Some(&input)) + } else { + // No tree available, use simple resume + format!( + "{}\n\nRespond with a valid JSON interaction tree. \ + Simulate the application as an implementer would build it from the spec.", + input + ) + }; + + match simulation::runner::resume_sim_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_node_ids(&mut response.root); + let root_id = response.root.node_id.clone(); + state.update_sim_session(&session_id, |s| { + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.interaction_tree = Some(response.root); + s.current_node_id = Some(root_id.clone()); + s.navigation_path = vec![root_id]; + s.tree_generation += 1; + s.status = simulation::SimStatus::Idle; + }); + + // Check if eager pregeneration is needed at the new root + maybe_eager_pregen(&state, &session_id); + } + Err(e) => { + tracing::error!("Simulation AI resume turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Check if the current node's most-likely interaction leads to a dead end +/// (leaf or shallow node). If so, and no pregeneration is already running, +/// spawn eager pregeneration targeting that child node. +fn maybe_eager_pregen(state: &Arc, session_id: &str) { + let pregen_info = { + let session = match state.get_sim_session(session_id) { + Some(s) => s, + None => return, + }; + + // Skip if pregeneration is already running + if session.pregen_target.is_some() { + return; + } + + let interaction_tree = match &session.interaction_tree { + Some(t) => t, + None => return, + }; + let current_id = match &session.current_node_id { + Some(id) => id, + None => return, + }; + let current_node = match tree::find_node(interaction_tree, current_id) { + Some(n) => n, + None => return, + }; + + // Check the first (most likely) interaction + let first = match current_node.interactions.first() { + Some(i) => i, + None => return, // No interactions at all — nothing to pregen + }; + + match &first.result { + None => { + // The most likely interaction is shallow (no result) — pregen from current node + // We'll generate a new tree continuing from the current position + Some(( + session.claude_session_id.clone().unwrap_or_default(), + current_id.clone(), + session.navigation_path.clone(), + session.tree_generation, + )) + } + Some(child) if tree::is_leaf(child) => { + // The most likely interaction leads to a leaf — pregen from that child + let mut path = session.navigation_path.clone(); + path.push(child.node_id.clone()); + Some(( + session.claude_session_id.clone().unwrap_or_default(), + child.node_id.clone(), + path, + session.tree_generation, + )) + } + _ => None, // First interaction has deep children, no need to pregen + } + }; + + if let Some((claude_sid, target_node_id, path, generation)) = pregen_info { + if claude_sid.is_empty() { + return; + } + + state.update_sim_session(session_id, |s| { + s.pregen_target = Some(target_node_id.clone()); + s.pregenerating = true; + }); + + let state_clone = state.clone(); + let sid_clone = session_id.to_string(); + tokio::spawn(async move { + orchestrate_eager_pregeneration( + state_clone, + sid_clone, + target_node_id, + path, + generation, + ) + .await; + }); + } +} + +/// Eagerly pre-generate a subtree and graft it onto the existing tree. +/// Runs as a background task. +async fn orchestrate_eager_pregeneration( + state: Arc, + session_id: String, + target_node_id: String, + path_to_target: Vec, + generation: u64, +) { + let (claude_sid, tree_data) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.claude_session_id.clone().unwrap_or_default(), + session.interaction_tree.clone(), + ) + }; + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); + return; + } + + let prompt = if let Some(ref interaction_tree) = tree_data { + let history = tree::collect_path_history(interaction_tree, &path_to_target); + simulation::build_tree_resume_prompt(&history, None) + } else { + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); + return; + }; + + tracing::info!( + session_id = %session_id, + target = %target_node_id, + "Starting eager tree pre-generation" + ); + + match simulation::runner::resume_sim_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_node_ids(&mut response.root); + state.update_sim_session(&session_id, |s| { + // Only graft if the tree hasn't been replaced since we started + if s.tree_generation == generation + && s.pregen_target.as_deref() == Some(&target_node_id) + { + if let Some(ref mut existing_tree) = s.interaction_tree { + tree::graft_tree(existing_tree, &target_node_id, response.root); + } + } + s.pregen_target = None; + s.pregenerating = false; + }); + tracing::info!( + session_id = %session_id, + target = %target_node_id, + "Eager tree pre-generation complete" + ); + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + target = %target_node_id, + "Eager tree pre-generation failed: {e}" + ); + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); + } + } +} + +/// Resume a simulation turn with a report question. +/// Stores the report explanation without replacing channel contents. +pub async fn orchestrate_report_turn(state: Arc, session_id: String, question: String) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + match simulation::runner::resume_sim_report_turn(&claude_sid, &question).await { + Ok(response) => { + state.update_sim_session(&session_id, |s| { + s.pending_report = Some(response); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation report turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Update the scenario and resume with scenario-update framing. +pub async fn orchestrate_scenario_update( + state: Arc, + session_id: String, + scenario: String, +) { + state.update_sim_session(&session_id, |s| { + s.scenario = Some(scenario.clone()); + }); + + let input = format!( + "SCENARIO UPDATE: The simulation scenario has changed. The new scenario is: {scenario}" + ); + orchestrate_ai_resume(state, session_id, input).await; +} + +// ── Game Mode Orchestration ────────────────────────────────────────── + +/// Run the initial game-mode turn. +/// Similar to `orchestrate_initial_turn` but uses game prompts and parses `GameTreeResponse`. +pub async fn orchestrate_game_initial_turn( + state: Arc, + session_id: String, + consume_whole_spec: bool, + directory: Option, +) { + let (spec_id, model, channels, focus_node_id, scenario, tree_branching) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.spec_id.clone(), + session.model.clone(), + session.channels.clone(), + session.root_node_id.clone().unwrap_or_default(), + session.scenario.clone(), + session.tree_branching, + ) + }; + + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to load focus node for game mode: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); + }); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to load spec summary for game mode: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); + }); + return; + } + }; + + let system_prompt = if consume_whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + simulation::build_game_system_prompt_whole_spec_with_tree( + &channels, + &focus_node, + &all_nodes, + &summary, + tree_branching, + ) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + simulation::build_game_system_prompt_with_tree( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + tree_branching, + ) + }; + + let system_prompt = if directory.is_some() { + simulation::append_code_aware_section(&system_prompt) + } else { + system_prompt + }; + let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); + + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); + + match simulation::runner::start_game_tree_turn(&config, &initial_prompt).await { + Ok((claude_session_id, mut response)) => { + tree::assign_game_node_ids(&mut response.root); + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.game_tree = Some(response.root); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Game initial tree turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Handle a player selecting an (interaction, outcome) pair in game mode. +/// +/// Updates channel_contents from the selected outcome's result, then spawns +/// a background task to update the spec based on the choice. +pub async fn orchestrate_game_select_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, +) { + let (spec_id, choice_info) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let game_tree = match &session.game_tree { + Some(t) => t, + None => { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No game tree available".to_string()); + }); + return; + } + }; + + let group = match game_tree.choice_groups.get(group_index) { + Some(g) => g, + None => { + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!( + "Invalid group index: {group_index}" + )); + }); + return; + } + }; + + let outcome = match group.outcomes.get(outcome_index) { + Some(o) => o, + None => { + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!( + "Invalid outcome index: {outcome_index}" + )); + }); + return; + } + }; + + ( + session.spec_id.clone(), + ( + group.interaction_label.clone(), + outcome.summary.clone(), + outcome.related_spec_nodes.clone(), + outcome.result.channels.clone(), + outcome.result.decisions.clone(), + ), + ) + }; + + let (interaction_label, outcome_summary, related_spec_nodes, channels, decisions) = choice_info; + + // Update channel contents from the selected outcome + state.update_sim_session(&session_id, |s| { + s.channel_contents = channels; + s.decisions = decisions; + // Clear the game tree so the next turn generates a new one + s.game_tree = None; + s.status = simulation::SimStatus::Idle; + }); + + // Spawn background spec update (fire-and-forget) + let state_clone = state.clone(); + let sid_clone = session_id.clone(); + tokio::spawn(async move { + orchestrate_game_spec_update( + state_clone, + sid_clone, + spec_id, + interaction_label, + outcome_summary, + related_spec_nodes, + None, + ) + .await; + }); +} + +/// Handle a player rejecting an outcome with a correction in game mode. +/// +/// Spawns a background AI call with the correction to determine spec updates +/// and generate a corrected tree continuation. +pub async fn orchestrate_game_reject_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, + correction: String, +) { + let (spec_id, reject_info) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let game_tree = match &session.game_tree { + Some(t) => t, + None => { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No game tree available".to_string()); + }); + return; + } + }; + + let group = match game_tree.choice_groups.get(group_index) { + Some(g) => g, + None => return, + }; + + let outcome = match group.outcomes.get(outcome_index) { + Some(o) => o, + None => return, + }; + + ( + session.spec_id.clone(), + ( + group.interaction_label.clone(), + outcome.summary.clone(), + outcome.related_spec_nodes.clone(), + ), + ) + }; + + let (interaction_label, outcome_summary, related_spec_nodes) = reject_info; + + // Set processing while we generate a corrected tree + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Processing; + }); + + // Spawn background spec update with the correction + let state_bg = state.clone(); + let sid_bg = session_id.clone(); + let spec_id_bg = spec_id.clone(); + let interaction_bg = interaction_label.clone(); + let outcome_bg = outcome_summary.clone(); + let related_bg = related_spec_nodes.clone(); + let correction_bg = correction.clone(); + tokio::spawn(async move { + orchestrate_game_spec_update( + state_bg, + sid_bg, + spec_id_bg, + interaction_bg, + outcome_bg, + related_bg, + Some(correction_bg), + ) + .await; + }); + + // Resume the AI with the correction to get a new game tree + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + let prompt = format!( + "The player REJECTED the outcome \"{outcome_summary}\" for interaction \"{interaction_label}\".\n\ + Player's correction: \"{correction}\"\n\n\ + Generate a new game choice tree from the current state, incorporating the player's correction. \ + Respond ONLY with a valid JSON object matching the game choice tree format." + ); + + match simulation::runner::resume_game_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_game_node_ids(&mut response.root); + state.update_sim_session(&session_id, |s| { + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.game_tree = Some(response.root); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Game reject resume turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Background task: use the AI to determine and apply spec updates based on +/// a player's game choice or rejection. +async fn orchestrate_game_spec_update( + state: Arc, + session_id: String, + spec_id: String, + interaction_label: String, + outcome_summary: String, + related_spec_nodes: Vec, + correction: Option, +) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + tracing::warn!("No claude session for game spec update, skipping"); + return; + } + + let prompt = simulation::build_game_spec_update_prompt( + &spec_id, + &interaction_label, + &outcome_summary, + &related_spec_nodes, + correction.as_deref(), + ); + + match simulation::runner::resume_game_spec_update_turn(&claude_sid, &prompt).await { + Ok(response_text) => { + // Parse the response to extract what was done + #[derive(Debug, serde::Deserialize)] + struct SpecUpdateResult { + action: String, + #[serde(default)] + node_id: String, + #[serde(default)] + description: String, + } + + let update = serde_json::from_str::(&response_text) + .or_else(|_| { + // Try extracting JSON from the response + let trimmed = response_text.trim(); + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + serde_json::from_str::(&trimmed[start..=end]) + } else { + Err(serde_json::from_str::("").unwrap_err()) + } + }); + + match update { + Ok(result) if result.action != "none" => { + let action = result.action; + let spec_update = simulation::GameSpecUpdate { + interaction_label: interaction_label.clone(), + outcome_summary: outcome_summary.clone(), + description: result.description, + node_id: result.node_id, + action: action.clone(), + }; + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(spec_update); + }); + tracing::info!( + session_id = %session_id, + action = %action, + "Game spec update applied" + ); + } + Ok(_) => { + tracing::info!( + session_id = %session_id, + "Game spec update: no changes needed" + ); + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + "Failed to parse game spec update response: {e}" + ); + } + } + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + "Game spec update turn failed: {e}" + ); + } + } +} diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs new file mode 100644 index 0000000..7c85878 --- /dev/null +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -0,0 +1,1027 @@ +use super::session::SimChannel; +use super::types::{SimInput, SimTreeNode}; +use crate::Node; +use spec_forest_db::SpecSummary; + +/// Build the system prompt for a simulation agent. +/// +/// The prompt instructs the agent on: +/// - JSON envelope output format (flat or tree based on tree_config) +/// - Channel semantics and which channels are active +/// - Spec node referencing conventions +/// - Input format (batched keypresses) +/// - Focus node context with ancestors and descendants +/// +/// When `tree_config` is Some((depth, branching)), the output format switches +/// to the interaction tree format instead of flat JSON. +pub fn build_system_prompt( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], +) -> String { + build_system_prompt_inner(channels, focus_node, ancestors, descendants, summary, other_roots, None) +} + +/// Build the system prompt with tree output format enabled. +pub fn build_system_prompt_with_tree( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_depth: u8, + tree_branching: u8, +) -> String { + build_system_prompt_inner(channels, focus_node, ancestors, descendants, summary, other_roots, Some((tree_depth, tree_branching))) +} + +/// Build the system prompt with game-mode tree output format. +/// +/// Game mode presents multiple alternative outcomes per interaction, +/// letting the user choose the correct behavior to refine the spec. +pub fn build_game_system_prompt_with_tree( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_branching: u8, +) -> String { + build_system_prompt_inner_v2( + channels, focus_node, ancestors, descendants, summary, other_roots, true, Some((1, tree_branching)), + ) +} + +/// Build the whole-spec system prompt with game-mode tree output format. +pub fn build_game_system_prompt_whole_spec_with_tree( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_branching: u8, +) -> String { + build_system_prompt_whole_spec_inner_v2( + channels, focus_node, all_nodes, summary, true, Some((1, tree_branching)), + ) +} + +fn build_system_prompt_inner( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_config: Option<(u8, u8)>, +) -> String { + build_system_prompt_inner_v2( + channels, focus_node, ancestors, descendants, summary, other_roots, false, tree_config, + ) +} + +fn build_system_prompt_inner_v2( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + game_mode: bool, + tree_config: Option<(u8, u8)>, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build ancestor chain section (root → focus) + let mut ancestor_section = String::new(); + if !ancestors.is_empty() { + for node in ancestors { + if node.id == focus_node.id { + continue; + } + ancestor_section.push_str(&format!("#### Node {} (depth ancestor)\n", node.id)); + ancestor_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + ancestor_section.push_str(&format!("**A:** {}\n", answer)); + } else { + ancestor_section.push_str("**A:** _(unanswered)_\n"); + } + ancestor_section.push('\n'); + } + } + + // Build descendants section + let mut descendant_section = String::new(); + if !descendants.is_empty() { + for node in descendants { + if node.id == focus_node.id { + continue; + } + descendant_section.push_str(&format!("#### Node {}\n", node.id)); + descendant_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + descendant_section.push_str(&format!("**A:** {}\n", answer)); + } else { + descendant_section.push_str("**A:** _(unanswered)_\n"); + } + descendant_section.push('\n'); + } + } + + // Build other roots summary (just questions, no answers) + let mut other_roots_section = String::new(); + if !other_roots.is_empty() { + for node in other_roots { + other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); + } + } + + let cardinal_rule = if game_mode { + build_game_cardinal_rule() + } else { + build_sim_cardinal_rule() + }; + + let output_format = match (game_mode, tree_config) { + (true, Some((_depth, branching))) => build_game_tree_output_format(branching, &channel_list), + (false, Some((depth, branching))) => build_tree_output_format(depth, branching, &channel_list), + (_, None) => build_flat_output_format(&channel_list), + }; + + format!( + r#"{cardinal_rule} + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap the JSON in code fences. Output raw JSON only — the very first character of your response must be `{{`. + +## Focus Node (Primary Context) +This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. + +{focus_section} +## Ancestor Chain (Hierarchical Context) +These are the ancestor nodes from the root of the spec down to the focus node, providing hierarchical context: + +{ancestor_section} +## Descendants (Detail) +These are the descendant nodes under the focus node, providing detailed specifications: + +{descendant_section} +## Spec Overview +The full spec "{spec_name}" has {answered} answered nodes, {unanswered} unanswered nodes, and {needs_review} nodes needing review. + +Other top-level areas of the spec (not loaded in detail): +{other_roots_section} +## IMPORTANT: Use Tools for Additional Context +You have access to spec-forest MCP tools. You MUST use these tools proactively to look up specification details whenever: +- The user's input touches behavior outside the focus node's subtree +- You need to understand how different parts of the spec relate +- You encounter a reference to a spec area not loaded above +- You are unsure about any aspect of the specification + +Available tools: +- **search_nodes**: Search for nodes by text — use this to find relevant spec nodes +- **get_node**: Get a specific node by ID — use this when you have a node ID +- **get_descendants**: Get a node's subtree — use this to explore an area in depth +- **get_spec_summary**: Get an overview of a spec + +If unsure whether the spec covers something, search the spec with tools first. If the spec +does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. + +{output_format} + +## Channel Semantics +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout. + Render the feature realistically and completely. Cite spec nodes with [^N] markers where + the spec drives a specific element. Do not clutter the output by flagging obvious, + low-entropy UI elements (buttons, scroll bars, standard layout) — only flag genuinely + ambiguous choices as spec_gaps. +- "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' +- "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages and warnings from the simulated application +- "logs": Application log output from the simulated application + +## Spec References +Use [^N] markers to show which spec nodes drive your simulation behavior. This helps the +user see which parts of the spec are active and effective. + +### Rules +1. When a simulated behavior IS grounded in a spec node, cite it with [^N] markers in the + text and include corresponding entries in the refs array. This is how the user confirms + the spec is producing the intended result. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3. +3. A citation must be SPECIFIC: the cited node must describe the element or behavior you + are rendering. Do NOT cite a loosely related or parent node to cover something it does + not specifically mention. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite + the node. +5. Low-entropy implementation details that any developer would add (submit buttons, standard + layout, obvious defaults) do NOT need citations or spec_gap entries. Just render them. +6. The refs array may be empty for a channel if that channel's content is entirely standard + implementation detail not driven by any specific spec node. + +## Spec Gaps (High-Entropy Decisions Only) +A spec_gap entry signals a decision where the spec is genuinely ambiguous and different +implementers could reasonably go different ways. The user needs to see these so they can +refine the spec. + +### When to flag a spec_gap +- You chose between multiple reasonable approaches and the spec does not indicate which + (e.g., error display strategy, navigation flow, data handling policy) +- The spec is silent on a user-facing behavior that would be visible and could surprise + the user if implemented differently +- A business rule or policy decision is implied but not stated + +### When NOT to flag a spec_gap +- Standard UI affordances (submit buttons, close buttons, scroll bars) +- Obvious default states (empty form fields, loading spinners) +- Conventional layout choices (header at top, navigation on left) +- Standard HTTP methods and status codes +- Any decision where there is clearly one right answer + +Keep spec_gap entries concise and actionable. Each one should suggest what the spec could +say to resolve the ambiguity. + +## Decisions +Each response MUST include a "decisions" array listing the significant decisions you made +this turn. + +### Rules +1. Each decision describes a meaningful action or behavior choice you made + (e.g., "Displayed login form with email and password fields", + "Chose inline error display for failed authentication", + "Returned 401 status on unauthenticated request"). +2. When a decision is grounded in a spec node, cite it in the refs array. +3. When a decision involves a high-entropy choice the spec does not cover, add a spec_gap + entry explaining the ambiguity and what you chose. +4. Low-entropy decisions (obvious implementation choices) can have empty refs and empty + spec_gaps — they do not need justification. +5. Decision refs use the SAME [^N] marker namespace as channel text refs. + Reuse markers that already appear in channel text where applicable. +6. Be granular: list each distinct decision separately. +7. The decisions array must NEVER be empty. Every turn involves at least one decision. + +## User Input +The user sends batched keypresses as structured JSON input. Each message contains: +{{"keys": ["a", "b", "Enter"], "raw_text": "ab\n"}} + +Simulate how the application would respond to these inputs based on the spec."#, + focus_section = focus_section, + ancestor_section = if ancestor_section.is_empty() { + "_(focus node is a root node)_\n".to_string() + } else { + ancestor_section + }, + descendant_section = if descendant_section.is_empty() { + "_(no descendants)_\n".to_string() + } else { + descendant_section + }, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + other_roots_section = if other_roots_section.is_empty() { + "_(none)_\n".to_string() + } else { + other_roots_section + }, + output_format = output_format, + cardinal_rule = cardinal_rule, + ) +} + +/// Build the system prompt with the entire spec loaded. +/// +/// Similar to `build_system_prompt` but includes ALL nodes from the spec +/// instead of just the focus node's ancestors and descendants. +pub fn build_system_prompt_whole_spec( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, +) -> String { + build_system_prompt_whole_spec_inner(channels, focus_node, all_nodes, summary, None) +} + +/// Build the whole-spec system prompt with tree output format enabled. +pub fn build_system_prompt_whole_spec_with_tree( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_depth: u8, + tree_branching: u8, +) -> String { + build_system_prompt_whole_spec_inner(channels, focus_node, all_nodes, summary, Some((tree_depth, tree_branching))) +} + +fn build_system_prompt_whole_spec_inner( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_config: Option<(u8, u8)>, +) -> String { + build_system_prompt_whole_spec_inner_v2(channels, focus_node, all_nodes, summary, false, tree_config) +} + +fn build_system_prompt_whole_spec_inner_v2( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + game_mode: bool, + tree_config: Option<(u8, u8)>, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build complete spec section with all nodes + let mut all_nodes_section = String::new(); + for node in all_nodes { + if node.id == focus_node.id { + continue; + } + all_nodes_section.push_str(&format!("#### Node {}\n", node.id)); + all_nodes_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + all_nodes_section.push_str(&format!("**A:** {}\n", answer)); + } else { + all_nodes_section.push_str("**A:** _(unanswered)_\n"); + } + all_nodes_section.push('\n'); + } + + let cardinal_rule = if game_mode { + build_game_cardinal_rule() + } else { + build_sim_cardinal_rule() + }; + + let output_format = match (game_mode, tree_config) { + (true, Some((_depth, branching))) => build_game_tree_output_format(branching, &channel_list), + (false, Some((depth, branching))) => build_tree_output_format(depth, branching, &channel_list), + (_, None) => build_flat_output_format(&channel_list), + }; + + format!( + r#"{cardinal_rule} + +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap the JSON in code fences. Output raw JSON only — the very first character of your response must be `{{`. + +## Focus Node (Primary Context) +This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. + +{focus_section} +## Complete Specification (All Nodes) +The entire spec has been loaded. All nodes are listed below: + +{all_nodes_section} +## Spec Overview +The full spec "{spec_name}" has {answered} answered nodes, {unanswered} unanswered nodes, and {needs_review} nodes needing review. + +## Additional Tools +You have access to spec-forest MCP tools if you need to explore relationships between nodes: + +Available tools: +- **search_nodes**: Search for nodes by text — use this to find relevant spec nodes +- **get_node**: Get a specific node by ID — use this when you have a node ID +- **get_descendants**: Get a node's subtree — use this to explore an area in depth +- **get_spec_summary**: Get an overview of a spec + +If unsure whether the spec covers something, search the spec with tools first. If the spec +does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. + +{output_format} + +## Channel Semantics +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout. + Render the feature realistically and completely. Cite spec nodes with [^N] markers where + the spec drives a specific element. Do not clutter the output by flagging obvious, + low-entropy UI elements (buttons, scroll bars, standard layout) — only flag genuinely + ambiguous choices as spec_gaps. +- "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' +- "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages and warnings from the simulated application +- "logs": Application log output from the simulated application + +## Spec References +Use [^N] markers to show which spec nodes drive your simulation behavior. This helps the +user see which parts of the spec are active and effective. + +### Rules +1. When a simulated behavior IS grounded in a spec node, cite it with [^N] markers in the + text and include corresponding entries in the refs array. This is how the user confirms + the spec is producing the intended result. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3. +3. A citation must be SPECIFIC: the cited node must describe the element or behavior you + are rendering. Do NOT cite a loosely related or parent node to cover something it does + not specifically mention. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite + the node. +5. Low-entropy implementation details that any developer would add (submit buttons, standard + layout, obvious defaults) do NOT need citations or spec_gap entries. Just render them. +6. The refs array may be empty for a channel if that channel's content is entirely standard + implementation detail not driven by any specific spec node. + +## Spec Gaps (High-Entropy Decisions Only) +A spec_gap entry signals a decision where the spec is genuinely ambiguous and different +implementers could reasonably go different ways. The user needs to see these so they can +refine the spec. + +### When to flag a spec_gap +- You chose between multiple reasonable approaches and the spec does not indicate which + (e.g., error display strategy, navigation flow, data handling policy) +- The spec is silent on a user-facing behavior that would be visible and could surprise + the user if implemented differently +- A business rule or policy decision is implied but not stated + +### When NOT to flag a spec_gap +- Standard UI affordances (submit buttons, close buttons, scroll bars) +- Obvious default states (empty form fields, loading spinners) +- Conventional layout choices (header at top, navigation on left) +- Standard HTTP methods and status codes +- Any decision where there is clearly one right answer + +Keep spec_gap entries concise and actionable. Each one should suggest what the spec could +say to resolve the ambiguity. + +## Decisions +Each response MUST include a "decisions" array listing the significant decisions you made +this turn. + +### Rules +1. Each decision describes a meaningful action or behavior choice you made + (e.g., "Displayed login form with email and password fields", + "Chose inline error display for failed authentication", + "Returned 401 status on unauthenticated request"). +2. When a decision is grounded in a spec node, cite it in the refs array. +3. When a decision involves a high-entropy choice the spec does not cover, add a spec_gap + entry explaining the ambiguity and what you chose. +4. Low-entropy decisions (obvious implementation choices) can have empty refs and empty + spec_gaps — they do not need justification. +5. Decision refs use the SAME [^N] marker namespace as channel text refs. + Reuse markers that already appear in channel text where applicable. +6. Be granular: list each distinct decision separately. +7. The decisions array must NEVER be empty. Every turn involves at least one decision. + +## User Input +The user sends batched keypresses as structured JSON input. Each message contains: +{{"keys": ["a", "b", "Enter"], "raw_text": "ab\n"}} + +Simulate how the application would respond to these inputs based on the spec."#, + focus_section = focus_section, + all_nodes_section = if all_nodes_section.is_empty() { + "_(no other nodes)_\n".to_string() + } else { + all_nodes_section + }, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + output_format = output_format, + cardinal_rule = cardinal_rule, + ) +} + +/// Build the initial prompt for the first simulation turn. +/// +/// When `scenario` is provided, the simulation starts from the described state +/// and conditions instead of the default initial state. +pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + match scenario { + Some(desc) if !desc.trim().is_empty() => format!( + "Initialize the simulation with the following scenario:\n\n\ + {desc}\n\n\ + Render the application state as an implementer would build it, across channels: {channel_list}. \ + Cite spec nodes that drive specific behaviors. For high-entropy decisions where the \ + spec is ambiguous, flag them as spec_gaps. Low-entropy implementation details need \ + no special treatment — just render them naturally." + ), + _ => format!( + "Initialize the simulation. Render the application's starting state as an implementer \ + would build it from the spec, across channels: {channel_list}. \ + Cite spec nodes that drive specific behaviors. For high-entropy decisions where the \ + spec is ambiguous, flag them as spec_gaps. Low-entropy implementation details need \ + no special treatment — just render them naturally." + ), + } +} + +/// Append code-aware instructions to a simulation system prompt. +/// +/// When the user enables "Explore Code", this tells the agent it can +/// read the actual codebase as a reference while treating the spec as +/// the source of truth. +pub fn append_code_aware_section(base_prompt: &str) -> String { + format!( + r#"{base_prompt} + +## Code-Aware Mode +You have access to the actual codebase via Read, Glob, and Grep tools. Your working +directory is set to the project root. + +### Rules for code consultation +1. **The spec is the source of truth.** When the spec and the code conflict, follow the spec. + If you notice a discrepancy, mention it as an observation but simulate according to the spec. +2. **Use code to fill gaps.** When the spec is silent on an implementation detail, consult the + code to see how it was actually built. This reduces speculation and produces a more accurate + simulation. +3. **Still flag high-entropy decisions as spec_gaps.** Even if the code makes a particular + choice, if the spec does not cover that decision and it is high-entropy (different + implementers could reasonably choose differently), flag it as a spec_gap. The code's choice + is informative but does not substitute for a spec decision. +4. **Cite code findings briefly.** When you consult the code, you may mention what you found + (e.g., "the codebase uses X approach for this") but keep it concise. The simulation output + should still focus on what the spec produces, not on documenting the code. +5. **Do not read the entire codebase.** Only consult files relevant to the current simulation + context. Use Glob and Grep to find relevant files efficiently."# + ) +} + +/// Build the flat (non-tree) output format section for system prompts. +fn build_flat_output_format(channel_list: &str) -> String { + format!( + r#"## Output Format +Every response must be a JSON object with this schema: +{{{{ + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ] +}}}} + +Active channels: {channel_list} + +You MUST include an entry for each active channel in every response."#, + channel_list = channel_list, + ) +} + +/// Build the tree output format section for system prompts. +/// +/// Instructs the AI to return a tree of interaction nodes instead of a flat response. +pub fn build_tree_output_format(depth: u8, branching: u8, channel_list: &str) -> String { + format!( + r#"## Output Format — Flat Interaction Tree (nodes + edges) +Every response must be a JSON object with two arrays: "nodes" and "edges". +This flat adjacency-list format avoids deep nesting. The first node in the array is the root. + +Schema: +{{{{ + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ] + }}}}, + {{{{ + "id": "n1", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + ], + "edges": [ + {{{{ + "from": "root", + "to": "n1", + "label": "Short description of user action (e.g., Click Login)", + "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}} + }}}} + ] +}}}} + +Active channels: {channel_list} + +## Interaction Tree Rules +1. Generate {depth} levels of interactions (the root is level 0, its children are level 1, etc.). +2. Every node (including nodes at maximum depth) should have {branching} predicted interactions — the most likely user actions. +3. For interactions at maximum depth: include the edge (with label + input) but you may OMIT the target node from "nodes". This gives action suggestions at the tree boundary without requiring full state. +4. Each edge must include: + - **from**: The ID of the parent node. + - **to**: The ID of the child node. + - **label**: A concise, human-readable description of the action (shown as a choice in the UI). + - **input**: The exact keys and raw_text the user would send for this action. +5. Choose interactions that represent the MOST LIKELY user actions at each state. + Prioritize actions that exercise different parts of the spec and cover distinct user paths. +6. Every node must include entries for ALL active channels. +7. Every node must include a non-empty decisions array. +8. The [^N] marker namespace is PER NODE — each node's refs are self-contained. +9. Node IDs must be short unique strings (e.g., "root", "n1", "n2", etc.)."#, + channel_list = channel_list, + depth = depth, + branching = branching, + ) +} + +/// Build a resume prompt that replays the user's path through the previous tree +/// and optionally includes a custom input for a tree miss. +pub fn build_tree_resume_prompt( + history: &[(&SimInput, &SimTreeNode)], + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !history.is_empty() { + prompt.push_str( + "The user navigated through these interactions since the last generation:\n\n", + ); + for (i, (input, node)) in history.iter().enumerate() { + // Summarize what the user did and what state resulted + let ui_summary = node + .channels + .get("ui") + .map(|c| { + // Take first 200 chars of UI for context + let text = &c.text; + if text.len() > 200 { + format!("{}...", &text[..text.floor_char_boundary(200)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + + prompt.push_str(&format!( + "{}. User input: keys={:?}, raw_text={:?}\n Resulting UI state: {}\n\n", + i + 1, + input.keys, + input.raw_text, + ui_summary, + )); + } + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "Now the user provided a custom input that was NOT one of the predicted interactions:\n{}\n\n", + input + )); + } + None => { + prompt.push_str( + "The user has reached the end of the predicted interaction tree.\n\n", + ); + } + } + + prompt.push_str( + "Generate the next interaction tree from the current state. \ + Respond ONLY with a valid JSON object matching the interaction tree format. \ + Do NOT include any text before or after the JSON. Do NOT use code fences. \ + The first character of your response must be `{`. \ + Simulate the application as an implementer would build it from the spec. \ + Cite spec nodes that drive specific behaviors. Only flag spec_gaps for high-entropy \ + decisions where the spec is genuinely ambiguous — not for obvious implementation details.", + ); + + prompt +} + +// ── Cardinal rules ─────────────────────────────────────────────────── + +fn build_sim_cardinal_rule() -> String { + r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD +You are simulating the program that an AI developer would build from this specification. +Your job is to predict the implementation — not to audit the spec. + +Mental model: +1. An implementing AI will eventually build this software from the spec. +2. You predict what that implementer would create. +3. The user interacts with your simulation to preview what the spec leads to. +4. This helps the user refine the spec BEFORE actual implementation. + +When simulating, you will inevitably make decisions the spec does not explicitly cover. +Apply an ENTROPY test to each such decision: + +- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag + it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, + a close button on a modal, standard HTTP status codes for obvious cases. +- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different + choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. + Examples: what happens on auth failure (redirect vs inline error vs modal), pagination + strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution + strategy. + +The goal is NOT to flag every invented detail — it is to surface the decisions that actually +matter and that the spec should clarify."# + .to_string() +} + +fn build_game_cardinal_rule() -> String { + r#"## CARDINAL RULE: GUIDE THE PLAYER TOWARD HIGH-ENTROPY DESIGN DECISIONS +You are helping the user design their application through play. The user navigates the +simulated application, and at each step you present interactions that lead to meaningful +specification decisions. Their choices resolve ambiguities and update the spec. + +Mental model: +1. The user is "playing" their future application to discover what it should do. +2. Your job is to steer them toward the INTERESTING decisions — the places where the spec + is silent or ambiguous and different implementers would make different choices. +3. When the user picks an outcome, that choice becomes part of the specification. +4. Low-entropy interactions (obvious, one-right-answer) are fine for navigation, but the + GOAL is to surface high-entropy decision points where the player's choice matters. + +## Interaction Selection Strategy +Choose interactions that LEAD TOWARD high-entropy situations: +- PRIORITIZE interactions that expose genuine design ambiguity — where the spec doesn't + specify what should happen and reasonable implementations would diverge. These are the + interactions where the player's choice teaches the spec something new. +- Include 1-2 "navigation" interactions (obvious outcomes) to keep the game moving, but + always include at least one interaction that reaches a genuine decision point. +- AVOID interactions where the outcome is completely obvious (e.g., "click expand arrow" → + "tree expands"). These waste the player's time without resolving any spec ambiguity. +- SEEK OUT interactions that touch unanswered spec nodes, spec gaps, or areas where the + spec is vague. These are the highest-value interactions. + +## Outcome Generation +For each interaction: +- HIGH-ENTROPY interactions (the spec is ambiguous): Generate 2-3 genuinely different + outcomes. Each should represent a plausible design direction an implementer might take. + Example: "Submit form" → "Shows inline validation errors" vs "Shows error modal" vs + "Navigates to error page". The player's choice RESOLVES the ambiguity. +- LOW-ENTROPY interactions (obvious result): Provide a single outcome. Don't fabricate + fake alternatives for interactions that have one clear answer. +- Annotate each outcome with the spec node IDs that justify it (related_spec_nodes). + If no spec node covers it, leave related_spec_nodes empty — this signals new information + the spec needs. + +The goal is NOT to present a menu of random options. It is to DISCOVER the decisions the +spec needs to make, and let the player make them through natural interaction."# + .to_string() +} + +// ── Game mode output format ────────────────────────────────────────── + +/// Build the game-mode tree output format section for system prompts. +/// +/// Instructs the AI to return a game tree with grouped choice outcomes +/// instead of the regular interaction tree. +pub fn build_game_tree_output_format(branching: u8, channel_list: &str) -> String { + format!( + r#"## Output Format — Flat Game Choice Tree (nodes + edges) +Every response must be a JSON object with two arrays: "nodes" and "edges". +This flat format avoids deep nesting. The first node is the root (current state). +Edges represent possible interactions. Multiple edges with the SAME "label" form a choice +group — each edge is an alternative outcome the player can pick. + +Schema: +{{{{ + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ] + }}}}, + {{{{ + "id": "n1", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}}, + {{{{ + "id": "n2", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + ], + "edges": [ + {{{{ + "from": "root", + "to": "n1", + "label": "Press X", + "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, + "outcome_summary": "Starts audio playback", + "related_spec_nodes": ["node-id-1", "node-id-2"] + }}}}, + {{{{ + "from": "root", + "to": "n2", + "label": "Press X", + "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, + "outcome_summary": "Loads new sample", + "related_spec_nodes": [] + }}}} + ] +}}}} + +Active channels: {channel_list} + +## Game Choice Tree Rules +1. Generate {branching} predicted interactions (distinct labels). +2. **Prioritize high-entropy interactions.** At least half of the interactions should target + genuine spec ambiguities — interactions where the spec is silent or vague and the player's + choice would resolve a meaningful design question. The remaining may be lower-entropy + interactions for navigation continuity. +3. HIGH-ENTROPY interactions should have 2-3 edges with the SAME label but different + outcome_summary values — these are alternative outcomes the player chooses between. + LOW-ENTROPY interactions (one obvious answer) should have 1 edge. +4. Each edge's target node contains the complete simulation state (all active channels + decisions) + that would result from that outcome. +5. Each edge MUST include: + - **outcome_summary**: A concise description of what happens (shown alongside the label). + - **related_spec_nodes**: Array of spec node IDs that justify this outcome. Empty array if + the outcome is based on assumptions not covered by the spec. Outcomes with empty + related_spec_nodes are the highest value — they represent NEW information for the spec. +6. Make outcomes genuinely distinct. Bad: "Button turns blue" vs "Button turns dark blue". + Good: "Shows inline error" vs "Opens error modal" vs "Redirects to error page". +7. Every node must include entries for ALL active channels. +8. Every node must include a non-empty decisions array. +9. The [^N] marker namespace is PER NODE — each node's refs are self-contained. +10. When choosing interactions, consult the spec for unanswered nodes, nodes flagged for review, + and areas with known spec_gaps — these are the richest sources of high-entropy interactions. +11. Node IDs must be short unique strings (e.g., "root", "n1", "n2", etc.)."#, + channel_list = channel_list, + branching = branching, + ) +} + +// ── Game mode resume prompt ────────────────────────────────────────── + +/// Build a resume prompt for game mode that replays the player's choices +/// and generates the next game choice tree. +pub fn build_game_resume_prompt( + choice_history: &[(String, String)], // (interaction_label, chosen_outcome_summary) + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !choice_history.is_empty() { + prompt.push_str( + "The player made these design choices since the last generation:\n\n", + ); + for (i, (interaction, outcome)) in choice_history.iter().enumerate() { + prompt.push_str(&format!( + "{}. Interaction: {} → Chosen outcome: {}\n", + i + 1, + interaction, + outcome, + )); + } + prompt.push('\n'); + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "Now the player provided a custom input that was NOT one of the predicted interactions:\n{}\n\n", + input + )); + } + None => { + prompt.push_str( + "The player has completed all available choices.\n\n", + ); + } + } + + prompt.push_str( + "Generate the next game choice tree from the current state. \ + Respond ONLY with a valid JSON object matching the game choice tree format. \ + Do NOT include any text before or after the JSON. Do NOT use code fences. \ + The first character of your response must be `{`. \ + PRIORITIZE interactions that lead to high-entropy decision points — places where \ + the spec is ambiguous or silent and the player's choice would resolve a meaningful \ + design question. Use search_nodes and get_descendants to find unanswered nodes and \ + spec gaps that could inform your interaction choices. Include navigation interactions \ + to keep the game moving, but always surface at least one genuine design decision.", + ); + + prompt +} + +// ── Game mode spec update prompt ───────────────────────────────────── + +/// Build a prompt for the background AI call that determines what spec changes +/// to make based on a player's game choice or rejection. +pub fn build_game_spec_update_prompt( + spec_id: &str, + interaction_label: &str, + outcome_summary: &str, + related_spec_nodes: &[String], + correction: Option<&str>, +) -> String { + let mut prompt = String::new(); + + prompt.push_str("You are updating a specification based on a player's design choice in game mode.\n\n"); + + match correction { + Some(correction_text) => { + prompt.push_str(&format!( + "The player REJECTED the outcome for interaction \"{}\".\n\ + Rejected outcome: \"{}\"\n\ + Player's correction: \"{}\"\n\n", + interaction_label, outcome_summary, correction_text + )); + } + None => { + prompt.push_str(&format!( + "The player CHOSE this outcome for interaction \"{}\":\n\ + Chosen outcome: \"{}\"\n\n", + interaction_label, outcome_summary + )); + } + } + + if !related_spec_nodes.is_empty() { + prompt.push_str("Related spec nodes (from the AI's annotation):\n"); + for node_id in related_spec_nodes { + prompt.push_str(&format!("- {}\n", node_id)); + } + prompt.push('\n'); + } + + prompt.push_str(&format!( + "Use the spec-forest tools to update the spec (spec_id: {spec_id}).\n\n\ + Instructions:\n\ + 1. First, use get_node to read the related spec nodes (if any) to understand context.\n\ + 2. If the choice confirms an existing spec answer, no update is needed — respond with \ + {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ + 3. If the choice contradicts or refines an existing answer, use answer_question to \ + update it. Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \ + \"description\": \"...\"}}.\n\ + 4. If the choice reveals new information not covered by any spec node, use \ + search_nodes to find the best parent, then add_children + answer_question \ + to add a new Q&A. Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \ + \"description\": \"...\"}}.\n\n\ + Respond with a raw JSON object describing what you did. \ + No markdown, no explanation, no code fences — raw JSON only." + )); + + prompt +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs new file mode 100644 index 0000000..c3b0de9 --- /dev/null +++ b/crates/spec-forest/src/simulation/runner.rs @@ -0,0 +1,1301 @@ +use super::types::{ + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, + GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, + SimTreeResponse, +}; +use std::collections::HashMap; +use std::error::Error; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; + +const CLAUDE_TIMEOUT: Duration = Duration::from_secs(600); + +/// A single line from `--output-format stream-json` NDJSON output. +/// Only the fields we care about are deserialized; unknown fields are ignored. +#[derive(serde::Deserialize)] +struct StreamEvent { + #[serde(rename = "type")] + event_type: String, + result: Option, + session_id: Option, + content_block: Option, +} + +#[derive(serde::Deserialize)] +struct ContentBlock { + #[serde(rename = "type")] + block_type: String, + name: Option, +} + +/// Spawn a `claude` command and stream its NDJSON stdout line-by-line, +/// logging events in real time. Returns (result_text, session_id). +async fn run_claude_streaming( + mut cmd: tokio::process::Command, +) -> Result<(String, String), Box> { + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().ok_or("failed to capture stdout")?; + let mut reader = BufReader::new(stdout).lines(); + + let mut result_text: Option = None; + let mut session_id: Option = None; + let mut tool_names: Vec = Vec::new(); + + let stream_future = async { + while let Some(line) = reader.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + tracing::debug!( + event = truncate_to_char_boundary(&line, 200), + "stream-json event" + ); + + let event: StreamEvent = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + match event.event_type.as_str() { + "result" => { + result_text = event.result; + if event.session_id.is_some() { + session_id = event.session_id; + } + } + "content_block_start" => { + if let Some(cb) = &event.content_block { + if cb.block_type == "tool_use" { + if let Some(name) = &cb.name { + tracing::info!(tool = %name, "Claude calling tool"); + tool_names.push(name.clone()); + } + } + } + } + _ => {} + } + } + Ok::<(), Box>(()) + }; + + match tokio::time::timeout(CLAUDE_TIMEOUT, stream_future).await { + Ok(result) => result?, + Err(_) => { + let _ = child.kill().await; + return Err("claude CLI timed out after 600 seconds".into()); + } + } + + let status = child.wait().await?; + if !status.success() { + let mut stderr_buf = Vec::new(); + if let Some(mut stderr) = child.stderr.take() { + tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut stderr_buf).await?; + } + let stderr = String::from_utf8_lossy(&stderr_buf); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + if !tool_names.is_empty() { + tracing::info!( + tool_count = tool_names.len(), + tools = ?tool_names, + "Tool calls during Claude session" + ); + } + + let result_text = result_text.ok_or("No 'result' event found in stream-json output")?; + let session_id = session_id.unwrap_or_default(); + + Ok((result_text, session_id)) +} + +fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { + if max_bytes >= s.len() { + return s; + } + // Find the largest char boundary <= max_bytes + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +/// Parse NDJSON from `--output-format stream-json` and extract the final result. +/// +/// Returns (result_text, session_id). Logs tool call metadata via tracing. +/// Used by tests; production code uses `run_claude_streaming` for real-time logging. +#[cfg(test)] +fn extract_stream_result(raw: &str) -> Result<(String, String), Box> { + let mut result_text: Option = None; + let mut session_id: Option = None; + let mut tool_names: Vec = Vec::new(); + + for line in raw.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let event: StreamEvent = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => continue, + }; + + match event.event_type.as_str() { + "result" => { + result_text = event.result; + if event.session_id.is_some() { + session_id = event.session_id; + } + } + "content_block_start" => { + if let Some(cb) = &event.content_block { + if cb.block_type == "tool_use" { + if let Some(name) = &cb.name { + tool_names.push(name.clone()); + } + } + } + } + _ => {} + } + } + + let result_text = result_text.ok_or("No 'result' event found in stream-json output")?; + let session_id = session_id.unwrap_or_default(); + + Ok((result_text, session_id)) +} + +/// Try to extract and deserialize a JSON object from text that may contain +/// surrounding prose, markdown code fences, or other non-JSON content. +/// +/// Attempts in order: +/// 1. Direct parse of the trimmed text +/// 2. Extract from ```json ... ``` fence +/// 3. Extract from plain ``` ... ``` fence +/// 4. Find first `{` to last `}` and parse that substring +/// +/// Returns the best serde error message on failure (from the longest +/// `{…}` substring attempted in step 4). +fn extract_json(text: &str) -> Result { + let trimmed = text.trim(); + + // 1. Direct parse + if let Ok(v) = serde_json::from_str::(trimmed) { + return Ok(v); + } + + // 2. ```json fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = trimmed[start + 7..start + 7 + end].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + return Ok(v); + } + } + } + + // 3. Plain ``` fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = trimmed[start + 4..start + 4 + end].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + return Ok(v); + } + } + } + + // 4. Try each `{` position (paired with the last `}`) until one parses. + // This handles prose before the JSON even if the prose contains braces. + // Keep the first (longest) serde error as the best diagnostic. + let last_brace = trimmed.rfind('}').ok_or_else(|| "no '}' found in response".to_string())?; + let mut search_from = 0; + let mut best_err: Option = None; + let mut attempts = 0u32; + while let Some(pos) = trimmed[search_from..].find('{') { + let start = search_from + pos; + if start >= last_brace { + break; + } + let json_str = &trimmed[start..=last_brace]; + match serde_json::from_str::(json_str) { + Ok(v) => return Ok(v), + Err(e) => { + tracing::debug!( + attempt = attempts, + start_byte = start, + end_byte = last_brace, + substr_len = json_str.len(), + error = %e, + "extract_json step 4 attempt failed" + ); + if best_err.is_none() { + best_err = Some(e.to_string()); + } + } + } + attempts += 1; + search_from = start + 1; + } + + tracing::debug!(attempts, "extract_json: all steps failed"); + Err(best_err.unwrap_or_else(|| "no '{' found in response".to_string())) +} + +// ── Flat → Nested conversion ───────────────────────────────────────── + +/// Convert a flat adjacency-list tree into a nested `SimTreeResponse`. +fn flat_to_sim_tree(flat: FlatTree) -> Result { + if flat.nodes.is_empty() { + return Err("flat tree has no nodes".into()); + } + + // Index nodes by id + let mut node_map: HashMap = HashMap::new(); + let root_id = flat.nodes[0].id.clone(); + for node in flat.nodes { + node_map.insert(node.id.clone(), node); + } + + // Group edges by parent + let mut children: HashMap> = HashMap::new(); + for edge in &flat.edges { + children.entry(edge.from.clone()).or_default().push(edge); + } + + fn build_node( + id: &str, + node_map: &mut HashMap, + children: &HashMap>, + ) -> Result { + let flat_node = node_map + .remove(id) + .ok_or_else(|| format!("edge references unknown node '{id}'"))?; + + let interactions = match children.get(id) { + Some(edges) => edges + .iter() + .map(|edge| { + let result = if node_map.contains_key(&edge.to) { + Some(build_node(&edge.to, node_map, children)?) + } else { + // Target already consumed or is a leaf-only reference + None + }; + Ok(PredictedInteraction { + label: edge.label.clone(), + input: edge.input.clone(), + result, + }) + }) + .collect::, String>>()?, + None => vec![], + }; + + Ok(SimTreeNode { + node_id: String::new(), + channels: flat_node.channels, + decisions: flat_node.decisions, + interactions, + }) + } + + let root = build_node(&root_id, &mut node_map, &children)?; + Ok(SimTreeResponse { root }) +} + +/// Convert a flat adjacency-list tree into a nested `GameTreeResponse`. +fn flat_to_game_tree(flat: FlatTree) -> Result { + if flat.nodes.is_empty() { + return Err("flat tree has no nodes".into()); + } + + // Index nodes by id + let mut node_map: HashMap = HashMap::new(); + let root_id = flat.nodes[0].id.clone(); + for node in flat.nodes { + node_map.insert(node.id.clone(), node); + } + + let root_node = node_map + .remove(&root_id) + .ok_or("root node missing from map")?; + + // Group edges from root by (label, input) to form choice groups. + // Preserve insertion order with a Vec of keys + a map. + let root_edges: Vec<&FlatEdge> = flat + .edges + .iter() + .filter(|e| e.from == root_id) + .collect(); + + let mut group_keys: Vec<(String, super::types::SimInput)> = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + + for edge in &root_edges { + // Use label as the group key (edges with same label = same choice group) + let key = edge.label.clone(); + if !groups.contains_key(&key) { + group_keys.push((key.clone(), edge.input.clone())); + } + groups.entry(key).or_default().push(edge); + } + + let mut choice_groups = Vec::new(); + for (label, input) in group_keys { + let edges = groups.remove(&label).unwrap_or_default(); + let outcomes = edges + .into_iter() + .map(|edge| { + let target = node_map.remove(&edge.to).ok_or_else(|| { + format!("edge references unknown node '{}'", edge.to) + })?; + Ok(GameOutcome { + summary: edge.outcome_summary.clone().unwrap_or_default(), + related_spec_nodes: edge.related_spec_nodes.clone(), + result: SimTreeNode { + node_id: String::new(), + channels: target.channels, + decisions: target.decisions, + interactions: vec![], + }, + }) + }) + .collect::, String>>()?; + + choice_groups.push(GameChoiceGroup { + interaction_label: label, + input, + outcomes, + }); + } + + Ok(GameTreeResponse { + root: GameTreeRoot { + node_id: String::new(), + channels: root_node.channels, + decisions: root_node.decisions, + choice_groups, + }, + }) +} + +/// Configuration for starting a simulation turn. +pub struct SimConfig { + pub model: String, + pub system_prompt: String, + pub mcp_url: String, + pub allowed_tools: String, + pub directory: Option, +} + +impl SimConfig { + pub fn new(model: String, system_prompt: String, mcp_url: String, directory: Option) -> Self { + Self { + model, + system_prompt, + mcp_url, + directory, + allowed_tools: [ + "mcp__spec-forest__search_nodes", + "mcp__spec-forest__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + "Read", + "Glob", + "Grep", + ] + .join(","), + } + } + + /// Config with spec read + write tools (no filesystem access). + /// Used for lean game batch generation and spec updates. + /// The system prompt controls when write tools are used. + pub fn spec_read_write(model: String, system_prompt: String, mcp_url: String) -> Self { + Self { + model, + system_prompt, + mcp_url, + directory: None, + allowed_tools: [ + "mcp__spec-forest__search_nodes", + "mcp__spec-forest__search_features", + "mcp__spec-forest__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + "mcp__spec-forest__add_children", + "mcp__spec-forest__answer_question", + "mcp__spec-forest__add_feature", + ] + .join(","), + } + } +} + +/// Start the first simulation turn. Returns (claude_session_id, response). +/// +/// Spawns `claude --print` with a system prompt and MCP config. +/// The session ID is captured for subsequent `--resume` calls. +pub async fn start_sim_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, SimResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting simulation turn" + ); + + let (response_text, session_id) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Simulation initial turn complete" + ); + + let response = parse_sim_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing simulation session with new user input. +pub async fn resume_sim_turn( + claude_session_id: &str, + input: &str, +) -> Result> { + let prompt = format!( + "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. \ + Simulate the application as an implementer would build it from the spec. \ + Cite spec nodes that drive specific behaviors. Only flag spec_gaps for high-entropy \ + decisions where the spec is genuinely ambiguous — not for obvious implementation details. \ + Include a decisions array listing significant decisions with refs or spec_gaps as appropriate.", + input + ); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(&prompt); + + tracing::info!( + session_id = %claude_session_id, + input_chars = input.len(), + "Resuming simulation turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Simulation resume turn complete" + ); + + parse_sim_response(&response_text) +} + +/// Resume an existing simulation session with a user report. +/// +/// Unlike `resume_sim_turn`, this instructs the agent to explain why +/// the simulation behaves a certain way rather than updating channels. +pub async fn resume_sim_report_turn( + claude_session_id: &str, + input: &str, +) -> Result> { + let prompt = format!( + "{}\n\n\ + The user is reporting unexpected behavior in the simulation. \ + Do NOT update the simulation channels. Respond with ONLY a raw JSON object — no text, \ + no markdown, no code fences. The first character of your response must be {{.\n\ + Schema: {{\"explanation\": \"your explanation here with [^N] markers\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ + Explain WHY the simulation behaves this way, citing spec nodes with [^N] markers \ + and corresponding entries in the refs array. If the user found a genuine spec gap, \ + acknowledge it and explain what is missing from the spec.", + input + ); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(&prompt); + + tracing::info!( + session_id = %claude_session_id, + input_chars = input.len(), + "Resuming simulation report turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Simulation report turn complete" + ); + + parse_sim_report_response(&response_text) +} + +/// Start the first simulation turn with tree output. Returns (claude_session_id, tree_response). +pub async fn start_sim_tree_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, SimTreeResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting simulation tree turn" + ); + + let (response_text, session_id) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Simulation initial tree turn complete" + ); + + let response = parse_sim_tree_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing simulation session with tree output. +pub async fn resume_sim_tree_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming simulation tree turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Simulation resume tree turn complete" + ); + + parse_sim_tree_response(&response_text) +} + +/// Parse the agent's text response into a SimTreeResponse. +/// +/// Tries flat adjacency-list format first, then nested format, +/// then falls back to wrapping a flat SimResponse in a single-node tree. +fn parse_sim_tree_response(text: &str) -> Result> { + // 1. Try flat adjacency-list format (preferred) + if let Ok(flat) = extract_json::(text) { + match flat_to_sim_tree(flat) { + Ok(tree) => { + tracing::info!("Parsed simulation tree from flat adjacency-list format"); + return Ok(tree); + } + Err(e) => tracing::debug!(error = %e, "Flat tree conversion failed"), + } + } + + // 2. Try nested format (legacy) + let tree_err = match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => e, + }; + + // 3. Fallback: try parsing as flat SimResponse and wrap in single-node tree + if let Ok(flat) = parse_sim_response(text) { + tracing::warn!("Tree parse failed, fell back to flat SimResponse"); + return Ok(SimTreeResponse { + root: SimTreeNode { + node_id: String::new(), + channels: flat.channels, + decisions: flat.decisions, + interactions: vec![], + }, + }); + } + + Err(format!( + "Failed to parse simulation tree response as JSON.\nSerde error: {tree_err}\nRaw response:\n{}", + text.trim() + ) + .into()) +} + +/// Parse the agent's text response into a SimResponse JSON envelope. +/// +/// The agent should return valid JSON, but we try to extract it from +/// surrounding text if needed (e.g., markdown code fences). +fn parse_sim_response(text: &str) -> Result> { + match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => Err(format!( + "Failed to parse simulation response as JSON.\nSerde error: {e}\nRaw response:\n{}", + text.trim() + ) + .into()), + } +} + +/// Parse the agent's text response into a SimReportResponse JSON envelope. +fn parse_sim_report_response( + text: &str, +) -> Result> { + match extract_json::(text) { + Ok(response) => Ok(response), + Err(e) => Err(format!( + "Failed to parse simulation report response as JSON.\nSerde error: {e}\nRaw response:\n{}", + text.trim() + ) + .into()), + } +} + +// ── Game mode runner functions ──────────────────────────────────────── + +/// Start the first game-mode turn with game tree output. Returns (claude_session_id, game_tree_response). +pub async fn start_game_tree_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, GameTreeResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting game tree turn" + ); + + let (response_text, session_id) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Game initial tree turn complete" + ); + + let response = parse_game_tree_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing game-mode session with game tree output. +pub async fn resume_game_tree_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming game tree turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Game resume tree turn complete" + ); + + parse_game_tree_response(&response_text) +} + +/// Parse the agent's text response into a GameTreeResponse. +/// +/// Tries flat adjacency-list format first, then nested format, +/// then falls back to wrapping a flat SimResponse in a single-node game tree. +fn parse_game_tree_response(text: &str) -> Result> { + // 1. Try flat adjacency-list format (preferred) + if let Ok(flat) = extract_json::(text) { + match flat_to_game_tree(flat) { + Ok(tree) => { + tracing::info!("Parsed game tree from flat adjacency-list format"); + return Ok(tree); + } + Err(e) => tracing::debug!(error = %e, "Flat game tree conversion failed"), + } + } + + // 2. Try nested format (legacy) + let tree_err = match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => e, + }; + + // 3. Fallback: try parsing as flat SimResponse and wrap in single-node game tree + if let Ok(flat) = parse_sim_response(text) { + tracing::warn!("Game tree parse failed, fell back to flat SimResponse"); + return Ok(GameTreeResponse { + root: GameTreeRoot { + node_id: String::new(), + channels: flat.channels, + decisions: flat.decisions, + choice_groups: vec![], + }, + }); + } + + Err(format!( + "Failed to parse game tree response as JSON.\nSerde error: {tree_err}\nRaw response:\n{}", + text.trim() + ) + .into()) +} + +/// Resume a game-mode session for a background spec update. +/// +/// Uses the spec update prompt and parses a simple JSON response describing +/// what spec changes were made. Returns the raw JSON string. +pub async fn resume_game_spec_update_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming game spec update turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Game spec update turn complete" + ); + + Ok(response_text) +} + +// ── Lean game mode runner functions ────────────────────────────────── + +/// Start the first lean game turn. Returns (claude_session_id, batch_response). +pub async fn start_lean_batch_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, super::lean_types::LeanBatchResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting lean batch turn" + ); + + let (response_text, session_id) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean initial batch turn complete" + ); + + let response = parse_lean_batch_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing lean game session for the next batch. +pub async fn resume_lean_batch_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming lean batch turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean resume batch turn complete" + ); + + parse_lean_batch_response(&response_text) +} + +/// Resume the main lean session for a spec update turn. +/// +/// Returns the AI's plain text response (summary of changes made). +pub async fn resume_lean_spec_update_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming lean spec update turn" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Lean spec update turn complete" + ); + + Ok(response_text) +} + +const WARMUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Run a minimal Haiku call for warmup scenarios. No system prompt, no MCP tools. +pub async fn run_warmup_haiku( + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg("claude-haiku-4-5-20251001") + .arg("-p") + .arg(prompt); + + tracing::info!(prompt_chars = prompt.len(), "Starting warmup Haiku call"); + + let stream_future = run_claude_streaming(cmd); + match tokio::time::timeout(WARMUP_TIMEOUT, stream_future).await { + Ok(Ok((response_text, _session_id))) => { + tracing::info!( + response_chars = response_text.len(), + "Warmup Haiku call complete" + ); + Ok(response_text) + } + Ok(Err(e)) => Err(e), + Err(_) => Err("warmup Haiku call timed out after 30 seconds".into()), + } +} + +/// Parse the AI's text response into a LeanBatchResponse. +fn parse_lean_batch_response( + text: &str, +) -> Result> { + // Try flat format. + if let Ok(flat) = extract_json::(text) { + let batch = super::lean_graph::flat_to_batch(flat); + tracing::info!("Parsed lean batch from flat format"); + return Ok(batch); + } + + Err(format!( + "Failed to parse lean batch response as JSON.\nRaw response:\n{}", + text.trim() + ) + .into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_direct_json() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert!(response.channels.contains_key("ui")); + assert_eq!(response.channels["ui"].text, "Hello"); + } + + #[test] + fn parse_json_in_code_fence() { + let input = r#"Here is the simulation: +```json +{"channels": {"ui": {"text": "World", "refs": []}}} +```"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].text, "World"); + } + + #[test] + fn parse_json_with_surrounding_text() { + let input = r#"The simulation state is: +{"channels": {"ui": {"text": "Test", "refs": [{"marker": "[^1]", "node_id": "abc"}]}}} +Hope that helps!"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].refs[0].node_id, "abc"); + } + + #[test] + fn parse_json_after_prose_with_braces() { + // Simulates the real failure: LLM outputs thinking text (which may contain {}) + // before the actual JSON response. + let input = r#"Now I have a thorough understanding of the game mode implementation. Let me construct the simulation. +{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].text, "Hello"); + } + + #[test] + fn parse_tree_json_after_prose() { + let input = r#"Let me build the interaction tree for this spec. +{"root": {"channels": {"ui": {"text": "Welcome", "refs": []}}, "decisions": [{"description": "Rendered welcome", "refs": [], "spec_gaps": []}], "interactions": []}}"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.channels["ui"].text, "Welcome"); + } + + #[test] + fn parse_with_spec_gaps() { + let input = + r#"{"channels": {"ui": {"text": "Gap", "refs": [], "spec_gaps": ["No spec for this", "Another assumption"]}}}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].spec_gaps.len(), 2); + assert_eq!(response.channels["ui"].spec_gaps[0], "No spec for this"); + assert_eq!( + response.channels["ui"].spec_gaps[1], + "Another assumption" + ); + } + + #[test] + fn parse_with_decisions() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}, "decisions": [{"description": "Displayed greeting", "refs": [{"marker": "[^1]", "node_id": "abc"}], "spec_gaps": []}, {"description": "Assumed dark theme", "refs": [], "spec_gaps": ["No spec defines the color theme"]}]}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.decisions.len(), 2); + assert_eq!(response.decisions[0].description, "Displayed greeting"); + assert_eq!(response.decisions[0].refs.len(), 1); + assert!(response.decisions[0].spec_gaps.is_empty()); + assert_eq!(response.decisions[1].description, "Assumed dark theme"); + assert_eq!(response.decisions[1].spec_gaps.len(), 1); + } + + #[test] + fn parse_without_decisions_defaults_empty() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert!(response.decisions.is_empty()); + } + + #[test] + fn stream_result_happy_path() { + let ndjson = r#"{"type":"message_start","session_id":"ses-123"} +{"type":"content_block_start","content_block":{"type":"text"}} +{"type":"content_block_delta"} +{"type":"content_block_stop"} +{"type":"result","result":"final text here","session_id":"ses-123"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "final text here"); + assert_eq!(session_id, "ses-123"); + } + + #[test] + fn stream_result_with_tool_calls() { + let ndjson = r#"{"type":"message_start","session_id":"ses-456"} +{"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp__spec-forest__search_nodes","id":"tool_1"}} +{"type":"content_block_delta"} +{"type":"content_block_stop"} +{"type":"content_block_start","content_block":{"type":"tool_use","name":"Read","id":"tool_2"}} +{"type":"content_block_stop"} +{"type":"content_block_start","content_block":{"type":"text"}} +{"type":"result","result":"the answer","session_id":"ses-456"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "the answer"); + assert_eq!(session_id, "ses-456"); + } + + #[test] + fn stream_result_no_result_event() { + let ndjson = r#"{"type":"message_start","session_id":"ses-789"} +{"type":"content_block_start","content_block":{"type":"text"}}"#; + assert!(extract_stream_result(ndjson).is_err()); + } + + #[test] + fn stream_result_skips_malformed_lines() { + let ndjson = "not json at all\n\ +{broken\n\ +{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"ses-abc\"}"; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "ok"); + assert_eq!(session_id, "ses-abc"); + } + + #[test] + fn stream_result_empty_session_id_on_resume() { + let ndjson = r#"{"type":"result","result":"resumed response"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "resumed response"); + assert_eq!(session_id, ""); + } + + // ── Flat tree conversion tests ────────────────────────────────── + + #[test] + fn flat_to_sim_tree_basic() { + let input = r#"{ + "nodes": [ + {"id": "root", "channels": {"ui": {"text": "Welcome", "refs": []}}, "decisions": [{"description": "Rendered welcome", "refs": [], "spec_gaps": []}]}, + {"id": "n1", "channels": {"ui": {"text": "Login page", "refs": []}}, "decisions": [{"description": "Showed login", "refs": [], "spec_gaps": []}]} + ], + "edges": [ + {"from": "root", "to": "n1", "label": "Click Login", "input": {"keys": ["Enter"], "raw_text": "\n"}} + ] + }"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.channels["ui"].text, "Welcome"); + assert_eq!(response.root.interactions.len(), 1); + assert_eq!(response.root.interactions[0].label, "Click Login"); + let child = response.root.interactions[0].result.as_ref().unwrap(); + assert_eq!(child.channels["ui"].text, "Login page"); + } + + #[test] + fn flat_to_sim_tree_multi_level() { + let input = r#"{ + "nodes": [ + {"id": "root", "channels": {"ui": {"text": "Home", "refs": []}}, "decisions": []}, + {"id": "n1", "channels": {"ui": {"text": "Page A", "refs": []}}, "decisions": []}, + {"id": "n2", "channels": {"ui": {"text": "Page B", "refs": []}}, "decisions": []}, + {"id": "n3", "channels": {"ui": {"text": "Deep", "refs": []}}, "decisions": []} + ], + "edges": [ + {"from": "root", "to": "n1", "label": "Go A", "input": {"keys": ["a"], "raw_text": "a"}}, + {"from": "root", "to": "n2", "label": "Go B", "input": {"keys": ["b"], "raw_text": "b"}}, + {"from": "n1", "to": "n3", "label": "Go deep", "input": {"keys": ["d"], "raw_text": "d"}} + ] + }"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.interactions.len(), 2); + let a = response.root.interactions[0].result.as_ref().unwrap(); + assert_eq!(a.channels["ui"].text, "Page A"); + assert_eq!(a.interactions.len(), 1); + assert_eq!(a.interactions[0].label, "Go deep"); + let deep = a.interactions[0].result.as_ref().unwrap(); + assert_eq!(deep.channels["ui"].text, "Deep"); + } + + #[test] + fn flat_to_sim_tree_leaf_edges() { + // Edges to nodes not in the nodes array produce interactions with result=None + let input = r#"{ + "nodes": [ + {"id": "root", "channels": {"ui": {"text": "Home", "refs": []}}, "decisions": []} + ], + "edges": [ + {"from": "root", "to": "missing", "label": "Leaf action", "input": {"keys": ["x"], "raw_text": "x"}} + ] + }"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.interactions.len(), 1); + assert!(response.root.interactions[0].result.is_none()); + } + + #[test] + fn flat_to_game_tree_basic() { + let input = r#"{ + "nodes": [ + {"id": "root", "channels": {"ui": {"text": "Game start", "refs": []}}, "decisions": [{"description": "Initial state", "refs": [], "spec_gaps": []}]}, + {"id": "n1", "channels": {"ui": {"text": "Outcome A", "refs": []}}, "decisions": [{"description": "Did A", "refs": [], "spec_gaps": []}]}, + {"id": "n2", "channels": {"ui": {"text": "Outcome B", "refs": []}}, "decisions": [{"description": "Did B", "refs": [], "spec_gaps": []}]} + ], + "edges": [ + {"from": "root", "to": "n1", "label": "Press X", "input": {"keys": ["x"], "raw_text": "x"}, "outcome_summary": "Starts playback", "related_spec_nodes": ["spec-1"]}, + {"from": "root", "to": "n2", "label": "Press X", "input": {"keys": ["x"], "raw_text": "x"}, "outcome_summary": "Loads sample", "related_spec_nodes": []} + ] + }"#; + let response = parse_game_tree_response(input).unwrap(); + assert_eq!(response.root.channels["ui"].text, "Game start"); + assert_eq!(response.root.choice_groups.len(), 1); + let group = &response.root.choice_groups[0]; + assert_eq!(group.interaction_label, "Press X"); + assert_eq!(group.outcomes.len(), 2); + assert_eq!(group.outcomes[0].summary, "Starts playback"); + assert_eq!(group.outcomes[0].related_spec_nodes, vec!["spec-1"]); + assert_eq!(group.outcomes[0].result.channels["ui"].text, "Outcome A"); + assert_eq!(group.outcomes[1].summary, "Loads sample"); + } + + #[test] + fn flat_to_game_tree_multiple_groups() { + let input = r#"{ + "nodes": [ + {"id": "root", "channels": {"ui": {"text": "State", "refs": []}}, "decisions": []}, + {"id": "n1", "channels": {"ui": {"text": "A", "refs": []}}, "decisions": []}, + {"id": "n2", "channels": {"ui": {"text": "B", "refs": []}}, "decisions": []} + ], + "edges": [ + {"from": "root", "to": "n1", "label": "Press X", "input": {"keys": ["x"], "raw_text": "x"}, "outcome_summary": "Result X"}, + {"from": "root", "to": "n2", "label": "Press Y", "input": {"keys": ["y"], "raw_text": "y"}, "outcome_summary": "Result Y"} + ] + }"#; + let response = parse_game_tree_response(input).unwrap(); + assert_eq!(response.root.choice_groups.len(), 2); + assert_eq!(response.root.choice_groups[0].interaction_label, "Press X"); + assert_eq!(response.root.choice_groups[1].interaction_label, "Press Y"); + } + + #[test] + fn flat_tree_parsed_through_sim_tree_with_prose() { + let input = r#"Here is the simulation tree: + {"nodes": [{"id": "root", "channels": {"ui": {"text": "Hello", "refs": []}}, "decisions": []}], "edges": []}"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.channels["ui"].text, "Hello"); + assert!(response.root.interactions.is_empty()); + } +} diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs new file mode 100644 index 0000000..33756a3 --- /dev/null +++ b/crates/spec-forest/src/simulation/session.rs @@ -0,0 +1,218 @@ +use super::lean_graph::LeanGraph; +use super::types::{ + ChannelContent, Decision, GameSpecUpdate, GameTreeRoot, SimReportResponse, SimTreeNode, +}; +use super::warmup_types::{WarmupCapture, WarmupScenario}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SimChannel { + Ui, + Audio, + Network, + Errors, + Logs, +} + +impl SimChannel { + pub const ALL: &[SimChannel] = &[ + SimChannel::Ui, + SimChannel::Audio, + SimChannel::Network, + SimChannel::Errors, + SimChannel::Logs, + ]; + + pub fn key(&self) -> &'static str { + match self { + SimChannel::Ui => "ui", + SimChannel::Audio => "audio", + SimChannel::Network => "network", + SimChannel::Errors => "errors", + SimChannel::Logs => "logs", + } + } + + pub fn from_key(s: &str) -> Option { + match s { + "ui" => Some(SimChannel::Ui), + "audio" => Some(SimChannel::Audio), + "network" => Some(SimChannel::Network), + "errors" => Some(SimChannel::Errors), + "logs" => Some(SimChannel::Logs), + _ => None, + } + } +} + +impl fmt::Display for SimChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimChannel::Ui => write!(f, "UI"), + SimChannel::Audio => write!(f, "Audio"), + SimChannel::Network => write!(f, "Network"), + SimChannel::Errors => write!(f, "Errors"), + SimChannel::Logs => write!(f, "Logs"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SimStatus { + Idle, + Processing, + Error(String), + Ended, +} + +#[derive(Clone)] +pub struct SimSession { + pub id: String, + pub spec_id: String, + pub root_node_id: Option, + pub model: String, + /// Claude CLI session ID for `--resume`. + pub claude_session_id: Option, + pub channels: Vec, + pub status: SimStatus, + /// Latest channel content from the most recent agent response. + pub channel_contents: HashMap, + /// Pending report explanation from the most recent report turn. + pub pending_report: Option, + /// Decisions from the most recent agent response. + pub decisions: Vec, + /// Optional scenario description for the simulation. + pub scenario: Option, + /// Whether to load the entire spec into context. + pub whole_spec: bool, + /// Optional project directory for code-aware simulation. + pub directory: Option, + /// The current interaction tree (set after each AI generation). + pub interaction_tree: Option, + /// Current position in the tree (node_id of the node the user is viewing). + pub current_node_id: Option, + /// Path of node_ids from root to current position (for history replay). + pub navigation_path: Vec, + /// Tree depth to request from AI (number of interaction levels). + pub tree_depth: u8, + /// Number of predicted interactions per node. + pub tree_branching: u8, + /// Node ID where pregeneration is currently targeting, if any. + pub pregen_target: Option, + /// Generation counter, incremented on each full tree replacement. + /// Used to invalidate stale pregenerations. + pub tree_generation: u64, + /// Whether background pregeneration is in progress. + pub pregenerating: bool, + /// Whether this session is in game mode (spec-updating play-through). + pub game_mode: bool, + /// The current game-mode interaction tree (set after each AI generation). + pub game_tree: Option, + /// Log of spec updates triggered by game choices during this session. + pub game_spec_updates: Vec, + // ── Lean game mode fields ─────────────────────────────────────────── + /// Whether this session is in lean game mode (DAG-based efficient play). + pub lean_mode: bool, + /// The DAG of all generated nodes and edges. + pub lean_graph: Option, + /// Current position in the DAG. + pub lean_current_node_id: Option, + /// Breadcrumb trail for back-navigation. + pub lean_navigation_path: Vec, + /// Depth of each batch generation (default 3). + pub lean_batch_depth: u8, + /// Whether background batch generation is in progress. + pub lean_generating: bool, + /// Node ID where pregeneration is targeting, if any. + pub lean_generation_target: Option, + /// Generation counter, incremented on modifications to invalidate stale pregens. + pub lean_generation: u64, + /// Chronological history of all navigation actions (forward and back). + /// Append-only. Used for send-actions. + pub lean_action_history: Vec, + /// How many entries in lean_action_history have been sent via "send actions." + pub lean_sent_history_len: usize, + /// Whether a spec update prompt is running on the main session. + pub lean_spec_updating: bool, + /// Queued leaf navigation (current_node_id, edge_index) to run after spec update. + pub lean_queued_leaf: Option<(String, usize)>, + /// Queued send-actions request (user_notes) waiting for pregen to finish. + pub lean_queued_send: Option, + // ── Warmup fields (fast Haiku interactions while main game loads) ── + /// Whether warmup interactions are active. + pub warmup_active: bool, + /// Current warmup scenario shown to the player. + pub warmup_scenario: Option, + /// Whether a warmup Haiku call is in flight. + pub warmup_generating: bool, + /// Whether the real game is ready but the player hasn't transitioned yet. + pub warmup_game_ready: bool, + /// Collected warmup Q&A pairs for later spec feeding. + pub warmup_captures: Vec, + /// Generation counter for warmup, used to discard stale responses. + pub warmup_generation: u64, + /// Remaining spec node IDs + questions for warmup scenarios. + pub warmup_remaining_nodes: Vec<(String, String)>, +} + +impl SimSession { + pub fn new( + id: String, + spec_id: String, + root_node_id: Option, + model: String, + channels: Vec, + scenario: Option, + ) -> Self { + Self { + id, + spec_id, + root_node_id, + model, + claude_session_id: None, + channels, + status: SimStatus::Idle, + channel_contents: HashMap::new(), + pending_report: None, + decisions: Vec::new(), + scenario, + whole_spec: false, + directory: None, + interaction_tree: None, + current_node_id: None, + navigation_path: Vec::new(), + tree_depth: 4, + tree_branching: 2, + pregen_target: None, + tree_generation: 0, + pregenerating: false, + game_mode: false, + game_tree: None, + game_spec_updates: Vec::new(), + lean_mode: false, + lean_graph: None, + lean_current_node_id: None, + lean_navigation_path: Vec::new(), + lean_batch_depth: 3, + lean_generating: false, + lean_generation_target: None, + lean_generation: 0, + lean_action_history: Vec::new(), + lean_sent_history_len: 0, + lean_spec_updating: false, + lean_queued_leaf: None, + lean_queued_send: None, + warmup_active: false, + warmup_scenario: None, + warmup_generating: false, + warmup_game_ready: false, + warmup_captures: Vec::new(), + warmup_generation: 0, + warmup_remaining_nodes: Vec::new(), + } + } +} diff --git a/crates/spec-forest/src/simulation/tree.rs b/crates/spec-forest/src/simulation/tree.rs new file mode 100644 index 0000000..c28cc15 --- /dev/null +++ b/crates/spec-forest/src/simulation/tree.rs @@ -0,0 +1,370 @@ +use super::types::{GameTreeRoot, SimInput, SimTreeNode}; +use serde::Serialize; +use uuid::Uuid; + +/// A single entry in the breadcrumb trail through the interaction tree. +#[derive(Debug, Clone, Serialize)] +pub struct BreadcrumbEntry { + pub node_id: String, + pub label: String, +} + +/// Recursively assign unique node IDs to every node in the tree. +/// Called after parsing the AI response (the AI does not produce IDs). +pub fn assign_node_ids(node: &mut SimTreeNode) { + node.node_id = Uuid::new_v4().to_string(); + for interaction in &mut node.interactions { + if let Some(ref mut result) = interaction.result { + assign_node_ids(result); + } + } +} + +/// Find a node by ID anywhere in the tree. +pub fn find_node<'a>(tree: &'a SimTreeNode, node_id: &str) -> Option<&'a SimTreeNode> { + if tree.node_id == node_id { + return Some(tree); + } + for interaction in &tree.interactions { + if let Some(ref result) = interaction.result { + if let Some(found) = find_node(result, node_id) { + return Some(found); + } + } + } + None +} + +/// Find a node by ID anywhere in the tree (mutable reference). +pub fn find_node_mut<'a>(tree: &'a mut SimTreeNode, node_id: &str) -> Option<&'a mut SimTreeNode> { + if tree.node_id == node_id { + return Some(tree); + } + for interaction in &mut tree.interactions { + if let Some(ref mut result) = interaction.result { + if let Some(found) = find_node_mut(result, node_id) { + return Some(found); + } + } + } + None +} + +/// Find which predicted interaction matches the given user input. +/// Returns the index into `node.interactions` if found. +/// +/// Matching strategy: +/// 1. Exact `raw_text` match (after trimming) +/// 2. Exact `keys` match +/// 3. Case-insensitive `raw_text` match +pub fn find_matching_interaction(node: &SimTreeNode, input: &SimInput) -> Option { + let input_text = input.raw_text.trim(); + + // Exact raw_text match + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.raw_text.trim() == input_text { + return Some(i); + } + } + + // Exact keys match + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.keys == input.keys { + return Some(i); + } + } + + // Case-insensitive raw_text match + let input_lower = input_text.to_lowercase(); + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.raw_text.trim().to_lowercase() == input_lower { + return Some(i); + } + } + + None +} + +/// Check if a node is a leaf (no interactions, or all interactions are shallow with no result). +pub fn is_leaf(node: &SimTreeNode) -> bool { + node.interactions.is_empty() + || node.interactions.iter().all(|i| i.result.is_none()) +} + +/// Graft a generated subtree onto a target node. +/// Replaces the target node's interactions with the new subtree's interactions. +pub fn graft_tree(tree: &mut SimTreeNode, target_node_id: &str, new_subtree: SimTreeNode) -> bool { + if let Some(target) = find_node_mut(tree, target_node_id) { + target.interactions = new_subtree.interactions; + true + } else { + false + } +} + +/// Reconstruct the user's journey through the tree for replaying to the AI. +/// Given the tree and the path of node_ids from root to current position, +/// returns the sequence of (input, resulting_node) pairs. +pub fn collect_path_history<'a>( + tree: &'a SimTreeNode, + path: &[String], +) -> Vec<(&'a SimInput, &'a SimTreeNode)> { + let mut history = Vec::new(); + let mut current = tree; + + // path[0] is the root, so we start from path[1..] which are child node IDs + for target_id in path.iter().skip(1) { + let mut found = false; + for interaction in ¤t.interactions { + if let Some(ref result) = interaction.result { + if result.node_id == *target_id { + history.push((&interaction.input, result)); + current = result; + found = true; + break; + } + } + } + if !found { + break; + } + } + + history +} + +/// Build a breadcrumb trail from the root to the current position. +/// Returns a vec of (node_id, label) pairs. The root is labelled "Start"; +/// each subsequent entry uses the label of the interaction that led to it. +pub fn collect_breadcrumbs(tree: &SimTreeNode, path: &[String]) -> Vec { + let mut crumbs = Vec::new(); + if path.is_empty() { + return crumbs; + } + + crumbs.push(BreadcrumbEntry { + node_id: path[0].clone(), + label: "Start".to_string(), + }); + + let mut current = tree; + for target_id in path.iter().skip(1) { + let mut found = false; + for interaction in ¤t.interactions { + if let Some(ref result) = interaction.result { + if result.node_id == *target_id { + crumbs.push(BreadcrumbEntry { + node_id: target_id.clone(), + label: interaction.label.clone(), + }); + current = result; + found = true; + break; + } + } + } + if !found { + break; + } + } + + crumbs +} + +// ── Game tree helpers ───────────────────────────────────────────────── + +/// Recursively assign unique node IDs to every node in a game tree. +pub fn assign_game_node_ids(root: &mut GameTreeRoot) { + root.node_id = Uuid::new_v4().to_string(); + for group in &mut root.choice_groups { + for outcome in &mut group.outcomes { + assign_node_ids(&mut outcome.result); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::types::{ChannelContent, PredictedInteraction, SimTreeNode}; + use std::collections::HashMap; + + fn make_leaf(text: &str) -> SimTreeNode { + let mut channels = HashMap::new(); + channels.insert( + "ui".to_string(), + ChannelContent { + text: text.to_string(), + refs: vec![], + spec_gaps: vec![], + }, + ); + SimTreeNode { + node_id: String::new(), + channels, + decisions: vec![], + interactions: vec![], + } + } + + fn make_tree() -> SimTreeNode { + SimTreeNode { + node_id: String::new(), + channels: HashMap::new(), + decisions: vec![], + interactions: vec![ + PredictedInteraction { + label: "Click Login".to_string(), + input: SimInput { + keys: vec!["Enter".to_string()], + raw_text: "\n".to_string(), + }, + result: Some(make_leaf("Login form")), + }, + PredictedInteraction { + label: "Type hello".to_string(), + input: SimInput { + keys: vec![ + "h".to_string(), + "e".to_string(), + "l".to_string(), + "l".to_string(), + "o".to_string(), + ], + raw_text: "hello".to_string(), + }, + result: Some(make_leaf("Search results")), + }, + ], + } + } + + #[test] + fn test_assign_node_ids() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + assert!(!tree.node_id.is_empty()); + let r0 = tree.interactions[0].result.as_ref().unwrap(); + let r1 = tree.interactions[1].result.as_ref().unwrap(); + assert!(!r0.node_id.is_empty()); + assert!(!r1.node_id.is_empty()); + // All IDs should be unique + assert_ne!(tree.node_id, r0.node_id); + assert_ne!(tree.node_id, r1.node_id); + assert_ne!(r0.node_id, r1.node_id); + } + + #[test] + fn test_find_node() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let child_id = tree.interactions[1].result.as_ref().unwrap().node_id.clone(); + + let found = find_node(&tree, &child_id).unwrap(); + assert_eq!(found.channels["ui"].text, "Search results"); + + assert!(find_node(&tree, "nonexistent").is_none()); + } + + #[test] + fn test_find_matching_interaction_exact_text() { + let tree = make_tree(); + let input = SimInput { + keys: vec![], + raw_text: "hello".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(1)); + } + + #[test] + fn test_find_matching_interaction_exact_keys() { + let tree = make_tree(); + let input = SimInput { + keys: vec!["Enter".to_string()], + raw_text: "different".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(0)); + } + + #[test] + fn test_find_matching_interaction_case_insensitive() { + let tree = make_tree(); + let input = SimInput { + keys: vec![], + raw_text: "HELLO".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(1)); + } + + #[test] + fn test_find_matching_interaction_no_match() { + let tree = make_tree(); + let input = SimInput { + keys: vec!["Tab".to_string()], + raw_text: "something else".to_string(), + }; + assert!(find_matching_interaction(&tree, &input).is_none()); + } + + #[test] + fn test_is_leaf() { + let tree = make_tree(); + assert!(!is_leaf(&tree)); + assert!(is_leaf(tree.interactions[0].result.as_ref().unwrap())); + } + + #[test] + fn test_collect_path_history() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + let child_id = tree.interactions[0].result.as_ref().unwrap().node_id.clone(); + + let path = vec![root_id, child_id]; + let history = collect_path_history(&tree, &path); + assert_eq!(history.len(), 1); + assert_eq!(history[0].0.raw_text, "\n"); + assert_eq!(history[0].1.channels["ui"].text, "Login form"); + } + + #[test] + fn test_collect_breadcrumbs() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + let child_id = tree.interactions[0] + .result + .as_ref() + .unwrap() + .node_id + .clone(); + + let path = vec![root_id.clone(), child_id.clone()]; + let crumbs = collect_breadcrumbs(&tree, &path); + assert_eq!(crumbs.len(), 2); + assert_eq!(crumbs[0].label, "Start"); + assert_eq!(crumbs[0].node_id, root_id); + assert_eq!(crumbs[1].label, "Click Login"); + assert_eq!(crumbs[1].node_id, child_id); + } + + #[test] + fn test_collect_breadcrumbs_root_only() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + + let path = vec![root_id.clone()]; + let crumbs = collect_breadcrumbs(&tree, &path); + assert_eq!(crumbs.len(), 1); + assert_eq!(crumbs[0].label, "Start"); + } + + #[test] + fn test_collect_breadcrumbs_empty_path() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let crumbs = collect_breadcrumbs(&tree, &[]); + assert!(crumbs.is_empty()); + } +} diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs new file mode 100644 index 0000000..883df9c --- /dev/null +++ b/crates/spec-forest/src/simulation/types.rs @@ -0,0 +1,216 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// JSON envelope the simulation agent must produce for each response. +/// +/// Example: +/// ```json +/// { +/// "channels": { +/// "ui": { +/// "text": "+---------+\n| Hello |\n+---------+\n[^1]", +/// "refs": [{"marker": "[^1]", "node_id": "abc123"}] +/// }, +/// "network": { +/// "text": "[NET] POST /api/users -> 201", +/// "refs": [] +/// } +/// } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimResponse { + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelContent { + pub text: String, + #[serde(default)] + pub refs: Vec, + /// One entry per ungrounded assumption in this channel. + #[serde(default)] + pub spec_gaps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeRef { + pub marker: String, + pub node_id: String, +} + +/// A discrete decision the AI made during a simulation turn. +/// +/// Each decision describes one specific action or behavior choice, +/// with references to the spec nodes that justify it, or spec_gaps +/// for ungrounded assumptions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision { + pub description: String, + #[serde(default)] + pub refs: Vec, + #[serde(default)] + pub spec_gaps: Vec, +} + +/// Structured input sent to the agent for each user interaction turn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimInput { + pub keys: Vec, + pub raw_text: String, +} + +/// A node in the pre-computed interaction tree. +/// +/// Each node contains the same output as `SimResponse` (channels + decisions), +/// plus a set of predicted next interactions with pre-computed results. +/// Leaf nodes have an empty `interactions` vec. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimTreeNode { + /// Unique identifier assigned server-side after parsing (not produced by AI). + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the simulation. + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, + /// Predicted next interactions with pre-computed results. + #[serde(default)] + pub interactions: Vec, +} + +/// A predicted user interaction that leads to a child tree node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PredictedInteraction { + /// Human-readable label for the interaction (e.g., "Click Login", "Type email"). + pub label: String, + /// The input this interaction represents. + pub input: SimInput, + /// The pre-computed simulation output if the user takes this interaction. + /// `None` for shallow interactions at the tree boundary (label + input only). + #[serde(default)] + pub result: Option, +} + +/// The full tree response the AI produces in a single generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimTreeResponse { + pub root: SimTreeNode, +} + +// ── Game Mode Types ────────────────────────────────────────────────── + +/// A group of outcomes for a single interaction in game mode. +/// +/// Unlike `PredictedInteraction` which has one result per interaction, +/// game mode presents multiple possible outcomes for the same user action, +/// letting the player pick the correct behavior. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameChoiceGroup { + /// Human-readable label for the interaction (e.g., "Press X"). + pub interaction_label: String, + /// The input this interaction represents. + pub input: SimInput, + /// Alternative outcomes the player chooses between. + pub outcomes: Vec, +} + +/// One possible outcome of an interaction in game mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameOutcome { + /// Short summary of what happens (e.g., "Starts audio playback"). + pub summary: String, + /// Full pre-computed simulation output for this outcome. + pub result: SimTreeNode, + /// Spec node IDs that this outcome relates to (for spec updates on selection). + #[serde(default)] + pub related_spec_nodes: Vec, +} + +/// The game-mode tree response envelope produced by the AI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameTreeResponse { + pub root: GameTreeRoot, +} + +/// Root node of a game-mode interaction tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameTreeRoot { + /// Unique identifier assigned server-side after parsing. + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the game. + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, + /// Grouped interaction choices with multiple outcomes each. + #[serde(default)] + pub choice_groups: Vec, +} + +/// Record of a spec update triggered by a game choice or rejection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameSpecUpdate { + pub interaction_label: String, + pub outcome_summary: String, + /// Human-readable description of what was changed in the spec. + pub description: String, + /// Which spec node was affected. + pub node_id: String, + /// What action was taken: "add_qa", "update_answer", "add_feature", or "none". + #[serde(default)] + pub action: String, +} + +/// Structured input for behavior reporting. +#[derive(Debug, Clone, Serialize)] +pub struct SimReport { + pub description: String, +} + +/// Response envelope for a report query. +/// +/// Unlike `SimResponse`, this does NOT replace channel contents. +/// It provides an explanation of why the simulation behaves a certain way, +/// with spec node references. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimReportResponse { + pub explanation: String, + #[serde(default)] + pub refs: Vec, +} + +// ── Flat Wire Format ──────────────────────────────────────────────── + +/// Flat adjacency-list format for Claude's JSON output. +/// Converted to nested `SimTreeResponse` / `GameTreeResponse` after parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatTree { + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatNode { + pub id: String, + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatEdge { + pub from: String, + pub to: String, + pub label: String, + pub input: SimInput, + /// Game mode only: short description of what happens. + #[serde(default)] + pub outcome_summary: Option, + /// Game mode only: spec node IDs that justify this outcome. + #[serde(default)] + pub related_spec_nodes: Vec, +} diff --git a/crates/spec-forest/src/simulation/warmup_orchestrate.rs b/crates/spec-forest/src/simulation/warmup_orchestrate.rs new file mode 100644 index 0000000..b7265b5 --- /dev/null +++ b/crates/spec-forest/src/simulation/warmup_orchestrate.rs @@ -0,0 +1,169 @@ +use std::sync::Arc; +use tracing::{error, info}; + +use super::session::SimStatus; +use super::warmup_types::{WarmupCapture, WarmupScenario}; +use crate::state::AppState; + +/// Start warmup interactions while the main lean game loads. +/// +/// Collects high-entropy spec nodes and generates the first warmup scenario +/// via Haiku. +pub async fn start_warmup(state: Arc, session_id: String) { + info!(session_id, "Starting warmup interactions"); + + let spec_id = match state.get_sim_session(&session_id) { + Some(s) => s.spec_id.clone(), + None => return, + }; + + // Collect candidate nodes (already prioritised: unanswered first, then needs-review). + let mut candidates = + super::lean_orchestrate::collect_high_entropy_nodes(&state, &spec_id, 20, None, true); + if candidates.is_empty() { + info!(session_id, "No candidate nodes for warmup"); + return; + } + + // Simple deterministic shuffle: reverse to start from the tail of the priority list, + // giving a mix of unanswered and needs-review nodes. + candidates.reverse(); + + let (node_id, node_question) = candidates.remove(0); + + state.update_sim_session(&session_id, |s| { + s.warmup_active = true; + s.warmup_generating = true; + s.warmup_generation = 1; + s.warmup_remaining_nodes = candidates; + }); + + generate_warmup_scenario(state, session_id, node_id, node_question).await; +} + +/// Generate a single warmup scenario from a spec node using Haiku. +async fn generate_warmup_scenario( + state: Arc, + session_id: String, + node_id: String, + node_question: String, +) { + let warmup_gen = match state.get_sim_session(&session_id) { + Some(s) => s.warmup_generation, + None => return, + }; + + let prompt = build_warmup_prompt(&node_question); + + match super::runner::run_warmup_haiku(&prompt).await { + Ok(scenario_text) => { + state.update_sim_session(&session_id, |s| { + // Discard if generation changed (game loaded or warmup cancelled). + if s.warmup_generation != warmup_gen { + return; + } + // If the real game already loaded while we were generating, skip. + if s.status == SimStatus::Idle && s.lean_graph.is_some() { + s.warmup_active = false; + s.warmup_generating = false; + return; + } + s.warmup_scenario = Some(WarmupScenario { + node_id: node_id.clone(), + node_question: node_question.clone(), + scenario_text, + responded: false, + }); + s.warmup_generating = false; + }); + info!(session_id, "Warmup scenario generated"); + } + Err(e) => { + error!(session_id, error = %e, "Warmup Haiku call failed"); + state.update_sim_session(&session_id, |s| { + s.warmup_active = false; + s.warmup_generating = false; + }); + } + } +} + +/// Handle a player's response to a warmup scenario. +/// +/// Captures the response, then either transitions to the real game (if ready) +/// or cycles to the next warmup scenario. +pub async fn handle_warmup_response(state: Arc, session_id: String, response: String) { + let (should_transition, next_node) = { + let mut transition = false; + let mut next = None; + + state.update_sim_session(&session_id, |s| { + if let Some(scenario) = s.warmup_scenario.take() { + s.warmup_captures.push(WarmupCapture { + node_id: scenario.node_id, + node_question: scenario.node_question, + scenario_text: scenario.scenario_text, + player_response: response.clone(), + }); + } + + if s.warmup_game_ready { + // Real game is ready — transition. + s.warmup_active = false; + transition = true; + } else if let Some(node) = s.warmup_remaining_nodes.pop() { + // Cycle to next scenario. + s.warmup_generating = true; + next = Some(node); + } else { + // No more nodes — deactivate warmup. + s.warmup_active = false; + } + }); + + (transition, next) + }; + + if should_transition { + info!(session_id, "Warmup transitioning to real game"); + return; + } + + if let Some((node_id, node_question)) = next_node { + generate_warmup_scenario(state, session_id, node_id, node_question).await; + } +} + +/// Signal that the real game has loaded. If no active warmup interaction, +/// deactivate immediately. Otherwise, set the flag for transition after +/// the player finishes their current scenario. +pub fn signal_game_ready(state: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + s.warmup_game_ready = true; + // If no scenario is active (or already responded), transition now. + let scenario_pending = s + .warmup_scenario + .as_ref() + .is_some_and(|sc| !sc.responded); + if !scenario_pending && !s.warmup_generating { + s.warmup_active = false; + } + }); + info!(session_id, "Warmup: real game ready signal sent"); +} + +fn build_warmup_prompt(node_question: &str) -> String { + format!( + r#"You are running a quick scenario for a software specification exploration game. + +The player is exploring a software specification. Present a SHORT scenario (2-3 sentences) that puts the player in a concrete situation where this question matters: + +"{node_question}" + +Rules: +- Text only, no markdown formatting +- Present a specific situation, then ask what the player would do or decide +- Under 100 words +- Be direct and specific, not abstract"# + ) +} diff --git a/crates/spec-forest/src/simulation/warmup_types.rs b/crates/spec-forest/src/simulation/warmup_types.rs new file mode 100644 index 0000000..9061ee5 --- /dev/null +++ b/crates/spec-forest/src/simulation/warmup_types.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +/// A warmup scenario currently being shown to the player while the main game loads. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarmupScenario { + /// The spec node ID this warmup explores. + pub node_id: String, + /// The spec node's question text. + pub node_question: String, + /// AI-generated scenario text shown to the player. + pub scenario_text: String, + /// Whether the player has responded to this scenario. + pub responded: bool, +} + +/// A completed warmup exchange, captured for later spec updates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarmupCapture { + /// Spec node ID this exchange was about. + pub node_id: String, + /// The spec question being explored. + pub node_question: String, + /// The scenario presented to the player. + pub scenario_text: String, + /// The player's response. + pub player_response: String, +} diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index ecaa213..ac3d44e 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -1,8 +1,9 @@ use crate::dir_context::DirectoryContextCache; use crate::explore::{ExploreSession, ExploreStatusResponse}; use crate::ingest::{IngestSession, IngestStatusResponse}; -use crate::op_channel::{OpError, OpRequest, OpSource}; +use crate::op_channel::{OpError, OpNotification, OpRequest, OpSource}; use crate::prompt_log::PromptLog; +use crate::simulation::SimSession; use crate::sync::{OpReceiver, SyncHandle}; use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; use parking_lot::Mutex; @@ -10,7 +11,7 @@ use serde::Serialize; use spec_forest_db::Database; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{broadcast, mpsc}; use tokio::sync::Mutex as TokioMutex; #[derive(Debug, Clone, Serialize)] @@ -30,6 +31,8 @@ pub struct AppState { generation_status: Mutex>, explore_sessions: Mutex>, ingest_sessions: Mutex>, + sim_sessions: Mutex>, + mcp_url: Mutex>, sync_handle: TokioMutex>, sync_op_rx: std::sync::Mutex>, sync_url: Mutex>, @@ -38,6 +41,7 @@ pub struct AppState { user_name: Mutex, undo_state: Mutex, op_tx: Option>, + op_notify_tx: broadcast::Sender, dir_context_cache: DirectoryContextCache, } @@ -52,12 +56,15 @@ impl AppState { let embedder = TextEmbedding::try_new( InitOptions::new(EmbeddingModel::BGESmallENV15).with_show_download_progress(true), )?; + let (op_notify_tx, _) = broadcast::channel(64); Ok(Self { db: Mutex::new(db), embedder, generation_status: Mutex::new(HashMap::new()), explore_sessions: Mutex::new(HashMap::new()), ingest_sessions: Mutex::new(HashMap::new()), + sim_sessions: Mutex::new(HashMap::new()), + mcp_url: Mutex::new(None), sync_handle: TokioMutex::new(None), sync_op_rx: std::sync::Mutex::new(None), sync_url: Mutex::new(None), @@ -66,6 +73,7 @@ impl AppState { user_name: Mutex::new("anonymous".to_string()), undo_state: Mutex::new(spec_forest_db::undo::UndoState::new()), op_tx: None, + op_notify_tx, dir_context_cache: DirectoryContextCache::new(), }) } @@ -76,12 +84,15 @@ impl AppState { let embedder = TextEmbedding::try_new( InitOptions::new(EmbeddingModel::BGESmallENV15).with_show_download_progress(false), )?; + let (op_notify_tx, _) = broadcast::channel(64); Ok(Self { db: Mutex::new(db), embedder, generation_status: Mutex::new(HashMap::new()), explore_sessions: Mutex::new(HashMap::new()), ingest_sessions: Mutex::new(HashMap::new()), + sim_sessions: Mutex::new(HashMap::new()), + mcp_url: Mutex::new(None), sync_handle: TokioMutex::new(None), sync_op_rx: std::sync::Mutex::new(None), sync_url: Mutex::new(None), @@ -90,6 +101,7 @@ impl AppState { user_name: Mutex::new("anonymous".to_string()), undo_state: Mutex::new(spec_forest_db::undo::UndoState::new()), op_tx: None, + op_notify_tx, dir_context_cache: DirectoryContextCache::new(), }) } @@ -119,6 +131,13 @@ impl AppState { *self.sync_password.lock() = Some(password); } + pub fn try_check_sync_connected(&self) -> Option { + match self.sync_handle.try_lock() { + Ok(guard) => Some(guard.as_ref().map_or(false, |h| h.is_connected())), + Err(_) => None, + } + } + pub fn needs_sync_password(&self) -> bool { self.sync_url.lock().is_some() && self.sync_password.lock().is_none() } @@ -144,12 +163,12 @@ impl AppState { let token = match action { AuthAction::Login => { let t = handle.login(&username, &password).await?; - eprintln!("sync: authenticated as {username}"); + tracing::info!("sync: authenticated as {username}"); t } AuthAction::Register => { let t = handle.register(&username, &password).await?; - eprintln!("sync: registered and authenticated as {username}"); + tracing::info!("sync: registered and authenticated as {username}"); t } }; @@ -173,7 +192,7 @@ impl AppState { .map(|s| (s.name.clone(), s.last_seq)) .collect(), Err(e) => { - eprintln!("sync: failed to list specs for subscribe: {e}"); + tracing::error!("sync: failed to list specs for subscribe: {e}"); return; } } @@ -181,9 +200,9 @@ impl AppState { if let Some(handle) = self.get_sync_handle().await { for (spec_name, last_seq) in &remote_specs { if let Err(e) = handle.subscribe(spec_name, *last_seq).await { - eprintln!("sync: failed to subscribe to {spec_name}: {e}"); + tracing::error!("sync: failed to subscribe to {spec_name}: {e}"); } else { - eprintln!("sync: subscribed to {spec_name} from seq {last_seq}"); + tracing::info!("sync: subscribed to {spec_name} from seq {last_seq}"); } } } @@ -217,11 +236,22 @@ impl AppState { self.op_tx.as_ref() } + /// Subscribe to op commit notifications. + pub fn subscribe_op_notifications(&self) -> broadcast::Receiver { + self.op_notify_tx.subscribe() + } + + /// Send an op notification (called by op_loop after commit). + pub fn notify_op(&self, notification: OpNotification) { + let _ = self.op_notify_tx.send(notification); + } + pub fn dir_context_cache(&self) -> &DirectoryContextCache { &self.dir_context_cache } pub async fn submit_op(&self, spec_id: &str, op: spec_forest_protocol::SpecOp) -> Result { + tracing::debug!(spec_id, op_type = crate::op_loop::spec_op_type_name(&op), "submit_op"); let branch_id = self.db().get_active_branch_id(spec_id) .map_err(|e| OpError::Database(format!("Failed to get active branch: {e}")))?; self.submit_op_with_branch(spec_id, op, branch_id).await @@ -402,6 +432,230 @@ impl AppState { pub fn ingest_sessions_lock(&self) -> parking_lot::MutexGuard<'_, HashMap> { self.ingest_sessions.lock() } + + // --- Simulation sessions --- + + pub fn set_sim_session(&self, session: SimSession) { + self.sim_sessions.lock().insert(session.id.clone(), session); + } + + pub fn update_sim_session(&self, session_id: &str, f: F) { + if let Some(session) = self.sim_sessions.lock().get_mut(session_id) { + f(session); + } + } + + pub fn remove_sim_session(&self, session_id: &str) { + self.sim_sessions.lock().remove(session_id); + } + + pub fn get_sim_session_status(&self, session_id: &str) -> Option { + self.sim_sessions.lock().get(session_id).map(|s| s.status.clone()) + } + + /// List all simulation sessions with their ID, spec_id, status, scenario, and channels. + pub fn list_sim_sessions( + &self, + ) -> Vec<( + String, + String, + crate::simulation::SimStatus, + Option, + Vec, + )> { + self.sim_sessions + .lock() + .iter() + .map(|(id, s)| { + ( + id.clone(), + s.spec_id.clone(), + s.status.clone(), + s.scenario.clone(), + s.channels.clone(), + ) + }) + .collect() + } + + /// Get a clone of a simulation session by ID. + pub fn get_sim_session(&self, session_id: &str) -> Option { + self.sim_sessions.lock().get(session_id).cloned() + } + + pub fn get_sim_claude_session_id(&self, session_id: &str) -> Option { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| s.claude_session_id.clone()) + } + + pub fn get_sim_channel_contents( + &self, + session_id: &str, + ) -> Option> { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.channel_contents.clone()) + } + + /// Take the pending report from a simulation session (returns and clears it). + pub fn take_sim_pending_report( + &self, + session_id: &str, + ) -> Option { + self.sim_sessions + .lock() + .get_mut(session_id) + .and_then(|s| s.pending_report.take()) + } + + /// Get the decisions from the most recent simulation turn. + pub fn get_sim_decisions( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.decisions.clone()) + .unwrap_or_default() + } + + /// Get the predicted interactions at the current tree position. + pub fn get_sim_interactions( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| { + let tree = s.interaction_tree.as_ref()?; + let current_id = s.current_node_id.as_ref()?; + let node = crate::simulation::tree::find_node(tree, current_id)?; + Some(node.interactions.clone()) + }) + .unwrap_or_default() + } + + /// Get the game choice groups from a game-mode session. + pub fn get_sim_game_choice_groups( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| s.game_tree.as_ref().map(|t| t.choice_groups.clone())) + .unwrap_or_default() + } + + /// Get the game spec updates log from a game-mode session. + pub fn get_sim_game_spec_updates( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.game_spec_updates.clone()) + .unwrap_or_default() + } + + /// Navigate back to the previous node in the simulation tree. + /// Returns the node data (channels, decisions) if successful, None if already at root. + pub fn sim_navigate_back( + &self, + session_id: &str, + ) -> Option<( + std::collections::HashMap, + Vec, + )> { + self.sim_sessions.lock().get_mut(session_id).and_then(|s| { + if s.navigation_path.len() <= 1 { + return None; + } + s.navigation_path.pop(); + let parent_id = s.navigation_path.last()?.clone(); + s.current_node_id = Some(parent_id.clone()); + let tree = s.interaction_tree.as_ref()?; + let node = crate::simulation::tree::find_node(tree, &parent_id)?; + s.channel_contents = node.channels.clone(); + s.decisions = node.decisions.clone(); + Some((node.channels.clone(), node.decisions.clone())) + }) + } + + /// Navigate to a specific node in the simulation breadcrumb trail. + /// The node_id must be present in the current navigation_path. + /// Truncates the path to end at that node and returns its data. + pub fn sim_navigate_to( + &self, + session_id: &str, + node_id: &str, + ) -> Option<( + std::collections::HashMap, + Vec, + )> { + self.sim_sessions.lock().get_mut(session_id).and_then(|s| { + let idx = s.navigation_path.iter().position(|id| id == node_id)?; + s.navigation_path.truncate(idx + 1); + s.current_node_id = Some(node_id.to_string()); + let tree = s.interaction_tree.as_ref()?; + let node = crate::simulation::tree::find_node(tree, node_id)?; + s.channel_contents = node.channels.clone(); + s.decisions = node.decisions.clone(); + Some((node.channels.clone(), node.decisions.clone())) + }) + } + + /// Get the breadcrumb trail for the current simulation position. + pub fn get_sim_breadcrumbs( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| { + let tree = s.interaction_tree.as_ref()?; + Some(crate::simulation::tree::collect_breadcrumbs( + tree, + &s.navigation_path, + )) + }) + .unwrap_or_default() + } + + /// Check if background pregeneration is running for a simulation session. + pub fn get_sim_pregenerating(&self, session_id: &str) -> bool { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.pregenerating) + .unwrap_or(false) + } + + /// Get the length of the navigation path for the simulation session. + pub fn get_sim_nav_depth(&self, session_id: &str) -> usize { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.navigation_path.len()) + .unwrap_or(0) + } + + // --- MCP URL --- + + pub fn mcp_url(&self) -> Option { + self.mcp_url.lock().clone() + } + + pub fn set_mcp_url(&self, url: String) { + *self.mcp_url.lock() = Some(url); + } } #[cfg(test)] diff --git a/crates/spec-forest/src/sync/bridge.rs b/crates/spec-forest/src/sync/bridge.rs index c08431a..d22667a 100644 --- a/crates/spec-forest/src/sync/bridge.rs +++ b/crates/spec-forest/src/sync/bridge.rs @@ -34,7 +34,7 @@ async fn bridge_inner( let spec_id = match resolve_spec_id(state, spec_name, &op) { Some(id) => id, None => { - eprintln!("sync bridge: cannot resolve spec_name={spec_name} for op seq={seq}"); + tracing::warn!("sync bridge: cannot resolve spec_name={spec_name} for op seq={seq}"); continue; } }; @@ -52,21 +52,21 @@ async fn bridge_inner( }; if op_tx.send(request).await.is_err() { - eprintln!("sync bridge: op channel closed, stopping"); + tracing::warn!("sync bridge: op channel closed, stopping"); break; } match resp_rx.await { Ok(Ok(_)) => {} Ok(Err(e)) => { - eprintln!("sync bridge: failed to apply op seq={seq} for {spec_name}: {e}"); + tracing::warn!("sync bridge: failed to apply op seq={seq} for {spec_name}: {e}"); } Err(_) => { - eprintln!("sync bridge: response channel dropped for seq={seq}"); + tracing::warn!("sync bridge: response channel dropped for seq={seq}"); } } } - eprintln!("sync bridge: ended (sync client disconnected)"); + tracing::warn!("sync bridge: ended (sync client disconnected)"); } /// If the op is `CreateSpec`, override its locality to `"remote"`. diff --git a/crates/spec-forest/src/sync/connection.rs b/crates/spec-forest/src/sync/connection.rs index c540fec..fac1bee 100644 --- a/crates/spec-forest/src/sync/connection.rs +++ b/crates/spec-forest/src/sync/connection.rs @@ -46,7 +46,7 @@ impl Connection { url: url.to_string(), reason: e.to_string(), }; - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err })?; @@ -77,7 +77,7 @@ impl Connection { } let json = serde_json::to_string(msg).map_err(|e| { let err = SyncError::Serialize(e.to_string()); - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err })?; self.sink @@ -87,7 +87,7 @@ impl Connection { .await .map_err(|e| { let err = SyncError::SendFailed(e.to_string()); - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err }) } @@ -112,22 +112,22 @@ async fn reader_loop( Ok(server_msg) => { if let Some(sequenced) = pending.dispatch(server_msg).await { if op_tx.send(sequenced).await.is_err() { - eprintln!("sync: op channel closed, reader stopping"); + tracing::warn!("sync: op channel closed, reader stopping"); break; } } } Err(e) => { - eprintln!("sync: failed to parse server message: {e}"); + tracing::error!("sync: failed to parse server message: {e}"); } } } Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => { - eprintln!("sync: server closed connection"); + tracing::info!("sync: server closed connection"); break; } Err(e) => { - eprintln!("sync: websocket read error: {e}"); + tracing::error!("sync: websocket read error: {e}"); break; } _ => {} diff --git a/crates/spec-forest/src/sync/dispatch.rs b/crates/spec-forest/src/sync/dispatch.rs index 48a5801..08bfc3d 100644 --- a/crates/spec-forest/src/sync/dispatch.rs +++ b/crates/spec-forest/src/sync/dispatch.rs @@ -123,7 +123,7 @@ impl PendingRequests { None } ServerMessage::TokenExpiring { remaining_secs } => { - eprintln!("sync: token expiring in {remaining_secs}s"); + tracing::warn!("sync: token expiring in {remaining_secs}s"); None } } @@ -139,7 +139,7 @@ impl PendingRequests { let _ = tx.send(Ok(())); } PendingSlot::MemberList(_) => { - eprintln!( + tracing::warn!( "sync: unexpected Ack for pending MemberList (client_ref={client_ref})" ); } @@ -168,7 +168,7 @@ impl PendingRequests { if !matched { let _ = self.unmatched_error_tx.send(Some(message.to_string())); } - eprintln!("sync: server error: {message}"); + tracing::warn!("sync: server error: {message}"); } async fn dispatch_auth_error(&self, message: &str, client_ref: Option) { @@ -195,7 +195,7 @@ impl PendingRequests { if !matched { let _ = self.unmatched_error_tx.send(Some(message.to_string())); } - eprintln!("sync: auth error: {message}"); + tracing::warn!("sync: auth error: {message}"); } async fn dispatch_member_list( @@ -210,12 +210,12 @@ impl PendingRequests { let _ = tx.send(Ok((members, creator))); } PendingSlot::Ack(_) => { - eprintln!( + tracing::warn!( "sync: unexpected MemberList for pending Ack (client_ref={client_ref})" ); } PendingSlot::SubmitAck(_) => { - eprintln!( + tracing::warn!( "sync: unexpected MemberList for pending Submit (client_ref={client_ref})" ); } diff --git a/crates/spec-forest/src/sync/reconnect.rs b/crates/spec-forest/src/sync/reconnect.rs index c4b6fe7..d797386 100644 --- a/crates/spec-forest/src/sync/reconnect.rs +++ b/crates/spec-forest/src/sync/reconnect.rs @@ -77,12 +77,12 @@ pub(super) fn spawn_reconnect_task( continue; } - eprintln!("sync: connection lost, attempting reconnect..."); + tracing::warn!("sync: connection lost, attempting reconnect..."); let mut reconnected = false; for attempt in 0..policy.max_attempts { let delay = policy.delay_for(attempt); - eprintln!( + tracing::warn!( "sync: reconnect attempt {}/{} in {delay:?}", attempt + 1, policy.max_attempts @@ -98,7 +98,7 @@ pub(super) fn spawn_reconnect_task( .await { Ok(new_conn) => { - eprintln!("sync: reconnected successfully"); + tracing::warn!("sync: reconnected successfully"); *connection.lock().await = new_conn; // Re-subscribe to all tracked specs. @@ -110,7 +110,7 @@ pub(super) fn spawn_reconnect_task( from_seq: *from_seq, }; if let Err(e) = conn.send(&msg).await { - eprintln!( + tracing::warn!( "sync: failed to re-subscribe to {spec_name}: {e}" ); } @@ -119,13 +119,13 @@ pub(super) fn spawn_reconnect_task( break; } Err(e) => { - eprintln!("sync: reconnect attempt {} failed: {e}", attempt + 1); + tracing::warn!("sync: reconnect attempt {} failed: {e}", attempt + 1); } } } if !reconnected { - eprintln!( + tracing::warn!( "sync: failed to reconnect after {} attempts, will keep trying", policy.max_attempts ); diff --git a/crates/spec-forest/src/sync/request.rs b/crates/spec-forest/src/sync/request.rs index e2e71aa..a29e5cc 100644 --- a/crates/spec-forest/src/sync/request.rs +++ b/crates/spec-forest/src/sync/request.rs @@ -40,12 +40,12 @@ where match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { Ok(Ok(result)) => extract(result), Ok(Err(_)) => { - eprintln!("sync: {timeout_label}: response channel dropped"); + tracing::error!("sync: {timeout_label}: response channel dropped"); Err(SyncError::ChannelDropped) } Err(_) => { pending.remove(client_ref).await; - eprintln!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); + tracing::error!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); Err(SyncError::Timeout(timeout_label)) } } @@ -63,11 +63,11 @@ pub(super) async fn send_and_wait_singleton( match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { Ok(Ok(value)) => Ok(value), Ok(Err(_)) => { - eprintln!("sync: {timeout_label}: response channel dropped"); + tracing::error!("sync: {timeout_label}: response channel dropped"); Err(SyncError::ChannelDropped) } Err(_) => { - eprintln!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); + tracing::error!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); Err(SyncError::Timeout(timeout_label)) } } diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index a1ec854..1c946e4 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -105,18 +105,12 @@ pub struct AddFeatureParams { pub model: Option, } -// -- Mutation -- - #[derive(Debug, Default, Deserialize, JsonSchema)] -pub struct UpdateAnswerParams { - #[schemars(description = "ID of the node to update")] +pub struct RegenerateFeatureParams { + #[schemars(description = "ID of the feature node to regenerate")] pub node_id: String, - #[schemars(description = "New answer text")] - pub answer_text: String, - #[schemars(description = "Residual uncertainty after this answer (0.0 = fully resolved, 1.0 = not resolved at all)")] - pub residual_entropy: Option, - #[schemars(description = "Reasoning for the residual entropy score")] - pub residual_entropy_reasoning: Option, + #[schemars(description = "AI model to use: 'opus', 'sonnet', or 'haiku' (default: opus)")] + pub model: Option, } // -- Directory -- @@ -230,3 +224,174 @@ pub struct DeleteAnnotationParams { #[schemars(description = "ID of the annotation to delete")] pub annotation_id: String, } + +// -- Simulation tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimCreateSessionParams { + #[schemars(description = "ID of the specification to simulate")] + pub spec_id: String, + #[schemars( + description = "Node ID to focus the simulation on. Determines which subtree of the spec is loaded into the simulation agent's context." + )] + pub focus_node_id: String, + #[schemars( + description = "Which output channels to activate. Array of: 'ui', 'audio', 'network', 'errors', 'logs'. Default: ['ui']" + )] + pub channels: Option>, + #[schemars( + description = "Optional scenario description. The simulation starts from the described state instead of the default initial state. Example: 'The user has already logged in and navigated to the settings page.'" + )] + pub scenario: Option, + #[schemars(description = "AI model to use: 'opus', 'sonnet', or 'haiku' (default: 'sonnet')")] + pub model: Option, + #[schemars( + description = "If true, loads the entire spec into context instead of just the focus node's subtree. Useful for small specs. Default: false" + )] + pub whole_spec: Option, + #[schemars( + description = "Interaction tree depth: how many levels of predicted interactions to pre-compute. Range 1-3. Default: 2" + )] + pub tree_depth: Option, + #[schemars( + description = "Interaction tree branching factor: how many predicted interactions per node. Range 2-4. Default: 3" + )] + pub tree_branching: Option, + #[schemars( + description = "If true, enables game mode: the simulation presents alternative outcomes per interaction and the player's choices update the spec. Default: false" + )] + pub game_mode: Option, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimSessionIdParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimStartParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "If true, enables code-aware mode by loading the project directory from the spec. The simulation agent will be able to read the codebase for context. Default: false" + )] + pub explore_code: Option, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimSendInputParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Array of key names to simulate (e.g. ['a', 'b', 'Enter', 'Tab', 'Escape']). Use standard key names." + )] + pub keys: Vec, + #[schemars( + description = "Raw text equivalent of the key input (e.g. 'ab\\n'). This is what the simulated application receives as text input." + )] + pub raw_text: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimAskReportParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Question about observed simulation behavior. Example: 'Why does the login form show an inline error instead of a modal?'" + )] + pub question: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimUpdateScenarioParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "New scenario description. The simulation agent will re-render all channels as if starting from this scenario." + )] + pub scenario: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimGetChannelsParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Optional: only return these channels (e.g. ['ui']). Default: return all active channels." + )] + pub channels: Option>, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimListSessionsParams {} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimNavigateToParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Node ID to navigate to. Must be a node_id from the breadcrumbs array returned by sim_get_status." + )] + pub node_id: String, +} + +// -- Game mode tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GameSelectOutcomeParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the interaction group (0-based)")] + pub group_index: usize, + #[schemars(description = "Index of the outcome within the group (0-based)")] + pub outcome_index: usize, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GameRejectOutcomeParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the interaction group (0-based)")] + pub group_index: usize, + #[schemars(description = "Index of the outcome within the group (0-based)")] + pub outcome_index: usize, + #[schemars( + description = "What should happen instead. Describe the correct behavior that this interaction should produce." + )] + pub correction: String, +} + +// -- Lean game mode tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanNavigateParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the edge/interaction to follow (0-based)")] + pub edge_index: usize, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanQueryParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Question about the current simulation state")] + pub question: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanModifyParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Modification to apply to the simulation output")] + pub modification: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct LeanWarmupRespondParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Player's response to the warmup scenario")] + pub response: String, +} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 7b7c913..e4fb976 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -63,10 +63,11 @@ impl SpecForestServer { // -- Spec lifecycle tools -- #[tool(description = "Create a new specification project")] - fn create_spec( + async fn create_spec( &self, Parameters(params): Parameters, ) -> Result { + tracing::debug!(name = %params.name, "mcp: create_spec"); let mode: spec_forest_db::SpecMode = params .mode .as_deref() @@ -95,8 +96,7 @@ impl SpecForestServer { locality: Some(locality.to_string()), }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&spec_id, op)) + self.state.submit_op(&spec_id, op).await .map_err(op_err)?; // Directory is local-only — set it directly, not via the op log @@ -119,7 +119,7 @@ impl SpecForestServer { } #[tool(description = "Seed a spec with a root document. Creates the root node with a mode-appropriate root question and the document as the answer.")] - fn seed_spec( + async fn seed_spec( &self, Parameters(params): Parameters, ) -> Result { @@ -146,9 +146,7 @@ impl SpecForestServer { embedding, }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(¶ms.spec_id, op)) + let _seq = self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -206,49 +204,34 @@ impl SpecForestServer { } #[tool( - description = "Answer an unanswered question node. Generates and stores the embedding. You MUST also provide residual_entropy (0.0 = answer fully resolves the question, 1.0 = answer doesn't resolve it at all) and residual_entropy_reasoning to score how much uncertainty the answer leaves." + description = "Answer or update the answer on a question node. Works for both unanswered nodes (spawns child question generation) and already-answered nodes (spawns descendant review). Generates embedding and evaluates entropy in the background. You MAY provide residual_entropy (0.0 = answer fully resolves the question, 1.0 = answer doesn't resolve it at all) and residual_entropy_reasoning to skip automatic entropy evaluation." )] - fn answer_question( + async fn answer_question( &self, Parameters(params): Parameters, ) -> Result { - let node_before = self - .state - .db() - .get_node(¶ms.node_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let embed_text = format!("{}\n{}", node_before.question, params.answer_text); - let embedding = self - .state - .embed(&embed_text) + tracing::debug!(node_id = %params.node_id, "mcp: answer_question"); + let model = "opus".to_string(); + let node = crate::api::answer_node( + &self.state, + ¶ms.node_id, + params.answer_text, + model, + true, + params.residual_entropy, + params.residual_entropy_reasoning, + ).await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let op = SpecOp::AnswerQuestion { - node_id: params.node_id.clone(), - answer: params.answer_text, - embedding, - residual_entropy: params.residual_entropy, - residual_entropy_reasoning: params.residual_entropy_reasoning, - }; - - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node_before.spec_id, op)) - .map_err(op_err)?; - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&serde_json::json!({ - "node_id": params.node_id, - "status": "applied" - })) - .unwrap(), + serde_json::to_string_pretty(&node).unwrap(), )])) } #[tool( description = "Create child question nodes linked to a parent. Each child starts as unanswered." )] - fn add_children( + async fn add_children( &self, Parameters(params): Parameters, ) -> Result { @@ -284,9 +267,7 @@ impl SpecForestServer { children: child_ops, }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&parent_node.spec_id, op)) + let _seq = self.state.submit_op(&parent_node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -417,46 +398,8 @@ impl SpecForestServer { // -- Mutation & review tools -- - #[tool(description = "Update the answer on an existing answered node. Re-generates the embedding. Does NOT mark descendants — call trigger_review separately. You MUST also provide residual_entropy (0.0 = fully resolved, 1.0 = not resolved) and residual_entropy_reasoning.")] - fn update_answer( - &self, - Parameters(params): Parameters, - ) -> Result { - let node_before = self - .state - .db() - .get_node(¶ms.node_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let embed_text = format!("{}\n{}", node_before.question, params.answer_text); - let embedding = self - .state - .embed(&embed_text) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - - let op = SpecOp::UpdateAnswer { - node_id: params.node_id.clone(), - answer: params.answer_text, - embedding, - residual_entropy: params.residual_entropy, - residual_entropy_reasoning: params.residual_entropy_reasoning, - }; - - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node_before.spec_id, op)) - .map_err(op_err)?; - - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&serde_json::json!({ - "node_id": params.node_id, - "status": "applied" - })) - .unwrap(), - )])) - } - #[tool(description = "Mark all descendants of a node as needs_review. Returns the count of affected nodes.")] - fn trigger_review( + async fn trigger_review( &self, Parameters(params): Parameters, ) -> Result { @@ -470,9 +413,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node.spec_id, op)) + let _seq = self.state.submit_op(&node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -500,7 +441,7 @@ impl SpecForestServer { } #[tool(description = "Soft-delete a node and all its descendants")] - fn delete_node( + async fn delete_node( &self, Parameters(params): Parameters, ) -> Result { @@ -514,9 +455,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node.spec_id, op)) + let _seq = self.state.submit_op(&node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -531,7 +470,7 @@ impl SpecForestServer { // -- Feature tools -- #[tool(description = "Add a new feature root node to a spec. Creates a root with the given content.")] - fn add_feature( + async fn add_feature( &self, Parameters(params): Parameters, ) -> Result { @@ -542,9 +481,7 @@ impl SpecForestServer { content: params.content.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(¶ms.spec_id, op)) + let _seq = self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; // Spawn summary regeneration after successful apply @@ -564,7 +501,7 @@ impl SpecForestServer { } #[tool(description = "Remove a feature root and garbage-collect exclusive descendants. Shared nodes survive.")] - fn remove_feature( + async fn remove_feature( &self, Parameters(params): Parameters, ) -> Result { @@ -579,9 +516,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&spec_id, op)) + let _seq = self.state.submit_op(&spec_id, op).await .map_err(op_err)?; // Spawn summary regeneration after successful apply @@ -600,6 +535,35 @@ impl SpecForestServer { )])) } + #[tool(description = "Regenerate a feature's description based on current directory state. Updates the feature in-place, preserving all descendants, and triggers cascade review on children.")] + fn regenerate_feature( + &self, + Parameters(params): Parameters, + ) -> Result { + let node = self + .state + .db() + .get_node(¶ms.node_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let spec_id = node.spec_id.clone(); + let model = params.model.unwrap_or_else(|| "opus".to_string()); + + crate::generate::spawn_feature_regeneration( + self.state.clone(), + params.node_id.clone(), + model, + ); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "node_id": params.node_id, + "spec_id": spec_id, + "status": "regenerating" + })) + .unwrap(), + )])) + } + // -- Directory tools -- #[tool(description = "Set or clear the project directory for a spec. When set on development specs, AI generation will explore this directory for codebase context. Pass null or omit directory to clear.")] @@ -790,14 +754,16 @@ impl SpecForestServer { // -- Annotation tools -- #[tool(description = "Add an annotation (review comment) to a node for a specific commit range")] - fn add_annotation( + async fn add_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let branch_id = resolve_branch_id(&db, ¶ms.spec_id, params.branch.as_deref())?; - let annotation_id = uuid::Uuid::new_v4().to_string(); - drop(db); + let (branch_id, annotation_id) = { + let db = self.state.db(); + let branch_id = resolve_branch_id(&db, ¶ms.spec_id, params.branch.as_deref())?; + let annotation_id = uuid::Uuid::new_v4().to_string(); + (branch_id, annotation_id) + }; let op = SpecOp::AddAnnotation { annotation_id: annotation_id.clone(), @@ -811,8 +777,7 @@ impl SpecForestServer { seq_end: params.seq_end, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(¶ms.spec_id, op)) + self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -853,23 +818,22 @@ impl SpecForestServer { } #[tool(description = "Update the content of an existing annotation")] - fn update_annotation( + async fn update_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let op = SpecOp::UpdateAnnotation { annotation_id: params.annotation_id.clone(), content: params.content, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -883,15 +847,15 @@ impl SpecForestServer { } #[tool(description = "Resolve or unresolve an annotation")] - fn resolve_annotation( + async fn resolve_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let resolved = params.resolved.unwrap_or(true); let op = SpecOp::ResolveAnnotation { @@ -899,8 +863,7 @@ impl SpecForestServer { resolved, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -914,33 +877,1206 @@ impl SpecForestServer { } #[tool(description = "Soft-delete an annotation")] - fn delete_annotation( + async fn delete_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let op = SpecOp::DeleteAnnotation { annotation_id: params.annotation_id, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( "Annotation deleted".to_string(), )])) } + + // --- Simulation tools --- + + #[tool(description = "Create a new simulation session for a spec. Does not start the simulation — call sim_start after creation. Returns the session ID.")] + fn sim_create_session( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::{SimChannel, SimSession}; + + let channels: Vec = match ¶ms.channels { + Some(names) => { + let mut chs = Vec::new(); + for name in names { + match SimChannel::from_key(name) { + Some(ch) => chs.push(ch), + None => { + return Err(ErrorData::invalid_params( + format!("Invalid channel: '{name}'. Valid: ui, audio, network, errors, logs"), + None, + )); + } + } + } + chs + } + None => vec![SimChannel::Ui], + }; + + let model = params.model.unwrap_or_else(|| "sonnet".to_string()); + let whole_spec = params.whole_spec.unwrap_or(false); + let tree_depth = params.tree_depth.unwrap_or(4).clamp(1, 6); + let tree_branching = params.tree_branching.unwrap_or(2).clamp(2, 4); + let session_id = uuid::Uuid::new_v4().to_string(); + + let mut session = SimSession::new( + session_id.clone(), + params.spec_id.clone(), + Some(params.focus_node_id.clone()), + model, + channels.clone(), + params.scenario, + ); + session.whole_spec = whole_spec; + session.tree_depth = tree_depth; + session.tree_branching = tree_branching; + session.game_mode = params.game_mode.unwrap_or(false); + + self.state.set_sim_session(session); + + let channel_keys: Vec<&str> = channels.iter().map(|c| c.key()).collect(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": session_id, + "spec_id": params.spec_id, + "focus_node_id": params.focus_node_id, + "channels": channel_keys, + "status": "idle" + })) + .unwrap(), + )])) + } + + #[tool(description = "Start the initial simulation turn. The session must be in 'idle' status. Sets status to 'processing' and spawns the AI simulation in the background. Poll sim_get_status until status returns to 'idle', then read results with sim_get_channels. Set explore_code to true to enable code-aware mode (loads the spec's project directory so the agent can read the codebase).")] + fn sim_start( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + match &session.status { + SimStatus::Idle | SimStatus::Error(_) => {} + SimStatus::Processing => { + return Err(ErrorData::invalid_params("Session is already processing", None)); + } + SimStatus::Ended => { + return Err(ErrorData::invalid_params("Session has ended", None)); + } + } + + // If explore_code is true, look up the spec's directory from the db + let directory = if params.explore_code.unwrap_or(false) { + let dir = self + .state + .db() + .get_spec(&session.spec_id) + .ok() + .and_then(|s| s.directory); + if dir.is_none() { + return Err(ErrorData::invalid_params( + "explore_code is true but the spec has no directory set. Use set_directory first.", + None, + )); + } + dir + } else { + None + }; + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let whole_spec = session.whole_spec; + let game_mode = session.game_mode; + + tokio::spawn(async move { + if game_mode { + crate::simulation::orchestrate::orchestrate_game_initial_turn( + state, + sid, + whole_spec, + directory, + ) + .await; + } else { + crate::simulation::orchestrate::orchestrate_initial_turn( + state, + sid, + whole_spec, + directory, + ) + .await; + } + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Send user input to a running simulation. Simulates keypresses and text input. The session must be 'idle'. If the input matches a predicted interaction in the pre-computed tree, the response is instant (tree_hit=true, status=idle). Otherwise sets status to 'processing' for AI generation — poll sim_get_status until idle. Use sim_get_interactions first to see available predicted choices for instant response.")] + fn sim_send_input( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::{SimInput, SimStatus}; + use crate::simulation::tree; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to send input", + None, + )); + } + + let input = SimInput { + keys: params.keys, + raw_text: params.raw_text, + }; + + // Check tree for matching interaction with a pre-computed result + let tree_hit = if let (Some(interaction_tree), Some(current_id)) = + (&session.interaction_tree, &session.current_node_id) + { + if let Some(current_node) = tree::find_node(interaction_tree, current_id) { + tree::find_matching_interaction(current_node, &input) + .map(|idx| current_node.interactions[idx].result.is_some()) + .unwrap_or(false) + } else { + false + } + } else { + false + }; + + let input_json = serde_json::to_string(&input).unwrap(); + + if tree_hit { + // The orchestrate_resume_turn will handle the tree navigation synchronously + // but we still spawn it to keep the interface consistent + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json) + .await; + }); + + // Check if the matched child is a leaf + let updated_session = self.state.get_sim_session(¶ms.session_id); + let at_leaf = updated_session + .as_ref() + .and_then(|s| { + s.interaction_tree.as_ref().and_then(|t| { + s.current_node_id + .as_ref() + .and_then(|id| tree::find_node(t, id)) + .map(tree::is_leaf) + }) + }) + .unwrap_or(false); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "tree_hit": true, + "at_leaf": at_leaf, + })) + .unwrap(), + )])) + } else { + // Tree miss — need AI generation + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "tree_hit": false, + "at_leaf": false, + })) + .unwrap(), + )])) + } + } + + #[tool(description = "Ask the simulation agent to explain a specific behavior without changing the simulation state. Use this when you observe something unexpected and want to understand WHY it happens. The explanation will include spec node references. Session must be 'idle'. Poll sim_get_status until idle, then read the explanation with sim_get_report.")] + fn sim_ask_report( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to ask a report", + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let question = params.question; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_report_turn(state, sid, question).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Update the scenario for an existing simulation session. The simulation agent will re-render all channels based on the new scenario. Session must be 'idle'. Poll sim_get_status until idle, then read results with sim_get_channels.")] + fn sim_update_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to update scenario", + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let scenario = params.scenario; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_scenario_update(state, sid, scenario).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the current status of a simulation session. Use this to poll until an async operation completes. Status values: 'idle' (ready for input or results available), 'processing' (turn in progress), 'error' (something went wrong), 'ended' (session terminated).")] + fn sim_get_status( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let (status_str, error) = match &session.status { + crate::simulation::SimStatus::Idle => ("idle", None), + crate::simulation::SimStatus::Processing => ("processing", None), + crate::simulation::SimStatus::Error(e) => ("error", Some(e.clone())), + crate::simulation::SimStatus::Ended => ("ended", None), + }; + + let at_leaf = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + .map(crate::simulation::tree::is_leaf) + }) + .unwrap_or(false); + + let has_interactions = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + .map(|n| !n.interactions.is_empty()) + }) + .unwrap_or(false); + + let breadcrumbs = self.state.get_sim_breadcrumbs(¶ms.session_id); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "spec_id": session.spec_id, + "status": status_str, + "error": error, + "scenario": session.scenario, + "has_channel_contents": !session.channel_contents.is_empty(), + "has_pending_report": session.pending_report.is_some(), + "at_leaf": at_leaf, + "has_interactions": has_interactions, + "game_mode": session.game_mode, + "has_game_choices": session.game_tree.as_ref().map_or(false, |t| !t.choice_groups.is_empty()), + "game_spec_updates_count": session.game_spec_updates.len(), + "breadcrumbs": breadcrumbs, + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the current channel contents, decisions, and spec gaps from the most recent simulation turn. Returns all active channels at once. Each channel includes rendered text, spec node references, and spec gaps (ambiguities the spec should address). Also includes the decisions array and a deduplicated list of all spec gaps.")] + fn sim_get_channels( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimChannel; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + // Filter channels if requested + let filter: Option> = params.channels.map(|names| { + names + .iter() + .filter_map(|n| SimChannel::from_key(n).map(|c| c.key().to_string())) + .collect() + }); + + let mut channels_json = serde_json::Map::new(); + let mut all_spec_gaps: Vec = Vec::new(); + + for (key, content) in &session.channel_contents { + if let Some(ref f) = filter { + if !f.contains(key) { + continue; + } + } + all_spec_gaps.extend(content.spec_gaps.clone()); + channels_json.insert( + key.clone(), + serde_json::to_value(content).unwrap_or_default(), + ); + } + + // Collect spec gaps from decisions too + for decision in &session.decisions { + all_spec_gaps.extend(decision.spec_gaps.clone()); + } + all_spec_gaps.sort(); + all_spec_gaps.dedup(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": match &session.status { + crate::simulation::SimStatus::Idle => "idle", + crate::simulation::SimStatus::Processing => "processing", + crate::simulation::SimStatus::Error(_) => "error", + crate::simulation::SimStatus::Ended => "ended", + }, + "channels": channels_json, + "decisions": session.decisions, + "all_spec_gaps": all_spec_gaps, + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the predicted interactions available at the current position in the interaction tree. Returns labels and inputs for each predicted choice. Sending input matching one of these predictions will result in an instant response (no AI latency). Returns empty if at a leaf node or no tree is available.")] + fn sim_get_interactions( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let interactions: Vec = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + }) + .map(|node| { + node.interactions + .iter() + .map(|i| { + serde_json::json!({ + "label": i.label, + "input": { + "keys": i.input.keys, + "raw_text": i.input.raw_text, + } + }) + }) + .collect() + }) + .unwrap_or_default(); + + let at_leaf = interactions.is_empty() + && session.interaction_tree.is_some(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "interactions": interactions, + "at_leaf": at_leaf, + })) + .unwrap(), + )])) + } + + #[tool(description = "Navigate to a specific point in the simulation breadcrumb trail. Use node_id values from the breadcrumbs array in sim_get_status. This allows jumping back to any previous position in the interaction history.")] + fn sim_navigate_to( + &self, + Parameters(params): Parameters, + ) -> Result { + match self + .state + .sim_navigate_to(¶ms.session_id, ¶ms.node_id) + { + Some(_) => { + let breadcrumbs = self.state.get_sim_breadcrumbs(¶ms.session_id); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "navigated_to": params.node_id, + "breadcrumbs": breadcrumbs, + })) + .unwrap(), + )])) + } + None => Err(ErrorData::invalid_params( + format!( + "Cannot navigate to node '{}': not found in current breadcrumb trail", + params.node_id + ), + None, + )), + } + } + + #[tool(description = "Get the pending report explanation from the most recent sim_ask_report call. Consumes the report (subsequent calls return null until a new report is requested). Returns the explanation with spec node references.")] + fn sim_get_report( + &self, + Parameters(params): Parameters, + ) -> Result { + // Verify session exists + if self.state.get_sim_session(¶ms.session_id).is_none() { + return Err(ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + )); + } + + let report = self.state.take_sim_pending_report(¶ms.session_id); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "report": report, + })) + .unwrap(), + )])) + } + + #[tool(description = "List all active simulation sessions with their status, scenario, and channels.")] + fn sim_list_sessions( + &self, + Parameters(_params): Parameters, + ) -> Result { + let sessions = self.state.list_sim_sessions(); + let list: Vec = sessions + .into_iter() + .map(|(id, spec_id, status, scenario, channels)| { + let status_str = match &status { + crate::simulation::SimStatus::Idle => "idle", + crate::simulation::SimStatus::Processing => "processing", + crate::simulation::SimStatus::Error(_) => "error", + crate::simulation::SimStatus::Ended => "ended", + }; + let channel_keys: Vec<&str> = channels.iter().map(|c| c.key()).collect(); + serde_json::json!({ + "session_id": id, + "spec_id": spec_id, + "status": status_str, + "scenario": scenario, + "channels": channel_keys, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "sessions": list, + })) + .unwrap(), + )])) + } + + #[tool(description = "End a simulation session and free its resources.")] + fn sim_end( + &self, + Parameters(params): Parameters, + ) -> Result { + if self.state.get_sim_session(¶ms.session_id).is_none() { + return Err(ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = crate::simulation::SimStatus::Ended; + }); + self.state.remove_sim_session(¶ms.session_id); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "ended" + })) + .unwrap(), + )])) + } + + // ── Game Mode Tools ────────────────────────────────────────────── + + #[tool(description = "Get available interaction choices with predicted outcomes for a game-mode session. Each choice group has an interaction label and multiple alternative outcomes. The player picks the correct (interaction, outcome) pair. Returns empty if not in game mode or no game tree available.")] + fn game_get_choices( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": false, + "choice_groups": [] + })) + .unwrap(), + )])); + } + + let choice_groups: Vec = session + .game_tree + .as_ref() + .map(|t| { + t.choice_groups + .iter() + .enumerate() + .map(|(gi, group)| { + let outcomes: Vec = group + .outcomes + .iter() + .enumerate() + .map(|(oi, outcome)| { + serde_json::json!({ + "outcome_index": oi, + "summary": outcome.summary, + "related_spec_nodes": outcome.related_spec_nodes, + }) + }) + .collect(); + serde_json::json!({ + "group_index": gi, + "interaction_label": group.interaction_label, + "outcomes": outcomes, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": true, + "choice_groups": choice_groups, + })) + .unwrap(), + )])) + } + + #[tool(description = "Select an (interaction, outcome) pair in game mode. The player has chosen which outcome is correct for this interaction. Updates channel contents from the selected outcome and spawns a background spec update. Session must be idle and in game mode.")] + fn game_select_outcome( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Err(ErrorData::invalid_params("Session is not in game mode", None)); + } + + match &session.status { + SimStatus::Idle => {} + _ => { + return Err(ErrorData::invalid_params( + "Session must be idle to select an outcome", + None, + )); + } + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let gi = params.group_index; + let oi = params.outcome_index; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_game_select_outcome(state, sid, gi, oi) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "group_index": params.group_index, + "outcome_index": params.outcome_index, + })) + .unwrap(), + )])) + } + + #[tool(description = "Reject an outcome in game mode and provide a correction. The player says 'that's not what should happen' and describes the correct behavior. Triggers a background spec update with the correction and regenerates the game tree. Session must be idle and in game mode.")] + fn game_reject_outcome( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Err(ErrorData::invalid_params("Session is not in game mode", None)); + } + + match &session.status { + SimStatus::Idle => {} + _ => { + return Err(ErrorData::invalid_params( + "Session must be idle to reject an outcome", + None, + )); + } + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let gi = params.group_index; + let oi = params.outcome_index; + let correction = params.correction.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_game_reject_outcome( + state, sid, gi, oi, correction, + ) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "group_index": params.group_index, + "outcome_index": params.outcome_index, + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the log of spec updates triggered by game choices during this session. Returns a list of changes made to the spec DAG as a result of the player's design decisions.")] + fn game_get_spec_updates( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let updates: Vec = session + .game_spec_updates + .iter() + .map(|u| { + serde_json::json!({ + "interaction_label": u.interaction_label, + "outcome_summary": u.outcome_summary, + "description": u.description, + "node_id": u.node_id, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": session.game_mode, + "updates": updates, + })) + .unwrap(), + )])) + } + + // ── Lean Game Mode Tools ──────────────────────────────────────── + + #[tool(description = "Get the current lean game output (channels, interactions, breadcrumbs). Returns the current node's channels and available edges with their types (generative, shortcut, leaf).")] + fn lean_get_output( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + // If warmup is active (main game still loading), return warmup content. + if session.warmup_active { + let mut response = serde_json::json!({ + "session_id": params.session_id, + "mode": "warmup", + "status": format!("{:?}", session.status), + "game_ready": session.warmup_game_ready, + }); + if let Some(ref scenario) = session.warmup_scenario { + response["warmup_scenario"] = serde_json::json!({ + "scenario_text": scenario.scenario_text, + "node_question": scenario.node_question, + "responded": scenario.responded, + }); + } else if session.warmup_generating { + response["warmup_generating"] = serde_json::json!(true); + } + return Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])); + } + + let (channels, edges, breadcrumbs) = if let Some(ref graph) = session.lean_graph { + let current_id = session.lean_current_node_id.as_deref().unwrap_or(""); + let channels = graph + .get_node(current_id) + .map(|n| &n.channels) + .cloned() + .unwrap_or_default(); + let edges: Vec = graph + .get_edges(current_id) + .iter() + .enumerate() + .map(|(i, e)| { + let at_frontier = e.edge_kind + == crate::simulation::LeanEdgeKind::Generative + && graph.has_leaf_edges(&e.target_node_id); + serde_json::json!({ + "index": i, + "label": e.label, + "edge_kind": format!("{:?}", e.edge_kind), + "target_node_id": e.target_node_id, + "at_frontier": at_frontier, + }) + }) + .collect(); + let crumbs = graph.collect_breadcrumbs(&session.lean_navigation_path); + (channels, edges, crumbs) + } else { + (std::collections::HashMap::new(), vec![], vec![]) + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": format!("{:?}", session.status), + "channels": channels, + "edges": edges, + "breadcrumbs": breadcrumbs, + "pregenerating": session.lean_generating, + })) + .unwrap(), + )])) + } + + #[tool(description = "Navigate to an interaction in lean game mode. Provide the edge index (0-based). Generative and shortcut edges navigate instantly. Leaf edges trigger batch generation.")] + fn lean_navigate( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + if session.status != crate::simulation::SimStatus::Idle { + return Err(ErrorData::invalid_params("Session is not idle", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let ei = params.edge_index; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_navigate(state, sid, ei).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Navigate back one step in the lean game breadcrumb trail. Always instant.")] + fn lean_go_back( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + crate::simulation::lean_orchestrate::orchestrate_lean_go_back( + self.state.clone(), + ¶ms.session_id, + ); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "ok", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Ask a question about the current lean game simulation state. Returns an explanation with spec node references.")] + fn lean_query( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let question = params.question; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_query(state, sid, question).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Modify the simulation output in lean game mode. Regenerates the DAG batch from the current node with the modification applied.")] + fn lean_modify( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let modification = params.modification; + tokio::spawn(async move { + crate::simulation::lean_orchestrate::orchestrate_lean_modify( + state, + sid, + modification, + ) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "processing", + "session_id": params.session_id, + }) + .to_string(), + )])) + } + + #[tool(description = "Respond to a warmup scenario while the main lean game loads. Your response will be captured as spec feedback. The warmup will cycle to a new scenario or transition to the real game when ready.")] + fn lean_warmup_respond( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.lean_mode { + return Err(ErrorData::invalid_params("Session is not in lean mode", None)); + } + if !session.warmup_active { + return Err(ErrorData::invalid_params( + "Warmup is not active. The main game may have already loaded.", + None, + )); + } + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let response = params.response; + tokio::spawn(async move { + crate::simulation::warmup_orchestrate::handle_warmup_response(state, sid, response) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "status": "ok", + "session_id": params.session_id, + "message": "Response captured. Poll lean_get_output for the next warmup scenario or the real game." + }) + .to_string(), + )])) + } + + #[tool(description = "Get the log of spec updates triggered during lean game play. Same format as game_get_spec_updates.")] + fn lean_get_spec_updates( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let updates: Vec = session + .game_spec_updates + .iter() + .map(|u| { + serde_json::json!({ + "description": u.description, + "node_id": u.node_id, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "lean_mode": session.lean_mode, + "updates": updates, + })) + .unwrap(), + )])) + } } #[tool_handler] impl ServerHandler for SpecForestServer { fn get_info(&self) -> ServerInfo { + tracing::debug!("mcp: get_info"); ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_server_info(Implementation::new( diff --git a/docs/tui-missing-features.md b/docs/tui-missing-features.md new file mode 100644 index 0000000..128f1da --- /dev/null +++ b/docs/tui-missing-features.md @@ -0,0 +1,72 @@ +# TUI Missing Features + +Features present in the web UI but not yet in the TUI, ordered by importance. + +1. **Search & Filtering** + 1a. Global semantic search across nodes + 1b. Filter outliner by state (unanswered/answered/needs_review/deleted) + 1c. Filter outliner by entropy score + 1d. Filter outliner by tags + 1e. Search specs by name in gallery + 1f. "Next Question" jump to highest-entropy unanswered node + +2. **Branching & Version Control** + 2a. Branch panel (create, switch, merge, delete branches) + 2b. Undo / Redo + 2c. Time travel to any sequence number + 2d. Timeline slider with milestone creation and diff anchors + 2e. Update review status (draft/ready_for_review/approved/merged) + +3. **Review & Diff** + 3a. Review sidebar showing branch diffs (created/edited/deleted/metadata-only) + 3b. Word-level diff view with color coding + 3c. Range diff comparison + 3d. Review banner with read-only mode indicator + +4. **Outputs & Narrative** + 4a. Output panel listing spec-level and node-level outputs (summary, plan, action_items, narrative) + 4b. Generate outputs with context size display + 4c. Narrative panel with interactive node references + 4d. Copy / delete outputs + +5. **Conflict Resolution** + 5a. Display merge conflicts (edit/edit, edit/delete, structural) + 5b. Split-view comparison + 5c. Pick left/right resolution + 5d. Custom edit mode for conflicts + +6. **Annotations** + 6a. Create annotation threads on nodes + 6b. Reply to annotations + 6c. Edit / resolve / unresolve / delete annotations + 6d. Author attribution and timestamps + +7. **Node Display** + 7a. Entropy / Impact / Subtree Entropy badges on nodes + 7b. Tag badges on nodes + 7c. Inline editing of question and answer text (without external editor) + 7d. Breadcrumb ancestor navigation in main workspace + +8. **Dashboard** + 8a. Progress ring showing answered/unanswered/needs_review counts + 8b. High-entropy nodes list + 8c. Nodes needing review list + 8d. Recent outputs view + +9. **Source & Ingest** + 9a. Source panel with multiple ingest modes (answer, recursive, shadow, shadow-regenerate) + 9b. Ingest progress monitoring with pause/resume/cancel + 9c. Active ingests tracking + +10. **Spec Management** + ~~10a. Delete spec from gallery (with confirmation)~~ + 10b. Subscribe to remote specs / browse remote specs modal + +11. **Collaboration** + 11a. Access panel (view members, grant/revoke access) + 11b. Identity modal with register/login flow + +12. **Explore Controls** + 12a. Entire-graph vs subtree toggle + 12b. End-on-answer toggle + 12c. Granular progress tracking (completed, skipped, in_flight) diff --git a/macos/build.sh b/macos/build.sh index 9fe6908..533d00c 100755 --- a/macos/build.sh +++ b/macos/build.sh @@ -33,6 +33,8 @@ for f in "$MACOS_DIR/Resources/"*.png "$MACOS_DIR/Resources/"*.icns; do [ -f "$f" ] && cp "$f" "$APP_DIR/Contents/Resources/" || true done +codesign --force --deep --sign - "$APP_DIR" + echo "==> Creating DMG..." DMG_PATH="$BUILD_DIR/Spec Forest.dmg" rm -f "$DMG_PATH"