diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 54ef21f..a869d2e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -13,6 +13,7 @@ pub enum Action { OpenSeedFromDir, OpenSyncConfig, OpenModelConfig, + DeleteSpec, // Text input (shared across InputName, SyncPasswordInput) TypeChar(char), @@ -108,6 +109,7 @@ pub enum Action { SimChannelToggleWholeSpec, SimChannelToggleExploreCode, SimChannelToggleGameMode, + SimChannelToggleLeanMode, SimChannelConfirm, SimChannelCancel, @@ -158,6 +160,26 @@ pub enum Action { 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, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 2580f83..0f8716f 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -93,7 +93,10 @@ pub struct App { 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, @@ -126,6 +129,7 @@ pub enum Screen { 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)] @@ -203,7 +207,9 @@ impl App { 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, @@ -352,6 +358,17 @@ impl App { 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 @@ -395,9 +412,15 @@ impl App { } async fn execute_action(&mut self, action: Action) { - if action != Action::DeleteNode && action != Action::Noop { + 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, @@ -431,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(); } @@ -836,6 +861,15 @@ impl App { } 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 { @@ -958,7 +992,10 @@ impl App { 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 => { @@ -984,7 +1021,10 @@ impl App { 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) => { @@ -1383,6 +1423,276 @@ impl App { } } + // ── 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; } @@ -1425,6 +1735,34 @@ impl App { 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); @@ -1433,25 +1771,46 @@ impl App { self.sim_notifications .retain(|n| n.session_id != session_id); - // Reconstruct SimulationState from the AppState session data + // Reconstruct state from the AppState session data if let Some(session) = self.state.get_sim_session(session_id) { - 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); + 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); + } } } @@ -2220,6 +2579,47 @@ impl App { } } + // ── Delete spec ────────────────────────────────────────── + + async fn delete_spec(&mut self) { + if !matches!(self.screen, Screen::SpecList) { + return; + } + + let spec = match self.specs.get(self.selected) { + Some(spec) => spec.clone(), + None => { + self.message = Some("No spec selected".to_string()); + return; + } + }; + + 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 { + let label = truncate_str(&spec.name, 40); + self.pending_delete = Some(spec.id.clone()); + self.message = Some(format!("Press d again to delete '{label}'")); + } + } + // ── Candidate operations ─────────────────────────────────── pub fn refresh_candidates_if_needed(&mut self) { @@ -2346,6 +2746,10 @@ impl App { 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(); @@ -2595,6 +2999,181 @@ impl App { } } + 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, @@ -2621,19 +3200,34 @@ impl App { 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; }); - 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(), - }; + 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(); @@ -2659,21 +3253,45 @@ impl App { if let Some(ref mut sim) = self.sim_state { sim.processing = true; } + if let Some(ref mut lean) = self.lean_state { + lean.processing = true; + } - 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; - }); + 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) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 5aab5a4..d01735c 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -175,6 +175,15 @@ pub fn regenerate_feature( .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, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index c316e22..ee08cf2 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -34,6 +34,52 @@ pub fn map_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, } } @@ -174,6 +220,7 @@ fn map_sim_channel_picker_key(key: KeyCode) -> Action { 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, @@ -202,6 +249,7 @@ fn map_spec_list_key(key: KeyCode) -> Action { 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, 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 6c72a02..751fb71 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -5,6 +5,7 @@ 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; diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 74b74d0..3cf04d5 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -4,6 +4,7 @@ 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; @@ -41,6 +42,7 @@ pub fn render(app: &App, frame: &mut 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) diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index 1ecf603..3d0aee3 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -26,6 +26,7 @@ fn help_sections(app: &App) -> Vec { title: "Actions", bindings: vec![ ("c", "Create spec"), + ("d", "Delete spec"), ("s", "Seed from directory"), ("m", "Model config"), ("y", "Sync config"), @@ -315,6 +316,19 @@ fn help_sections(app: &App) -> Vec { ], }] } + 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"), + ], + }], } } 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/sim_channel_picker.rs b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs index b0e51ed..a3d90e6 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -18,6 +18,7 @@ pub fn render(app: &App, frame: &mut Frame) { 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()); @@ -121,6 +122,20 @@ pub fn render(app: &App, frame: &mut Frame) { .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( @@ -129,12 +144,12 @@ pub fn render(app: &App, frame: &mut Frame) { ), ]; let badge_line = super::common::render_footer_line( - &[("Space", "Toggle"), ("g", "Game"), ("Enter", "Start"), ("Esc", "Cancel")], + &[("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[4]); + frame.render_widget(footer, chunks[5]); } diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 2e450dc..2b12c01 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -50,7 +50,7 @@ pub fn render(app: &App, frame: &mut Frame) { Line::from(msg.clone()) } else { super::common::render_footer_line( - &[("Enter", "Open"), ("c", "Create"), ("q", "Quit"), ("?", "Help")], + &[("Enter", "Open"), ("c", "Create"), ("d", "Delete"), ("q", "Quit"), ("?", "Help")], app.sync_disconnect_indicator(), ) }; diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 34f36ee..5884c40 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -1,9 +1,15 @@ +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, @@ -14,6 +20,8 @@ pub use 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, 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 index a6819ee..0561a11 100644 --- a/crates/spec-forest/src/simulation/orchestrate.rs +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -852,18 +852,20 @@ async fn orchestrate_game_spec_update( 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 = %result.action, + action = %action, "Game spec update applied" ); } diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 38b40fb..c3b0de9 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,6 +1,7 @@ use super::types::{ - FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, GameTreeRoot, - PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, + GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, + SimTreeResponse, }; use std::collections::HashMap; use std::error::Error; @@ -416,6 +417,29 @@ impl SimConfig { .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). @@ -863,6 +887,170 @@ pub async fn resume_game_spec_update_turn( 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::*; diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index f394dbf..33756a3 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,6 +1,8 @@ +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; @@ -112,6 +114,49 @@ pub struct SimSession { 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 { @@ -148,6 +193,26 @@ impl SimSession { 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/types.rs b/crates/spec-forest/src/simulation/types.rs index e010e33..883df9c 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -159,6 +159,9 @@ pub struct GameSpecUpdate { 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. 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/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 9bcead7..1c946e4 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -361,3 +361,37 @@ pub struct GameRejectOutcomeParams { )] 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 58bda9a..e4fb976 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1760,6 +1760,317 @@ impl SpecForestServer { .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] 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)