From 529e00c75448b440305786735bac44b122e05bb6 Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:10:34 +1100 Subject: [PATCH 001/100] fix: remove stale user_name field from ServerConfig in TUI --- crates/spec-forest-tui/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index db87363..2c40f8a 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -72,11 +72,14 @@ async fn main() -> Result<(), Box> { db_path: cli.db_path, sync_url: cli.sync_url, log_prompts: cli.log_prompts, - user_name: cli.user_name, }; let (router, ct, state) = spec_forest::build_server(config).await?; + if let Some(ref name) = cli.user_name { + state.set_user_name(name.clone()); + } + // Spawn HTTP server (REST API + MCP) in background let addr = format!("{host}:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; From 281ab2ea35e262543634089d968c2843b38e37bb Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:17:18 +1100 Subject: [PATCH 002/100] fix: show tree panel by default when opening a spec in TUI --- crates/spec-forest-tui/src/app.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index ab1bde1..3588b49 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -317,8 +317,13 @@ impl App { self.nodes = nodes; self.node_selected = 0; self.tree_state = TreeState::new(); - self.tree_visible = false; - self.tree_focused = false; + self.tree_visible = true; + self.tree_focused = true; + let db = self.state.db(); + if let Err(e) = self.tree_state.rebuild(&db, spec_id) { + tracing::error!("Tree rebuild failed: {e}"); + self.message = Some(e.to_string()); + } self.screen = Screen::SpecView { spec_id: spec_id.to_string(), }; From 676b02965209f4f6310e0d2e39f844cfad10bbcb Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:17:41 +1100 Subject: [PATCH 003/100] fix: standardize data directory to ~/.spec-forest across all crates --- crates/spec-forest-sync/src/main.rs | 2 +- crates/spec-forest-tui/src/main.rs | 8 ++++---- crates/spec-forest/src/main.rs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/spec-forest-sync/src/main.rs b/crates/spec-forest-sync/src/main.rs index 02d65a7..8772b01 100644 --- a/crates/spec-forest-sync/src/main.rs +++ b/crates/spec-forest-sync/src/main.rs @@ -25,7 +25,7 @@ struct Cli { fn default_db_path() -> String { dirs::home_dir() - .map(|p| p.join(".spec_forest").join("sync.db")) + .map(|p| p.join(".spec-forest").join("sync.db")) .unwrap_or_else(|| std::path::PathBuf::from("sync.db")) .to_string_lossy() .to_string() diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index 2c40f8a..eb21d18 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -14,9 +14,9 @@ use tracing_subscriber::EnvFilter; fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".specdag"); - std::fs::create_dir_all(&dir).expect("could not create ~/.specdag directory"); - dir.join("specdag.db").to_string_lossy().into_owned() + .join(".spec-forest"); + std::fs::create_dir_all(&dir).expect("could not create ~/.spec-forest directory"); + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] @@ -57,7 +57,7 @@ async fn main() -> Result<(), Box> { // Initialize file-based logging (stdout is the TUI) let log_dir = dirs::home_dir() .expect("could not determine home directory") - .join(".specdag"); + .join(".spec-forest"); let file_appender = tracing_appender::rolling::daily(&log_dir, "tui.log"); tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive("spec_forest_tui=info".parse().unwrap())) diff --git a/crates/spec-forest/src/main.rs b/crates/spec-forest/src/main.rs index 5321b7c..79ac86a 100644 --- a/crates/spec-forest/src/main.rs +++ b/crates/spec-forest/src/main.rs @@ -5,9 +5,9 @@ use tokio::net::TcpListener; fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".spec_forest"); - std::fs::create_dir_all(&dir).expect("could not create ~/.spec_forest directory"); - dir.join("spec_forest.db").to_string_lossy().into_owned() + .join(".spec-forest"); + std::fs::create_dir_all(&dir).expect("could not create ~/.spec-forest directory"); + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] From cfdb42be8dda35073d867d6709366526106b75df Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:40:32 +1100 Subject: [PATCH 004/100] feat: replace text input with tree-based directory explorer for seed from dir The seed feature now opens a full filesystem tree browser instead of a plain text path input. Users can expand/collapse directories, navigate with arrow keys, go above home with Backspace, and select with S. --- crates/spec-forest-tui/src/action.rs | 13 +- crates/spec-forest-tui/src/app.rs | 68 +++++--- crates/spec-forest-tui/src/commands.rs | 10 -- crates/spec-forest-tui/src/dir_browser.rs | 166 +++++++++++++++++++ crates/spec-forest-tui/src/input.rs | 19 ++- crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/ui.rs | 3 +- crates/spec-forest-tui/src/ui/dir_browser.rs | 69 ++++++++ crates/spec-forest-tui/src/ui/spec_list.rs | 2 +- crates/spec-forest-tui/tests/tui_tests.rs | 14 +- 10 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 crates/spec-forest-tui/src/dir_browser.rs create mode 100644 crates/spec-forest-tui/src/ui/dir_browser.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 942d802..3a88672 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -10,11 +10,11 @@ pub enum Action { // Spec list OpenCreateSpec, - OpenSeedFromFile, + OpenSeedFromDir, OpenSyncConfig, OpenModelConfig, - // Text input (shared across InputName, InputFile, SyncPasswordInput) + // Text input (shared across InputName, SyncPasswordInput) TypeChar(char), DeleteChar, Cancel, @@ -37,6 +37,15 @@ pub enum Action { TreeDown, EditTreeNode, + // Directory browser + DirBrowserUp, + DirBrowserDown, + DirBrowserExpand, + DirBrowserCollapse, + DirBrowserGoToParent, + DirBrowserSelect, + DirBrowserCancel, + // Sync SyncLogin, SyncRegister, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 3588b49..da57c9b 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -11,6 +11,7 @@ use spec_forest::state::{AppState, GenerationStatus}; use crate::action::Action; use crate::commands; +use crate::dir_browser::DirBrowserState; use crate::editor; use crate::error::handle_result; use crate::input; @@ -41,13 +42,14 @@ pub struct App { pub explore_session_id: Option, pub generation_statuses: HashMap, pub explore_status: Option, + pub dir_browser: Option, } #[derive(Clone)] pub enum Screen { SpecList, InputName, - InputFile, + DirBrowser, SpecView { spec_id: String }, SyncConfig, SyncPasswordInput, @@ -82,6 +84,7 @@ impl App { explore_session_id: None, generation_statuses: HashMap::new(), explore_status: None, + dir_browser: None, } } @@ -139,9 +142,9 @@ impl App { self.input.clear(); self.screen = Screen::InputName; } - Action::OpenSeedFromFile => { - self.input.clear(); - self.screen = Screen::InputFile; + Action::OpenSeedFromDir => { + self.dir_browser = Some(DirBrowserState::new()); + self.screen = Screen::DirBrowser; } Action::OpenSyncConfig => { let status = spec_forest::api::sync_status(&self.state).await; @@ -179,6 +182,28 @@ impl App { Action::CollapseTreeNode => self.collapse_tree_node(), Action::EditTreeNode => self.edit_tree_node().await, + // Directory browser + Action::DirBrowserUp => { + if let Some(ref mut db) = self.dir_browser { db.select_up(); } + } + Action::DirBrowserDown => { + if let Some(ref mut db) = self.dir_browser { db.select_down(); } + } + Action::DirBrowserExpand => { + if let Some(ref mut db) = self.dir_browser { db.expand_selected(); } + } + Action::DirBrowserCollapse => { + if let Some(ref mut db) = self.dir_browser { db.collapse_selected(); } + } + Action::DirBrowserGoToParent => { + if let Some(ref mut db) = self.dir_browser { db.go_to_parent(); } + } + Action::DirBrowserSelect => self.seed_from_directory().await, + Action::DirBrowserCancel => { + self.dir_browser = None; + self.screen = Screen::SpecList; + } + // Sync Action::SyncLogin => { self.sync_register = false; @@ -264,7 +289,7 @@ impl App { fn cancel_input(&mut self) { match &self.screen { - Screen::InputName | Screen::InputFile => { + Screen::InputName => { self.screen = Screen::SpecList; } Screen::SyncPasswordInput => { @@ -286,7 +311,6 @@ impl App { } match self.screen.clone() { Screen::InputName => self.create_spec_with_seed().await, - Screen::InputFile => self.seed_from_file().await, Screen::SyncPasswordInput => self.do_sync_connect().await, _ => {} } @@ -380,38 +404,44 @@ impl App { } } - async fn seed_from_file(&mut self) { - let path = std::mem::take(&mut self.input); - let content = match commands::read_file_content(&path) { - Ok(c) => c, - Err(e) => { - self.message = Some(e.to_string()); - self.screen = Screen::SpecList; - return; - } + async fn seed_from_directory(&mut self) { + let (dir_path_str, name) = match self.dir_browser { + Some(ref browser) => match browser.selected_path() { + Some(p) => (p.to_string_lossy().to_string(), browser.selected_dir_name()), + None => return, + }, + None => return, }; - let name = commands::file_stem(&path); match commands::create_spec(&self.state, name).await { Ok(spec) => { - match commands::seed_spec(&self.state, &spec.id, content, self.model.clone()).await + match commands::seed_spec( + &self.state, + &spec.id, + dir_path_str, + self.model.clone(), + ) + .await { Ok(()) => { self.message = Some(format!("Seeded: {}", spec.name)); self.refresh_specs(); + self.dir_browser = None; self.open_spec(&spec.id); } Err(e) => { - tracing::error!("Seed from file failed: {e}"); + tracing::error!("Seed from directory failed: {e}"); self.message = Some(e.to_string()); self.refresh_specs(); + self.dir_browser = None; self.screen = Screen::SpecList; } } } Err(e) => { - tracing::error!("Create spec from file failed: {e}"); + tracing::error!("Create spec from directory failed: {e}"); self.message = Some(e.to_string()); + self.dir_browser = None; self.screen = Screen::SpecList; } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 5fb9e44..f9b3888 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -95,13 +95,3 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } -pub fn read_file_content(path: &str) -> Result { - std::fs::read_to_string(path).map_err(TuiError::Io) -} - -pub fn file_stem(path: &str) -> String { - std::path::Path::new(path) - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "seeded-spec".to_string()) -} diff --git a/crates/spec-forest-tui/src/dir_browser.rs b/crates/spec-forest-tui/src/dir_browser.rs new file mode 100644 index 0000000..6577751 --- /dev/null +++ b/crates/spec-forest-tui/src/dir_browser.rs @@ -0,0 +1,166 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug)] +pub struct DirEntry { + pub path: PathBuf, + pub name: String, + pub depth: usize, +} + +pub struct DirBrowserState { + pub root: PathBuf, + pub entries: Vec, + pub selected: usize, + expanded: HashSet, +} + +impl DirBrowserState { + pub fn new() -> Self { + let root = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let mut state = Self { + root, + entries: Vec::new(), + selected: 0, + expanded: HashSet::new(), + }; + state.expanded.insert(state.root.clone()); + state.rebuild(); + state + } + + pub fn rebuild(&mut self) { + self.entries.clear(); + self.entries.push(DirEntry { + path: self.root.clone(), + name: self + .root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.root.to_string_lossy().to_string()), + depth: 0, + }); + if self.expanded.contains(&self.root) { + self.push_children(&self.root.clone(), 1); + } + self.clamp_selection(); + } + + fn push_children(&mut self, dir: &Path, depth: usize) { + let children = read_dir_sorted(dir); + for child in children { + let name = child + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let is_expanded = self.expanded.contains(&child); + self.entries.push(DirEntry { + path: child.clone(), + name, + depth, + }); + if is_expanded { + self.push_children(&child, depth + 1); + } + } + } + + pub fn select_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn select_down(&mut self) { + if !self.entries.is_empty() && self.selected < self.entries.len() - 1 { + self.selected += 1; + } + } + + pub fn expand_selected(&mut self) { + let Some(entry) = self.entries.get(self.selected) else { + return; + }; + let path = entry.path.clone(); + if self.expanded.contains(&path) { + self.expanded.remove(&path); + } else { + self.expanded.insert(path); + } + self.rebuild(); + } + + pub fn collapse_selected(&mut self) { + let Some(entry) = self.entries.get(self.selected) else { + return; + }; + let path = entry.path.clone(); + let depth = entry.depth; + + if self.expanded.contains(&path) { + self.expanded.remove(&path); + self.rebuild(); + } else if depth > 0 { + for i in (0..self.selected).rev() { + if self.entries[i].depth == depth - 1 { + self.selected = i; + break; + } + } + } + } + + pub fn go_to_parent(&mut self) { + if let Some(parent) = self.root.parent() { + let parent = parent.to_path_buf(); + if parent != self.root { + let old_root = self.root.clone(); + self.root = parent; + self.expanded.clear(); + self.expanded.insert(self.root.clone()); + self.expanded.insert(old_root.clone()); + self.rebuild(); + // Select the old root in the new listing + if let Some(idx) = self.entries.iter().position(|e| e.path == old_root) { + self.selected = idx; + } + } + } + } + + pub fn selected_path(&self) -> Option<&Path> { + self.entries.get(self.selected).map(|e| e.path.as_path()) + } + + pub fn selected_dir_name(&self) -> String { + self.entries + .get(self.selected) + .map(|e| e.name.clone()) + .unwrap_or_else(|| "seeded-spec".to_string()) + } + + pub fn is_expanded(&self, path: &Path) -> bool { + self.expanded.contains(path) + } + + fn clamp_selection(&mut self) { + if !self.entries.is_empty() && self.selected >= self.entries.len() { + self.selected = self.entries.len() - 1; + } + } +} + +fn read_dir_sorted(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut dirs: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + dirs.sort_by(|a, b| { + let a_name = a.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); + let b_name = b.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); + a_name.cmp(&b_name) + }); + dirs +} diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 8511677..36ecd58 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -14,7 +14,8 @@ pub fn map_key( ) -> Action { match screen { Screen::SpecList => map_spec_list_key(key), - Screen::InputName | Screen::InputFile | Screen::SyncPasswordInput => map_input_key(key), + Screen::InputName | Screen::SyncPasswordInput => map_input_key(key), + Screen::DirBrowser => map_dir_browser_key(key), Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -25,7 +26,7 @@ fn map_spec_list_key(key: KeyCode) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Char('c') => Action::OpenCreateSpec, - KeyCode::Char('s') => Action::OpenSeedFromFile, + KeyCode::Char('s') => Action::OpenSeedFromDir, KeyCode::Char('y') => Action::OpenSyncConfig, KeyCode::Char('m') => Action::OpenModelConfig, KeyCode::Up => Action::NavigateUp, @@ -45,6 +46,20 @@ fn map_input_key(key: KeyCode) -> Action { } } +fn map_dir_browser_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::DirBrowserUp, + KeyCode::Down => Action::DirBrowserDown, + KeyCode::Right | KeyCode::Enter => Action::DirBrowserExpand, + KeyCode::Left => Action::DirBrowserCollapse, + KeyCode::Backspace => Action::DirBrowserGoToParent, + KeyCode::Char('s') => Action::DirBrowserSelect, + KeyCode::Esc => Action::DirBrowserCancel, + _ => Action::Noop, + } +} + fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index 72d457c..a9fdb97 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -1,6 +1,7 @@ pub mod action; pub mod app; pub mod commands; +pub mod dir_browser; pub mod editor; pub mod error; pub mod input; diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 048e797..be45fbe 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -1,4 +1,5 @@ mod common; +mod dir_browser; mod input_screen; mod model_config; mod spec_list; @@ -13,7 +14,7 @@ pub fn render(app: &App, frame: &mut Frame) { match &app.screen { Screen::SpecList => spec_list::render(app, frame), Screen::InputName => input_screen::render_input(app, frame, "Spec name:"), - Screen::InputFile => input_screen::render_input(app, frame, "File path:"), + Screen::DirBrowser => dir_browser::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), diff --git a/crates/spec-forest-tui/src/ui/dir_browser.rs b/crates/spec-forest-tui/src/ui/dir_browser.rs new file mode 100644 index 0000000..e7881d1 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/dir_browser.rs @@ -0,0 +1,69 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let Some(ref browser) = app.dir_browser else { + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .split(frame.area()); + + let items: Vec = browser + .entries + .iter() + .map(|entry| { + let indent = " ".repeat(entry.depth); + let icon = if browser.is_expanded(&entry.path) { + "v " + } else { + "> " + }; + ListItem::new(Line::from(format!("{indent}{icon}{}", entry.name))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Seed from directory "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if !browser.entries.is_empty() { + state.select(Some(browser.selected)); + } + frame.render_stateful_widget(list, chunks[0], &mut state); + + let path_text = browser + .selected_path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let path_display = + Paragraph::new(path_text).block(Block::default().borders(Borders::ALL)); + frame.render_widget(path_display, chunks[1]); + + let footer = Paragraph::new("[S] Select [Enter/\u{2192}] Expand [\u{2190}] Collapse [Bksp] Parent [Esc] Cancel") + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[2]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 5c3234f..d3cb2d9 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -38,7 +38,7 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = app .message .as_deref() - .unwrap_or("[c] Create [s] Seed from file [m] Model [y] Sync [Enter] Open [q] Quit"); + .unwrap_or("[c] Create [s] Seed from dir [m] Model [y] Sync [Enter] Open [q] Quit"); let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 25aea35..f434ccc 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -113,11 +113,12 @@ async fn test_input_name_shows_prompt() { } #[tokio::test] -async fn test_input_file_shows_prompt() { +async fn test_dir_browser_shows_title() { let mut app = make_app(); - app.screen = Screen::InputFile; + app.dir_browser = Some(spec_forest_tui::dir_browser::DirBrowserState::new()); + app.screen = Screen::DirBrowser; let output = render_to_string(&app); - assert!(output.contains("File path:"), "should show file prompt"); + assert!(output.contains("Seed from directory"), "should show dir browser title"); } #[tokio::test] @@ -176,7 +177,8 @@ async fn test_create_key() { async fn test_seed_key() { let mut app = make_app(); app.handle_key(KeyCode::Char('s')).await; - assert!(matches!(app.screen, Screen::InputFile)); + assert!(matches!(app.screen, Screen::DirBrowser)); + assert!(app.dir_browser.is_some()); } #[tokio::test] @@ -572,8 +574,8 @@ fn test_input_map_spec_list_navigate() { #[test] fn test_input_map_shared_text_input() { - // InputName, InputFile, and SyncPasswordInput all share the same mapping - for screen in [Screen::InputName, Screen::InputFile, Screen::SyncPasswordInput] { + // InputName and SyncPasswordInput share the same mapping + for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( input::map_key(&screen, KeyCode::Esc, false, false, false), Action::Cancel From 8c13c0db9fb65a64841b3b25ae8a12ad752e6b2e Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:45:55 +1100 Subject: [PATCH 005/100] feat: add depth picker when seeding spec from directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After selecting a directory, a new screen lets you choose exploration depth (1–5) before seeding. Uses the existing recursive ingest backend instead of single-level seed_spec, with background status polling. --- crates/spec-forest-tui/src/action.rs | 6 + crates/spec-forest-tui/src/app.rs | 119 +++++++++++++----- crates/spec-forest-tui/src/commands.rs | 32 +++++ crates/spec-forest-tui/src/input.rs | 12 ++ crates/spec-forest-tui/src/ui.rs | 2 + crates/spec-forest-tui/src/ui/depth_picker.rs | 61 +++++++++ crates/spec-forest/src/lib.rs | 2 +- 7 files changed, 200 insertions(+), 34 deletions(-) create mode 100644 crates/spec-forest-tui/src/ui/depth_picker.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 3a88672..064fda7 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -46,6 +46,12 @@ pub enum Action { DirBrowserSelect, DirBrowserCancel, + // Depth picker + DepthPickerUp, + DepthPickerDown, + DepthPickerConfirm, + DepthPickerCancel, + // Sync SyncLogin, SyncRegister, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index da57c9b..9c38bd8 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -7,6 +7,7 @@ use futures::StreamExt; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use spec_forest::explore::{ExploreStatus, ExploreStatusResponse}; +use spec_forest::ingest::IngestState; use spec_forest::state::{AppState, GenerationStatus}; use crate::action::Action; @@ -20,6 +21,14 @@ use crate::ui; pub const MODELS: &[&str] = &["opus", "sonnet", "haiku"]; +pub const DEPTH_OPTIONS: &[(&str, &str)] = &[ + ("Depth 1", "Features only"), + ("Depth 2", "Features + questions + answers"), + ("Depth 3", "Three levels of Q&A"), + ("Depth 4", "Four levels of Q&A"), + ("Depth 5", "Full deep exploration (slow)"), +]; + pub struct App { pub state: Arc, pub screen: Screen, @@ -43,6 +52,9 @@ pub struct App { pub generation_statuses: HashMap, pub explore_status: Option, pub dir_browser: Option, + pub depth_selected: usize, + pub depth_picker_dir: Option<(String, String)>, + pub ingest_session_id: Option, } #[derive(Clone)] @@ -50,6 +62,7 @@ pub enum Screen { SpecList, InputName, DirBrowser, + DepthPicker, SpecView { spec_id: String }, SyncConfig, SyncPasswordInput, @@ -85,6 +98,9 @@ impl App { generation_statuses: HashMap::new(), explore_status: None, dir_browser: None, + depth_selected: 0, + depth_picker_dir: None, + ingest_session_id: None, } } @@ -198,12 +214,37 @@ impl App { Action::DirBrowserGoToParent => { if let Some(ref mut db) = self.dir_browser { db.go_to_parent(); } } - Action::DirBrowserSelect => self.seed_from_directory().await, + Action::DirBrowserSelect => { + if let Some(ref browser) = self.dir_browser { + if let Some(p) = browser.selected_path() { + let dir_path = p.to_string_lossy().to_string(); + let name = browser.selected_dir_name(); + self.depth_picker_dir = Some((dir_path, name)); + self.depth_selected = 0; + self.screen = Screen::DepthPicker; + } + } + } Action::DirBrowserCancel => { self.dir_browser = None; self.screen = Screen::SpecList; } + // Depth picker + Action::DepthPickerUp => { + self.depth_selected = self.depth_selected.saturating_sub(1); + } + Action::DepthPickerDown => { + if self.depth_selected < DEPTH_OPTIONS.len() - 1 { + self.depth_selected += 1; + } + } + Action::DepthPickerConfirm => self.seed_from_directory_with_depth().await, + Action::DepthPickerCancel => { + self.depth_picker_dir = None; + self.screen = Screen::DirBrowser; + } + // Sync Action::SyncLogin => { self.sync_register = false; @@ -404,42 +445,35 @@ impl App { } } - async fn seed_from_directory(&mut self) { - let (dir_path_str, name) = match self.dir_browser { - Some(ref browser) => match browser.selected_path() { - Some(p) => (p.to_string_lossy().to_string(), browser.selected_dir_name()), - None => return, - }, + async fn seed_from_directory_with_depth(&mut self) { + let (dir_path, name) = match self.depth_picker_dir.take() { + Some(d) => d, None => return, }; - - match commands::create_spec(&self.state, name).await { - Ok(spec) => { - match commands::seed_spec( - &self.state, - &spec.id, - dir_path_str, - self.model.clone(), - ) - .await - { - Ok(()) => { - self.message = Some(format!("Seeded: {}", spec.name)); - self.refresh_specs(); - self.dir_browser = None; - self.open_spec(&spec.id); - } - Err(e) => { - tracing::error!("Seed from directory failed: {e}"); - self.message = Some(e.to_string()); - self.refresh_specs(); - self.dir_browser = None; - self.screen = Screen::SpecList; - } - } + let depth = self.depth_selected + 1; + + match commands::ingest_recursive( + &self.state, + name.clone(), + None, + None, + None, + dir_path, + depth, + self.model.clone(), + ) + .await + { + Ok(resp) => { + let spec_id = resp.spec_id.clone(); + self.ingest_session_id = Some(resp.session_id); + self.message = Some(format!("Ingesting '{}' at depth {}...", name, depth)); + self.refresh_specs(); + self.dir_browser = None; + self.open_spec(&spec_id); } Err(e) => { - tracing::error!("Create spec from directory failed: {e}"); + tracing::error!("Ingest recursive failed: {e}"); self.message = Some(e.to_string()); self.dir_browser = None; self.screen = Screen::SpecList; @@ -735,6 +769,24 @@ impl App { } } + if let Some(sid) = self.ingest_session_id.clone() { + match commands::poll_ingest_status(&self.state, &sid) { + Ok(status) => { + let done = matches!( + status.status, + IngestState::Done | IngestState::Cancelled + ); + if done { + self.ingest_session_id = None; + self.message = Some("Ingest complete".to_string()); + } + } + Err(_) => { + self.ingest_session_id = None; + } + } + } + if prev_busy || self.is_busy() { self.refresh_nodes(&spec_id); self.rebuild_tree_if_visible(&spec_id); @@ -747,6 +799,7 @@ impl App { .explore_status .as_ref() .is_some_and(|s| s.status == ExploreStatus::Running) + || self.ingest_session_id.is_some() } fn refresh_nodes(&mut self, spec_id: &str) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index f9b3888..4e0ded2 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -85,6 +85,38 @@ pub fn poll_explore_status( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn ingest_recursive( + state: &Arc, + spec_name: String, + spec_description: Option, + mode: Option<&str>, + locality: Option<&str>, + dir_path: String, + depth: usize, + model: String, +) -> Result { + spec_forest::api::ingest_recursive( + state, + spec_name, + spec_description, + mode, + locality, + dir_path, + depth, + Some(model), + ) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn poll_ingest_status( + state: &AppState, + session_id: &str, +) -> Result { + spec_forest::api::ingest_status(state, session_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn connect_sync( state: &Arc, password: String, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 36ecd58..5271408 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -16,6 +16,7 @@ pub fn map_key( Screen::SpecList => map_spec_list_key(key), Screen::InputName | Screen::SyncPasswordInput => map_input_key(key), Screen::DirBrowser => map_dir_browser_key(key), + Screen::DepthPicker => map_depth_picker_key(key), Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -60,6 +61,17 @@ fn map_dir_browser_key(key: KeyCode) -> Action { } } +fn map_depth_picker_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::DepthPickerUp, + KeyCode::Down => Action::DepthPickerDown, + KeyCode::Enter => Action::DepthPickerConfirm, + KeyCode::Esc => Action::DepthPickerCancel, + _ => Action::Noop, + } +} + fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index be45fbe..0b7910d 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -1,4 +1,5 @@ mod common; +mod depth_picker; mod dir_browser; mod input_screen; mod model_config; @@ -15,6 +16,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SpecList => spec_list::render(app, frame), Screen::InputName => input_screen::render_input(app, frame, "Spec name:"), Screen::DirBrowser => dir_browser::render(app, frame), + Screen::DepthPicker => depth_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), diff --git a/crates/spec-forest-tui/src/ui/depth_picker.rs b/crates/spec-forest-tui/src/ui/depth_picker.rs new file mode 100644 index 0000000..e8bee69 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/depth_picker.rs @@ -0,0 +1,61 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, DEPTH_OPTIONS}; + +pub fn render(app: &App, frame: &mut Frame) { + let dir_name = app + .depth_picker_dir + .as_ref() + .map(|(_, name)| name.as_str()) + .unwrap_or("directory"); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let items: Vec = DEPTH_OPTIONS + .iter() + .enumerate() + .map(|(i, (label, desc))| { + let style = if i == app.depth_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled( + format!(" {label} \u{2014} {desc}"), + style, + ))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Exploration depth for: {dir_name} ")), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.depth_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer = Paragraph::new("[Up/Down] Select [Enter] Confirm [Esc] Back") + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 4a01f11..3f4a2da 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -3,7 +3,7 @@ mod dir_context; pub mod explore; mod generate; mod http; -mod ingest; +pub mod ingest; pub mod op_channel; mod op_loop; mod prompt_log; From d01dad1f0fddd3e1d5c53303a16c5f6d2bbd1ece Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 15:58:22 +1100 Subject: [PATCH 006/100] feat: display candidate answers in TUI main panel for user selection Add candidate browsing and acceptance to the spec_view content panel. When a node has candidates, they appear below the answer section with [/] to browse and [y] to accept. Candidates persist after acceptance. --- crates/spec-forest-tui/src/action.rs | 5 + crates/spec-forest-tui/src/app.rs | 69 ++++++++++ crates/spec-forest-tui/src/input.rs | 6 + crates/spec-forest-tui/src/ui/spec_view.rs | 41 ++++++ crates/spec-forest-tui/tests/tui_tests.rs | 139 +++++++++++++++++++++ 5 files changed, 260 insertions(+) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 064fda7..c12bcd6 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -59,5 +59,10 @@ pub enum Action { // Model SelectModel, + // Candidates + CandidateNext, + CandidatePrev, + AcceptCandidate, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 9c38bd8..64cc4b4 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -9,6 +9,7 @@ use ratatui::backend::CrosstermBackend; use spec_forest::explore::{ExploreStatus, ExploreStatusResponse}; use spec_forest::ingest::IngestState; use spec_forest::state::{AppState, GenerationStatus}; +use spec_forest_db::candidate::CandidateAnswer; use crate::action::Action; use crate::commands; @@ -55,6 +56,9 @@ pub struct App { pub depth_selected: usize, pub depth_picker_dir: Option<(String, String)>, pub ingest_session_id: Option, + pub candidates: Vec, + pub candidate_selected: usize, + pub candidate_node_id: Option, } #[derive(Clone)] @@ -101,6 +105,9 @@ impl App { depth_selected: 0, depth_picker_dir: None, ingest_session_id: None, + candidates: Vec::new(), + candidate_selected: 0, + candidate_node_id: None, } } @@ -114,6 +121,7 @@ impl App { terminal.clear()?; self.needs_redraw = false; } + self.refresh_candidates_if_needed(); terminal.draw(|frame| ui::render(self, frame))?; let event = tokio::time::timeout(Duration::from_millis(250), reader.next()).await; @@ -263,6 +271,18 @@ impl App { self.message = Some(format!("Model set to: {}", self.model)); self.screen = Screen::SpecList; } + + // Candidates + Action::CandidateNext => { + if !self.candidates.is_empty() { + self.candidate_selected = + (self.candidate_selected + 1).min(self.candidates.len() - 1); + } + } + Action::CandidatePrev => { + self.candidate_selected = self.candidate_selected.saturating_sub(1); + } + Action::AcceptCandidate => self.accept_candidate().await, } } @@ -738,6 +758,54 @@ impl App { } } + // ── Candidate operations ─────────────────────────────────── + + pub fn refresh_candidates_if_needed(&mut self) { + let current_node_id = self.get_selected_node().map(|n| n.id.clone()); + if current_node_id == self.candidate_node_id { + return; + } + self.candidate_node_id = current_node_id.clone(); + self.candidate_selected = 0; + if let Some(node_id) = current_node_id { + let db = self.state.db(); + match db.get_candidates(&node_id) { + Ok(c) => self.candidates = c, + Err(e) => { + tracing::warn!("Failed to load candidates: {e}"); + self.candidates = Vec::new(); + } + } + } else { + self.candidates = Vec::new(); + } + } + + async fn accept_candidate(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let Some(candidate) = self.candidates.get(self.candidate_selected).cloned() else { + return; + }; + let node_id = candidate.node_id.clone(); + let answer_text = candidate.answer.clone(); + + match commands::submit_answer(&self.state, &node_id, answer_text, self.model.clone()).await + { + Ok(()) => { + self.message = Some("Candidate accepted".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Err(e) => { + tracing::error!("Accept candidate failed: {e}"); + self.message = Some(e.to_string()); + } + } + } + // ── Background polling ────────────────────────────────────── fn poll_background_status(&mut self) { @@ -790,6 +858,7 @@ impl App { if prev_busy || self.is_busy() { self.refresh_nodes(&spec_id); self.rebuild_tree_if_visible(&spec_id); + self.candidate_node_id = None; // force candidate reload } } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5271408..24d342b 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -95,6 +95,9 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('X') => Action::FullExplore, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, + KeyCode::Char(']') => Action::CandidateNext, + KeyCode::Char('[') => Action::CandidatePrev, + KeyCode::Char('y') => Action::AcceptCandidate, _ => Action::Noop, } } @@ -109,6 +112,9 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('X') => Action::FullExplore, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, + KeyCode::Char(']') => Action::CandidateNext, + KeyCode::Char('[') => Action::CandidatePrev, + KeyCode::Char('y') => Action::AcceptCandidate, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index e692537..69d8581 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -54,6 +54,8 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() + } else if !app.candidates.is_empty() { + "[[] prev []] next [y] accept candidate [a] AI answer [e] Edit [t] Tree [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { "[a] AI answer [x] Explore [X] Full explore [e] Edit [t] Tree [Tab] Focus [Bksp] Back [q] Quit".to_string() } else { @@ -115,6 +117,45 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { } } + // Candidates section + if !app.candidates.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "Candidates ({}) ── [/] browse [y] accept", + app.candidates.len() + ), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for (i, candidate) in app.candidates.iter().enumerate() { + let is_selected = i == app.candidate_selected; + let marker = if is_selected { "▸ " } else { " " }; + let rank_label = format!("{}Candidate {}", marker, i + 1); + + let header_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + lines.push(Line::from(Span::styled(rank_label, header_style))); + + if is_selected { + lines.push(Line::from("")); + for text_line in candidate.answer.lines() { + lines.push(Line::from(text_line.to_string())); + } + lines.push(Line::from("")); + } + } + } + let content = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }); diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index f434ccc..9f6a28a 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -716,3 +716,142 @@ fn test_truncate_str_multibyte_utf8() { let mixed = "ab🎉cd"; assert_eq!(truncate_str(mixed, 3), "ab🎉"); } + +// ── Candidate tests ───────────────────────────────────────── + +fn make_app_with_candidates() -> (App, String, String) { + let (mut app, spec_id, root_id) = make_app_with_tree(); + // Insert candidates for the root node + { + let db = app.state.db(); + db.upsert_candidates( + &root_id, + &[ + ("c1".to_string(), "First candidate answer".to_string(), 0), + ("c2".to_string(), "Second candidate answer".to_string(), 1), + ("c3".to_string(), "Third candidate answer".to_string(), 2), + ], + ) + .unwrap(); + } + // Select root node and load candidates + app.tree_visible = false; + app.tree_focused = false; + // Find root node index in flat list + let root_idx = app.nodes.iter().position(|n| n.id == root_id).unwrap_or(0); + app.node_selected = root_idx; + app.refresh_candidates_if_needed(); + (app, spec_id, root_id) +} + +#[tokio::test] +async fn test_candidates_render_when_present() { + let (app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidates.len(), 3); + let output = render_to_string_sized(&app, 120, 40); + assert!( + output.contains("Candidates (3)"), + "should show candidates header" + ); + assert!( + output.contains("First candidate answer"), + "should show selected candidate text" + ); +} + +#[tokio::test] +async fn test_candidate_navigation() { + let (mut app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidate_selected, 0); + + // Navigate next + app.handle_key(KeyCode::Char(']')).await; + assert_eq!(app.candidate_selected, 1); + + app.handle_key(KeyCode::Char(']')).await; + assert_eq!(app.candidate_selected, 2); + + // Bounds: can't go past end + app.handle_key(KeyCode::Char(']')).await; + assert_eq!(app.candidate_selected, 2); + + // Navigate prev + app.handle_key(KeyCode::Char('[')).await; + assert_eq!(app.candidate_selected, 1); + + app.handle_key(KeyCode::Char('[')).await; + assert_eq!(app.candidate_selected, 0); + + // Bounds: can't go below 0 + app.handle_key(KeyCode::Char('[')).await; + assert_eq!(app.candidate_selected, 0); +} + +#[test] +fn test_accept_candidate_key_mapping() { + // Verify y maps to AcceptCandidate in both modes + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), true, true, false), + Action::AcceptCandidate + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), false, false, false), + Action::AcceptCandidate + ); +} + +#[tokio::test] +async fn test_candidates_clear_when_switching_nodes() { + let (mut app, _spec_id, _root_id) = make_app_with_candidates(); + assert_eq!(app.candidates.len(), 3, "should have candidates for root"); + + // Navigate to a different node (child without candidates) + app.handle_key(KeyCode::Down).await; + app.refresh_candidates_if_needed(); + assert!( + app.candidates.is_empty(), + "candidates should be empty for node without candidates" + ); + assert_eq!(app.candidate_selected, 0, "selection should reset"); +} + +#[test] +fn test_input_map_candidate_keys_tree() { + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char(']'), true, true, false), + Action::CandidateNext + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('['), true, true, false), + Action::CandidatePrev + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), true, true, false), + Action::AcceptCandidate + ); +} + +#[test] +fn test_input_map_candidate_keys_flat_list() { + let screen = Screen::SpecView { + spec_id: "s".to_string(), + }; + assert_eq!( + input::map_key(&screen, KeyCode::Char(']'), false, false, false), + Action::CandidateNext + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('['), false, false, false), + Action::CandidatePrev + ); + assert_eq!( + input::map_key(&screen, KeyCode::Char('y'), false, false, false), + Action::AcceptCandidate + ); +} From 7489c29d7d2f56007af1d722d4747e3ebea44b25 Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 16:01:24 +1100 Subject: [PATCH 007/100] feat: add mode/locality picker to TUI spec creation flows Both create-spec and seed-from-directory flows now prompt the user to choose between local/remote and development/exploration before proceeding, passing the selection through to the spec-forest API. --- crates/spec-forest-tui/src/action.rs | 6 ++ crates/spec-forest-tui/src/app.rs | 83 +++++++++++++++++-- crates/spec-forest-tui/src/commands.rs | 4 +- crates/spec-forest-tui/src/input.rs | 12 +++ crates/spec-forest-tui/src/ui.rs | 2 + .../src/ui/spec_options_picker.rs | 52 ++++++++++++ 6 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 crates/spec-forest-tui/src/ui/spec_options_picker.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index c12bcd6..88d199b 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -52,6 +52,12 @@ pub enum Action { DepthPickerConfirm, DepthPickerCancel, + // Spec options picker (mode + locality) + SpecOptionsUp, + SpecOptionsDown, + SpecOptionsConfirm, + SpecOptionsCancel, + // Sync SyncLogin, SyncRegister, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 64cc4b4..30b6b24 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -30,6 +30,14 @@ pub const DEPTH_OPTIONS: &[(&str, &str)] = &[ ("Depth 5", "Full deep exploration (slow)"), ]; +/// (display_label, mode_str, locality_str) +pub const SPEC_OPTIONS: &[(&str, &str, &str)] = &[ + ("Local Development", "development", "local"), + ("Local Exploration", "exploration", "local"), + ("Remote Development", "development", "remote"), + ("Remote Exploration", "exploration", "remote"), +]; + pub struct App { pub state: Arc, pub screen: Screen, @@ -59,6 +67,8 @@ pub struct App { pub candidates: Vec, pub candidate_selected: usize, pub candidate_node_id: Option, + pub spec_options_selected: usize, + pub spec_options_source: SpecOptionsSource, } #[derive(Clone)] @@ -67,12 +77,20 @@ pub enum Screen { InputName, DirBrowser, DepthPicker, + SpecOptionsPicker, SpecView { spec_id: String }, SyncConfig, SyncPasswordInput, ModelConfig, } +#[derive(Clone)] +pub enum SpecOptionsSource { + None, + CreateSpec, + SeedFromDir, +} + impl App { pub fn new(state: Arc) -> Self { let specs = commands::refresh_spec_list(&state).unwrap_or_else(|e| { @@ -108,6 +126,8 @@ impl App { candidates: Vec::new(), candidate_selected: 0, candidate_node_id: None, + spec_options_selected: 0, + spec_options_source: SpecOptionsSource::None, } } @@ -247,7 +267,11 @@ impl App { self.depth_selected += 1; } } - Action::DepthPickerConfirm => self.seed_from_directory_with_depth().await, + Action::DepthPickerConfirm => { + self.spec_options_selected = 0; + self.spec_options_source = SpecOptionsSource::SeedFromDir; + self.screen = Screen::SpecOptionsPicker; + } Action::DepthPickerCancel => { self.depth_picker_dir = None; self.screen = Screen::DirBrowser; @@ -272,6 +296,18 @@ impl App { self.screen = Screen::SpecList; } + // Spec options picker + Action::SpecOptionsUp => { + self.spec_options_selected = self.spec_options_selected.saturating_sub(1); + } + Action::SpecOptionsDown => { + if self.spec_options_selected < SPEC_OPTIONS.len() - 1 { + self.spec_options_selected += 1; + } + } + Action::SpecOptionsConfirm => self.confirm_spec_options().await, + Action::SpecOptionsCancel => self.cancel_spec_options(), + // Candidates Action::CandidateNext => { if !self.candidates.is_empty() { @@ -371,7 +407,11 @@ impl App { return; } match self.screen.clone() { - Screen::InputName => self.create_spec_with_seed().await, + Screen::InputName => { + self.spec_options_selected = 0; + self.spec_options_source = SpecOptionsSource::CreateSpec; + self.screen = Screen::SpecOptionsPicker; + } Screen::SyncPasswordInput => self.do_sync_connect().await, _ => {} } @@ -420,7 +460,36 @@ impl App { } } - async fn create_spec_with_seed(&mut self) { + async fn confirm_spec_options(&mut self) { + let (_, mode, locality) = SPEC_OPTIONS[self.spec_options_selected]; + match self.spec_options_source.clone() { + SpecOptionsSource::CreateSpec => { + self.create_spec_with_seed(mode, locality).await; + } + SpecOptionsSource::SeedFromDir => { + self.seed_from_directory_with_depth(mode, locality).await; + } + SpecOptionsSource::None => { + self.screen = Screen::SpecList; + } + } + } + + fn cancel_spec_options(&mut self) { + match self.spec_options_source { + SpecOptionsSource::CreateSpec => { + self.screen = Screen::InputName; + } + SpecOptionsSource::SeedFromDir => { + self.screen = Screen::DepthPicker; + } + SpecOptionsSource::None => { + self.screen = Screen::SpecList; + } + } + } + + async fn create_spec_with_seed(&mut self, mode: &str, locality: &str) { let name = std::mem::take(&mut self.input); self.needs_redraw = true; @@ -433,7 +502,7 @@ impl App { } }; - match commands::create_spec(&self.state, name).await { + match commands::create_spec(&self.state, name, Some(mode), Some(locality)).await { Ok(spec) => { if let Some(content) = seed_content { match commands::seed_spec(&self.state, &spec.id, content, self.model.clone()) @@ -465,7 +534,7 @@ impl App { } } - async fn seed_from_directory_with_depth(&mut self) { + async fn seed_from_directory_with_depth(&mut self, mode: &str, locality: &str) { let (dir_path, name) = match self.depth_picker_dir.take() { Some(d) => d, None => return, @@ -476,8 +545,8 @@ impl App { &self.state, name.clone(), None, - None, - None, + Some(mode), + Some(locality), dir_path, depth, self.model.clone(), diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 4e0ded2..d9c224d 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -19,8 +19,10 @@ pub fn load_spec_nodes( pub async fn create_spec( state: &Arc, name: String, + mode: Option<&str>, + locality: Option<&str>, ) -> Result { - spec_forest::api::create_spec(state, name, None, None, None, None) + spec_forest::api::create_spec(state, name, None, mode, locality, None) .await .map_err(|e| TuiError::Api(e.to_string())) } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 24d342b..4c1b269 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -17,6 +17,7 @@ pub fn map_key( Screen::InputName | Screen::SyncPasswordInput => map_input_key(key), Screen::DirBrowser => map_dir_browser_key(key), Screen::DepthPicker => map_depth_picker_key(key), + Screen::SpecOptionsPicker => map_spec_options_key(key), Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -72,6 +73,17 @@ fn map_depth_picker_key(key: KeyCode) -> Action { } } +fn map_spec_options_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('q') => Action::Quit, + KeyCode::Up => Action::SpecOptionsUp, + KeyCode::Down => Action::SpecOptionsDown, + KeyCode::Enter => Action::SpecOptionsConfirm, + KeyCode::Esc => Action::SpecOptionsCancel, + _ => Action::Noop, + } +} + fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 0b7910d..6af84af 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -4,6 +4,7 @@ mod dir_browser; mod input_screen; mod model_config; mod spec_list; +mod spec_options_picker; mod spec_view; mod sync_config; @@ -17,6 +18,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::InputName => input_screen::render_input(app, frame, "Spec name:"), Screen::DirBrowser => dir_browser::render(app, frame), Screen::DepthPicker => depth_picker::render(app, frame), + Screen::SpecOptionsPicker => spec_options_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), diff --git a/crates/spec-forest-tui/src/ui/spec_options_picker.rs b/crates/spec-forest-tui/src/ui/spec_options_picker.rs new file mode 100644 index 0000000..83e2036 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_options_picker.rs @@ -0,0 +1,52 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, SPEC_OPTIONS}; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let items: Vec = SPEC_OPTIONS + .iter() + .enumerate() + .map(|(i, (label, _, _))| { + let style = if i == app.spec_options_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(format!(" {label}"), style))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Select Mode & Locality "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.spec_options_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer = Paragraph::new("[Up/Down] Select [Enter] Confirm [Esc] Back") + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} From c221bd3a571d25946d046fa6f71ed3b0012b02dc Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 16:04:17 +1100 Subject: [PATCH 008/100] feat: add config menu with username setting to TUI --- crates/spec-forest-tui/src/action.rs | 4 +++ crates/spec-forest-tui/src/app.rs | 33 +++++++++++++++++ crates/spec-forest-tui/src/input.rs | 13 +++++++ crates/spec-forest-tui/src/ui.rs | 3 ++ crates/spec-forest-tui/src/ui/config.rs | 42 ++++++++++++++++++++++ crates/spec-forest-tui/src/ui/spec_list.rs | 2 +- 6 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 crates/spec-forest-tui/src/ui/config.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 88d199b..a9e53ed 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -65,6 +65,10 @@ pub enum Action { // Model SelectModel, + // Config + OpenConfig, + SetUsername, + // Candidates CandidateNext, CandidatePrev, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 30b6b24..c52be95 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -69,6 +69,7 @@ pub struct App { pub candidate_node_id: Option, pub spec_options_selected: usize, pub spec_options_source: SpecOptionsSource, + pub config_selected: usize, } #[derive(Clone)] @@ -82,6 +83,8 @@ pub enum Screen { SyncConfig, SyncPasswordInput, ModelConfig, + Config, + UsernameInput, } #[derive(Clone)] @@ -128,6 +131,7 @@ impl App { candidate_node_id: None, spec_options_selected: 0, spec_options_source: SpecOptionsSource::None, + config_selected: 0, } } @@ -308,6 +312,16 @@ impl App { Action::SpecOptionsConfirm => self.confirm_spec_options().await, Action::SpecOptionsCancel => self.cancel_spec_options(), + // Config + Action::OpenConfig => { + self.config_selected = 0; + self.screen = Screen::Config; + } + Action::SetUsername => { + self.input = self.state.user_name(); + self.screen = Screen::UsernameInput; + } + // Candidates Action::CandidateNext => { if !self.candidates.is_empty() { @@ -332,6 +346,9 @@ impl App { Screen::ModelConfig => { self.model_selected = self.model_selected.saturating_sub(1); } + Screen::Config => { + self.config_selected = self.config_selected.saturating_sub(1); + } Screen::SpecView { .. } => { if self.node_selected > 0 { self.node_selected -= 1; @@ -353,6 +370,9 @@ impl App { self.model_selected += 1; } } + Screen::Config => { + // Currently only one config item (username), but ready for more + } Screen::SpecView { .. } => { if !self.nodes.is_empty() && self.node_selected < self.nodes.len() - 1 { self.node_selected += 1; @@ -396,6 +416,13 @@ impl App { Screen::ModelConfig => { self.screen = Screen::SpecList; } + Screen::UsernameInput => { + self.input.clear(); + self.screen = Screen::Config; + } + Screen::Config => { + self.screen = Screen::SpecList; + } _ => {} } } @@ -413,6 +440,12 @@ impl App { self.screen = Screen::SpecOptionsPicker; } Screen::SyncPasswordInput => self.do_sync_connect().await, + Screen::UsernameInput => { + let name = std::mem::take(&mut self.input); + self.state.set_user_name(name.clone()); + self.message = Some(format!("Username set to: {name}")); + self.screen = Screen::Config; + } _ => {} } } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 4c1b269..7657b5f 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -21,6 +21,18 @@ pub fn map_key( Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), + Screen::Config => map_config_key(key), + Screen::UsernameInput => map_input_key(key), + } +} + +fn map_config_key(key: KeyCode) -> Action { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::NavigateUp, + KeyCode::Down => Action::NavigateDown, + KeyCode::Enter => Action::SetUsername, + _ => Action::Noop, } } @@ -31,6 +43,7 @@ fn map_spec_list_key(key: KeyCode) -> Action { KeyCode::Char('s') => Action::OpenSeedFromDir, KeyCode::Char('y') => Action::OpenSyncConfig, KeyCode::Char('m') => Action::OpenModelConfig, + KeyCode::Char('g') => Action::OpenConfig, KeyCode::Up => Action::NavigateUp, KeyCode::Down => Action::NavigateDown, KeyCode::Enter => Action::Select, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 6af84af..4bf9dfe 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -1,4 +1,5 @@ mod common; +mod config; mod depth_picker; mod dir_browser; mod input_screen; @@ -23,5 +24,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), Screen::ModelConfig => model_config::render(app, frame), + Screen::Config => config::render(app, frame), + Screen::UsernameInput => input_screen::render_input(app, frame, "Username:"), } } diff --git a/crates/spec-forest-tui/src/ui/config.rs b/crates/spec-forest-tui/src/ui/config.rs new file mode 100644 index 0000000..a16c90a --- /dev/null +++ b/crates/spec-forest-tui/src/ui/config.rs @@ -0,0 +1,42 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let username = app.state.user_name(); + let items: Vec = vec![ListItem::new(Line::from(vec![ + Span::raw(" Username: "), + Span::styled(username, Style::default().fg(Color::Cyan)), + ]))]; + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Config ")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.config_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_text = app + .message + .as_deref() + .unwrap_or("[Up/Down] Select [Enter] Edit [Esc] Back"); + let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index d3cb2d9..160a959 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -38,7 +38,7 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = app .message .as_deref() - .unwrap_or("[c] Create [s] Seed from dir [m] Model [y] Sync [Enter] Open [q] Quit"); + .unwrap_or("[c] Create [s] Seed from dir [m] Model [y] Sync [g] Config [Enter] Open [q] Quit"); let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } From 68b2227404bd125e00aec12e6db63f27c8a3694e Mon Sep 17 00:00:00 2001 From: freesig Date: Mon, 30 Mar 2026 16:16:56 +1100 Subject: [PATCH 009/100] feat: add feature and question creation to TUI spec view Wire [f] and [n] keybindings in SpecView to create new root features and child questions via the external editor, using existing backend create_feature and add_child APIs. --- crates/spec-forest-tui/src/action.rs | 2 + crates/spec-forest-tui/src/app.rs | 86 ++++++++++++++++++++++ crates/spec-forest-tui/src/commands.rs | 22 ++++++ crates/spec-forest-tui/src/editor.rs | 18 +++++ crates/spec-forest-tui/src/input.rs | 4 + crates/spec-forest-tui/src/ui/spec_view.rs | 6 +- 6 files changed, 135 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index a9e53ed..9dc47f5 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -29,6 +29,8 @@ pub enum Action { TogglePause, CancelExplore, EditNextQuestion, + AddFeature, + AddQuestion, // Tree navigation ExpandOrCollapseTreeNode, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index c52be95..d27e729 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -222,6 +222,8 @@ impl App { Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), Action::EditNextQuestion => self.edit_next_question().await, + Action::AddFeature => self.add_feature().await, + Action::AddQuestion => self.add_question().await, // Tree Action::TreeUp => self.tree_state.select_up(), @@ -860,6 +862,90 @@ impl App { } } + // ── Add feature / question ─────────────────────────────── + + async fn add_feature(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + self.needs_redraw = true; + match editor::edit_new_feature() { + Ok(Some(content)) => { + match commands::create_feature( + &self.state, + &spec_id, + content, + self.model.clone(), + ) + .await + { + Ok(node) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Feature added: {label}")); + } + Err(e) => { + tracing::error!("Create feature failed: {e}"); + self.message = Some(e.to_string()); + } + } + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Ok(None) => self.message = Some("Cancelled".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + + async fn add_question(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + let parent_id = match self.get_selected_node() { + Some(node) => node.id.clone(), + None => { + self.message = Some("No node selected".to_string()); + return; + } + }; + + self.needs_redraw = true; + match editor::edit_new_question() { + Ok(Some(question)) => { + match commands::add_child( + &self.state, + &parent_id, + question, + self.model.clone(), + ) + .await + { + Ok(node) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Question added: {label}")); + } + Err(e) => { + tracing::error!("Add child failed: {e}"); + self.message = Some(e.to_string()); + } + } + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Ok(None) => self.message = Some("Cancelled".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + // ── Candidate operations ─────────────────────────────────── pub fn refresh_candidates_if_needed(&mut self) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index d9c224d..cf9665d 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -119,6 +119,28 @@ pub fn poll_ingest_status( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn create_feature( + state: &Arc, + spec_id: &str, + content: String, + model: String, +) -> Result { + spec_forest::api::create_feature(state, spec_id, content, model) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn add_child( + state: &Arc, + parent_id: &str, + question: String, + model: String, +) -> Result { + spec_forest::api::add_child(state, parent_id, question, model) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn connect_sync( state: &Arc, password: String, diff --git a/crates/spec-forest-tui/src/editor.rs b/crates/spec-forest-tui/src/editor.rs index 56361e1..28928d8 100644 --- a/crates/spec-forest-tui/src/editor.rs +++ b/crates/spec-forest-tui/src/editor.rs @@ -74,6 +74,24 @@ pub fn edit_question(question: &str, current_answer: Option<&str>) -> io::Result } } +/// Opens the editor for entering a new feature description. +pub fn edit_new_feature() -> io::Result> { + let initial = "# Enter a description for the new feature.\n# Lines starting with # are ignored.\n# Save and quit to submit. Leave empty to cancel.\n\n"; + match run_editor(initial)? { + Some(s) if s.is_empty() => Ok(None), + other => Ok(other), + } +} + +/// Opens the editor for entering a new child question. +pub fn edit_new_question() -> io::Result> { + let initial = "# Enter a question to add as a child of the selected node.\n# Lines starting with # are ignored.\n# Save and quit to submit. Leave empty to cancel.\n\n"; + match run_editor(initial)? { + Some(s) if s.is_empty() => Ok(None), + other => Ok(other), + } +} + /// Opens the user's default editor with a blank temp file for entering seed content. /// Returns `Some(content)` if the user wrote something, `None` if left empty or aborted. pub fn edit_seed() -> io::Result> { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 7657b5f..6c23dd5 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -123,6 +123,8 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char(']') => Action::CandidateNext, KeyCode::Char('[') => Action::CandidatePrev, KeyCode::Char('y') => Action::AcceptCandidate, + KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('n') => Action::AddQuestion, _ => Action::Noop, } } @@ -140,6 +142,8 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char(']') => Action::CandidateNext, KeyCode::Char('[') => Action::CandidatePrev, KeyCode::Char('y') => Action::AcceptCandidate, + KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('n') => Action::AddQuestion, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 69d8581..e751a14 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -55,11 +55,11 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept candidate [a] AI answer [e] Edit [t] Tree [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI answer [x] Explore [X] Full explore [e] Edit [t] Tree [Tab] Focus [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Tab] Focus [Bksp] Back [q] Quit".to_string() } else { - "[a] AI answer [x] Explore [X] Full explore [e] Edit [t] Tree [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Bksp] Back [q] Quit" .to_string() }; let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); From 27238c0d806383136c9f312526371b3091ec3846 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 08:24:26 +1100 Subject: [PATCH 010/100] fix: persist username to local DB when set via TUI config menu --- crates/spec-forest-tui/src/app.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index d27e729..d78fcf1 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -8,6 +8,7 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use spec_forest::explore::{ExploreStatus, ExploreStatusResponse}; use spec_forest::ingest::IngestState; +use spec_forest::api; use spec_forest::state::{AppState, GenerationStatus}; use spec_forest_db::candidate::CandidateAnswer; @@ -444,8 +445,14 @@ impl App { Screen::SyncPasswordInput => self.do_sync_connect().await, Screen::UsernameInput => { let name = std::mem::take(&mut self.input); - self.state.set_user_name(name.clone()); - self.message = Some(format!("Username set to: {name}")); + match api::set_user_name(&self.state, &name) { + Ok(()) => { + self.message = Some(format!("Username set to: {name}")); + } + Err(e) => { + self.message = Some(format!("Failed to save username: {e}")); + } + } self.screen = Screen::Config; } _ => {} From 606df3bcb552262081265bc611f3fc1669d3f9ef Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 08:25:55 +1100 Subject: [PATCH 011/100] feat: add ad-hoc codesigning to macOS build script --- macos/build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/build.sh b/macos/build.sh index 9fe6908..533d00c 100755 --- a/macos/build.sh +++ b/macos/build.sh @@ -33,6 +33,8 @@ for f in "$MACOS_DIR/Resources/"*.png "$MACOS_DIR/Resources/"*.icns; do [ -f "$f" ] && cp "$f" "$APP_DIR/Contents/Resources/" || true done +codesign --force --deep --sign - "$APP_DIR" + echo "==> Creating DMG..." DMG_PATH="$BUILD_DIR/Spec Forest.dmg" rm -f "$DMG_PATH" From da57f43b514587cc1b1f2404b8b9b91768f06c6b Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 08:34:21 +1100 Subject: [PATCH 012/100] feat: show sync disconnection indicator in TUI footer Poll sync connection status on every tick and display a red "Sync: not connected" label in the footer when a sync URL is configured but the connection is not established (not logged in, login failed, or connection dropped). --- crates/spec-forest-tui/src/app.rs | 14 ++++++++++++++ crates/spec-forest-tui/src/ui/spec_list.rs | 12 ++++++++++-- crates/spec-forest-tui/src/ui/spec_view.rs | 10 +++++++++- crates/spec-forest/src/state.rs | 7 +++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index d78fcf1..df6fe41 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1003,7 +1003,21 @@ impl App { // ── Background polling ────────────────────────────────────── + pub fn sync_disconnect_indicator(&self) -> Option<&'static str> { + if self.state.sync_url().is_some() && !self.sync_connected { + Some("Sync: not connected") + } else { + None + } + } + fn poll_background_status(&mut self) { + if self.state.sync_url().is_some() { + if let Some(connected) = self.state.try_check_sync_connected() { + self.sync_connected = connected; + } + } + let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 160a959..fba9118 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -2,7 +2,7 @@ use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; @@ -39,6 +39,14 @@ pub fn render(app: &App, frame: &mut Frame) { .message .as_deref() .unwrap_or("[c] Create [s] Seed from dir [m] Model [y] Sync [g] Config [Enter] Open [q] Quit"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(label) = app.sync_disconnect_indicator() { + Line::from(vec![ + Span::styled(format!(" {label} "), Style::default().fg(Color::Red)), + Span::raw(footer_text), + ]) + } else { + Line::from(footer_text) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index e751a14..a88f900 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -62,7 +62,15 @@ pub fn render(app: &App, frame: &mut Frame) { "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Bksp] Back [q] Quit" .to_string() }; - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(label) = app.sync_disconnect_indicator() { + Line::from(vec![ + Span::styled(format!(" {label} "), Style::default().fg(Color::Red)), + Span::raw(footer_text), + ]) + } else { + Line::from(footer_text) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, footer_chunk); } diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index ecaa213..a3590a9 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -119,6 +119,13 @@ impl AppState { *self.sync_password.lock() = Some(password); } + pub fn try_check_sync_connected(&self) -> Option { + match self.sync_handle.try_lock() { + Ok(guard) => Some(guard.as_ref().map_or(false, |h| h.is_connected())), + Err(_) => None, + } + } + pub fn needs_sync_password(&self) -> bool { self.sync_url.lock().is_some() && self.sync_password.lock().is_none() } From 7d38d85206c32e8aea0a809ac3074f98db1d7578 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:16:18 +1100 Subject: [PATCH 013/100] feat: add spec settings screen with active directory management Add a settings screen accessible from the spec view (press [g]) that shows the current active directory and allows changing it via the directory browser or clearing it. The directory automatically flows to AI prompts for development-mode specs once set. --- crates/spec-forest-tui/src/action.rs | 5 + crates/spec-forest-tui/src/app.rs | 96 +++++++++++++++++-- crates/spec-forest-tui/src/commands.rs | 9 ++ crates/spec-forest-tui/src/input.rs | 13 +++ crates/spec-forest-tui/src/ui.rs | 2 + .../spec-forest-tui/src/ui/spec_settings.rs | 57 +++++++++++ crates/spec-forest-tui/src/ui/spec_view.rs | 6 +- 7 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 crates/spec-forest-tui/src/ui/spec_settings.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 9dc47f5..863800c 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -71,6 +71,11 @@ pub enum Action { OpenConfig, SetUsername, + // Spec settings + OpenSpecSettings, + SetSpecDirectory, + ClearSpecDirectory, + // Candidates CandidateNext, CandidatePrev, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index df6fe41..7946bea 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -71,6 +71,8 @@ pub struct App { pub spec_options_selected: usize, pub spec_options_source: SpecOptionsSource, pub config_selected: usize, + pub dir_browser_source: DirBrowserSource, + pub spec_settings_selected: usize, } #[derive(Clone)] @@ -81,6 +83,7 @@ pub enum Screen { DepthPicker, SpecOptionsPicker, SpecView { spec_id: String }, + SpecSettings { spec_id: String }, SyncConfig, SyncPasswordInput, ModelConfig, @@ -95,6 +98,12 @@ pub enum SpecOptionsSource { SeedFromDir, } +#[derive(Clone)] +pub enum DirBrowserSource { + SeedFromDir, + SpecSettings { spec_id: String }, +} + impl App { pub fn new(state: Arc) -> Self { let specs = commands::refresh_spec_list(&state).unwrap_or_else(|e| { @@ -133,6 +142,8 @@ impl App { spec_options_selected: 0, spec_options_source: SpecOptionsSource::None, config_selected: 0, + dir_browser_source: DirBrowserSource::SeedFromDir, + spec_settings_selected: 0, } } @@ -192,6 +203,7 @@ impl App { self.screen = Screen::InputName; } Action::OpenSeedFromDir => { + self.dir_browser_source = DirBrowserSource::SeedFromDir; self.dir_browser = Some(DirBrowserState::new()); self.screen = Screen::DirBrowser; } @@ -253,16 +265,48 @@ impl App { if let Some(ref browser) = self.dir_browser { if let Some(p) = browser.selected_path() { let dir_path = p.to_string_lossy().to_string(); - let name = browser.selected_dir_name(); - self.depth_picker_dir = Some((dir_path, name)); - self.depth_selected = 0; - self.screen = Screen::DepthPicker; + match &self.dir_browser_source { + DirBrowserSource::SeedFromDir => { + let name = browser.selected_dir_name(); + self.depth_picker_dir = Some((dir_path, name)); + self.depth_selected = 0; + self.screen = Screen::DepthPicker; + } + DirBrowserSource::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.dir_browser = None; + match commands::update_directory( + &self.state, + &spec_id, + Some(dir_path.clone()), + ) { + Ok(_) => { + self.message = + Some(format!("Directory set to: {dir_path}")); + self.refresh_specs(); + } + Err(e) => { + self.message = + Some(format!("Failed to set directory: {e}")); + } + } + self.screen = Screen::SpecSettings { spec_id }; + } + } } } } Action::DirBrowserCancel => { self.dir_browser = None; - self.screen = Screen::SpecList; + match &self.dir_browser_source { + DirBrowserSource::SeedFromDir => { + self.screen = Screen::SpecList; + } + DirBrowserSource::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecSettings { spec_id }; + } + } } // Depth picker @@ -325,6 +369,37 @@ impl App { self.screen = Screen::UsernameInput; } + // Spec settings + Action::OpenSpecSettings => { + if let Screen::SpecView { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.spec_settings_selected = 0; + self.screen = Screen::SpecSettings { spec_id }; + } + } + Action::SetSpecDirectory => { + if let Screen::SpecSettings { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.dir_browser_source = DirBrowserSource::SpecSettings { spec_id }; + self.dir_browser = Some(DirBrowserState::new()); + self.screen = Screen::DirBrowser; + } + } + Action::ClearSpecDirectory => { + if let Screen::SpecSettings { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + match commands::update_directory(&self.state, &spec_id, None) { + Ok(_) => { + self.message = Some("Directory cleared".to_string()); + self.refresh_specs(); + } + Err(e) => { + self.message = Some(format!("Failed to clear directory: {e}")); + } + } + } + } + // Candidates Action::CandidateNext => { if !self.candidates.is_empty() { @@ -352,6 +427,9 @@ impl App { Screen::Config => { self.config_selected = self.config_selected.saturating_sub(1); } + Screen::SpecSettings { .. } => { + self.spec_settings_selected = self.spec_settings_selected.saturating_sub(1); + } Screen::SpecView { .. } => { if self.node_selected > 0 { self.node_selected -= 1; @@ -373,8 +451,8 @@ impl App { self.model_selected += 1; } } - Screen::Config => { - // Currently only one config item (username), but ready for more + Screen::Config | Screen::SpecSettings { .. } => { + // Currently only one item in each, but ready for more } Screen::SpecView { .. } => { if !self.nodes.is_empty() && self.node_selected < self.nodes.len() - 1 { @@ -426,6 +504,10 @@ impl App { Screen::Config => { self.screen = Screen::SpecList; } + Screen::SpecSettings { spec_id } => { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + } _ => {} } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index cf9665d..2cf35e8 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -141,6 +141,15 @@ pub async fn add_child( .map_err(|e| TuiError::Api(e.to_string())) } +pub fn update_directory( + state: &AppState, + spec_id: &str, + directory: Option, +) -> Result { + spec_forest::api::update_directory(state, spec_id, directory) + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn connect_sync( state: &Arc, password: String, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 6c23dd5..5f4d78e 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -19,6 +19,7 @@ pub fn map_key( Screen::DepthPicker => map_depth_picker_key(key), Screen::SpecOptionsPicker => map_spec_options_key(key), Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), + Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), Screen::Config => map_config_key(key), @@ -102,6 +103,7 @@ fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Ac KeyCode::Char('q') => Action::Quit, KeyCode::Backspace => Action::GoBack, KeyCode::Char('t') => Action::ToggleTree, + KeyCode::Char('g') => Action::OpenSpecSettings, KeyCode::Tab if tree_visible => Action::SwitchFocus, _ if tree_focused && tree_visible => map_tree_key(key), _ => map_flat_list_key(key), @@ -157,6 +159,17 @@ fn map_sync_config_key(key: KeyCode, has_url: bool) -> Action { } } +fn map_spec_settings_key(key: KeyCode) -> Action { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::NavigateUp, + KeyCode::Down => Action::NavigateDown, + KeyCode::Enter => Action::SetSpecDirectory, + KeyCode::Char('d') => Action::ClearSpecDirectory, + _ => Action::Noop, + } +} + fn map_model_config_key(key: KeyCode) -> Action { match key { KeyCode::Esc => Action::Cancel, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 4bf9dfe..507ca62 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -6,6 +6,7 @@ mod input_screen; mod model_config; mod spec_list; mod spec_options_picker; +mod spec_settings; mod spec_view; mod sync_config; @@ -21,6 +22,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::DepthPicker => depth_picker::render(app, frame), Screen::SpecOptionsPicker => spec_options_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), + Screen::SpecSettings { .. } => spec_settings::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), Screen::ModelConfig => model_config::render(app, frame), diff --git a/crates/spec-forest-tui/src/ui/spec_settings.rs b/crates/spec-forest-tui/src/ui/spec_settings.rs new file mode 100644 index 0000000..7711839 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_settings.rs @@ -0,0 +1,57 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(frame.area()); + + let spec_id = match &app.screen { + Screen::SpecSettings { spec_id } => spec_id, + _ => return, + }; + + let directory_display = app + .specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_deref()) + .unwrap_or("Not set"); + + let items: Vec = vec![ListItem::new(Line::from(vec![ + Span::raw(" Directory: "), + Span::styled(directory_display, Style::default().fg(Color::Cyan)), + ]))]; + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Spec Settings "), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.spec_settings_selected)); + frame.render_stateful_widget(list, chunks[0], &mut state); + + let footer_text = app + .message + .as_deref() + .unwrap_or("[Enter] Change Directory [d] Clear Directory [Esc] Back"); + let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index a88f900..59ad617 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -55,11 +55,11 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Tab] Focus [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { From b997cf3543cac599a73492a21a984f92d0792e5e Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:16:31 +1100 Subject: [PATCH 014/100] fix: use hyphenated names for spec-forest DB path and app name --- crates/spec-forest-app/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest-app/src/main.rs b/crates/spec-forest-app/src/main.rs index 3e18ed3..c1d2f56 100644 --- a/crates/spec-forest-app/src/main.rs +++ b/crates/spec-forest-app/src/main.rs @@ -28,14 +28,14 @@ async fn static_handler(uri: Uri) -> Response { fn default_db_path() -> String { let dir = dirs::home_dir() .expect("could not determine home directory") - .join(".spec_forest"); + .join(".spec-forest"); std::fs::create_dir_all(&dir).expect("could not create ~/.spec_forest directory"); - dir.join("spec_forest.db").to_string_lossy().into_owned() + dir.join("spec-forest.db").to_string_lossy().into_owned() } #[derive(Parser)] #[command( - name = "spec_forest-app", + name = "spec-forest-app", about = "Spec Forest server with embedded web UI" )] struct Cli { From 38453ac00b1416516be5979de980900f1813ba34 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:30:44 +1100 Subject: [PATCH 015/100] fix: replace eprintln with tracing to prevent TUI display corruption Sync module and other shared library code used eprintln!() which writes to stderr and corrupts the TUI alternate screen, leaving lingering text after operations like sync login. Switched all eprintln calls to tracing macros so messages route to the log file instead. Also fixed a test that used an 80-column terminal too narrow for the full footer text. --- crates/spec-forest-tui/tests/tui_tests.rs | 2 +- crates/spec-forest/src/api/server.rs | 2 +- crates/spec-forest/src/http.rs | 4 ++-- crates/spec-forest/src/ingest.rs | 14 +++++++------- crates/spec-forest/src/lib.rs | 6 +++--- crates/spec-forest/src/state.rs | 10 +++++----- crates/spec-forest/src/sync/bridge.rs | 10 +++++----- crates/spec-forest/src/sync/connection.rs | 14 +++++++------- crates/spec-forest/src/sync/dispatch.rs | 12 ++++++------ crates/spec-forest/src/sync/reconnect.rs | 12 ++++++------ crates/spec-forest/src/sync/request.rs | 8 ++++---- 11 files changed, 47 insertions(+), 47 deletions(-) diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 9f6a28a..29faba4 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -88,7 +88,7 @@ fn render_to_string(app: &App) -> String { #[tokio::test] async fn test_spec_list_shows_footer() { let app = make_app(); - let output = render_to_string(&app); + let output = render_to_string_sized(&app, 120, 24); assert!(output.contains("Quit"), "footer should show Quit"); assert!(output.contains("Create"), "footer should show Create"); assert!(output.contains("Seed"), "footer should show Seed"); diff --git a/crates/spec-forest/src/api/server.rs b/crates/spec-forest/src/api/server.rs index 307952b..0903c42 100644 --- a/crates/spec-forest/src/api/server.rs +++ b/crates/spec-forest/src/api/server.rs @@ -86,7 +86,7 @@ pub fn get_candidates( node_id: &str, ) -> Result, ApiError> { let candidates = state.db().get_candidates(node_id)?; - eprintln!( + tracing::debug!( "[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len() diff --git a/crates/spec-forest/src/http.rs b/crates/spec-forest/src/http.rs index 59ac639..40d9bd1 100644 --- a/crates/spec-forest/src/http.rs +++ b/crates/spec-forest/src/http.rs @@ -68,7 +68,7 @@ impl IntoResponse for AppError { (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": msg}))).into_response() } AppError::Internal(msg) => { - eprintln!("HTTP 500: {}", msg); + tracing::error!("HTTP 500: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({"error": msg}))).into_response() } } @@ -862,7 +862,7 @@ async fn get_candidates_handler( Path(node_id): Path, ) -> Result>, AppError> { let candidates = crate::api::get_candidates(&state, &node_id)?; - eprintln!("[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len()); + tracing::debug!("[candidates] GET /nodes/{}/candidates → {} results", node_id, candidates.len()); Ok(Json(candidates)) } diff --git a/crates/spec-forest/src/ingest.rs b/crates/spec-forest/src/ingest.rs index 67696fb..f45e17f 100644 --- a/crates/spec-forest/src/ingest.rs +++ b/crates/spec-forest/src/ingest.rs @@ -395,7 +395,7 @@ async fn run_answer_coordinator(state: SharedState, session_id: String) { } } Err(e) => { - eprintln!("[ingest] Failed to answer node {}: {}", node_id, e); + tracing::error!("[ingest] Failed to answer node {}: {}", node_id, e); let sessions = state.ingest_sessions_lock(); if let Some(session) = sessions.get(&session_id) { session.failed.fetch_add(1, Ordering::Relaxed); @@ -572,7 +572,7 @@ async fn run_shadow_coordinator(state: SharedState, session_id: String, regenera } } Err(e) => { - eprintln!("[ingest] Failed to shadow node {}: {}", node_id, e); + tracing::error!("[ingest] Failed to shadow node {}: {}", node_id, e); let sessions = state.ingest_sessions_lock(); if let Some(session) = sessions.get(&session_id) { session.failed.fetch_add(1, Ordering::Relaxed); @@ -748,7 +748,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { let response = match crate::generate::run_claude_in_dir_cached(&state, &spec_id, &prompt, &model, &dir_path, "ingest_feature_extraction").await { Ok(r) => r, Err(e) => { - eprintln!("[ingest] Feature extraction failed: {}", e); + tracing::error!("[ingest] Feature extraction failed: {}", e); state.update_ingest_session(&session_id, |s| { s.status = IngestState::Done; }); cleanup_session(state, &session_id).await; return; @@ -758,7 +758,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { let features = match parse_feature_response(&response) { Ok(f) => f, Err(e) => { - eprintln!("[ingest] Failed to parse features: {}", e); + tracing::error!("[ingest] Failed to parse features: {}", e); state.update_ingest_session(&session_id, |s| { s.status = IngestState::Done; }); cleanup_session(state, &session_id).await; return; @@ -793,11 +793,11 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { if applied { feature_node_ids.push(node_id); } else { - eprintln!("[ingest] Feature root {} not applied after extended wait", node_id); + tracing::error!("[ingest] Feature root {} not applied after extended wait", node_id); } } Err(e) => { - eprintln!("[ingest] Failed to create feature root: {}", e); + tracing::error!("[ingest] Failed to create feature root: {}", e); } } } @@ -894,7 +894,7 @@ async fn run_recursive_coordinator(state: SharedState, session_id: String) { } } Err(e) => { - eprintln!("[ingest] recursive answer failed: {}", e); + tracing::error!("[ingest] recursive answer failed: {}", e); let sessions = state.ingest_sessions_lock(); if let Some(s) = sessions.get(&session_id_c) { s.failed.fetch_add(1, Ordering::Relaxed); diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 3f4a2da..aadf93d 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -43,7 +43,7 @@ pub async fn build_app_state( let log = PromptLog::new(log_path) .map_err(|e| format!("Failed to open prompt log file '{}': {}", log_path, e))?; state.set_prompt_log(log); - eprintln!("Logging AI prompts to {log_path}"); + tracing::info!("Logging AI prompts to {log_path}"); } // Load persisted username from the database @@ -53,8 +53,8 @@ pub async fn build_app_state( if let Some(ref sync_url) = config.sync_url { state.set_sync_url(sync_url.clone()); - eprintln!("Sync server configured at {sync_url}"); - eprintln!("Enter sync password in the web UI to connect"); + tracing::info!("Sync server configured at {sync_url}"); + tracing::info!("Enter sync password in the web UI to connect"); } let (op_tx, op_rx) = tokio::sync::mpsc::channel::(256); diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index a3590a9..45f3aa5 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -151,12 +151,12 @@ impl AppState { let token = match action { AuthAction::Login => { let t = handle.login(&username, &password).await?; - eprintln!("sync: authenticated as {username}"); + tracing::info!("sync: authenticated as {username}"); t } AuthAction::Register => { let t = handle.register(&username, &password).await?; - eprintln!("sync: registered and authenticated as {username}"); + tracing::info!("sync: registered and authenticated as {username}"); t } }; @@ -180,7 +180,7 @@ impl AppState { .map(|s| (s.name.clone(), s.last_seq)) .collect(), Err(e) => { - eprintln!("sync: failed to list specs for subscribe: {e}"); + tracing::error!("sync: failed to list specs for subscribe: {e}"); return; } } @@ -188,9 +188,9 @@ impl AppState { if let Some(handle) = self.get_sync_handle().await { for (spec_name, last_seq) in &remote_specs { if let Err(e) = handle.subscribe(spec_name, *last_seq).await { - eprintln!("sync: failed to subscribe to {spec_name}: {e}"); + tracing::error!("sync: failed to subscribe to {spec_name}: {e}"); } else { - eprintln!("sync: subscribed to {spec_name} from seq {last_seq}"); + tracing::info!("sync: subscribed to {spec_name} from seq {last_seq}"); } } } diff --git a/crates/spec-forest/src/sync/bridge.rs b/crates/spec-forest/src/sync/bridge.rs index c08431a..d22667a 100644 --- a/crates/spec-forest/src/sync/bridge.rs +++ b/crates/spec-forest/src/sync/bridge.rs @@ -34,7 +34,7 @@ async fn bridge_inner( let spec_id = match resolve_spec_id(state, spec_name, &op) { Some(id) => id, None => { - eprintln!("sync bridge: cannot resolve spec_name={spec_name} for op seq={seq}"); + tracing::warn!("sync bridge: cannot resolve spec_name={spec_name} for op seq={seq}"); continue; } }; @@ -52,21 +52,21 @@ async fn bridge_inner( }; if op_tx.send(request).await.is_err() { - eprintln!("sync bridge: op channel closed, stopping"); + tracing::warn!("sync bridge: op channel closed, stopping"); break; } match resp_rx.await { Ok(Ok(_)) => {} Ok(Err(e)) => { - eprintln!("sync bridge: failed to apply op seq={seq} for {spec_name}: {e}"); + tracing::warn!("sync bridge: failed to apply op seq={seq} for {spec_name}: {e}"); } Err(_) => { - eprintln!("sync bridge: response channel dropped for seq={seq}"); + tracing::warn!("sync bridge: response channel dropped for seq={seq}"); } } } - eprintln!("sync bridge: ended (sync client disconnected)"); + tracing::warn!("sync bridge: ended (sync client disconnected)"); } /// If the op is `CreateSpec`, override its locality to `"remote"`. diff --git a/crates/spec-forest/src/sync/connection.rs b/crates/spec-forest/src/sync/connection.rs index c540fec..fac1bee 100644 --- a/crates/spec-forest/src/sync/connection.rs +++ b/crates/spec-forest/src/sync/connection.rs @@ -46,7 +46,7 @@ impl Connection { url: url.to_string(), reason: e.to_string(), }; - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err })?; @@ -77,7 +77,7 @@ impl Connection { } let json = serde_json::to_string(msg).map_err(|e| { let err = SyncError::Serialize(e.to_string()); - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err })?; self.sink @@ -87,7 +87,7 @@ impl Connection { .await .map_err(|e| { let err = SyncError::SendFailed(e.to_string()); - eprintln!("sync: {err}"); + tracing::error!("sync: {err}"); err }) } @@ -112,22 +112,22 @@ async fn reader_loop( Ok(server_msg) => { if let Some(sequenced) = pending.dispatch(server_msg).await { if op_tx.send(sequenced).await.is_err() { - eprintln!("sync: op channel closed, reader stopping"); + tracing::warn!("sync: op channel closed, reader stopping"); break; } } } Err(e) => { - eprintln!("sync: failed to parse server message: {e}"); + tracing::error!("sync: failed to parse server message: {e}"); } } } Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => { - eprintln!("sync: server closed connection"); + tracing::info!("sync: server closed connection"); break; } Err(e) => { - eprintln!("sync: websocket read error: {e}"); + tracing::error!("sync: websocket read error: {e}"); break; } _ => {} diff --git a/crates/spec-forest/src/sync/dispatch.rs b/crates/spec-forest/src/sync/dispatch.rs index 48a5801..08bfc3d 100644 --- a/crates/spec-forest/src/sync/dispatch.rs +++ b/crates/spec-forest/src/sync/dispatch.rs @@ -123,7 +123,7 @@ impl PendingRequests { None } ServerMessage::TokenExpiring { remaining_secs } => { - eprintln!("sync: token expiring in {remaining_secs}s"); + tracing::warn!("sync: token expiring in {remaining_secs}s"); None } } @@ -139,7 +139,7 @@ impl PendingRequests { let _ = tx.send(Ok(())); } PendingSlot::MemberList(_) => { - eprintln!( + tracing::warn!( "sync: unexpected Ack for pending MemberList (client_ref={client_ref})" ); } @@ -168,7 +168,7 @@ impl PendingRequests { if !matched { let _ = self.unmatched_error_tx.send(Some(message.to_string())); } - eprintln!("sync: server error: {message}"); + tracing::warn!("sync: server error: {message}"); } async fn dispatch_auth_error(&self, message: &str, client_ref: Option) { @@ -195,7 +195,7 @@ impl PendingRequests { if !matched { let _ = self.unmatched_error_tx.send(Some(message.to_string())); } - eprintln!("sync: auth error: {message}"); + tracing::warn!("sync: auth error: {message}"); } async fn dispatch_member_list( @@ -210,12 +210,12 @@ impl PendingRequests { let _ = tx.send(Ok((members, creator))); } PendingSlot::Ack(_) => { - eprintln!( + tracing::warn!( "sync: unexpected MemberList for pending Ack (client_ref={client_ref})" ); } PendingSlot::SubmitAck(_) => { - eprintln!( + tracing::warn!( "sync: unexpected MemberList for pending Submit (client_ref={client_ref})" ); } diff --git a/crates/spec-forest/src/sync/reconnect.rs b/crates/spec-forest/src/sync/reconnect.rs index c4b6fe7..d797386 100644 --- a/crates/spec-forest/src/sync/reconnect.rs +++ b/crates/spec-forest/src/sync/reconnect.rs @@ -77,12 +77,12 @@ pub(super) fn spawn_reconnect_task( continue; } - eprintln!("sync: connection lost, attempting reconnect..."); + tracing::warn!("sync: connection lost, attempting reconnect..."); let mut reconnected = false; for attempt in 0..policy.max_attempts { let delay = policy.delay_for(attempt); - eprintln!( + tracing::warn!( "sync: reconnect attempt {}/{} in {delay:?}", attempt + 1, policy.max_attempts @@ -98,7 +98,7 @@ pub(super) fn spawn_reconnect_task( .await { Ok(new_conn) => { - eprintln!("sync: reconnected successfully"); + tracing::warn!("sync: reconnected successfully"); *connection.lock().await = new_conn; // Re-subscribe to all tracked specs. @@ -110,7 +110,7 @@ pub(super) fn spawn_reconnect_task( from_seq: *from_seq, }; if let Err(e) = conn.send(&msg).await { - eprintln!( + tracing::warn!( "sync: failed to re-subscribe to {spec_name}: {e}" ); } @@ -119,13 +119,13 @@ pub(super) fn spawn_reconnect_task( break; } Err(e) => { - eprintln!("sync: reconnect attempt {} failed: {e}", attempt + 1); + tracing::warn!("sync: reconnect attempt {} failed: {e}", attempt + 1); } } } if !reconnected { - eprintln!( + tracing::warn!( "sync: failed to reconnect after {} attempts, will keep trying", policy.max_attempts ); diff --git a/crates/spec-forest/src/sync/request.rs b/crates/spec-forest/src/sync/request.rs index e2e71aa..a29e5cc 100644 --- a/crates/spec-forest/src/sync/request.rs +++ b/crates/spec-forest/src/sync/request.rs @@ -40,12 +40,12 @@ where match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { Ok(Ok(result)) => extract(result), Ok(Err(_)) => { - eprintln!("sync: {timeout_label}: response channel dropped"); + tracing::error!("sync: {timeout_label}: response channel dropped"); Err(SyncError::ChannelDropped) } Err(_) => { pending.remove(client_ref).await; - eprintln!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); + tracing::error!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); Err(SyncError::Timeout(timeout_label)) } } @@ -63,11 +63,11 @@ pub(super) async fn send_and_wait_singleton( match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { Ok(Ok(value)) => Ok(value), Ok(Err(_)) => { - eprintln!("sync: {timeout_label}: response channel dropped"); + tracing::error!("sync: {timeout_label}: response channel dropped"); Err(SyncError::ChannelDropped) } Err(_) => { - eprintln!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); + tracing::error!("sync: {timeout_label}: timed out after {REQUEST_TIMEOUT:?}"); Err(SyncError::Timeout(timeout_label)) } } From 25d030661a96fddbe9b75d10afa288c8c66b9ead Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:34:46 +1100 Subject: [PATCH 016/100] feat: add toggleable log panel to TUI spec view Captures tracing events into an in-memory ring buffer via a custom tracing Layer, displayed as a bottom panel toggled with [l]. Supports scrolling with PgUp/PgDn. File logging is unaffected. --- crates/spec-forest-tui/src/action.rs | 5 + crates/spec-forest-tui/src/app.rs | 23 ++++- crates/spec-forest-tui/src/input.rs | 8 +- crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/log_buffer.rs | 108 +++++++++++++++++++++ crates/spec-forest-tui/src/main.rs | 22 ++++- crates/spec-forest-tui/src/ui.rs | 1 + crates/spec-forest-tui/src/ui/log_panel.rs | 54 +++++++++++ crates/spec-forest-tui/src/ui/spec_view.rs | 41 +++++--- crates/spec-forest-tui/tests/tui_tests.rs | 64 ++++++------ 10 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 crates/spec-forest-tui/src/log_buffer.rs create mode 100644 crates/spec-forest-tui/src/ui/log_panel.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 863800c..1db3da3 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -81,5 +81,10 @@ pub enum Action { CandidatePrev, AcceptCandidate, + // Log panel + ToggleLog, + LogScrollUp, + LogScrollDown, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7946bea..a6ee940 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -18,6 +18,7 @@ use crate::dir_browser::DirBrowserState; use crate::editor; use crate::error::handle_result; use crate::input; +use crate::log_buffer::SharedLogBuffer; use crate::tree_state::TreeState; use crate::ui; @@ -73,6 +74,9 @@ pub struct App { pub config_selected: usize, pub dir_browser_source: DirBrowserSource, pub spec_settings_selected: usize, + pub log_buffer: SharedLogBuffer, + pub log_visible: bool, + pub log_scroll_offset: usize, } #[derive(Clone)] @@ -105,7 +109,7 @@ pub enum DirBrowserSource { } impl App { - pub fn new(state: Arc) -> Self { + pub fn new(state: Arc, log_buffer: SharedLogBuffer) -> Self { let specs = commands::refresh_spec_list(&state).unwrap_or_else(|e| { tracing::warn!("Failed to load specs on startup: {e}"); Vec::new() @@ -144,6 +148,9 @@ impl App { config_selected: 0, dir_browser_source: DirBrowserSource::SeedFromDir, spec_settings_selected: 0, + log_buffer, + log_visible: false, + log_scroll_offset: 0, } } @@ -182,6 +189,7 @@ impl App { self.tree_visible, self.tree_focused, has_sync_url, + self.log_visible, ); self.execute_action(action).await; } @@ -238,6 +246,19 @@ impl App { Action::AddFeature => self.add_feature().await, Action::AddQuestion => self.add_question().await, + // Log panel + Action::ToggleLog => { + self.log_visible = !self.log_visible; + self.log_scroll_offset = 0; + } + Action::LogScrollUp => { + let max = self.log_buffer.lock().unwrap().len().saturating_sub(1); + self.log_scroll_offset = (self.log_scroll_offset + 5).min(max); + } + Action::LogScrollDown => { + self.log_scroll_offset = self.log_scroll_offset.saturating_sub(5); + } + // Tree Action::TreeUp => self.tree_state.select_up(), Action::TreeDown => self.tree_state.select_down(), diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5f4d78e..02eb7a1 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -11,6 +11,7 @@ pub fn map_key( tree_visible: bool, tree_focused: bool, has_sync_url: bool, + log_visible: bool, ) -> Action { match screen { Screen::SpecList => map_spec_list_key(key), @@ -18,7 +19,7 @@ pub fn map_key( Screen::DirBrowser => map_dir_browser_key(key), Screen::DepthPicker => map_depth_picker_key(key), Screen::SpecOptionsPicker => map_spec_options_key(key), - Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused), + Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused, log_visible), Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -98,12 +99,15 @@ fn map_spec_options_key(key: KeyCode) -> Action { } } -fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool) -> Action { +fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool, log_visible: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Backspace => Action::GoBack, KeyCode::Char('t') => Action::ToggleTree, + KeyCode::Char('l') => Action::ToggleLog, KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::PageUp if log_visible => Action::LogScrollUp, + KeyCode::PageDown if log_visible => Action::LogScrollDown, KeyCode::Tab if tree_visible => Action::SwitchFocus, _ if tree_focused && tree_visible => map_tree_key(key), _ => map_flat_list_key(key), diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index a9fdb97..6335162 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -5,5 +5,6 @@ pub mod dir_browser; pub mod editor; pub mod error; pub mod input; +pub mod log_buffer; pub mod tree_state; pub mod ui; diff --git a/crates/spec-forest-tui/src/log_buffer.rs b/crates/spec-forest-tui/src/log_buffer.rs new file mode 100644 index 0000000..6d6ae3d --- /dev/null +++ b/crates/spec-forest-tui/src/log_buffer.rs @@ -0,0 +1,108 @@ +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +pub struct LogEntry { + pub timestamp: String, + pub level: Level, + pub message: String, +} + +pub struct LogBuffer { + entries: VecDeque, + capacity: usize, +} + +pub type SharedLogBuffer = Arc>; + +impl LogBuffer { + pub fn new_shared(capacity: usize) -> SharedLogBuffer { + Arc::new(Mutex::new(LogBuffer { + entries: VecDeque::with_capacity(capacity), + capacity, + })) + } + + pub fn push(&mut self, entry: LogEntry) { + if self.entries.len() >= self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn entries(&self) -> &VecDeque { + &self.entries + } + + pub fn len(&self) -> usize { + self.entries.len() + } +} + +pub struct TuiLogLayer { + buffer: SharedLogBuffer, +} + +impl TuiLogLayer { + pub fn new(buffer: SharedLogBuffer) -> Self { + Self { buffer } + } +} + +struct MessageVisitor { + message: String, +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } + } +} + +fn format_timestamp() -> String { + let now = SystemTime::now(); + let duration = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let total_secs = duration.as_secs(); + // Get local time offset by using libc localtime + // For simplicity, just format as UTC HH:MM:SS + let secs_of_day = total_secs % 86400; + let hours = secs_of_day / 3600; + let minutes = (secs_of_day % 3600) / 60; + let seconds = secs_of_day % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} + +impl Layer for TuiLogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = MessageVisitor { + message: String::new(), + }; + event.record(&mut visitor); + + let entry = LogEntry { + timestamp: format_timestamp(), + level: *event.metadata().level(), + message: visitor.message, + }; + + if let Ok(mut buf) = self.buffer.lock() { + buf.push(entry); + } + } +} diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index eb21d18..d8d1d24 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -9,7 +9,9 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use spec_forest::ServerConfig; use spec_forest_tui::app; +use spec_forest_tui::log_buffer::{LogBuffer, TuiLogLayer}; use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; fn default_db_path() -> String { let dir = dirs::home_dir() @@ -59,10 +61,22 @@ async fn main() -> Result<(), Box> { .expect("could not determine home directory") .join(".spec-forest"); let file_appender = tracing_appender::rolling::daily(&log_dir, "tui.log"); - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive("spec_forest_tui=info".parse().unwrap())) + + let log_buffer = LogBuffer::new_shared(500); + + let file_layer = tracing_subscriber::fmt::layer() .with_writer(file_appender) - .with_ansi(false) + .with_ansi(false); + + let tui_layer = TuiLogLayer::new(log_buffer.clone()); + + let filter = EnvFilter::from_default_env() + .add_directive("spec_forest_tui=info".parse().unwrap()); + + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(tui_layer) .init(); let host = cli.host.clone(); @@ -109,7 +123,7 @@ async fn main() -> Result<(), Box> { let mut terminal = Terminal::new(backend)?; // Run TUI - let mut app = app::App::new(state); + let mut app = app::App::new(state, log_buffer); let result = app.run(&mut terminal).await; // Restore terminal diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 507ca62..8b82f11 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -3,6 +3,7 @@ mod config; mod depth_picker; mod dir_browser; mod input_screen; +pub(crate) mod log_panel; mod model_config; mod spec_list; mod spec_options_picker; diff --git a/crates/spec-forest-tui/src/ui/log_panel.rs b/crates/spec-forest-tui/src/ui/log_panel.rs new file mode 100644 index 0000000..56ca2f0 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/log_panel.rs @@ -0,0 +1,54 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use tracing::Level; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(" Logs "); + let inner_height = area.height.saturating_sub(2) as usize; + + let buf = app.log_buffer.lock().unwrap(); + let entries = buf.entries(); + let total = entries.len(); + + // log_scroll_offset 0 = show most recent at bottom + let end = total.saturating_sub(app.log_scroll_offset); + let start = end.saturating_sub(inner_height); + + let lines: Vec = entries + .range(start..end) + .map(|entry| { + let level_color = match entry.level { + Level::ERROR => Color::Red, + Level::WARN => Color::Yellow, + Level::INFO => Color::Green, + Level::DEBUG => Color::Cyan, + Level::TRACE => Color::DarkGray, + }; + let level_str = match entry.level { + Level::ERROR => "ERROR", + Level::WARN => " WARN", + Level::INFO => " INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + }; + Line::from(vec![ + Span::styled( + format!("{} ", entry.timestamp), + Style::default().fg(Color::DarkGray), + ), + Span::styled(format!("{level_str} "), Style::default().fg(level_color)), + Span::raw(&entry.message), + ]) + }) + .collect(); + + let content = Paragraph::new(lines).block(block); + frame.render_widget(content, area); +} diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 59ad617..013f02c 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -18,48 +18,57 @@ pub fn render(app: &App, frame: &mut Frame) { .as_ref() .is_some_and(|s| matches!(s.status, ExploreStatus::Running | ExploreStatus::Paused)); - let constraints = if has_explore_bar { - vec![ - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(3), - ] - } else { - vec![Constraint::Min(3), Constraint::Length(3)] - }; + let mut constraints = vec![Constraint::Min(3)]; + + if app.log_visible { + constraints.push(Constraint::Percentage(30)); + } + if has_explore_bar { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(3)); let chunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(frame.area()); + let mut idx = 0; + let main_area = if app.tree_visible { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(chunks[0]); + .split(chunks[idx]); render_tree_panel(app, frame, h_chunks[0]); h_chunks[1] } else { - chunks[0] + chunks[idx] }; + idx += 1; render_node_content(app, frame, main_area); + if app.log_visible { + super::log_panel::render(app, frame, chunks[idx]); + idx += 1; + } + if has_explore_bar { - render_explore_status_bar(app, frame, chunks[1]); + render_explore_status_bar(app, frame, chunks[idx]); + idx += 1; } - let footer_chunk = if has_explore_bar { chunks[2] } else { chunks[1] }; + let footer_chunk = chunks[idx]; let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 29faba4..73f2fa5 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -18,7 +18,7 @@ fn make_app() -> App { let dir = Box::leak(Box::new(dir)); let _ = dir; let state = Arc::new(AppState::new(db_path.to_str().unwrap()).unwrap()); - App::new(state) + App::new(state, spec_forest_tui::log_buffer::LogBuffer::new_shared(100)) } fn make_spec(name: &str) -> Spec { @@ -546,28 +546,28 @@ async fn test_flat_list_and_tree_node_count_consistency() { #[test] fn test_input_map_spec_list_quit() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false); assert_eq!(action, Action::Quit); } #[test] fn test_input_map_spec_list_create() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false); assert_eq!(action, Action::OpenCreateSpec); } #[test] fn test_input_map_spec_list_navigate() { assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false), Action::NavigateDown ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false), Action::Select ); } @@ -577,19 +577,19 @@ fn test_input_map_shared_text_input() { // InputName and SyncPasswordInput share the same mapping for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( - input::map_key(&screen, KeyCode::Esc, false, false, false), + input::map_key(&screen, KeyCode::Esc, false, false, false, false), Action::Cancel ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, false, false, false), + input::map_key(&screen, KeyCode::Enter, false, false, false, false), Action::Submit ); assert_eq!( - input::map_key(&screen, KeyCode::Backspace, false, false, false), + input::map_key(&screen, KeyCode::Backspace, false, false, false, false), Action::DeleteChar ); assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false), Action::TypeChar('a') ); } @@ -602,15 +602,15 @@ fn test_input_map_spec_view_tree_focused() { }; // With tree visible and focused, keys go to tree handlers assert_eq!( - input::map_key(&screen, KeyCode::Up, true, true, false), + input::map_key(&screen, KeyCode::Up, true, true, false, false), Action::TreeUp ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, true, true, false), + input::map_key(&screen, KeyCode::Enter, true, true, false, false), Action::ExpandOrCollapseTreeNode ); assert_eq!( - input::map_key(&screen, KeyCode::Left, true, true, false), + input::map_key(&screen, KeyCode::Left, true, true, false, false), Action::CollapseTreeNode ); } @@ -622,11 +622,11 @@ fn test_input_map_spec_view_flat_list() { }; // Without tree, keys go to flat list handlers assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false), Action::AiAnswer ); assert_eq!( - input::map_key(&screen, KeyCode::Char('e'), false, false, false), + input::map_key(&screen, KeyCode::Char('e'), false, false, false, false), Action::EditNextQuestion ); } @@ -637,7 +637,7 @@ fn test_input_map_spec_view_toggle_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('t'), false, false, false), + input::map_key(&screen, KeyCode::Char('t'), false, false, false, false), Action::ToggleTree ); } @@ -649,12 +649,12 @@ fn test_input_map_spec_view_tab_focus() { }; // Tab only works when tree is visible assert_eq!( - input::map_key(&screen, KeyCode::Tab, true, true, false), + input::map_key(&screen, KeyCode::Tab, true, true, false, false), Action::SwitchFocus ); // Tab when tree not visible goes to flat list (noop) assert_eq!( - input::map_key(&screen, KeyCode::Tab, false, false, false), + input::map_key(&screen, KeyCode::Tab, false, false, false, false), Action::Noop ); } @@ -662,11 +662,11 @@ fn test_input_map_spec_view_tab_focus() { #[test] fn test_input_map_sync_config_with_url() { assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false), Action::SyncLogin ); assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true), + input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false), Action::SyncRegister ); } @@ -675,7 +675,7 @@ fn test_input_map_sync_config_with_url() { fn test_input_map_sync_config_without_url() { // Without URL, login/register are noop assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false), Action::Noop ); } @@ -683,15 +683,15 @@ fn test_input_map_sync_config_without_url() { #[test] fn test_input_map_model_config() { assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false), Action::SelectModel ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false), Action::Cancel ); } @@ -794,11 +794,11 @@ fn test_accept_candidate_key_mapping() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false), Action::AcceptCandidate ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false), Action::AcceptCandidate ); } @@ -824,15 +824,15 @@ fn test_input_map_candidate_keys_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), true, true, false), + input::map_key(&screen, KeyCode::Char(']'), true, true, false, false), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), true, true, false), + input::map_key(&screen, KeyCode::Char('['), true, true, false, false), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false), Action::AcceptCandidate ); } @@ -843,15 +843,15 @@ fn test_input_map_candidate_keys_flat_list() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), false, false, false), + input::map_key(&screen, KeyCode::Char(']'), false, false, false, false), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), false, false, false), + input::map_key(&screen, KeyCode::Char('['), false, false, false, false), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false), Action::AcceptCandidate ); } From e9e4aa4668c50707f1c06266ede687c54dac9667 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:44:54 +1100 Subject: [PATCH 017/100] feat: improve TUI answer candidate UX with full visibility and editor support Show all candidate answer texts instead of only the selected one, fix misleading [/] browse hint to show actual keybindings, and add [E] to load the selected candidate into the external editor for editing before submission. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 46 ++++++++++++++++++++++ crates/spec-forest-tui/src/input.rs | 2 + crates/spec-forest-tui/src/ui/spec_view.rs | 22 +++++++---- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 1db3da3..6eb479a 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -80,6 +80,7 @@ pub enum Action { CandidateNext, CandidatePrev, AcceptCandidate, + EditCandidate, // Log panel ToggleLog, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a6ee940..861de57 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -432,6 +432,7 @@ impl App { self.candidate_selected = self.candidate_selected.saturating_sub(1); } Action::AcceptCandidate => self.accept_candidate().await, + Action::EditCandidate => self.edit_candidate().await, } } @@ -1104,6 +1105,51 @@ impl App { } } + async fn edit_candidate(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let Some(candidate) = self.candidates.get(self.candidate_selected).cloned() else { + return; + }; + let node = match self.state.db().get_node(&candidate.node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to get node for candidate edit: {e}"); + self.message = Some(format!("Error: {e}")); + return; + } + }; + + self.needs_redraw = true; + match editor::edit_question(&node.question, Some(&candidate.answer)) { + Ok(Some(new_answer)) => { + match commands::submit_answer( + &self.state, + &node.id, + new_answer, + self.model.clone(), + ) + .await + { + Ok(()) => self.message = Some("Edited candidate submitted".to_string()), + Err(e) => { + tracing::error!("Submit edited candidate failed: {e}"); + self.message = Some(e.to_string()); + } + } + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Ok(None) => self.message = Some("No changes".to_string()), + Err(e) => { + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); + } + } + } + // ── Background polling ────────────────────────────────────── pub fn sync_disconnect_indicator(&self) -> Option<&'static str> { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 02eb7a1..993590a 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -129,6 +129,7 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char(']') => Action::CandidateNext, KeyCode::Char('[') => Action::CandidatePrev, KeyCode::Char('y') => Action::AcceptCandidate, + KeyCode::Char('E') => Action::EditCandidate, KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, _ => Action::Noop, @@ -147,6 +148,7 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('c') => Action::CancelExplore, KeyCode::Char(']') => Action::CandidateNext, KeyCode::Char('[') => Action::CandidatePrev, + KeyCode::Char('E') => Action::EditCandidate, KeyCode::Char('y') => Action::AcceptCandidate, KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 013f02c..fa82a16 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -64,7 +64,7 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [a] AI [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { @@ -140,7 +140,7 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { lines.push(Line::from("")); lines.push(Line::from(Span::styled( format!( - "Candidates ({}) ── [/] browse [y] accept", + "Candidates ({}) ── [[] prev []] next [y] accept [E] edit", app.candidates.len() ), Style::default() @@ -163,13 +163,19 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { }; lines.push(Line::from(Span::styled(rank_label, header_style))); - if is_selected { - lines.push(Line::from("")); - for text_line in candidate.answer.lines() { - lines.push(Line::from(text_line.to_string())); - } - lines.push(Line::from("")); + let text_style = if is_selected { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }; + lines.push(Line::from("")); + for text_line in candidate.answer.lines() { + lines.push(Line::from(Span::styled( + text_line.to_string(), + text_style, + ))); } + lines.push(Line::from("")); } } From b1fd7ecf17aea9e254f5950daf64e6c79769e383 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 10:46:46 +1100 Subject: [PATCH 018/100] feat: add node deletion to TUI with press-twice confirmation --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 42 ++++++++++++++++++++++ crates/spec-forest-tui/src/commands.rs | 9 +++++ crates/spec-forest-tui/src/input.rs | 2 ++ crates/spec-forest-tui/src/ui/spec_view.rs | 6 ++-- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 6eb479a..1a7c46b 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -31,6 +31,7 @@ pub enum Action { EditNextQuestion, AddFeature, AddQuestion, + DeleteNode, // Tree navigation ExpandOrCollapseTreeNode, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 861de57..37842da 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -77,6 +77,7 @@ pub struct App { pub log_buffer: SharedLogBuffer, pub log_visible: bool, pub log_scroll_offset: usize, + pub pending_delete: Option, } #[derive(Clone)] @@ -151,6 +152,7 @@ impl App { log_buffer, log_visible: false, log_scroll_offset: 0, + pending_delete: None, } } @@ -195,6 +197,9 @@ impl App { } async fn execute_action(&mut self, action: Action) { + if action != Action::DeleteNode && action != Action::Noop { + self.pending_delete = None; + } match action { Action::Noop => {} Action::Quit => self.should_quit = true, @@ -245,6 +250,7 @@ impl App { Action::EditNextQuestion => self.edit_next_question().await, Action::AddFeature => self.add_feature().await, Action::AddQuestion => self.add_question().await, + Action::DeleteNode => self.delete_node().await, // Log panel Action::ToggleLog => { @@ -1057,6 +1063,42 @@ impl App { } } + // ── Delete node ────────────────────────────────────────── + + async fn delete_node(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + + let node = match self.get_selected_node() { + Some(node) => node.clone(), + None => { + self.message = Some("No node selected".to_string()); + return; + } + }; + + if self.pending_delete.as_deref() == Some(&node.id) { + self.pending_delete = None; + match commands::delete_node(&self.state, &node.id).await { + Ok(_) => { + self.message = Some("Node deleted".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } + Err(e) => { + tracing::error!("Delete node failed: {e}"); + self.message = Some(e.to_string()); + } + } + } else { + let label = truncate_str(&node.question, 40); + self.pending_delete = Some(node.id.clone()); + self.message = Some(format!("Press d again to delete '{label}'")); + } + } + // ── Candidate operations ─────────────────────────────────── pub fn refresh_candidates_if_needed(&mut self) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 2cf35e8..4408305 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -150,6 +150,15 @@ pub fn update_directory( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn delete_node( + state: &Arc, + node_id: &str, +) -> Result { + spec_forest::api::delete_node(state, node_id) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn connect_sync( state: &Arc, password: String, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 993590a..5743d81 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -132,6 +132,7 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('E') => Action::EditCandidate, KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, + KeyCode::Char('d') => Action::DeleteNode, _ => Action::Noop, } } @@ -152,6 +153,7 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('y') => Action::AcceptCandidate, KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, + KeyCode::Char('d') => Action::DeleteNode, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index fa82a16..004d73a 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -64,11 +64,11 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { From 939b38c8898aa9c7de0ded5f42c89bd52214ea0f Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:08:41 +1100 Subject: [PATCH 019/100] fix: make tree the source of truth for TUI node selection Tab focus switching no longer desyncs the displayed node. Removed node_selected flat list index in favor of always using tree_state for selection. Main panel Up/Down now navigates siblings. --- crates/spec-forest-tui/src/action.rs | 2 + crates/spec-forest-tui/src/app.rs | 33 +++----------- crates/spec-forest-tui/src/input.rs | 4 +- crates/spec-forest-tui/src/tree_state.rs | 32 +++++++++++++ crates/spec-forest-tui/tests/tui_tests.rs | 55 ++++++++++++----------- 5 files changed, 71 insertions(+), 55 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 1a7c46b..85a7228 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -39,6 +39,8 @@ pub enum Action { TreeUp, TreeDown, EditTreeNode, + SiblingUp, + SiblingDown, // Directory browser DirBrowserUp, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 37842da..b4bf4d9 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -46,7 +46,6 @@ pub struct App { pub specs: Vec, pub selected: usize, pub nodes: Vec, - pub node_selected: usize, pub input: String, pub message: Option, pub should_quit: bool, @@ -121,7 +120,6 @@ impl App { specs, selected: 0, nodes: Vec::new(), - node_selected: 0, input: String::new(), message: None, should_quit: false, @@ -268,6 +266,8 @@ impl App { // Tree Action::TreeUp => self.tree_state.select_up(), Action::TreeDown => self.tree_state.select_down(), + Action::SiblingUp => self.tree_state.select_prev_sibling(), + Action::SiblingDown => self.tree_state.select_next_sibling(), Action::ExpandOrCollapseTreeNode => self.expand_or_collapse_tree_node(), Action::CollapseTreeNode => self.collapse_tree_node(), Action::EditTreeNode => self.edit_tree_node().await, @@ -458,11 +458,7 @@ impl App { Screen::SpecSettings { .. } => { self.spec_settings_selected = self.spec_settings_selected.saturating_sub(1); } - Screen::SpecView { .. } => { - if self.node_selected > 0 { - self.node_selected -= 1; - } - } + Screen::SpecView { .. } => {} _ => {} } } @@ -482,11 +478,7 @@ impl App { Screen::Config | Screen::SpecSettings { .. } => { // Currently only one item in each, but ready for more } - Screen::SpecView { .. } => { - if !self.nodes.is_empty() && self.node_selected < self.nodes.len() - 1 { - self.node_selected += 1; - } - } + Screen::SpecView { .. } => {} _ => {} } } @@ -592,7 +584,6 @@ impl App { match commands::load_spec_nodes(&self.state, spec_id) { Ok(nodes) => { self.nodes = nodes; - self.node_selected = 0; self.tree_state = TreeState::new(); self.tree_visible = true; self.tree_focused = true; @@ -768,7 +759,6 @@ impl App { self.message = Some(e.to_string()); } drop(db); - self.sync_flat_list_to_tree(); } fn collapse_tree_node(&mut self) { @@ -793,23 +783,12 @@ impl App { } } - fn sync_flat_list_to_tree(&mut self) { - if let Some(node_id) = self.tree_state.selected_node_id() - && let Some(idx) = self.nodes.iter().position(|n| n.id == node_id) - { - self.node_selected = idx; - } - } // ── Node operations ───────────────────────────────────────── pub fn get_selected_node(&self) -> Option<&spec_forest_db::Node> { - if self.tree_focused && self.tree_visible { - let node_id = self.tree_state.selected_node_id()?; - self.nodes.iter().find(|n| n.id == node_id) - } else { - self.nodes.get(self.node_selected) - } + let node_id = self.tree_state.selected_node_id()?; + self.nodes.iter().find(|n| n.id == node_id) } fn trigger_ai_answer(&mut self) { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5743d81..1d572f5 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -139,8 +139,8 @@ fn map_tree_key(key: KeyCode) -> Action { fn map_flat_list_key(key: KeyCode) -> Action { match key { - KeyCode::Up => Action::NavigateUp, - KeyCode::Down => Action::NavigateDown, + KeyCode::Up => Action::SiblingUp, + KeyCode::Down => Action::SiblingDown, KeyCode::Char('e') => Action::EditNextQuestion, KeyCode::Char('a') => Action::AiAnswer, KeyCode::Char('x') => Action::ExploreNode, diff --git a/crates/spec-forest-tui/src/tree_state.rs b/crates/spec-forest-tui/src/tree_state.rs index 8c40672..79cf645 100644 --- a/crates/spec-forest-tui/src/tree_state.rs +++ b/crates/spec-forest-tui/src/tree_state.rs @@ -50,6 +50,38 @@ impl TreeState { } } + pub fn select_prev_sibling(&mut self) { + let Some(current) = self.entries.get(self.selected) else { + return; + }; + let depth = current.depth; + for i in (0..self.selected).rev() { + if self.entries[i].depth < depth { + break; + } + if self.entries[i].depth == depth { + self.selected = i; + return; + } + } + } + + pub fn select_next_sibling(&mut self) { + let Some(current) = self.entries.get(self.selected) else { + return; + }; + let depth = current.depth; + for i in (self.selected + 1)..self.entries.len() { + if self.entries[i].depth < depth { + break; + } + if self.entries[i].depth == depth { + self.selected = i; + return; + } + } + } + pub fn expand_selected( &mut self, db: &Database, diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 73f2fa5..7349ecc 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -7,6 +7,7 @@ use spec_forest_db::{ChildInput, CreateSpec, Locality, Node, NodeState, Spec, Sp use spec_forest_tui::action::Action; use spec_forest_tui::app::{App, Screen}; use spec_forest_tui::input; +use spec_forest_tui::tree_state::TreeEntry; use spec_forest_tui::ui; use tempfile::tempdir; @@ -132,6 +133,17 @@ async fn test_spec_view_shows_selected_node_content() { app.screen = Screen::SpecView { spec_id: "spec-MySpec".to_string(), }; + app.tree_visible = true; + app.tree_focused = true; + for node in &app.nodes { + app.tree_state.entries.push(TreeEntry { + node_id: node.id.clone(), + depth: 0, + has_children: false, + label: node.question.clone(), + node_state: node.state, + }); + } // Default selection is 0, so the first node's question should appear let output = render_to_string(&app); assert!( @@ -146,9 +158,7 @@ async fn test_spec_view_shows_selected_node_content() { #[tokio::test] async fn test_spec_view_tree_shows_markers() { - let (mut app, _spec_id, _root_id) = make_app_with_tree(); - // Enable tree view - app.handle_key(KeyCode::Char('t')).await; + let (app, _spec_id, _root_id) = make_app_with_tree(); let output = render_to_string_sized(&app, 120, 30); assert!( output.contains("[?]") || output.contains("[+]"), @@ -322,6 +332,12 @@ fn make_app_with_tree() -> (App, String, String) { app.screen = Screen::SpecView { spec_id: spec_id.clone(), }; + app.tree_visible = true; + app.tree_focused = true; + { + let db = app.state.db(); + app.tree_state.rebuild(&db, &spec_id).unwrap(); + } (app, spec_id, root_id) } @@ -339,8 +355,6 @@ async fn test_flat_list_enter_does_not_change_state() { app.screen = Screen::SpecView { spec_id: "spec-S".to_string(), }; - app.node_selected = 0; - // Press Enter on an answered node in the flat list app.handle_key(KeyCode::Enter).await; @@ -350,7 +364,6 @@ async fn test_flat_list_enter_does_not_change_state() { matches!(app.screen, Screen::SpecView { .. }), "screen should still be SpecView after Enter" ); - assert_eq!(app.node_selected, 0, "selection should not change"); // TODO: Once fixed, Enter should do something useful (e.g. show detail or expand). } @@ -361,12 +374,6 @@ async fn test_flat_list_enter_does_not_change_state() { #[tokio::test] async fn test_tree_expand_shows_children() { let (mut app, _spec_id, _root_id) = make_app_with_tree(); - - // Press 't' to show tree — this rebuilds tree from DB - app.handle_key(KeyCode::Char('t')).await; - - assert!(app.tree_visible, "tree should be visible after pressing t"); - assert!(app.tree_focused, "tree should be focused after pressing t"); assert!( !app.tree_state.entries.is_empty(), "tree should have entries after rebuild" @@ -422,8 +429,6 @@ async fn test_tree_expand_shows_children() { #[tokio::test] async fn test_tree_expand_with_right_arrow() { let (mut app, _spec_id, _root_id) = make_app_with_tree(); - - app.handle_key(KeyCode::Char('t')).await; let initial_count = app.tree_state.entries.len(); // Right arrow should expand just like Enter @@ -489,8 +494,11 @@ async fn test_tree_shows_all_root_nodes() { app.nodes = db.get_nodes_by_spec(&spec_id).unwrap(); } - // Open tree - app.handle_key(KeyCode::Char('t')).await; + // Rebuild tree to pick up extra roots + { + let db = app.state.db(); + app.tree_state.rebuild(&db, &spec_id).unwrap(); + } // The tree should contain all root nodes let root_entries: Vec<_> = app @@ -528,9 +536,6 @@ async fn test_flat_list_and_tree_node_count_consistency() { flat_count ); - // Open tree and expand all nodes - app.handle_key(KeyCode::Char('t')).await; - // Expand the root app.handle_key(KeyCode::Enter).await; @@ -734,12 +739,9 @@ fn make_app_with_candidates() -> (App, String, String) { ) .unwrap(); } - // Select root node and load candidates - app.tree_visible = false; - app.tree_focused = false; - // Find root node index in flat list - let root_idx = app.nodes.iter().position(|n| n.id == root_id).unwrap_or(0); - app.node_selected = root_idx; + // Select root node (tree_state.selected starts at 0 = root) and load candidates + app.tree_visible = true; + app.tree_focused = true; app.refresh_candidates_if_needed(); (app, spec_id, root_id) } @@ -808,7 +810,8 @@ async fn test_candidates_clear_when_switching_nodes() { let (mut app, _spec_id, _root_id) = make_app_with_candidates(); assert_eq!(app.candidates.len(), 3, "should have candidates for root"); - // Navigate to a different node (child without candidates) + // Expand root to reveal children, then navigate to a child + app.handle_key(KeyCode::Enter).await; app.handle_key(KeyCode::Down).await; app.refresh_candidates_if_needed(); assert!( From 23dfd5260ea4608440cf6e71dcfc67f8daf3d75b Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:14:32 +1100 Subject: [PATCH 020/100] feat: add toggleable auto-explore setting to TUI config Answering a question no longer automatically generates child nodes by default. A new "Auto Explore" toggle in the Config screen (g) controls this behavior, persisted across sessions via the settings table. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 37 ++++++++++++-- crates/spec-forest-tui/src/commands.rs | 7 +-- crates/spec-forest-tui/src/input.rs | 11 ++-- crates/spec-forest-tui/src/ui/config.rs | 19 +++++-- crates/spec-forest-tui/tests/tui_tests.rs | 62 +++++++++++------------ crates/spec-forest/src/api/nodes.rs | 29 ++++++----- crates/spec-forest/src/http.rs | 2 +- 8 files changed, 109 insertions(+), 59 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 85a7228..7187f92 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -73,6 +73,7 @@ pub enum Action { // Config OpenConfig, SetUsername, + ToggleAutoExplore, // Spec settings OpenSpecSettings, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index b4bf4d9..5b139c1 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -77,6 +77,7 @@ pub struct App { pub log_visible: bool, pub log_scroll_offset: usize, pub pending_delete: Option, + pub auto_explore: bool, } #[derive(Clone)] @@ -114,6 +115,13 @@ impl App { tracing::warn!("Failed to load specs on startup: {e}"); Vec::new() }); + let auto_explore = state + .db() + .get_setting("auto_explore") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false); Self { state, screen: Screen::SpecList, @@ -151,6 +159,7 @@ impl App { log_visible: false, log_scroll_offset: 0, pending_delete: None, + auto_explore, } } @@ -190,6 +199,7 @@ impl App { self.tree_focused, has_sync_url, self.log_visible, + self.config_selected, ); self.execute_action(action).await; } @@ -395,6 +405,17 @@ impl App { self.input = self.state.user_name(); self.screen = Screen::UsernameInput; } + Action::ToggleAutoExplore => { + self.auto_explore = !self.auto_explore; + let _ = self.state.db().set_setting( + "auto_explore", + if self.auto_explore { "true" } else { "false" }, + ); + self.message = Some(format!( + "Auto explore: {}", + if self.auto_explore { "ON" } else { "OFF" } + )); + } // Spec settings Action::OpenSpecSettings => { @@ -475,8 +496,13 @@ impl App { self.model_selected += 1; } } - Screen::Config | Screen::SpecSettings { .. } => { - // Currently only one item in each, but ready for more + Screen::Config => { + if self.config_selected < 1 { + self.config_selected += 1; + } + } + Screen::SpecSettings { .. } => { + // Currently only one item, but ready for more } Screen::SpecView { .. } => {} _ => {} @@ -795,7 +821,7 @@ impl App { let Some(node) = self.get_selected_node().cloned() else { return; }; - commands::spawn_ai_answer(self.state.clone(), node.id.clone(), self.model.clone()); + commands::spawn_ai_answer(self.state.clone(), node.id.clone(), self.model.clone(), self.auto_explore); let q = truncate_str(&node.question, 40); self.message = Some(format!("AI answering: {q}...")); } @@ -896,6 +922,7 @@ impl App { &node.id, new_answer, self.model.clone(), + self.auto_explore, ) .await { @@ -932,6 +959,7 @@ impl App { &node.id, new_answer, self.model.clone(), + self.auto_explore, ) .await { @@ -1112,7 +1140,7 @@ impl App { let node_id = candidate.node_id.clone(); let answer_text = candidate.answer.clone(); - match commands::submit_answer(&self.state, &node_id, answer_text, self.model.clone()).await + match commands::submit_answer(&self.state, &node_id, answer_text, self.model.clone(), self.auto_explore).await { Ok(()) => { self.message = Some("Candidate accepted".to_string()); @@ -1151,6 +1179,7 @@ impl App { &node.id, new_answer, self.model.clone(), + self.auto_explore, ) .await { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 4408305..5cf8c51 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -44,16 +44,17 @@ pub async fn submit_answer( node_id: &str, answer: String, model: String, + generate: bool, ) -> Result<(), TuiError> { - spec_forest::api::answer_node(state, node_id, answer, model, true) + spec_forest::api::answer_node(state, node_id, answer, model, generate) .await .map(|_| ()) .map_err(|e| TuiError::Api(e.to_string())) } -pub fn spawn_ai_answer(state: Arc, node_id: String, model: String) { +pub fn spawn_ai_answer(state: Arc, node_id: String, model: String, generate: bool) { tokio::spawn(async move { - if let Err(e) = spec_forest::api::ai_answer(&state, &node_id, model).await { + if let Err(e) = spec_forest::api::ai_answer(&state, &node_id, model, generate).await { tracing::error!("AI answer failed for node {node_id}: {e}"); } }); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 1d572f5..e5ff8ac 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -12,6 +12,7 @@ pub fn map_key( tree_focused: bool, has_sync_url: bool, log_visible: bool, + config_selected: usize, ) -> Action { match screen { Screen::SpecList => map_spec_list_key(key), @@ -23,17 +24,21 @@ pub fn map_key( Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), - Screen::Config => map_config_key(key), + Screen::Config => map_config_key(key, config_selected), Screen::UsernameInput => map_input_key(key), } } -fn map_config_key(key: KeyCode) -> Action { +fn map_config_key(key: KeyCode, selected: usize) -> Action { match key { KeyCode::Esc => Action::Cancel, KeyCode::Up => Action::NavigateUp, KeyCode::Down => Action::NavigateDown, - KeyCode::Enter => Action::SetUsername, + KeyCode::Enter => match selected { + 0 => Action::SetUsername, + 1 => Action::ToggleAutoExplore, + _ => Action::Noop, + }, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/ui/config.rs b/crates/spec-forest-tui/src/ui/config.rs index a16c90a..c11e850 100644 --- a/crates/spec-forest-tui/src/ui/config.rs +++ b/crates/spec-forest-tui/src/ui/config.rs @@ -15,10 +15,21 @@ pub fn render(app: &App, frame: &mut Frame) { .split(frame.area()); let username = app.state.user_name(); - let items: Vec = vec![ListItem::new(Line::from(vec![ - Span::raw(" Username: "), - Span::styled(username, Style::default().fg(Color::Cyan)), - ]))]; + let (auto_explore_label, auto_explore_color) = if app.auto_explore { + ("ON", Color::Green) + } else { + ("OFF", Color::Red) + }; + let items: Vec = vec![ + ListItem::new(Line::from(vec![ + Span::raw(" Username: "), + Span::styled(username, Style::default().fg(Color::Cyan)), + ])), + ListItem::new(Line::from(vec![ + Span::raw(" Auto Explore: "), + Span::styled(auto_explore_label, Style::default().fg(auto_explore_color)), + ])), + ]; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" Config ")) diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 7349ecc..b89844d 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -551,28 +551,28 @@ async fn test_flat_list_and_tree_node_count_consistency() { #[test] fn test_input_map_spec_list_quit() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false, 0); assert_eq!(action, Action::Quit); } #[test] fn test_input_map_spec_list_create() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false, 0); assert_eq!(action, Action::OpenCreateSpec); } #[test] fn test_input_map_spec_list_navigate() { assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false, 0), Action::NavigateDown ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false), + input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false, 0), Action::Select ); } @@ -582,19 +582,19 @@ fn test_input_map_shared_text_input() { // InputName and SyncPasswordInput share the same mapping for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( - input::map_key(&screen, KeyCode::Esc, false, false, false, false), + input::map_key(&screen, KeyCode::Esc, false, false, false, false, 0), Action::Cancel ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, false, false, false, false), + input::map_key(&screen, KeyCode::Enter, false, false, false, false, 0), Action::Submit ); assert_eq!( - input::map_key(&screen, KeyCode::Backspace, false, false, false, false), + input::map_key(&screen, KeyCode::Backspace, false, false, false, false, 0), Action::DeleteChar ); assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, 0), Action::TypeChar('a') ); } @@ -607,15 +607,15 @@ fn test_input_map_spec_view_tree_focused() { }; // With tree visible and focused, keys go to tree handlers assert_eq!( - input::map_key(&screen, KeyCode::Up, true, true, false, false), + input::map_key(&screen, KeyCode::Up, true, true, false, false, 0), Action::TreeUp ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, true, true, false, false), + input::map_key(&screen, KeyCode::Enter, true, true, false, false, 0), Action::ExpandOrCollapseTreeNode ); assert_eq!( - input::map_key(&screen, KeyCode::Left, true, true, false, false), + input::map_key(&screen, KeyCode::Left, true, true, false, false, 0), Action::CollapseTreeNode ); } @@ -627,11 +627,11 @@ fn test_input_map_spec_view_flat_list() { }; // Without tree, keys go to flat list handlers assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, 0), Action::AiAnswer ); assert_eq!( - input::map_key(&screen, KeyCode::Char('e'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('e'), false, false, false, false, 0), Action::EditNextQuestion ); } @@ -642,7 +642,7 @@ fn test_input_map_spec_view_toggle_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('t'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('t'), false, false, false, false, 0), Action::ToggleTree ); } @@ -654,12 +654,12 @@ fn test_input_map_spec_view_tab_focus() { }; // Tab only works when tree is visible assert_eq!( - input::map_key(&screen, KeyCode::Tab, true, true, false, false), + input::map_key(&screen, KeyCode::Tab, true, true, false, false, 0), Action::SwitchFocus ); // Tab when tree not visible goes to flat list (noop) assert_eq!( - input::map_key(&screen, KeyCode::Tab, false, false, false, false), + input::map_key(&screen, KeyCode::Tab, false, false, false, false, 0), Action::Noop ); } @@ -667,11 +667,11 @@ fn test_input_map_spec_view_tab_focus() { #[test] fn test_input_map_sync_config_with_url() { assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false, 0), Action::SyncLogin ); assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false), + input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false, 0), Action::SyncRegister ); } @@ -680,7 +680,7 @@ fn test_input_map_sync_config_with_url() { fn test_input_map_sync_config_without_url() { // Without URL, login/register are noop assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false, 0), Action::Noop ); } @@ -688,15 +688,15 @@ fn test_input_map_sync_config_without_url() { #[test] fn test_input_map_model_config() { assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false, 0), Action::SelectModel ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false), + input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false, 0), Action::Cancel ); } @@ -796,11 +796,11 @@ fn test_accept_candidate_key_mapping() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, 0), Action::AcceptCandidate ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, 0), Action::AcceptCandidate ); } @@ -827,15 +827,15 @@ fn test_input_map_candidate_keys_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), true, true, false, false), + input::map_key(&screen, KeyCode::Char(']'), true, true, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), true, true, false, false), + input::map_key(&screen, KeyCode::Char('['), true, true, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, 0), Action::AcceptCandidate ); } @@ -846,15 +846,15 @@ fn test_input_map_candidate_keys_flat_list() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), false, false, false, false), + input::map_key(&screen, KeyCode::Char(']'), false, false, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), false, false, false, false), + input::map_key(&screen, KeyCode::Char('['), false, false, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, 0), Action::AcceptCandidate ); } diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index 4df2dca..24e23c9 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -226,6 +226,7 @@ pub async fn ai_answer( state: &Arc, id: &str, model: String, + generate: bool, ) -> Result { // Read pre-answer state let (was_unanswered, old_answer, is_root, spec_id) = { @@ -263,19 +264,21 @@ pub async fn ai_answer( // Trigger post-answer pipeline (same as answer_node) crate::generate::spawn_entropy_evaluation(state.clone(), id.to_string(), model.clone()); - if was_unanswered { - crate::generate::spawn_generation(state.clone(), id.to_string(), model.clone()); - } else { - let children = state.db().get_children(id)?; - if !children.is_empty() { - let old = old_answer.unwrap_or_default(); - crate::generate::spawn_review( - state.clone(), - id.to_string(), - old, - crate::generate::EditKind::Answer, - model.clone(), - ); + if generate { + if was_unanswered { + crate::generate::spawn_generation(state.clone(), id.to_string(), model.clone()); + } else { + let children = state.db().get_children(id)?; + if !children.is_empty() { + let old = old_answer.unwrap_or_default(); + crate::generate::spawn_review( + state.clone(), + id.to_string(), + old, + crate::generate::EditKind::Answer, + model.clone(), + ); + } } } diff --git a/crates/spec-forest/src/http.rs b/crates/spec-forest/src/http.rs index 40d9bd1..9465e95 100644 --- a/crates/spec-forest/src/http.rs +++ b/crates/spec-forest/src/http.rs @@ -471,7 +471,7 @@ async fn ai_answer_handler( axum::Json(body): axum::Json, ) -> Result, AppError> { let model = body.model.unwrap_or_else(|| "opus".to_string()); - Ok(Json(crate::api::ai_answer(&state, &id, model).await?)) + Ok(Json(crate::api::ai_answer(&state, &id, model, true).await?)) } async fn add_child_handler( From d320558ae76dd647227a260613cf9ba8aee55fe6 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:15:29 +1100 Subject: [PATCH 021/100] feat: add tracing for Claude CLI response model and size Log model, prompt size, response size, and elapsed time on success, failure, and timeout paths for better observability of Claude API calls. --- .../spec-forest/src/generate/claude_runner.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/spec-forest/src/generate/claude_runner.rs b/crates/spec-forest/src/generate/claude_runner.rs index 1374a78..4a25536 100644 --- a/crates/spec-forest/src/generate/claude_runner.rs +++ b/crates/spec-forest/src/generate/claude_runner.rs @@ -82,6 +82,12 @@ pub(super) async fn run_claude_in_dir( Ok(result) => result?, Err(_) => { let err_msg = "claude CLI timed out after 600 seconds"; + tracing::warn!( + model, + caller, + prompt_chars = prompt.len(), + "Claude CLI timed out after 600s" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Err(err_msg), start.elapsed()); } @@ -94,6 +100,14 @@ pub(super) async fn run_claude_in_dir( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let err_msg = format!("claude CLI failed: {}", stderr); + tracing::warn!( + model, + caller, + prompt_chars = prompt.len(), + stderr_chars = stderr.len(), + ?elapsed, + "Claude CLI failed" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Err(&err_msg), elapsed); } @@ -101,6 +115,14 @@ pub(super) async fn run_claude_in_dir( } let response = String::from_utf8(output.stdout)?; + tracing::info!( + model, + caller, + prompt_chars = prompt.len(), + response_chars = response.len(), + ?elapsed, + "Claude CLI responded" + ); if let Some(log) = prompt_log { log.log(caller, model, prompt, Ok(&response), elapsed); } From cca35bce1a576af5d7a3dd0a4a092bb6dbaa01b2 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:21:03 +1100 Subject: [PATCH 022/100] refactor: remove spec-forest-db runtime dependency from TUI The TUI now exclusively uses the spec-forest API layer instead of accessing the database directly. Added missing API functions (get_children, get_next_question, get/set_setting) and re-exported DB types from spec-forest so frontends don't need a direct dep. --- crates/spec-forest-tui/Cargo.toml | 2 +- crates/spec-forest-tui/src/app.rs | 40 +++++++++------------- crates/spec-forest-tui/src/commands.rs | 14 ++++---- crates/spec-forest-tui/src/error.rs | 12 +++++-- crates/spec-forest-tui/src/tree_state.rs | 29 ++++++++-------- crates/spec-forest-tui/src/ui/common.rs | 2 +- crates/spec-forest-tui/src/ui/spec_view.rs | 8 ++--- crates/spec-forest-tui/tests/tui_tests.rs | 10 ++---- crates/spec-forest/src/api/graph.rs | 5 +++ crates/spec-forest/src/api/nodes.rs | 5 +++ crates/spec-forest/src/api/server.rs | 9 +++++ crates/spec-forest/src/lib.rs | 6 ++++ 12 files changed, 80 insertions(+), 62 deletions(-) diff --git a/crates/spec-forest-tui/Cargo.toml b/crates/spec-forest-tui/Cargo.toml index 19b5852..257e864 100644 --- a/crates/spec-forest-tui/Cargo.toml +++ b/crates/spec-forest-tui/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [dependencies] spec-forest = { path = "../spec-forest" } -spec-forest-db = { path = "../spec-forest-db" } ratatui = "0.29" crossterm = { version = "0.28", features = ["event-stream"] } futures = "0.3" @@ -20,4 +19,5 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-appender = "0.2" [dev-dependencies] +spec-forest-db = { path = "../spec-forest-db" } tempfile = "3" diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 5b139c1..56530ca 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -10,7 +10,7 @@ use spec_forest::explore::{ExploreStatus, ExploreStatusResponse}; use spec_forest::ingest::IngestState; use spec_forest::api; use spec_forest::state::{AppState, GenerationStatus}; -use spec_forest_db::candidate::CandidateAnswer; +use spec_forest::CandidateAnswer; use crate::action::Action; use crate::commands; @@ -43,9 +43,9 @@ pub const SPEC_OPTIONS: &[(&str, &str, &str)] = &[ pub struct App { pub state: Arc, pub screen: Screen, - pub specs: Vec, + pub specs: Vec, pub selected: usize, - pub nodes: Vec, + pub nodes: Vec, pub input: String, pub message: Option, pub should_quit: bool, @@ -115,9 +115,7 @@ impl App { tracing::warn!("Failed to load specs on startup: {e}"); Vec::new() }); - let auto_explore = state - .db() - .get_setting("auto_explore") + let auto_explore = api::get_setting(&state, "auto_explore") .ok() .flatten() .map(|v| v == "true") @@ -407,7 +405,8 @@ impl App { } Action::ToggleAutoExplore => { self.auto_explore = !self.auto_explore; - let _ = self.state.db().set_setting( + let _ = api::set_setting( + &self.state, "auto_explore", if self.auto_explore { "true" } else { "false" }, ); @@ -613,8 +612,7 @@ impl App { self.tree_state = TreeState::new(); self.tree_visible = true; self.tree_focused = true; - let db = self.state.db(); - if let Err(e) = self.tree_state.rebuild(&db, spec_id) { + if let Err(e) = self.tree_state.rebuild(&self.state, spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } @@ -763,8 +761,7 @@ impl App { }; self.tree_visible = !self.tree_visible; if self.tree_visible { - let db = self.state.db(); - if let Err(e) = self.tree_state.rebuild(&db, &spec_id) { + if let Err(e) = self.tree_state.rebuild(&self.state, &spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } @@ -779,12 +776,10 @@ impl App { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let db = self.state.db(); - if let Err(e) = self.tree_state.expand_selected(&db, &spec_id) { + if let Err(e) = self.tree_state.expand_selected(&self.state, &spec_id) { tracing::error!("Tree expand failed: {e}"); self.message = Some(e.to_string()); } - drop(db); } fn collapse_tree_node(&mut self) { @@ -792,8 +787,7 @@ impl App { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let db = self.state.db(); - if let Err(e) = self.tree_state.collapse_selected(&db, &spec_id) { + if let Err(e) = self.tree_state.collapse_selected(&self.state, &spec_id) { tracing::error!("Tree collapse failed: {e}"); self.message = Some(e.to_string()); } @@ -801,8 +795,7 @@ impl App { fn rebuild_tree_if_visible(&mut self, spec_id: &str) { if self.tree_visible { - let db = self.state.db(); - if let Err(e) = self.tree_state.rebuild(&db, spec_id) { + if let Err(e) = self.tree_state.rebuild(&self.state, spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } @@ -812,7 +805,7 @@ impl App { // ── Node operations ───────────────────────────────────────── - pub fn get_selected_node(&self) -> Option<&spec_forest_db::Node> { + pub fn get_selected_node(&self) -> Option<&spec_forest::Node> { let node_id = self.tree_state.selected_node_id()?; self.nodes.iter().find(|n| n.id == node_id) } @@ -904,7 +897,7 @@ impl App { let Some(node_id) = self.tree_state.selected_node_id() else { return; }; - match self.state.db().get_node(node_id) { + match api::get_node(&self.state, node_id) { Ok(node) => node, Err(e) => { tracing::error!("Failed to get node for edit: {e}"); @@ -948,7 +941,7 @@ impl App { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, }; - let next = self.state.db().get_next_question(&spec_id); + let next = api::get_next_question(&self.state, &spec_id); match next { Ok(Some(node)) => { self.needs_redraw = true; @@ -1116,8 +1109,7 @@ impl App { self.candidate_node_id = current_node_id.clone(); self.candidate_selected = 0; if let Some(node_id) = current_node_id { - let db = self.state.db(); - match db.get_candidates(&node_id) { + match api::get_candidates(&self.state, &node_id) { Ok(c) => self.candidates = c, Err(e) => { tracing::warn!("Failed to load candidates: {e}"); @@ -1162,7 +1154,7 @@ impl App { let Some(candidate) = self.candidates.get(self.candidate_selected).cloned() else { return; }; - let node = match self.state.db().get_node(&candidate.node_id) { + let node = match api::get_node(&self.state, &candidate.node_id) { Ok(node) => node, Err(e) => { tracing::error!("Failed to get node for candidate edit: {e}"); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 5cf8c51..643db71 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -5,14 +5,14 @@ use spec_forest::state::AppState; use crate::error::TuiError; -pub fn refresh_spec_list(state: &AppState) -> Result, TuiError> { +pub fn refresh_spec_list(state: &AppState) -> Result, TuiError> { spec_forest::api::list_specs(state).map_err(|e| TuiError::Api(e.to_string())) } pub fn load_spec_nodes( state: &AppState, spec_id: &str, -) -> Result, TuiError> { +) -> Result, TuiError> { spec_forest::api::get_spec_nodes(state, spec_id).map_err(|e| TuiError::Api(e.to_string())) } @@ -21,7 +21,7 @@ pub async fn create_spec( name: String, mode: Option<&str>, locality: Option<&str>, -) -> Result { +) -> Result { spec_forest::api::create_spec(state, name, None, mode, locality, None) .await .map_err(|e| TuiError::Api(e.to_string())) @@ -125,7 +125,7 @@ pub async fn create_feature( spec_id: &str, content: String, model: String, -) -> Result { +) -> Result { spec_forest::api::create_feature(state, spec_id, content, model) .await .map_err(|e| TuiError::Api(e.to_string())) @@ -136,7 +136,7 @@ pub async fn add_child( parent_id: &str, question: String, model: String, -) -> Result { +) -> Result { spec_forest::api::add_child(state, parent_id, question, model) .await .map_err(|e| TuiError::Api(e.to_string())) @@ -146,7 +146,7 @@ pub fn update_directory( state: &AppState, spec_id: &str, directory: Option, -) -> Result { +) -> Result { spec_forest::api::update_directory(state, spec_id, directory) .map_err(|e| TuiError::Api(e.to_string())) } @@ -154,7 +154,7 @@ pub fn update_directory( pub async fn delete_node( state: &Arc, node_id: &str, -) -> Result { +) -> Result { spec_forest::api::delete_node(state, node_id) .await .map_err(|e| TuiError::Api(e.to_string())) diff --git a/crates/spec-forest-tui/src/error.rs b/crates/spec-forest-tui/src/error.rs index 4cd9414..8bcccc7 100644 --- a/crates/spec-forest-tui/src/error.rs +++ b/crates/spec-forest-tui/src/error.rs @@ -2,7 +2,7 @@ use std::fmt; #[derive(Debug)] pub enum TuiError { - Db(spec_forest_db::Error), + Db(spec_forest::DbError), Api(String), Io(std::io::Error), Editor(String), @@ -21,12 +21,18 @@ impl fmt::Display for TuiError { impl std::error::Error for TuiError {} -impl From for TuiError { - fn from(e: spec_forest_db::Error) -> Self { +impl From for TuiError { + fn from(e: spec_forest::DbError) -> Self { TuiError::Db(e) } } +impl From for TuiError { + fn from(e: spec_forest::ApiError) -> Self { + TuiError::Api(e.to_string()) + } +} + impl From for TuiError { fn from(e: std::io::Error) -> Self { TuiError::Io(e) diff --git a/crates/spec-forest-tui/src/tree_state.rs b/crates/spec-forest-tui/src/tree_state.rs index 79cf645..cb24131 100644 --- a/crates/spec-forest-tui/src/tree_state.rs +++ b/crates/spec-forest-tui/src/tree_state.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; -use spec_forest_db::{Database, NodeState}; +use spec_forest::state::AppState; +use spec_forest::{Node, NodeState}; use crate::error::TuiError; @@ -84,7 +85,7 @@ impl TreeState { pub fn expand_selected( &mut self, - db: &Database, + state: &AppState, spec_id: &str, ) -> Result<(), TuiError> { let Some(entry) = self.entries.get(self.selected) else { @@ -97,20 +98,20 @@ impl TreeState { if self.expanded.contains(&node_id) { self.expanded.remove(&node_id); } else { - let children = db.get_children(&node_id)?; + let children = spec_forest::api::get_children(state, &node_id)?; if children.is_empty() { self.known_leaves.insert(node_id); - self.rebuild(db, spec_id)?; + self.rebuild(state, spec_id)?; return Ok(()); } self.expanded.insert(node_id); } - self.rebuild(db, spec_id) + self.rebuild(state, spec_id) } pub fn collapse_selected( &mut self, - db: &Database, + state: &AppState, spec_id: &str, ) -> Result<(), TuiError> { let Some(entry) = self.entries.get(self.selected) else { @@ -121,7 +122,7 @@ impl TreeState { if self.expanded.contains(&node_id) { self.expanded.remove(&node_id); - self.rebuild(db, spec_id)?; + self.rebuild(state, spec_id)?; } else if depth > 0 { // Jump to parent: find the nearest entry above with depth - 1 for i in (0..self.selected).rev() { @@ -134,12 +135,12 @@ impl TreeState { Ok(()) } - pub fn rebuild(&mut self, db: &Database, spec_id: &str) -> Result<(), TuiError> { + pub fn rebuild(&mut self, state: &AppState, spec_id: &str) -> Result<(), TuiError> { self.known_leaves.clear(); self.entries.clear(); - let roots = db.get_roots(spec_id)?; + let roots = spec_forest::api::get_spec_roots(state, spec_id)?; for root in &roots { - self.push_node(db, root, 0)?; + self.push_node(state, root, 0)?; } self.clamp_selection(); Ok(()) @@ -153,8 +154,8 @@ impl TreeState { fn push_node( &mut self, - db: &Database, - node: &spec_forest_db::Node, + state: &AppState, + node: &Node, depth: usize, ) -> Result<(), TuiError> { let has_children = !self.known_leaves.contains(&node.id); @@ -169,7 +170,7 @@ impl TreeState { }); if self.expanded.contains(&node.id) { - let children = db.get_children(&node.id)?; + let children = spec_forest::api::get_children(state, &node.id)?; if children.is_empty() { self.known_leaves.insert(node.id.clone()); if let Some(entry) = self.entries.last_mut() { @@ -177,7 +178,7 @@ impl TreeState { } } for child in &children { - self.push_node(db, child, depth + 1)?; + self.push_node(state, child, depth + 1)?; } } Ok(()) diff --git a/crates/spec-forest-tui/src/ui/common.rs b/crates/spec-forest-tui/src/ui/common.rs index a2f1796..f5ac2bd 100644 --- a/crates/spec-forest-tui/src/ui/common.rs +++ b/crates/spec-forest-tui/src/ui/common.rs @@ -1,5 +1,5 @@ use ratatui::style::Color; -use spec_forest_db::NodeState; +use spec_forest::NodeState; pub fn spinner_char(tick: u64) -> char { const FRAMES: &[char] = &[ diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 004d73a..8abc18b 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -223,10 +223,10 @@ fn render_tree_panel(app: &App, frame: &mut Frame, area: Rect) { } None => { let s = match entry.node_state { - spec_forest_db::NodeState::Unanswered => "[?]", - spec_forest_db::NodeState::Answered => "[+]", - spec_forest_db::NodeState::NeedsReview => "[~]", - spec_forest_db::NodeState::Deleted => "[x]", + spec_forest::NodeState::Unanswered => "[?]", + spec_forest::NodeState::Answered => "[+]", + spec_forest::NodeState::NeedsReview => "[~]", + spec_forest::NodeState::Deleted => "[x]", }; (s.to_string(), state_color(entry.node_state)) } diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index b89844d..13ba0c0 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -334,10 +334,7 @@ fn make_app_with_tree() -> (App, String, String) { }; app.tree_visible = true; app.tree_focused = true; - { - let db = app.state.db(); - app.tree_state.rebuild(&db, &spec_id).unwrap(); - } + app.tree_state.rebuild(&app.state, &spec_id).unwrap(); (app, spec_id, root_id) } @@ -495,10 +492,7 @@ async fn test_tree_shows_all_root_nodes() { } // Rebuild tree to pick up extra roots - { - let db = app.state.db(); - app.tree_state.rebuild(&db, &spec_id).unwrap(); - } + app.tree_state.rebuild(&app.state, &spec_id).unwrap(); // The tree should contain all root nodes let root_entries: Vec<_> = app diff --git a/crates/spec-forest/src/api/graph.rs b/crates/spec-forest/src/api/graph.rs index 06ac609..427c7e3 100644 --- a/crates/spec-forest/src/api/graph.rs +++ b/crates/spec-forest/src/api/graph.rs @@ -66,6 +66,11 @@ pub fn get_spec_graph( }) } +pub fn get_children(state: &AppState, node_id: &str) -> Result, ApiError> { + let children = state.db().get_children(node_id)?; + Ok(children) +} + pub fn get_ancestors(state: &AppState, node_id: &str) -> Result, ApiError> { let nodes = state.db().get_ancestors(node_id)?; Ok(nodes) diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index 24e23c9..54bdf6b 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -364,3 +364,8 @@ pub async fn delete_node(state: &Arc, id: &str) -> Result Result, ApiError> { + Ok(state.db().get_next_question(spec_id)?) +} diff --git a/crates/spec-forest/src/api/server.rs b/crates/spec-forest/src/api/server.rs index 0903c42..1f064c4 100644 --- a/crates/spec-forest/src/api/server.rs +++ b/crates/spec-forest/src/api/server.rs @@ -69,6 +69,15 @@ pub fn set_user_name(state: &AppState, name: &str) -> Result<(), ApiError> { Ok(()) } +pub fn get_setting(state: &AppState, key: &str) -> Result, ApiError> { + Ok(state.db().get_setting(key)?) +} + +pub fn set_setting(state: &AppState, key: &str, value: &str) -> Result<(), ApiError> { + state.db().set_setting(key, value)?; + Ok(()) +} + pub fn get_shadow_answers( state: &AppState, node_id: &str, diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index aadf93d..49c84c7 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -14,6 +14,12 @@ mod tools; pub use api::ApiError; +// Re-export DB types for frontend crates +pub use spec_forest_db::{ + Error as DbError, Locality, Node, NodeState, Spec, SpecMode, SpecSummary, +}; +pub use spec_forest_db::candidate::CandidateAnswer; + use op_channel::OpRequest; use prompt_log::PromptLog; use rmcp::transport::streamable_http_server::{ From ae8ef05cafef58900c963f32bdc8f81be715c9ae Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:37:50 +1100 Subject: [PATCH 023/100] fix: show explore keys in TUI candidates footer --- crates/spec-forest-tui/src/ui/spec_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 8abc18b..08dd196 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -64,7 +64,7 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { From 2a723041563760c98ddc20b48b5564b0de038e7a Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:48:43 +1100 Subject: [PATCH 024/100] feat: display full node and spec IDs in TUI for easy copying --- crates/spec-forest-tui/src/ui/spec_list.rs | 15 +++++++++++++-- crates/spec-forest-tui/src/ui/spec_view.rs | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index fba9118..60f2e22 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -11,7 +11,7 @@ use crate::app::App; pub fn render(app: &App, frame: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(3)]) + .constraints([Constraint::Min(3), Constraint::Length(1), Constraint::Length(3)]) .split(frame.area()); let items: Vec = app @@ -35,6 +35,17 @@ pub fn render(app: &App, frame: &mut Frame) { } frame.render_stateful_widget(list, chunks[0], &mut state); + let id_text = if !app.specs.is_empty() { + format!(" ID: {}", app.specs[app.selected].id) + } else { + String::new() + }; + let id_line = Paragraph::new(Span::styled( + id_text, + Style::default().fg(Color::DarkGray), + )); + frame.render_widget(id_line, chunks[1]); + let footer_text = app .message .as_deref() @@ -48,5 +59,5 @@ pub fn render(app: &App, frame: &mut Frame) { Line::from(footer_text) }; let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[1]); + frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 08dd196..b64354a 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -99,6 +99,12 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + format!("ID: {}", node.id), + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( "Question", Style::default() From d8acf9c60852d9ce88445a93a66c2d70e7ca908b Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 11:58:47 +1100 Subject: [PATCH 025/100] fix: make TUI node edit load both question and answer The editor now shows both fields as editable sections (## Question / ## Answer) so existing answers are preserved and either field can be updated independently. The flat-list 'e' key now edits the selected node instead of fetching the next unanswered question. --- crates/spec-forest-tui/src/app.rs | 103 ++++++++++--------------- crates/spec-forest-tui/src/commands.rs | 12 +++ crates/spec-forest-tui/src/editor.rs | 64 ++++++++++++++- 3 files changed, 114 insertions(+), 65 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 56530ca..bf18ddf 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -253,7 +253,7 @@ impl App { Action::FullExplore => self.trigger_full_explore(), Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), - Action::EditNextQuestion => self.edit_next_question().await, + Action::EditNextQuestion => self.edit_tree_node().await, Action::AddFeature => self.add_feature().await, Action::AddQuestion => self.add_question().await, Action::DeleteNode => self.delete_node().await, @@ -908,73 +908,52 @@ impl App { }; self.needs_redraw = true; - match editor::edit_question(&node.question, node.answer.as_deref()) { - Ok(Some(new_answer)) => { - match commands::submit_answer( - &self.state, - &node.id, - new_answer, - self.model.clone(), - self.auto_explore, - ) - .await - { - Ok(()) => self.message = Some("Answer submitted".to_string()), - Err(e) => { - tracing::error!("Submit answer failed: {e}"); - self.message = Some(e.to_string()); + match editor::edit_node(&node.question, node.answer.as_deref()) { + Ok(Some(result)) => { + let mut updated = false; + if let Some(new_question) = result.question { + match commands::update_question( + &self.state, + &node.id, + new_question, + self.model.clone(), + ) + .await + { + Ok(()) => updated = true, + Err(e) => { + tracing::error!("Update question failed: {e}"); + self.message = Some(e.to_string()); + } } } - self.refresh_nodes(&spec_id); - self.rebuild_tree_if_visible(&spec_id); - } - Ok(None) => self.message = Some("No changes".to_string()), - Err(e) => { - tracing::error!("Editor failed: {e}"); - self.message = Some(format!("Editor error: {e}")); - } - } - } - - async fn edit_next_question(&mut self) { - let spec_id = match &self.screen { - Screen::SpecView { spec_id } => spec_id.clone(), - _ => return, - }; - let next = api::get_next_question(&self.state, &spec_id); - match next { - Ok(Some(node)) => { - self.needs_redraw = true; - match editor::edit_question(&node.question, node.answer.as_deref()) { - Ok(Some(new_answer)) => { - match commands::submit_answer( - &self.state, - &node.id, - new_answer, - self.model.clone(), - self.auto_explore, - ) - .await - { - Ok(()) => self.message = Some("Answer submitted".to_string()), - Err(e) => { - tracing::error!("Submit answer failed: {e}"); - self.message = Some(e.to_string()); - } + if let Some(new_answer) = result.answer { + match commands::submit_answer( + &self.state, + &node.id, + new_answer, + self.model.clone(), + self.auto_explore, + ) + .await + { + Ok(()) => updated = true, + Err(e) => { + tracing::error!("Submit answer failed: {e}"); + self.message = Some(e.to_string()); } - self.refresh_nodes(&spec_id); - } - Ok(None) => self.message = Some("No changes".to_string()), - Err(e) => { - tracing::error!("Editor failed: {e}"); - self.message = Some(format!("Editor error: {e}")); } } + if updated { + self.message = Some("Node updated".to_string()); + self.refresh_nodes(&spec_id); + self.rebuild_tree_if_visible(&spec_id); + } } - Ok(None) => self.message = Some("No unanswered questions".to_string()), + Ok(None) => self.message = Some("No changes".to_string()), Err(e) => { - tracing::error!("Failed to get next question: {e}"); - self.message = Some(format!("Error: {e}")); + tracing::error!("Editor failed: {e}"); + self.message = Some(format!("Editor error: {e}")); } } } @@ -1164,7 +1143,7 @@ impl App { }; self.needs_redraw = true; - match editor::edit_question(&node.question, Some(&candidate.answer)) { + match editor::edit_answer(&node.question, Some(&candidate.answer)) { Ok(Some(new_answer)) => { match commands::submit_answer( &self.state, diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 643db71..693f4e9 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -52,6 +52,18 @@ pub async fn submit_answer( .map_err(|e| TuiError::Api(e.to_string())) } +pub async fn update_question( + state: &Arc, + node_id: &str, + question: String, + model: String, +) -> Result<(), TuiError> { + spec_forest::api::update_question(state, node_id, question, model) + .await + .map(|_| ()) + .map_err(|e| TuiError::Api(e.to_string())) +} + pub fn spawn_ai_answer(state: Arc, node_id: String, model: String, generate: bool) { tokio::spawn(async move { if let Err(e) = spec_forest::api::ai_answer(&state, &node_id, model, generate).await { diff --git a/crates/spec-forest-tui/src/editor.rs b/crates/spec-forest-tui/src/editor.rs index 28928d8..3be2cdb 100644 --- a/crates/spec-forest-tui/src/editor.rs +++ b/crates/spec-forest-tui/src/editor.rs @@ -59,10 +59,68 @@ fn run_editor(initial_content: &str) -> io::Result> { Ok(Some(stripped)) } -/// Opens the user's default editor with a temp file containing the question -/// and current answer. Returns `Some(new_answer)` if the answer was changed, +/// Result of editing a node — contains only the fields that changed. +pub struct EditResult { + pub question: Option, + pub answer: Option, +} + +/// Opens the user's default editor with both the question and answer editable. +/// Returns `Some(EditResult)` with only the changed fields set, or `None` if +/// nothing changed or the editor was aborted. +pub fn edit_node(question: &str, current_answer: Option<&str>) -> io::Result> { + let answer_text = current_answer.unwrap_or(""); + let initial = format!( + "# Lines starting with # are ignored.\n# Save and quit to submit.\n\n## Question\n{question}\n\n## Answer\n{answer_text}" + ); + + let content = match run_editor(&initial)? { + Some(c) => c, + None => return Ok(None), + }; + + let (new_question, new_answer) = parse_question_answer(&content); + + let q_changed = new_question.trim() != question.trim(); + let a_changed = new_answer.trim() != answer_text.trim(); + + if !q_changed && !a_changed { + return Ok(None); + } + + Ok(Some(EditResult { + question: if q_changed { Some(new_question) } else { None }, + answer: if a_changed { Some(new_answer) } else { None }, + })) +} + +/// Parse editor content into (question, answer) by splitting on section markers. +fn parse_question_answer(content: &str) -> (String, String) { + // Look for ## Answer marker to split + if let Some(answer_pos) = content.find("## Answer") { + let question_part = &content[..answer_pos]; + let answer_part = &content[answer_pos + "## Answer".len()..]; + + // Strip ## Question marker from question part + let question = question_part + .strip_prefix("## Question") + .unwrap_or(question_part) + .trim() + .to_string(); + let answer = answer_part.trim().to_string(); + (question, answer) + } else if let Some(q) = content.strip_prefix("## Question") { + (q.trim().to_string(), String::new()) + } else { + // No markers found — treat entire content as question + (content.trim().to_string(), String::new()) + } +} + +/// Opens the user's default editor with the question as context (non-editable comment) +/// and the answer as editable text. Returns `Some(new_answer)` if the answer was changed, /// `None` if unchanged or the editor was aborted. -pub fn edit_question(question: &str, current_answer: Option<&str>) -> io::Result> { +pub fn edit_answer(question: &str, current_answer: Option<&str>) -> io::Result> { let answer_text = current_answer.unwrap_or(""); let initial = format!( "# Question: {question}\n# Lines starting with # are ignored.\n# Save and quit to submit.\n\n{answer_text}" From ed1ebf3f7de87de42c9767b8dcf9e3dc3b3f4894 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 12:27:25 +1100 Subject: [PATCH 026/100] fix: display tracing structured fields in TUI log panel --- crates/spec-forest-tui/src/log_buffer.rs | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/log_buffer.rs b/crates/spec-forest-tui/src/log_buffer.rs index 6d6ae3d..84e880f 100644 --- a/crates/spec-forest-tui/src/log_buffer.rs +++ b/crates/spec-forest-tui/src/log_buffer.rs @@ -57,18 +57,41 @@ impl TuiLogLayer { struct MessageVisitor { message: String, + fields: Vec<(String, String)>, +} + +impl MessageVisitor { + fn into_message(self) -> String { + if self.fields.is_empty() { + return self.message; + } + let mut out = self.message; + for (k, v) in self.fields { + out.push(' '); + out.push_str(&k); + out.push('='); + out.push_str(&v); + } + out + } } impl Visit for MessageVisitor { fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { if field.name() == "message" { self.message = format!("{:?}", value); + } else { + self.fields + .push((field.name().to_string(), format!("{:?}", value))); } } fn record_str(&mut self, field: &Field, value: &str) { if field.name() == "message" { self.message = value.to_string(); + } else { + self.fields + .push((field.name().to_string(), value.to_string())); } } } @@ -92,13 +115,14 @@ impl Layer for TuiLogLayer { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let mut visitor = MessageVisitor { message: String::new(), + fields: Vec::new(), }; event.record(&mut visitor); let entry = LogEntry { timestamp: format_timestamp(), level: *event.metadata().level(), - message: visitor.message, + message: visitor.into_message(), }; if let Ok(mut buf) = self.buffer.lock() { From f5c21c3b3b4694bcba7dc5efdc3ffd8bfa4fd3a0 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 12:39:05 +1100 Subject: [PATCH 027/100] fix: preserve section markers when stripping comments in TUI editor The comment filter in run_editor() was stripping all lines starting with '#', including the '## Question' and '## Answer' section markers that parse_question_answer() needs. This caused edited answers to be placed into the question field instead. --- crates/spec-forest-tui/src/editor.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/editor.rs b/crates/spec-forest-tui/src/editor.rs index 3be2cdb..a3f5b6c 100644 --- a/crates/spec-forest-tui/src/editor.rs +++ b/crates/spec-forest-tui/src/editor.rs @@ -50,7 +50,10 @@ fn run_editor(initial_content: &str) -> io::Result> { let content = std::fs::read_to_string(&path)?; let stripped: String = content .lines() - .filter(|line| !line.starts_with('#')) + .filter(|line| { + let trimmed = line.trim_start(); + !(trimmed.starts_with('#') && !trimmed.starts_with("##")) + }) .collect::>() .join("\n") .trim() From ff9fa0c297c6c0a5404af1138275a825342d0d32 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:26:57 +1100 Subject: [PATCH 028/100] feat: add simulation mode for interactive spec-driven software simulation Introduces a new TUI screen where users can interactively simulate their software based on spec nodes before implementation. An AI agent drives the simulation, producing per-channel output (UI, audio, network, errors, logs) grounded in spec node references. Key features: - Channel picker to select output channels before starting - Dual input modes (Normal/Insert) with Ctrl+Enter to submit - Tab/split-pane layout cycling (F5) for multi-channel views - Spec reference overlays ([^N] footnotes, number keys to inspect) - Spec gap indicators for ungrounded agent behavior - Behavior reporting mode (r key) for inverse traceability - Background processing with spinner, session resumption via --resume --- Cargo.lock | 2 + crates/spec-forest-tui/Cargo.toml | 2 + crates/spec-forest-tui/src/action.rs | 23 ++ crates/spec-forest-tui/src/app.rs | 312 ++++++++++++++++- crates/spec-forest-tui/src/commands.rs | 81 +++++ crates/spec-forest-tui/src/input.rs | 52 ++- crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/main.rs | 1 + crates/spec-forest-tui/src/simulation.rs | 78 +++++ crates/spec-forest-tui/src/ui.rs | 4 + .../src/ui/sim_channel_picker.rs | 66 ++++ crates/spec-forest-tui/src/ui/simulation.rs | 324 ++++++++++++++++++ crates/spec-forest-tui/src/ui/spec_view.rs | 6 +- crates/spec-forest-tui/tests/tui_tests.rs | 58 ++-- crates/spec-forest/src/lib.rs | 1 + crates/spec-forest/src/simulation.rs | 8 + crates/spec-forest/src/simulation/prompt.rs | 92 +++++ crates/spec-forest/src/simulation/runner.rs | 229 +++++++++++++ crates/spec-forest/src/simulation/session.rs | 89 +++++ crates/spec-forest/src/simulation/types.rs | 53 +++ crates/spec-forest/src/state.rs | 54 +++ 21 files changed, 1501 insertions(+), 35 deletions(-) create mode 100644 crates/spec-forest-tui/src/simulation.rs create mode 100644 crates/spec-forest-tui/src/ui/sim_channel_picker.rs create mode 100644 crates/spec-forest-tui/src/ui/simulation.rs create mode 100644 crates/spec-forest/src/simulation.rs create mode 100644 crates/spec-forest/src/simulation/prompt.rs create mode 100644 crates/spec-forest/src/simulation/runner.rs create mode 100644 crates/spec-forest/src/simulation/session.rs create mode 100644 crates/spec-forest/src/simulation/types.rs diff --git a/Cargo.lock b/Cargo.lock index 01b9a0d..d27c32e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3307,6 +3307,7 @@ dependencies = [ "dirs", "futures", "ratatui", + "serde_json", "spec-forest", "spec-forest-db", "tempfile", @@ -3315,6 +3316,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/spec-forest-tui/Cargo.toml b/crates/spec-forest-tui/Cargo.toml index 257e864..8bbc73b 100644 --- a/crates/spec-forest-tui/Cargo.toml +++ b/crates/spec-forest-tui/Cargo.toml @@ -17,6 +17,8 @@ tempfile = "3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-appender = "0.2" +uuid = { version = "1", features = ["v4"] } +serde_json = "1" [dev-dependencies] spec-forest-db = { path = "../spec-forest-db" } diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 7187f92..2b6431c 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -91,5 +91,28 @@ pub enum Action { LogScrollUp, LogScrollDown, + // Simulation - launch + LaunchSimulation, + + // Simulation - channel picker + SimChannelUp, + SimChannelDown, + SimChannelToggle, + SimChannelConfirm, + SimChannelCancel, + + // Simulation - screen + SimEnterInsert, + SimExitToNormal, + SimExitSimulation, + SimTypeChar(char), + SimDeleteChar, + SimSubmitInput, + SimCycleChannel, + SimCycleLayout, + SimEnterReport, + SimOpenRef(String), + SimCloseOverlay, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index bf18ddf..2ffdb02 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -78,6 +78,10 @@ pub struct App { pub log_scroll_offset: usize, pub pending_delete: Option, pub auto_explore: bool, + // Simulation + pub sim_state: Option, + pub sim_channel_selected: usize, + pub sim_channel_selection: std::collections::HashSet, } #[derive(Clone)] @@ -94,6 +98,8 @@ pub enum Screen { ModelConfig, Config, UsernameInput, + SimChannelPicker { spec_id: String }, + Simulation { spec_id: String, session_id: String }, } #[derive(Clone)] @@ -158,6 +164,9 @@ impl App { log_scroll_offset: 0, pending_delete: None, auto_explore, + sim_state: None, + sim_channel_selected: 0, + sim_channel_selection: std::collections::HashSet::new(), } } @@ -180,15 +189,29 @@ impl App { continue; } self.message = None; - self.handle_key(key.code).await; + self.handle_key(key.code, key.modifiers).await; } self.tick += 1; + if let Some(ref mut sim) = self.sim_state { + sim.tick = self.tick; + } self.poll_background_status(); } Ok(()) } - pub async fn handle_key(&mut self, key: KeyCode) { + pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { + // Simulation screen needs modifiers for Ctrl+Enter + if matches!(self.screen, Screen::Simulation { .. }) { + let mode = self + .sim_state + .as_ref() + .map(|s| s.mode) + .unwrap_or(crate::simulation::SimInputMode::Normal); + let action = input::map_sim_key(key, modifiers, mode); + self.execute_action(action).await; + return; + } let has_sync_url = self.state.sync_url().is_some(); let action = input::map_key( &self.screen, @@ -459,6 +482,153 @@ impl App { } Action::AcceptCandidate => self.accept_candidate().await, Action::EditCandidate => self.edit_candidate().await, + + // Simulation - launch + Action::LaunchSimulation => { + if let Screen::SpecView { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.sim_channel_selected = 0; + self.sim_channel_selection = [0].into(); // UI channel selected by default + self.screen = Screen::SimChannelPicker { spec_id }; + } + } + + // Simulation - channel picker + Action::SimChannelUp => { + self.sim_channel_selected = self.sim_channel_selected.saturating_sub(1); + } + Action::SimChannelDown => { + let max = spec_forest::simulation::SimChannel::ALL.len().saturating_sub(1); + self.sim_channel_selected = (self.sim_channel_selected + 1).min(max); + } + Action::SimChannelToggle => { + let idx = self.sim_channel_selected; + if self.sim_channel_selection.contains(&idx) { + self.sim_channel_selection.remove(&idx); + } else { + self.sim_channel_selection.insert(idx); + } + } + Action::SimChannelConfirm => { + if self.sim_channel_selection.is_empty() { + self.message = Some("Select at least one channel".to_string()); + } else if let Screen::SimChannelPicker { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.start_simulation(spec_id).await; + } + } + Action::SimChannelCancel => { + if let Screen::SimChannelPicker { ref spec_id } = self.screen { + let spec_id = spec_id.clone(); + self.screen = Screen::SpecView { spec_id }; + } + } + + // Simulation - screen + Action::SimEnterInsert => { + if let Some(ref mut sim) = self.sim_state { + sim.mode = crate::simulation::SimInputMode::Insert; + } + } + Action::SimExitToNormal => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + sim.report_mode = false; + sim.report_input.clear(); + } else { + sim.mode = crate::simulation::SimInputMode::Normal; + } + } + } + Action::SimExitSimulation => { + if let Some(ref mut sim) = self.sim_state { + // Close overlay first if open + if sim.overlay.is_some() { + sim.overlay = None; + return; + } + } + if let Screen::Simulation { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + self.state.remove_sim_session(&session_id); + self.sim_state = None; + self.screen = Screen::SpecView { spec_id }; + } + } + Action::SimTypeChar(c) => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + sim.report_input.push(c); + } else { + sim.input_buffer.push(c); + sim.input_cursor = sim.input_buffer.len(); + } + } + } + Action::SimDeleteChar => { + if let Some(ref mut sim) = self.sim_state { + if sim.report_mode { + sim.report_input.pop(); + } else { + sim.input_buffer.pop(); + sim.input_cursor = sim.input_buffer.len(); + } + } + } + Action::SimSubmitInput => { + self.submit_sim_input().await; + } + Action::SimCycleChannel => { + if let Some(ref mut sim) = self.sim_state { + sim.cycle_channel(); + } + } + Action::SimCycleLayout => { + if let Some(ref mut sim) = self.sim_state { + sim.cycle_layout(); + } + } + Action::SimEnterReport => { + if let Some(ref mut sim) = self.sim_state { + sim.report_mode = true; + sim.report_input.clear(); + sim.mode = crate::simulation::SimInputMode::Insert; + } + } + Action::SimOpenRef(marker) => { + if let Some(ref mut sim) = self.sim_state { + // Find the node_id for this marker in current channel contents + let node_id = sim + .channel_contents + .values() + .flat_map(|c| c.refs.iter()) + .find(|r| r.marker == marker) + .map(|r| r.node_id.clone()); + + if let Some(node_id) = node_id { + // Fetch node details + match spec_forest::api::get_node(&self.state, &node_id) { + Ok(node) => { + sim.overlay = Some(crate::simulation::RefOverlay { + node_id: node.id, + question: node.question, + answer: node.answer, + }); + } + Err(e) => { + self.message = + Some(format!("Failed to load ref node: {e}")); + } + } + } + } + } + Action::SimCloseOverlay => { + if let Some(ref mut sim) = self.sim_state { + sim.overlay = None; + } + } } } @@ -1188,6 +1358,11 @@ impl App { } } + // Poll simulation session if on simulation screen + if let Screen::Simulation { ref session_id, .. } = self.screen { + self.poll_sim_status(session_id.clone()); + } + let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, @@ -1258,6 +1433,139 @@ impl App { self.nodes = nodes; } } + + // ── Simulation ───────────────────────────────────────────── + + fn poll_sim_status(&mut self, session_id: String) { + if let Some(ref mut sim) = self.sim_state { + if let Some(status) = self.state.get_sim_session_status(&session_id) { + match status { + spec_forest::simulation::SimStatus::Idle => { + if sim.processing { + // Transition from processing to idle means turn completed + sim.processing = false; + // Pull latest channel contents + if let Some(contents) = + self.state.get_sim_channel_contents(&session_id) + { + sim.channel_contents = contents; + } + } + } + spec_forest::simulation::SimStatus::Processing => { + sim.processing = true; + } + spec_forest::simulation::SimStatus::Error(ref e) => { + sim.processing = false; + self.message = Some(format!("Simulation error: {e}")); + } + spec_forest::simulation::SimStatus::Ended => { + sim.processing = false; + } + } + } + } + } + + async fn start_simulation(&mut self, spec_id: String) { + use spec_forest::simulation::{SimChannel, SimSession}; + + let channels: Vec = self + .sim_channel_selection + .iter() + .filter_map(|&i| SimChannel::ALL.get(i).copied()) + .collect(); + + let session_id = uuid::Uuid::new_v4().to_string(); + let session = SimSession::new( + session_id.clone(), + spec_id.clone(), + None, + self.model.clone(), + channels.clone(), + ); + self.state.set_sim_session(session); + + let sim_state = crate::simulation::SimulationState::new( + session_id.clone(), + spec_id.clone(), + channels.clone(), + ); + self.sim_state = Some(sim_state); + self.screen = Screen::Simulation { + spec_id: spec_id.clone(), + session_id: session_id.clone(), + }; + + // Spawn the initial simulation turn in background + let state = self.state.clone(); + let model = self.model.clone(); + let sid = session_id.clone(); + let spec_id_for_task = spec_id.clone(); + let channels_for_task = channels; + + // Mark session as processing + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + if let Some(ref mut sim) = self.sim_state { + sim.processing = true; + } + + tokio::spawn(async move { + commands::run_sim_initial_turn( + state, + sid, + spec_id_for_task, + model, + channels_for_task, + ) + .await; + }); + } + + async fn submit_sim_input(&mut self) { + let (session_id, input_text) = match self.sim_state.as_mut() { + Some(sim) if !sim.processing => { + let text = if sim.report_mode { + let report = spec_forest::simulation::SimReport { + description: sim.report_input.clone(), + }; + sim.report_mode = false; + sim.report_input.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&report).unwrap_or_default() + } else { + let input = spec_forest::simulation::SimInput { + keys: sim + .input_buffer + .chars() + .map(|c| c.to_string()) + .collect(), + raw_text: sim.input_buffer.clone(), + }; + sim.input_buffer.clear(); + sim.input_cursor = 0; + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&input).unwrap_or_default() + }; + sim.processing = true; + (sim.session_id.clone(), text) + } + _ => return, + }; + + // Mark backend session as processing + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + commands::run_sim_resume_turn(state, sid, input_text).await; + }); + } } pub fn truncate_str(s: &str, max: usize) -> &str { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 693f4e9..2a48da1 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use spec_forest::explore::ExploreStatusResponse; +use spec_forest::simulation::{self, SimChannel}; use spec_forest::state::AppState; use crate::error::TuiError; @@ -182,3 +183,83 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } +// ── Simulation ───────────────────────────────────────────── + +/// Run the initial simulation turn in a background task. +/// Updates the sim session in AppState when complete. +pub async fn run_sim_initial_turn( + state: Arc, + session_id: String, + spec_id: String, + model: String, + channels: Vec, +) { + // Load spec nodes for prompt context + let nodes = match spec_forest::api::get_spec_nodes(&state, &spec_id) { + Ok(nodes) => nodes + .into_iter() + .filter(|n| n.answer.is_some()) + .collect::>(), + Err(e) => { + tracing::error!("Failed to load spec nodes for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!("Failed to load spec nodes: {e}")); + }); + return; + } + }; + + let system_prompt = simulation::build_system_prompt(&channels, &nodes); + let initial_prompt = simulation::build_initial_prompt(&channels); + + let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url); + + match simulation::runner::start_sim_turn(&config, &initial_prompt).await { + Ok((claude_session_id, response)) => { + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + s.channel_contents = response.channels; + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation initial turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Resume a simulation turn with user input. +/// Updates the sim session in AppState when complete. +pub async fn run_sim_resume_turn(state: Arc, session_id: String, input: String) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + match simulation::runner::resume_sim_turn(&claude_sid, &input).await { + Ok(response) => { + state.update_sim_session(&session_id, |s| { + s.channel_contents = response.channels; + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation resume turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index e5ff8ac..e73f8af 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -1,7 +1,8 @@ -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyModifiers}; use crate::action::Action; use crate::app::Screen; +use crate::simulation::SimInputMode; /// Maps a key event to a semantic action based on the current screen and UI state. /// This is a pure function with no side effects. @@ -26,6 +27,53 @@ pub fn map_key( Screen::ModelConfig => map_model_config_key(key), Screen::Config => map_config_key(key, config_selected), Screen::UsernameInput => map_input_key(key), + Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), + Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key + } +} + +/// Maps keys for the simulation screen. Needs modifiers for Ctrl+Enter. +pub fn map_sim_key(key: KeyCode, modifiers: KeyModifiers, mode: SimInputMode) -> Action { + match mode { + SimInputMode::Normal => map_sim_normal_key(key), + SimInputMode::Insert => map_sim_insert_key(key, modifiers), + } +} + +fn map_sim_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('i') => Action::SimEnterInsert, + KeyCode::Esc => Action::SimExitSimulation, + KeyCode::Tab => Action::SimCycleChannel, + KeyCode::F(5) => Action::SimCycleLayout, + KeyCode::Char('r') => Action::SimEnterReport, + // Number keys open spec reference overlays [^1] through [^9] + KeyCode::Char(c @ '1'..='9') => { + Action::SimOpenRef(format!("[^{}]", c)) + } + _ => Action::Noop, + } +} + +fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::SimExitToNormal, + KeyCode::Enter if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, + KeyCode::Backspace => Action::SimDeleteChar, + KeyCode::Char(c) => Action::SimTypeChar(c), + KeyCode::Enter => Action::SimTypeChar('\n'), + _ => Action::Noop, + } +} + +fn map_sim_channel_picker_key(key: KeyCode) -> Action { + match key { + KeyCode::Up => Action::SimChannelUp, + KeyCode::Down => Action::SimChannelDown, + KeyCode::Char(' ') => Action::SimChannelToggle, + KeyCode::Enter => Action::SimChannelConfirm, + KeyCode::Esc => Action::SimChannelCancel, + _ => Action::Noop, } } @@ -129,6 +177,7 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('a') => Action::AiAnswer, KeyCode::Char('x') => Action::ExploreNode, KeyCode::Char('X') => Action::FullExplore, + KeyCode::Char('s') => Action::LaunchSimulation, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, KeyCode::Char(']') => Action::CandidateNext, @@ -150,6 +199,7 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('a') => Action::AiAnswer, KeyCode::Char('x') => Action::ExploreNode, KeyCode::Char('X') => Action::FullExplore, + KeyCode::Char('s') => Action::LaunchSimulation, KeyCode::Char('p') => Action::TogglePause, KeyCode::Char('c') => Action::CancelExplore, KeyCode::Char(']') => Action::CandidateNext, diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index 6335162..4b126ae 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -6,5 +6,6 @@ pub mod editor; pub mod error; pub mod input; pub mod log_buffer; +pub mod simulation; pub mod tree_state; pub mod ui; diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index d8d1d24..307e9ef 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -97,6 +97,7 @@ async fn main() -> Result<(), Box> { // Spawn HTTP server (REST API + MCP) in background let addr = format!("{host}:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; + state.set_mcp_url(format!("http://{addr}/mcp")); eprintln!("MCP server: http://{addr}/mcp"); eprintln!("REST API: http://{addr}/api/"); diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs new file mode 100644 index 0000000..a8959de --- /dev/null +++ b/crates/spec-forest-tui/src/simulation.rs @@ -0,0 +1,78 @@ +use spec_forest::simulation::{ChannelContent, SimChannel}; +use std::collections::HashMap; + +/// TUI-side state for the simulation screen. +pub struct SimulationState { + pub session_id: String, + pub spec_id: String, + pub channels: Vec, + pub active_channel: usize, + pub layout: SimLayout, + pub input_buffer: String, + pub input_cursor: usize, + pub mode: SimInputMode, + pub overlay: Option, + pub report_mode: bool, + pub report_input: String, + pub channel_contents: HashMap, + pub processing: bool, + pub tick: u64, +} + +impl SimulationState { + pub fn new(session_id: String, spec_id: String, channels: Vec) -> Self { + Self { + session_id, + spec_id, + channels, + active_channel: 0, + layout: SimLayout::Tabs, + input_buffer: String::new(), + input_cursor: 0, + mode: SimInputMode::Normal, + overlay: None, + report_mode: false, + report_input: String::new(), + channel_contents: HashMap::new(), + processing: false, + tick: 0, + } + } + + pub fn active_channel_key(&self) -> Option<&str> { + self.channels.get(self.active_channel).map(|c| c.key()) + } + + pub fn cycle_channel(&mut self) { + if !self.channels.is_empty() { + self.active_channel = (self.active_channel + 1) % self.channels.len(); + } + } + + pub fn cycle_layout(&mut self) { + self.layout = match self.layout { + SimLayout::Tabs => SimLayout::SplitH, + SimLayout::SplitH => SimLayout::SplitV, + SimLayout::SplitV => SimLayout::Tabs, + }; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimLayout { + Tabs, + SplitH, + SplitV, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimInputMode { + Normal, + Insert, +} + +pub struct RefOverlay { + pub node_id: String, + pub question: String, + pub answer: Option, +} diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 8b82f11..ed6d9d3 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -5,6 +5,8 @@ mod dir_browser; mod input_screen; pub(crate) mod log_panel; mod model_config; +mod sim_channel_picker; +mod simulation; mod spec_list; mod spec_options_picker; mod spec_settings; @@ -29,5 +31,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::ModelConfig => model_config::render(app, frame), Screen::Config => config::render(app, frame), Screen::UsernameInput => input_screen::render_input(app, frame, "Username:"), + Screen::SimChannelPicker { .. } => sim_channel_picker::render(app, frame), + Screen::Simulation { .. } => simulation::render(app, frame), } } diff --git a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs new file mode 100644 index 0000000..7346456 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -0,0 +1,66 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; + +use spec_forest::simulation::SimChannel; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // channel list + Constraint::Length(3), // footer + ]) + .split(frame.area()); + + let items: Vec = SimChannel::ALL + .iter() + .enumerate() + .map(|(i, ch)| { + let selected = app.sim_channel_selection.contains(&i); + let checkbox = if selected { "[x]" } else { "[ ]" }; + let style = if i == app.sim_channel_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if selected { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled( + format!(" {checkbox} {ch}"), + style, + ))) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Select Simulation Channels (Space to toggle, Enter to start) "); + + let list = List::new(items).block(block); + frame.render_widget(list, chunks[0]); + + let selected_count = app.sim_channel_selection.len(); + let footer = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {selected_count} selected "), + Style::default().fg(Color::Cyan), + ), + Span::styled( + "[Space] Toggle [Enter] Start [Esc] Cancel", + Style::default().fg(Color::DarkGray), + ), + ])) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(footer, chunks[1]); +} diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs new file mode 100644 index 0000000..70e948f --- /dev/null +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -0,0 +1,324 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +use crate::app::App; +use crate::simulation::{SimInputMode, SimLayout}; +use super::common::spinner_char; + +pub fn render(app: &App, frame: &mut Frame) { + let sim = match &app.sim_state { + Some(s) => s, + None => return, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // tab bar + Constraint::Min(3), // channel content + Constraint::Length(3), // input area + Constraint::Length(1), // status bar + ]) + .split(frame.area()); + + render_tab_bar(app, frame, chunks[0]); + render_channel_content(app, frame, chunks[1]); + render_input_area(app, frame, chunks[2]); + render_status_bar(app, frame, chunks[3]); + + // Render overlay on top if present + if let Some(ref overlay) = sim.overlay { + render_ref_overlay(overlay, frame, frame.area()); + } +} + +fn render_tab_bar(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + let tabs: Vec = sim + .channels + .iter() + .enumerate() + .flat_map(|(i, ch)| { + let style = if i == sim.active_channel { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + let num = format!(" {}:{} ", i + 1, ch); + vec![Span::styled(num, style), Span::raw(" ")] + }) + .collect(); + + let layout_indicator = match sim.layout { + SimLayout::Tabs => "[Tabs]", + SimLayout::SplitH => "[Split-H]", + SimLayout::SplitV => "[Split-V]", + }; + + let mut line_spans = tabs; + line_spans.push(Span::styled( + format!(" {layout_indicator}"), + Style::default().fg(Color::DarkGray), + )); + + frame.render_widget(Paragraph::new(Line::from(line_spans)), area); +} + +fn render_channel_content(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + match sim.layout { + SimLayout::Tabs => { + // Show only the active channel + if let Some(key) = sim.active_channel_key() { + render_single_channel(sim, key, frame, area); + } + } + SimLayout::SplitH => { + if sim.channels.is_empty() { + return; + } + let constraints: Vec = sim + .channels + .iter() + .map(|_| Constraint::Ratio(1, sim.channels.len() as u32)) + .collect(); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(area); + for (i, ch) in sim.channels.iter().enumerate() { + render_single_channel(sim, ch.key(), frame, chunks[i]); + } + } + SimLayout::SplitV => { + if sim.channels.is_empty() { + return; + } + let constraints: Vec = sim + .channels + .iter() + .map(|_| Constraint::Ratio(1, sim.channels.len() as u32)) + .collect(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + for (i, ch) in sim.channels.iter().enumerate() { + render_single_channel(sim, ch.key(), frame, chunks[i]); + } + } + } +} + +fn render_single_channel( + sim: &crate::simulation::SimulationState, + channel_key: &str, + frame: &mut Frame, + area: Rect, +) { + let channel_content = sim.channel_contents.get(channel_key); + let content = channel_content.map(|c| c.text.as_str()).unwrap_or(""); + + // Parse text for [^N] markers and highlight them + let mut lines = render_text_with_refs(content); + + // Show spec gap warning if present + if let Some(gap) = channel_content.and_then(|c| c.spec_gap.as_deref()) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" SPEC GAP: {gap}"), + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ))); + } + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", channel_key)); + + let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +/// Parse text content and highlight [^N] reference markers. +fn render_text_with_refs(text: &str) -> Vec> { + let mut lines = Vec::new(); + + for line_str in text.lines() { + let mut spans = Vec::new(); + let mut remaining = line_str; + + while let Some(start) = remaining.find("[^") { + // Add text before the marker + if start > 0 { + spans.push(Span::raw(remaining[..start].to_string())); + } + + // Find the end of the marker + if let Some(end) = remaining[start..].find(']') { + let marker = &remaining[start..start + end + 1]; + spans.push(Span::styled( + marker.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + remaining = &remaining[start + end + 1..]; + } else { + spans.push(Span::raw(remaining[start..].to_string())); + remaining = ""; + break; + } + } + + if !remaining.is_empty() { + spans.push(Span::raw(remaining.to_string())); + } + + lines.push(Line::from(spans)); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "(no content yet)", + Style::default().fg(Color::DarkGray), + ))); + } + + lines +} + +fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + let (border_color, title) = match sim.mode { + SimInputMode::Normal => (Color::Gray, " Input [i to type] "), + SimInputMode::Insert => (Color::Green, " INSERT (Ctrl+Enter to send, Esc to exit) "), + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(title); + + let display_text = if sim.report_mode { + format!("[Report] {}", sim.report_input) + } else if sim.input_buffer.is_empty() && sim.mode == SimInputMode::Normal { + String::new() + } else { + sim.input_buffer.clone() + }; + + let paragraph = Paragraph::new(display_text) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let sim = app.sim_state.as_ref().unwrap(); + + let mut spans = Vec::new(); + + if sim.processing { + spans.push(Span::styled( + format!(" {} thinking... ", spinner_char(sim.tick)), + Style::default().fg(Color::Cyan), + )); + spans.push(Span::raw(" ")); + } + + let mode_str = match sim.mode { + SimInputMode::Normal => "NORMAL", + SimInputMode::Insert => "INSERT", + }; + spans.push(Span::styled( + format!(" {mode_str} "), + Style::default().fg(Color::Black).bg(match sim.mode { + SimInputMode::Normal => Color::Blue, + SimInputMode::Insert => Color::Green, + }), + )); + + spans.push(Span::raw(" ")); + + if !sim.processing { + spans.push(Span::styled( + "[i] Insert [Tab] Channel [F5] Layout [r] Report [Esc] Exit", + Style::default().fg(Color::DarkGray), + )); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_ref_overlay( + overlay: &crate::simulation::RefOverlay, + frame: &mut Frame, + area: Rect, +) { + // Centered overlay, 60% width, 40% height + let overlay_width = (area.width as f32 * 0.6) as u16; + let overlay_height = (area.height as f32 * 0.4) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = vec![ + Line::from(Span::styled( + format!("Node: {}", &overlay.node_id), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled("Q: ", Style::default().fg(Color::Yellow))), + Line::from(overlay.question.clone()), + Line::from(""), + ]; + + if let Some(ref answer) = overlay.answer { + lines.push(Line::from(Span::styled( + "A: ", + Style::default().fg(Color::Green), + ))); + lines.push(Line::from(answer.clone())); + } else { + lines.push(Line::from(Span::styled( + "(unanswered)", + Style::default().fg(Color::DarkGray), + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[Esc] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Spec Reference "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index b64354a..beb23c3 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -64,11 +64,11 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 13ba0c0..c457244 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend}; use spec_forest::state::AppState; use spec_forest_db::{ChildInput, CreateSpec, Locality, Node, NodeState, Spec, SpecMode}; @@ -171,14 +171,14 @@ async fn test_spec_view_tree_shows_markers() { #[tokio::test] async fn test_quit_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('q')).await; + app.handle_key(KeyCode::Char('q'), KeyModifiers::NONE).await; assert!(app.should_quit); } #[tokio::test] async fn test_create_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('c')).await; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE).await; assert!(matches!(app.screen, Screen::InputName)); assert!(app.input.is_empty()); } @@ -186,7 +186,7 @@ async fn test_create_key() { #[tokio::test] async fn test_seed_key() { let mut app = make_app(); - app.handle_key(KeyCode::Char('s')).await; + app.handle_key(KeyCode::Char('s'), KeyModifiers::NONE).await; assert!(matches!(app.screen, Screen::DirBrowser)); assert!(app.dir_browser.is_some()); } @@ -195,8 +195,8 @@ async fn test_seed_key() { async fn test_input_typing() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Char('h')).await; - app.handle_key(KeyCode::Char('i')).await; + app.handle_key(KeyCode::Char('h'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Char('i'), KeyModifiers::NONE).await; assert_eq!(app.input, "hi"); } @@ -204,9 +204,9 @@ async fn test_input_typing() { async fn test_input_backspace() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Char('a')).await; - app.handle_key(KeyCode::Char('b')).await; - app.handle_key(KeyCode::Backspace).await; + app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Char('b'), KeyModifiers::NONE).await; + app.handle_key(KeyCode::Backspace, KeyModifiers::NONE).await; assert_eq!(app.input, "a"); } @@ -214,7 +214,7 @@ async fn test_input_backspace() { async fn test_input_escape_returns_to_spec_list() { let mut app = make_app(); app.screen = Screen::InputName; - app.handle_key(KeyCode::Esc).await; + app.handle_key(KeyCode::Esc, KeyModifiers::NONE).await; assert!(matches!(app.screen, Screen::SpecList)); } @@ -223,11 +223,11 @@ async fn test_list_navigation() { let mut app = make_app(); app.specs = vec![make_spec("A"), make_spec("B"), make_spec("C")]; assert_eq!(app.selected, 0); - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 2); - app.handle_key(KeyCode::Up).await; + app.handle_key(KeyCode::Up, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); } @@ -236,11 +236,11 @@ async fn test_list_navigation_bounds() { let mut app = make_app(); app.specs = vec![make_spec("A"), make_spec("B")]; // Up at 0 stays at 0 - app.handle_key(KeyCode::Up).await; + app.handle_key(KeyCode::Up, KeyModifiers::NONE).await; assert_eq!(app.selected, 0); // Down to end, then one more stays at end - app.handle_key(KeyCode::Down).await; - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; assert_eq!(app.selected, 1); } @@ -249,7 +249,7 @@ async fn test_list_navigation_bounds() { #[tokio::test] async fn test_create_then_render_shows_input_screen() { let mut app = make_app(); - app.handle_key(KeyCode::Char('c')).await; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE).await; let output = render_to_string(&app); assert!(output.contains("Spec name:"), "should show input prompt after pressing c"); assert!(output.contains("Cancel"), "should show cancel hint"); @@ -353,7 +353,7 @@ async fn test_flat_list_enter_does_not_change_state() { spec_id: "spec-S".to_string(), }; // Press Enter on an answered node in the flat list - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // BUG: Enter does nothing — no drill-down, no expand, no detail view. // The screen and selection should remain unchanged (no crash at least). @@ -387,7 +387,7 @@ async fn test_tree_expand_shows_children() { let initial_count = app.tree_state.entries.len(); // Press Enter to expand the root node (tree is focused) - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // After expanding, children should appear in tree entries assert!( @@ -429,7 +429,7 @@ async fn test_tree_expand_with_right_arrow() { let initial_count = app.tree_state.entries.len(); // Right arrow should expand just like Enter - app.handle_key(KeyCode::Right).await; + app.handle_key(KeyCode::Right, KeyModifiers::NONE).await; assert!( app.tree_state.entries.len() > initial_count, @@ -531,7 +531,7 @@ async fn test_flat_list_and_tree_node_count_consistency() { ); // Expand the root - app.handle_key(KeyCode::Enter).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; // Tree entries after full expansion should match flat node count let tree_count = app.tree_state.entries.len(); @@ -761,25 +761,25 @@ async fn test_candidate_navigation() { assert_eq!(app.candidate_selected, 0); // Navigate next - app.handle_key(KeyCode::Char(']')).await; + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 1); - app.handle_key(KeyCode::Char(']')).await; + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 2); // Bounds: can't go past end - app.handle_key(KeyCode::Char(']')).await; + app.handle_key(KeyCode::Char(']'), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 2); // Navigate prev - app.handle_key(KeyCode::Char('[')).await; + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 1); - app.handle_key(KeyCode::Char('[')).await; + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 0); // Bounds: can't go below 0 - app.handle_key(KeyCode::Char('[')).await; + app.handle_key(KeyCode::Char('['), KeyModifiers::NONE).await; assert_eq!(app.candidate_selected, 0); } @@ -805,8 +805,8 @@ async fn test_candidates_clear_when_switching_nodes() { assert_eq!(app.candidates.len(), 3, "should have candidates for root"); // Expand root to reveal children, then navigate to a child - app.handle_key(KeyCode::Enter).await; - app.handle_key(KeyCode::Down).await; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE).await; + app.handle_key(KeyCode::Down, KeyModifiers::NONE).await; app.refresh_candidates_if_needed(); assert!( app.candidates.is_empty(), diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 49c84c7..e958115 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -7,6 +7,7 @@ pub mod ingest; pub mod op_channel; mod op_loop; mod prompt_log; +pub mod simulation; pub mod state; pub mod sync; mod tool_types; diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs new file mode 100644 index 0000000..2b9a2e4 --- /dev/null +++ b/crates/spec-forest/src/simulation.rs @@ -0,0 +1,8 @@ +mod prompt; +pub mod runner; +pub mod session; +pub mod types; + +pub use prompt::{build_initial_prompt, build_system_prompt}; +pub use session::{SimChannel, SimSession, SimStatus}; +pub use types::{ChannelContent, NodeRef, SimInput, SimReport, SimResponse}; diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs new file mode 100644 index 0000000..4322260 --- /dev/null +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -0,0 +1,92 @@ +use super::session::SimChannel; +use crate::Node; + +/// Build the system prompt for a simulation agent. +/// +/// The prompt instructs the agent on: +/// - JSON envelope output format +/// - Channel semantics and which channels are active +/// - Spec node referencing conventions +/// - Input format (batched keypresses) +pub fn build_system_prompt(channels: &[SimChannel], spec_nodes: &[Node]) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + let mut spec_context = String::new(); + for node in spec_nodes { + spec_context.push_str(&format!("### Node {}\n", node.id)); + spec_context.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + spec_context.push_str(&format!("**A:** {}\n", answer)); + } else { + spec_context.push_str("**A:** _(unanswered)_\n"); + } + spec_context.push('\n'); + } + + format!( + r#"You are simulating an interactive software application based on specification nodes. +You are rendering a terminal UI simulation. Your responses MUST be valid JSON. + +## Spec Context +{spec_context} +## Output Format +Every response must be a JSON object with this schema: +{{ + "channels": {{ + "": {{ + "text": "content with optional [^N] references", + "refs": [{{"marker": "[^1]", "node_id": "uuid"}}] + }} + }} +}} + +Active channels: {channel_list} + +You MUST include an entry for each active channel in every response. + +## Channel Semantics +- "ui": Unicode/ASCII art rendering of the simulated interface. Replace entirely each turn. This should be a realistic TUI representation using box-drawing characters, borders, and layout. +- "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' +- "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages and warnings from the simulated application +- "logs": Application log output from the simulated application + +## Spec References +- When your simulated behavior is grounded in a spec node, cite it as [^N] in the text and include the mapping in the refs array. +- When behavior is NOT grounded in any spec node, add a "spec_gap" field to that channel's object describing the ungrounded behavior. +- For nodes that are unanswered or marked as needing review, label the simulated behavior as SPECULATIVE and cite the node ID. + +## User Input +The user sends batched keypresses as structured JSON input. Each message contains: +{{"keys": ["a", "b", "Enter"], "raw_text": "ab\n"}} + +Simulate how the application would respond to these inputs based on the spec. + +## Available Tools +You have access to spec-forest MCP tools to look up specification details: +- search_nodes: Search for nodes by text +- get_node: Get a specific node by ID +- get_descendants: Get a node's subtree +- get_spec_summary: Get an overview of a spec + +Use these tools when you need additional context about the spec beyond what was provided above."# + ) +} + +/// Build the initial prompt for the first simulation turn. +pub fn build_initial_prompt(channels: &[SimChannel]) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + format!( + "Initialize the simulation. Show the application's starting state across channels: {channel_list}. \ + Render the initial UI and any startup events in the appropriate channels." + ) +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs new file mode 100644 index 0000000..9b715b0 --- /dev/null +++ b/crates/spec-forest/src/simulation/runner.rs @@ -0,0 +1,229 @@ +use super::types::SimResponse; +use std::error::Error; +use std::time::Duration; + +const CLAUDE_TIMEOUT: Duration = Duration::from_secs(600); + +/// Configuration for starting a simulation turn. +pub struct SimConfig { + pub model: String, + pub system_prompt: String, + pub mcp_url: String, + pub allowed_tools: String, +} + +impl SimConfig { + pub fn new(model: String, system_prompt: String, mcp_url: String) -> Self { + Self { + model, + system_prompt, + mcp_url, + allowed_tools: [ + "mcp__spec-forest__search_nodes", + "mcp__spec-forest__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + "Read", + "Glob", + "Grep", + ] + .join(","), + } + } +} + +/// Start the first simulation turn. Returns (claude_session_id, response). +/// +/// Spawns `claude --print` with a system prompt and MCP config. +/// The session ID is captured for subsequent `--resume` calls. +pub async fn start_sim_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, SimResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "url": config.mcp_url + } + } + }); + + let session_id = uuid::Uuid::new_v4().to_string(); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("text") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--session-id") + .arg(&session_id) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting simulation turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let response_text = String::from_utf8(output.stdout)?; + tracing::info!( + response_chars = response_text.len(), + "Simulation initial turn complete" + ); + + let response = parse_sim_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing simulation session with new user input. +pub async fn resume_sim_turn( + claude_session_id: &str, + input: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("text") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(input); + + tracing::info!( + session_id = %claude_session_id, + input_chars = input.len(), + "Resuming simulation turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let response_text = String::from_utf8(output.stdout)?; + tracing::info!( + response_chars = response_text.len(), + "Simulation resume turn complete" + ); + + parse_sim_response(&response_text) +} + +/// Parse the agent's text response into a SimResponse JSON envelope. +/// +/// The agent should return valid JSON, but we try to extract it from +/// surrounding text if needed (e.g., markdown code fences). +fn parse_sim_response(text: &str) -> Result> { + let trimmed = text.trim(); + + // Try direct parse first + if let Ok(response) = serde_json::from_str::(trimmed) { + return Ok(response); + } + + // Try extracting from markdown code fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = &trimmed[start + 7..start + 7 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try extracting from plain code fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = &trimmed[start + 4..start + 4 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try finding first { to last } + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + if start < end { + let json_str = &trimmed[start..=end]; + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + Err(format!( + "Failed to parse simulation response as JSON. Raw response:\n{}", + &trimmed[..trimmed.len().min(500)] + ) + .into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_direct_json() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert!(response.channels.contains_key("ui")); + assert_eq!(response.channels["ui"].text, "Hello"); + } + + #[test] + fn parse_json_in_code_fence() { + let input = r#"Here is the simulation: +```json +{"channels": {"ui": {"text": "World", "refs": []}}} +```"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].text, "World"); + } + + #[test] + fn parse_json_with_surrounding_text() { + let input = r#"The simulation state is: +{"channels": {"ui": {"text": "Test", "refs": [{"marker": "[^1]", "node_id": "abc"}]}}} +Hope that helps!"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].refs[0].node_id, "abc"); + } + + #[test] + fn parse_with_spec_gap() { + let input = + r#"{"channels": {"ui": {"text": "Gap", "refs": [], "spec_gap": "No spec for this"}}}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!( + response.channels["ui"].spec_gap.as_deref(), + Some("No spec for this") + ); + } +} diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs new file mode 100644 index 0000000..8526c5c --- /dev/null +++ b/crates/spec-forest/src/simulation/session.rs @@ -0,0 +1,89 @@ +use super::types::ChannelContent; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SimChannel { + Ui, + Audio, + Network, + Errors, + Logs, +} + +impl SimChannel { + pub const ALL: &[SimChannel] = &[ + SimChannel::Ui, + SimChannel::Audio, + SimChannel::Network, + SimChannel::Errors, + SimChannel::Logs, + ]; + + pub fn key(&self) -> &'static str { + match self { + SimChannel::Ui => "ui", + SimChannel::Audio => "audio", + SimChannel::Network => "network", + SimChannel::Errors => "errors", + SimChannel::Logs => "logs", + } + } +} + +impl fmt::Display for SimChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimChannel::Ui => write!(f, "UI"), + SimChannel::Audio => write!(f, "Audio"), + SimChannel::Network => write!(f, "Network"), + SimChannel::Errors => write!(f, "Errors"), + SimChannel::Logs => write!(f, "Logs"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SimStatus { + Idle, + Processing, + Error(String), + Ended, +} + +pub struct SimSession { + pub id: String, + pub spec_id: String, + pub root_node_id: Option, + pub model: String, + /// Claude CLI session ID for `--resume`. + pub claude_session_id: Option, + pub channels: Vec, + pub status: SimStatus, + /// Latest channel content from the most recent agent response. + pub channel_contents: HashMap, +} + +impl SimSession { + pub fn new( + id: String, + spec_id: String, + root_node_id: Option, + model: String, + channels: Vec, + ) -> Self { + Self { + id, + spec_id, + root_node_id, + model, + claude_session_id: None, + channels, + status: SimStatus::Idle, + channel_contents: HashMap::new(), + } + } +} diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs new file mode 100644 index 0000000..7761c5c --- /dev/null +++ b/crates/spec-forest/src/simulation/types.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// JSON envelope the simulation agent must produce for each response. +/// +/// Example: +/// ```json +/// { +/// "channels": { +/// "ui": { +/// "text": "+---------+\n| Hello |\n+---------+\n[^1]", +/// "refs": [{"marker": "[^1]", "node_id": "abc123"}] +/// }, +/// "network": { +/// "text": "[NET] POST /api/users -> 201", +/// "refs": [] +/// } +/// } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimResponse { + pub channels: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelContent { + pub text: String, + #[serde(default)] + pub refs: Vec, + /// Present when the agent's behavior is not grounded in any spec node. + #[serde(default)] + pub spec_gap: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeRef { + pub marker: String, + pub node_id: String, +} + +/// Structured input sent to the agent for each user interaction turn. +#[derive(Debug, Clone, Serialize)] +pub struct SimInput { + pub keys: Vec, + pub raw_text: String, +} + +/// Structured input for behavior reporting. +#[derive(Debug, Clone, Serialize)] +pub struct SimReport { + pub description: String, +} diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 45f3aa5..d7c2727 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -3,6 +3,7 @@ use crate::explore::{ExploreSession, ExploreStatusResponse}; use crate::ingest::{IngestSession, IngestStatusResponse}; use crate::op_channel::{OpError, OpRequest, OpSource}; use crate::prompt_log::PromptLog; +use crate::simulation::SimSession; use crate::sync::{OpReceiver, SyncHandle}; use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; use parking_lot::Mutex; @@ -30,6 +31,8 @@ pub struct AppState { generation_status: Mutex>, explore_sessions: Mutex>, ingest_sessions: Mutex>, + sim_sessions: Mutex>, + mcp_url: Mutex>, sync_handle: TokioMutex>, sync_op_rx: std::sync::Mutex>, sync_url: Mutex>, @@ -58,6 +61,8 @@ impl AppState { generation_status: Mutex::new(HashMap::new()), explore_sessions: Mutex::new(HashMap::new()), ingest_sessions: Mutex::new(HashMap::new()), + sim_sessions: Mutex::new(HashMap::new()), + mcp_url: Mutex::new(None), sync_handle: TokioMutex::new(None), sync_op_rx: std::sync::Mutex::new(None), sync_url: Mutex::new(None), @@ -82,6 +87,8 @@ impl AppState { generation_status: Mutex::new(HashMap::new()), explore_sessions: Mutex::new(HashMap::new()), ingest_sessions: Mutex::new(HashMap::new()), + sim_sessions: Mutex::new(HashMap::new()), + mcp_url: Mutex::new(None), sync_handle: TokioMutex::new(None), sync_op_rx: std::sync::Mutex::new(None), sync_url: Mutex::new(None), @@ -409,6 +416,53 @@ impl AppState { pub fn ingest_sessions_lock(&self) -> parking_lot::MutexGuard<'_, HashMap> { self.ingest_sessions.lock() } + + // --- Simulation sessions --- + + pub fn set_sim_session(&self, session: SimSession) { + self.sim_sessions.lock().insert(session.id.clone(), session); + } + + pub fn update_sim_session(&self, session_id: &str, f: F) { + if let Some(session) = self.sim_sessions.lock().get_mut(session_id) { + f(session); + } + } + + pub fn remove_sim_session(&self, session_id: &str) { + self.sim_sessions.lock().remove(session_id); + } + + pub fn get_sim_session_status(&self, session_id: &str) -> Option { + self.sim_sessions.lock().get(session_id).map(|s| s.status.clone()) + } + + pub fn get_sim_claude_session_id(&self, session_id: &str) -> Option { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| s.claude_session_id.clone()) + } + + pub fn get_sim_channel_contents( + &self, + session_id: &str, + ) -> Option> { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.channel_contents.clone()) + } + + // --- MCP URL --- + + pub fn mcp_url(&self) -> Option { + self.mcp_url.lock().clone() + } + + pub fn set_mcp_url(&self, url: String) { + *self.mcp_url.lock() = Some(url); + } } #[cfg(test)] From 144a2bdc7a50eea9000dd7f7fe3bd2871f370a80 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:33:37 +1100 Subject: [PATCH 029/100] fix: use Shift+Enter instead of Ctrl+Enter for simulation input submit --- crates/spec-forest-tui/src/app.rs | 2 +- crates/spec-forest-tui/src/input.rs | 4 ++-- crates/spec-forest-tui/src/ui/simulation.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 2ffdb02..c8068e2 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -201,7 +201,7 @@ impl App { } pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { - // Simulation screen needs modifiers for Ctrl+Enter + // Simulation screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::Simulation { .. }) { let mode = self .sim_state diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index e73f8af..720886f 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -32,7 +32,7 @@ pub fn map_key( } } -/// Maps keys for the simulation screen. Needs modifiers for Ctrl+Enter. +/// Maps keys for the simulation screen. Needs modifiers for Shift+Enter. pub fn map_sim_key(key: KeyCode, modifiers: KeyModifiers, mode: SimInputMode) -> Action { match mode { SimInputMode::Normal => map_sim_normal_key(key), @@ -58,7 +58,7 @@ fn map_sim_normal_key(key: KeyCode) -> Action { fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { match key { KeyCode::Esc => Action::SimExitToNormal, - KeyCode::Enter if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimSubmitInput, KeyCode::Backspace => Action::SimDeleteChar, KeyCode::Char(c) => Action::SimTypeChar(c), KeyCode::Enter => Action::SimTypeChar('\n'), diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 70e948f..50e27ad 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -204,7 +204,7 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { let (border_color, title) = match sim.mode { SimInputMode::Normal => (Color::Gray, " Input [i to type] "), - SimInputMode::Insert => (Color::Green, " INSERT (Ctrl+Enter to send, Esc to exit) "), + SimInputMode::Insert => (Color::Green, " INSERT (Shift+Enter to send, Esc to exit) "), }; let block = Block::default() From dba261e7f7068d41286f5ba9d29e004fd2758299 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:34:41 +1100 Subject: [PATCH 030/100] fix: show loading spinner in simulation channel panes during initial turn --- crates/spec-forest-tui/src/ui/simulation.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 50e27ad..fac4894 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -128,8 +128,18 @@ fn render_single_channel( let channel_content = sim.channel_contents.get(channel_key); let content = channel_content.map(|c| c.text.as_str()).unwrap_or(""); - // Parse text for [^N] markers and highlight them - let mut lines = render_text_with_refs(content); + // Show loading state when processing and no content yet + let mut lines = if content.is_empty() && sim.processing { + vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {} Initializing simulation...", spinner_char(sim.tick)), + Style::default().fg(Color::Cyan), + )), + ] + } else { + render_text_with_refs(content) + }; // Show spec gap warning if present if let Some(gap) = channel_content.and_then(|c| c.spec_gap.as_deref()) { From 749fbe879713917c8c7d0c5ede2203be4264bd08 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:45:46 +1100 Subject: [PATCH 031/100] fix: correct MCP config schema and display simulation errors in status bar The MCP config passed to claude CLI was missing the required "type": "http" field, causing immediate rejection. Additionally, simulation errors were invisible because the simulation screen never rendered app.message. --- crates/spec-forest-tui/src/ui/simulation.rs | 7 ++++++- crates/spec-forest/src/simulation/runner.rs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index fac4894..85da39e 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -264,7 +264,12 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { spans.push(Span::raw(" ")); - if !sim.processing { + if let Some(ref msg) = app.message { + spans.push(Span::styled( + format!(" {msg} "), + Style::default().fg(Color::Red), + )); + } else if !sim.processing { spans.push(Span::styled( "[i] Insert [Tab] Channel [F5] Layout [r] Report [Esc] Exit", Style::default().fg(Color::DarkGray), diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 9b715b0..113f6a3 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -43,6 +43,7 @@ pub async fn start_sim_turn( let mcp_config = serde_json::json!({ "mcpServers": { "spec-forest": { + "type": "http", "url": config.mcp_url } } From 22a95b745223e9d244fb18c08c09ab37d29577a8 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:51:40 +1100 Subject: [PATCH 032/100] feat: add shadow answer support to TUI for implementation status review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to generate and view shadow answers in the TUI, comparing spec answers against actual codebase implementation. Press Shift+S to trigger shadow generation, which shows a progress bar and populates implementation status icons (○/◐/●/⚡) in the tree view and detailed status/review in the node content panel. --- crates/spec-forest-tui/src/action.rs | 3 + crates/spec-forest-tui/src/app.rs | 94 ++++++++++++++++ crates/spec-forest-tui/src/commands.rs | 41 +++++++ crates/spec-forest-tui/src/input.rs | 2 + crates/spec-forest-tui/src/ui/common.rs | 10 ++ crates/spec-forest-tui/src/ui/spec_view.rs | 122 ++++++++++++++++++++- crates/spec-forest/src/lib.rs | 1 + 7 files changed, 267 insertions(+), 6 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 2b6431c..78f75a5 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -91,6 +91,9 @@ pub enum Action { LogScrollUp, LogScrollDown, + // Shadow answers + GenerateShadow, + // Simulation - launch LaunchSimulation, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index c8068e2..18c1c11 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -78,6 +78,12 @@ pub struct App { pub log_scroll_offset: usize, pub pending_delete: Option, pub auto_explore: bool, + // Shadow answers + pub shadow_session_id: Option, + pub shadow_status: Option, + pub implementation_statuses: HashMap)>, + pub shadow_answers: Vec, + pub shadow_node_id: Option, // Simulation pub sim_state: Option, pub sim_channel_selected: usize, @@ -164,6 +170,11 @@ impl App { log_scroll_offset: 0, pending_delete: None, auto_explore, + shadow_session_id: None, + shadow_status: None, + implementation_statuses: HashMap::new(), + shadow_answers: Vec::new(), + shadow_node_id: None, sim_state: None, sim_channel_selected: 0, sim_channel_selection: std::collections::HashSet::new(), @@ -181,6 +192,7 @@ impl App { self.needs_redraw = false; } self.refresh_candidates_if_needed(); + self.refresh_shadow_answers_if_needed(); terminal.draw(|frame| ui::render(self, frame))?; let event = tokio::time::timeout(Duration::from_millis(250), reader.next()).await; @@ -274,6 +286,7 @@ impl App { Action::AiAnswer => self.trigger_ai_answer(), Action::ExploreNode => self.trigger_explore_node(), Action::FullExplore => self.trigger_full_explore(), + Action::GenerateShadow => self.trigger_shadow_generation(), Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), Action::EditNextQuestion => self.edit_tree_node().await, @@ -786,6 +799,10 @@ impl App { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); } + self.implementation_statuses = + commands::load_implementation_statuses(&self.state, spec_id) + .unwrap_or_default(); + self.shadow_node_id = None; self.screen = Screen::SpecView { spec_id: spec_id.to_string(), }; @@ -1029,6 +1046,46 @@ impl App { } } + fn trigger_shadow_generation(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let spec = match self.specs.get(self.selected) { + Some(s) => s, + None => return, + }; + let dir_path = match &spec.directory { + Some(d) => d.clone(), + None => { + self.message = Some("No directory set. Use [g] Settings to set one.".to_string()); + return; + } + }; + if self.shadow_session_id.is_some() { + self.message = Some("Shadow generation already running".to_string()); + return; + } + let focus_node_id = self.get_selected_node().map(|n| n.id.clone()); + match commands::start_shadow( + &self.state, + spec_id, + dir_path, + self.model.clone(), + focus_node_id, + ) { + Ok(resp) => { + self.shadow_session_id = Some(resp.session_id.clone()); + self.shadow_status = Some(resp); + self.message = Some("Shadow generation started...".to_string()); + } + Err(e) => { + tracing::error!("Shadow generation failed: {e}"); + self.message = Some(format!("Shadow generation failed: {e}")); + } + } + } + fn toggle_explore_pause(&mut self) { if let Some(ref sid) = self.explore_session_id && let Some(ref status) = self.explore_status @@ -1270,6 +1327,18 @@ impl App { } } + fn refresh_shadow_answers_if_needed(&mut self) { + let current_node_id = self.get_selected_node().map(|n| n.id.clone()); + if current_node_id == self.shadow_node_id { + return; + } + self.shadow_node_id = current_node_id.clone(); + self.shadow_answers = match current_node_id { + Some(nid) => commands::get_shadow_answers(&self.state, &nid).unwrap_or_default(), + None => Vec::new(), + }; + } + async fn accept_candidate(&mut self) { let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), @@ -1409,6 +1478,30 @@ impl App { } } + if let Some(sid) = self.shadow_session_id.clone() { + match commands::poll_ingest_status(&self.state, &sid) { + Ok(status) => { + let done = matches!( + status.status, + IngestState::Done | IngestState::Cancelled + ); + self.shadow_status = Some(status); + if done { + self.shadow_session_id = None; + self.message = Some("Shadow generation complete".to_string()); + self.implementation_statuses = + commands::load_implementation_statuses(&self.state, &spec_id) + .unwrap_or_default(); + self.shadow_node_id = None; // force shadow answer reload + } + } + Err(_) => { + self.shadow_session_id = None; + self.shadow_status = None; + } + } + } + if prev_busy || self.is_busy() { self.refresh_nodes(&spec_id); self.rebuild_tree_if_visible(&spec_id); @@ -1423,6 +1516,7 @@ impl App { .as_ref() .is_some_and(|s| s.status == ExploreStatus::Running) || self.ingest_session_id.is_some() + || self.shadow_session_id.is_some() } fn refresh_nodes(&mut self, spec_id: &str) { diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 2a48da1..d5821c9 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -183,6 +183,47 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } +// ── Shadow answers ──────────────────────────────────────── + +pub fn start_shadow( + state: &Arc, + spec_id: String, + dir_path: String, + model: String, + focus_node_id: Option, +) -> Result { + spec_forest::api::ingest_shadow(state, spec_id, dir_path, Some(model), focus_node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn start_shadow_regenerate( + state: &Arc, + spec_id: String, + dir_path: String, + model: String, + focus_node_id: Option, +) -> Result { + spec_forest::api::ingest_shadow_regenerate(state, spec_id, dir_path, Some(model), focus_node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn get_shadow_answers( + state: &AppState, + node_id: &str, +) -> Result, TuiError> { + spec_forest::api::get_shadow_answers(state, node_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub fn load_implementation_statuses( + state: &AppState, + spec_id: &str, +) -> Result)>, TuiError> { + let db = state.db(); + db.get_aggregated_implementation_status(spec_id) + .map_err(|e| TuiError::Api(e.to_string())) +} + // ── Simulation ───────────────────────────────────────────── /// Run the initial simulation turn in a background task. diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 720886f..b102072 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -187,6 +187,7 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, KeyCode::Char('d') => Action::DeleteNode, + KeyCode::Char('S') => Action::GenerateShadow, _ => Action::Noop, } } @@ -209,6 +210,7 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('f') => Action::AddFeature, KeyCode::Char('n') => Action::AddQuestion, KeyCode::Char('d') => Action::DeleteNode, + KeyCode::Char('S') => Action::GenerateShadow, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/ui/common.rs b/crates/spec-forest-tui/src/ui/common.rs index f5ac2bd..a2b8b17 100644 --- a/crates/spec-forest-tui/src/ui/common.rs +++ b/crates/spec-forest-tui/src/ui/common.rs @@ -9,6 +9,16 @@ pub fn spinner_char(tick: u64) -> char { FRAMES[(tick % FRAMES.len() as u64) as usize] } +pub fn impl_status_icon(status: &str) -> (&'static str, Color) { + match status { + "unimplemented" => ("\u{25CB}", Color::DarkGray), // ○ + "partially_implemented" => ("\u{25D0}", Color::Yellow), // ◐ + "implemented" => ("\u{25CF}", Color::Green), // ● + "diverged" => ("\u{26A1}", Color::Red), // ⚡ + _ => ("?", Color::DarkGray), + } +} + pub fn state_color(state: NodeState) -> Color { match state { NodeState::Unanswered => Color::Red, diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index beb23c3..59be022 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -7,6 +7,7 @@ use ratatui::{ }; use spec_forest::explore::ExploreStatus; +use spec_forest::ingest::IngestState; use spec_forest::state::GenerationStatus; use crate::app::App; @@ -17,6 +18,7 @@ pub fn render(app: &App, frame: &mut Frame) { .explore_status .as_ref() .is_some_and(|s| matches!(s.status, ExploreStatus::Running | ExploreStatus::Paused)); + let has_shadow_bar = app.shadow_session_id.is_some(); let mut constraints = vec![Constraint::Min(3)]; @@ -26,6 +28,9 @@ pub fn render(app: &App, frame: &mut Frame) { if has_explore_bar { constraints.push(Constraint::Length(1)); } + if has_shadow_bar { + constraints.push(Constraint::Length(1)); + } constraints.push(Constraint::Length(3)); let chunks = Layout::default() @@ -59,16 +64,21 @@ pub fn render(app: &App, frame: &mut Frame) { idx += 1; } + if has_shadow_bar { + render_shadow_status_bar(app, frame, chunks[idx]); + idx += 1; + } + let footer_chunk = chunks[idx]; let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible { - "[a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { @@ -140,6 +150,77 @@ fn render_node_content(app: &App, frame: &mut Frame, area: Rect) { } } + // Implementation status section + if let Some((status, review)) = app.implementation_statuses.get(&node.id) { + let (icon, color) = super::common::impl_status_icon(status); + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Implementation Status", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), Style::default().fg(color)), + Span::styled( + status.replace('_', " "), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ])); + if let Some(review_text) = review { + lines.push(Line::from("")); + lines.push(Line::from(review_text.clone())); + } + } + + // Shadow answers detail section + if !app.shadow_answers.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Shadow Answers ({})", app.shadow_answers.len()), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for (i, shadow) in app.shadow_answers.iter().enumerate() { + let (icon, color) = shadow + .implementation_status + .as_deref() + .map(|s| super::common::impl_status_icon(s)) + .unwrap_or(("?", Color::DarkGray)); + + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), Style::default().fg(color)), + Span::styled( + format!("Shadow {} - {}", i + 1, shadow.source_dir), + Style::default().fg(Color::DarkGray), + ), + ])); + + if let Some(ref review) = shadow.implementation_review { + lines.push(Line::from(Span::styled( + format!(" {review}"), + Style::default().fg(Color::DarkGray), + ))); + } + + let answer_preview: String = + shadow.answer.lines().take(3).collect::>().join("\n"); + for line in answer_preview.lines() { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(Color::DarkGray), + ))); + } + lines.push(Line::from("")); + } + } + // Candidates section if !app.candidates.is_empty() { lines.push(Line::from("")); @@ -237,11 +318,18 @@ fn render_tree_panel(app: &App, frame: &mut Frame, area: Rect) { (s.to_string(), state_color(entry.node_state)) } }; - ListItem::new(Line::from(vec![ + let impl_icon = app.implementation_statuses.get(&entry.node_id).map(|(status, _)| { + super::common::impl_status_icon(status) + }); + let mut spans = vec![ Span::raw(format!("{indent}{expand_icon}")), Span::styled(format!("{state_str} "), Style::default().fg(state_fg)), - Span::raw(&entry.label), - ])) + ]; + if let Some((icon, color)) = impl_icon { + spans.push(Span::styled(format!("{icon} "), Style::default().fg(color))); + } + spans.push(Span::raw(&entry.label)); + ListItem::new(Line::from(spans)) }) .collect(); @@ -298,3 +386,25 @@ fn render_explore_status_bar(app: &App, frame: &mut Frame, area: Rect) { let bar = Paragraph::new(Span::styled(text, Style::default().fg(color))); frame.render_widget(bar, area); } + +fn render_shadow_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let Some(ref status) = app.shadow_status else { + return; + }; + let spin = spinner_char(app.tick); + let state_label = match status.status { + IngestState::Running => "running", + IngestState::Paused => "paused", + _ => "done", + }; + let text = format!( + " {spin} Shadow {state_label}: {}/{} done | {} skipped | {} failed", + status.completed, status.total, status.skipped, status.failed, + ); + let color = match status.status { + IngestState::Running => Color::Magenta, + _ => Color::Green, + }; + let bar = Paragraph::new(Span::styled(text, Style::default().fg(color))); + frame.render_widget(bar, area); +} diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index e958115..3f21133 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -20,6 +20,7 @@ pub use spec_forest_db::{ Error as DbError, Locality, Node, NodeState, Spec, SpecMode, SpecSummary, }; pub use spec_forest_db::candidate::CandidateAnswer; +pub use spec_forest_db::ShadowAnswer; use op_channel::OpRequest; use prompt_log::PromptLog; From 1eb58042e77f156f8cea3785b2f869b941f60d88 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 13:55:37 +1100 Subject: [PATCH 033/100] fix: add Ctrl+S as alternative submit binding for simulation input Shift+Enter is not reliably forwarded by tmux, making it impossible to submit simulation input in tmux sessions. Add Ctrl+S as a fallback. --- crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/ui/simulation.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index b102072..80b3815 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -59,6 +59,7 @@ fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { match key { KeyCode::Esc => Action::SimExitToNormal, KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimSubmitInput, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, KeyCode::Backspace => Action::SimDeleteChar, KeyCode::Char(c) => Action::SimTypeChar(c), KeyCode::Enter => Action::SimTypeChar('\n'), diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 85da39e..d0e3c0c 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -214,7 +214,7 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { let (border_color, title) = match sim.mode { SimInputMode::Normal => (Color::Gray, " Input [i to type] "), - SimInputMode::Insert => (Color::Green, " INSERT (Shift+Enter to send, Esc to exit) "), + SimInputMode::Insert => (Color::Green, " INSERT (Ctrl+S or Shift+Enter to send, Esc to exit) "), }; let block = Block::default() From a420f1da7bb26b54d63c34253e78028bc608332c Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:04:51 +1100 Subject: [PATCH 034/100] fix: reinforce JSON output format on simulation resume turns The model would break character on short inputs like "q", responding with plain text instead of JSON. Append a format reminder to each resume prompt to keep responses in the required JSON envelope. --- crates/spec-forest/src/simulation/runner.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 113f6a3..0cedc0e 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -101,6 +101,11 @@ pub async fn resume_sim_turn( claude_session_id: &str, input: &str, ) -> Result> { + let prompt = format!( + "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Do not break character.", + input + ); + let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") @@ -108,7 +113,7 @@ pub async fn resume_sim_turn( .arg("--resume") .arg(claude_session_id) .arg("-p") - .arg(input); + .arg(&prompt); tracing::info!( session_id = %claude_session_id, From cacbca54edad987e5f1766c7cc9f3c94b913090d Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:22:18 +1100 Subject: [PATCH 035/100] feat: focus simulation context on selected node with ancestors and descendants Instead of loading all spec nodes, the simulation now loads the selected node, its ancestor chain, descendants, a spec summary, and other root questions. The prompt strongly encourages querying MCP tools for additional context beyond the focus subtree. --- crates/spec-forest-tui/src/app.rs | 27 ++-- crates/spec-forest-tui/src/commands.rs | 51 ++++++-- crates/spec-forest/src/simulation/prompt.rs | 137 ++++++++++++++++---- 3 files changed, 174 insertions(+), 41 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 18c1c11..1d01bd8 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -104,7 +104,7 @@ pub enum Screen { ModelConfig, Config, UsernameInput, - SimChannelPicker { spec_id: String }, + SimChannelPicker { spec_id: String, node_id: String }, Simulation { spec_id: String, session_id: String }, } @@ -499,10 +499,14 @@ impl App { // Simulation - launch Action::LaunchSimulation => { if let Screen::SpecView { ref spec_id } = self.screen { - let spec_id = spec_id.clone(); - self.sim_channel_selected = 0; - self.sim_channel_selection = [0].into(); // UI channel selected by default - self.screen = Screen::SimChannelPicker { spec_id }; + if let Some(node_id) = self.tree_state.selected_node_id().map(|s| s.to_string()) { + let spec_id = spec_id.clone(); + self.sim_channel_selected = 0; + self.sim_channel_selection = [0].into(); // UI channel selected by default + self.screen = Screen::SimChannelPicker { spec_id, node_id }; + } else { + self.message = Some("Select a node to simulate".to_string()); + } } } @@ -525,13 +529,14 @@ impl App { Action::SimChannelConfirm => { if self.sim_channel_selection.is_empty() { self.message = Some("Select at least one channel".to_string()); - } else if let Screen::SimChannelPicker { ref spec_id } = self.screen { + } else if let Screen::SimChannelPicker { ref spec_id, ref node_id } = self.screen { let spec_id = spec_id.clone(); - self.start_simulation(spec_id).await; + let node_id = node_id.clone(); + self.start_simulation(spec_id, node_id).await; } } Action::SimChannelCancel => { - if let Screen::SimChannelPicker { ref spec_id } = self.screen { + if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { let spec_id = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; } @@ -1561,7 +1566,7 @@ impl App { } } - async fn start_simulation(&mut self, spec_id: String) { + async fn start_simulation(&mut self, spec_id: String, focus_node_id: String) { use spec_forest::simulation::{SimChannel, SimSession}; let channels: Vec = self @@ -1574,7 +1579,7 @@ impl App { let session = SimSession::new( session_id.clone(), spec_id.clone(), - None, + Some(focus_node_id.clone()), self.model.clone(), channels.clone(), ); @@ -1597,6 +1602,7 @@ impl App { let sid = session_id.clone(); let spec_id_for_task = spec_id.clone(); let channels_for_task = channels; + let focus_node_for_task = focus_node_id; // Mark session as processing self.state.update_sim_session(&session_id, |s| { @@ -1613,6 +1619,7 @@ impl App { spec_id_for_task, model, channels_for_task, + focus_node_for_task, ) .await; }); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index d5821c9..f8f7876 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -234,23 +234,56 @@ pub async fn run_sim_initial_turn( spec_id: String, model: String, channels: Vec, + focus_node_id: String, ) { - // Load spec nodes for prompt context - let nodes = match spec_forest::api::get_spec_nodes(&state, &spec_id) { - Ok(nodes) => nodes - .into_iter() - .filter(|n| n.answer.is_some()) - .collect::>(), + // Load focus node + let focus_node = match spec_forest::api::get_node(&state, &focus_node_id) { + Ok(node) => node, Err(e) => { - tracing::error!("Failed to load spec nodes for simulation: {e}"); + tracing::error!("Failed to load focus node for simulation: {e}"); state.update_sim_session(&session_id, |s| { - s.status = simulation::SimStatus::Error(format!("Failed to load spec nodes: {e}")); + s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); }); return; } }; - let system_prompt = simulation::build_system_prompt(&channels, &nodes); + // Load ancestors, descendants, summary, and roots + let ancestors = spec_forest::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = spec_forest::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + let summary = match spec_forest::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to load spec summary for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); + }); + return; + } + }; + + // Get root nodes, excluding any already in ancestors/descendants/focus + 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 = spec_forest::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + let system_prompt = simulation::build_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + ); let initial_prompt = simulation::build_initial_prompt(&channels); let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 4322260..08bcff1 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -1,5 +1,6 @@ use super::session::SimChannel; use crate::Node; +use spec_forest_db::SpecSummary; /// Build the system prompt for a simulation agent. /// @@ -8,31 +9,111 @@ use crate::Node; /// - Channel semantics and which channels are active /// - Spec node referencing conventions /// - Input format (batched keypresses) -pub fn build_system_prompt(channels: &[SimChannel], spec_nodes: &[Node]) -> String { +/// - Focus node context with ancestors and descendants +pub fn build_system_prompt( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], +) -> String { let channel_list = channels .iter() .map(|c| c.key()) .collect::>() .join(", "); - let mut spec_context = String::new(); - for node in spec_nodes { - spec_context.push_str(&format!("### Node {}\n", node.id)); - spec_context.push_str(&format!("**Q:** {}\n", node.question)); - if let Some(ref answer) = node.answer { - spec_context.push_str(&format!("**A:** {}\n", answer)); - } else { - spec_context.push_str("**A:** _(unanswered)_\n"); + // Build focus node section + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build ancestor chain section (root → focus) + let mut ancestor_section = String::new(); + if !ancestors.is_empty() { + for node in ancestors { + if node.id == focus_node.id { + continue; + } + ancestor_section.push_str(&format!("#### Node {} (depth ancestor)\n", node.id)); + ancestor_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + ancestor_section.push_str(&format!("**A:** {}\n", answer)); + } else { + ancestor_section.push_str("**A:** _(unanswered)_\n"); + } + ancestor_section.push('\n'); + } + } + + // Build descendants section + let mut descendant_section = String::new(); + if !descendants.is_empty() { + for node in descendants { + if node.id == focus_node.id { + continue; + } + descendant_section.push_str(&format!("#### Node {}\n", node.id)); + descendant_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + descendant_section.push_str(&format!("**A:** {}\n", answer)); + } else { + descendant_section.push_str("**A:** _(unanswered)_\n"); + } + descendant_section.push('\n'); + } + } + + // Build other roots summary (just questions, no answers) + let mut other_roots_section = String::new(); + if !other_roots.is_empty() { + for node in other_roots { + other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); } - spec_context.push('\n'); } format!( r#"You are simulating an interactive software application based on specification nodes. You are rendering a terminal UI simulation. Your responses MUST be valid JSON. -## Spec Context -{spec_context} +## Focus Node (Primary Context) +This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. + +{focus_section} +## Ancestor Chain (Hierarchical Context) +These are the ancestor nodes from the root of the spec down to the focus node, providing hierarchical context: + +{ancestor_section} +## Descendants (Detail) +These are the descendant nodes under the focus node, providing detailed specifications: + +{descendant_section} +## Spec Overview +The full spec "{spec_name}" has {answered} answered nodes, {unanswered} unanswered nodes, and {needs_review} nodes needing review. + +Other top-level areas of the spec (not loaded in detail): +{other_roots_section} +## IMPORTANT: Use Tools for Additional Context +You have access to spec-forest MCP tools. You MUST use these tools proactively to look up specification details whenever: +- The user's input touches behavior outside the focus node's subtree +- You need to understand how different parts of the spec relate +- You encounter a reference to a spec area not loaded above +- You are unsure about any aspect of the specification + +Available tools: +- **search_nodes**: Search for nodes by text — use this to find relevant spec nodes +- **get_node**: Get a specific node by ID — use this when you have a node ID +- **get_descendants**: Get a node's subtree — use this to explore an area in depth +- **get_spec_summary**: Get an overview of a spec + +Do NOT guess or fabricate behavior. If unsure, query the spec first. + ## Output Format Every response must be a JSON object with this schema: {{ @@ -64,16 +145,28 @@ You MUST include an entry for each active channel in every response. The user sends batched keypresses as structured JSON input. Each message contains: {{"keys": ["a", "b", "Enter"], "raw_text": "ab\n"}} -Simulate how the application would respond to these inputs based on the spec. - -## Available Tools -You have access to spec-forest MCP tools to look up specification details: -- search_nodes: Search for nodes by text -- get_node: Get a specific node by ID -- get_descendants: Get a node's subtree -- get_spec_summary: Get an overview of a spec - -Use these tools when you need additional context about the spec beyond what was provided above."# +Simulate how the application would respond to these inputs based on the spec."#, + focus_section = focus_section, + ancestor_section = if ancestor_section.is_empty() { + "_(focus node is a root node)_\n".to_string() + } else { + ancestor_section + }, + descendant_section = if descendant_section.is_empty() { + "_(no descendants)_\n".to_string() + } else { + descendant_section + }, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + other_roots_section = if other_roots_section.is_empty() { + "_(none)_\n".to_string() + } else { + other_roots_section + }, + channel_list = channel_list, ) } From 2a4010814dd99889a9a84ba988df4c176d586f46 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:24:31 +1100 Subject: [PATCH 036/100] fix: strengthen simulation prompt to enforce spec node references and spec gaps --- crates/spec-forest/src/simulation/prompt.rs | 25 +++++++++++++++++---- crates/spec-forest/src/simulation/runner.rs | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 08bcff1..b21f860 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -136,10 +136,27 @@ You MUST include an entry for each active channel in every response. - "errors": Error messages and warnings from the simulated application - "logs": Application log output from the simulated application -## Spec References -- When your simulated behavior is grounded in a spec node, cite it as [^N] in the text and include the mapping in the refs array. -- When behavior is NOT grounded in any spec node, add a "spec_gap" field to that channel's object describing the ungrounded behavior. -- For nodes that are unanswered or marked as needing review, label the simulated behavior as SPECULATIVE and cite the node ID. +## Spec References (CRITICAL) +Every simulated behavior MUST be traceable to the spec. This is the primary purpose of the simulation. + +### Rules +1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry + MUST cite at least one spec node using [^N] markers in the text, with corresponding entries + in the refs array. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — + do not just cite one. +3. If ANY behavior is not grounded in a spec node, you MUST add a "spec_gap" field to that + channel describing exactly what is ungrounded and why you chose that behavior. + Never leave behavior unattributed — either cite a node or declare a spec_gap. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. +5. The refs array must NEVER be empty unless the channel also has a spec_gap explaining why. + +### Example +Good: "+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[^3] [Submit]" +with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}},{{"marker":"[^3]","node_id":"..."}}] + +Bad: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" +with refs: [] and no spec_gap — this is NEVER acceptable. ## User Input The user sends batched keypresses as structured JSON input. Each message contains: diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 0cedc0e..384c50c 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -102,7 +102,7 @@ pub async fn resume_sim_turn( input: &str, ) -> Result> { let prompt = format!( - "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Do not break character.", + "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Every behavior MUST have [^N] spec refs or a spec_gap. Do not break character.", input ); From 94577b29f6ffc3e8e1f38bba7b0f7203f1158d28 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:26:24 +1100 Subject: [PATCH 037/100] feat: add cursor navigation and arrow key support to simulation insert mode --- crates/spec-forest-tui/src/action.rs | 4 ++ crates/spec-forest-tui/src/app.rs | 48 ++++++++++++++++++--- crates/spec-forest-tui/src/input.rs | 4 ++ crates/spec-forest-tui/src/ui/simulation.rs | 36 +++++++++++++++- 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 78f75a5..c5ee3c1 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -110,6 +110,10 @@ pub enum Action { SimExitSimulation, SimTypeChar(char), SimDeleteChar, + SimCursorLeft, + SimCursorRight, + SimCursorHome, + SimCursorEnd, SimSubmitInput, SimCycleChannel, SimCycleLayout, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 1d01bd8..776645b 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -579,8 +579,8 @@ impl App { if sim.report_mode { sim.report_input.push(c); } else { - sim.input_buffer.push(c); - sim.input_cursor = sim.input_buffer.len(); + sim.input_buffer.insert(sim.input_cursor, c); + sim.input_cursor += c.len_utf8(); } } } @@ -588,12 +588,50 @@ impl App { if let Some(ref mut sim) = self.sim_state { if sim.report_mode { sim.report_input.pop(); - } else { - sim.input_buffer.pop(); - sim.input_cursor = sim.input_buffer.len(); + } else if sim.input_cursor > 0 { + // Find the char boundary before the cursor + let prev = sim.input_buffer[..sim.input_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + sim.input_buffer.remove(prev); + sim.input_cursor = prev; + } + } + } + Action::SimCursorLeft => { + if let Some(ref mut sim) = self.sim_state { + if sim.input_cursor > 0 { + sim.input_cursor = sim.input_buffer[..sim.input_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); } } } + Action::SimCursorRight => { + if let Some(ref mut sim) = self.sim_state { + if sim.input_cursor < sim.input_buffer.len() { + sim.input_cursor += sim.input_buffer[sim.input_cursor..] + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(0); + } + } + } + Action::SimCursorHome => { + if let Some(ref mut sim) = self.sim_state { + sim.input_cursor = 0; + } + } + Action::SimCursorEnd => { + if let Some(ref mut sim) = self.sim_state { + sim.input_cursor = sim.input_buffer.len(); + } + } Action::SimSubmitInput => { self.submit_sim_input().await; } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 80b3815..3861080 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -61,6 +61,10 @@ fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimSubmitInput, KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, KeyCode::Backspace => Action::SimDeleteChar, + KeyCode::Left => Action::SimCursorLeft, + KeyCode::Right => Action::SimCursorRight, + KeyCode::Home => Action::SimCursorHome, + KeyCode::End => Action::SimCursorEnd, KeyCode::Char(c) => Action::SimTypeChar(c), KeyCode::Enter => Action::SimTypeChar('\n'), _ => Action::Noop, diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index d0e3c0c..262e56c 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -230,11 +230,39 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { sim.input_buffer.clone() }; - let paragraph = Paragraph::new(display_text) + let paragraph = Paragraph::new(display_text.clone()) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); + + // Show blinking cursor in insert mode + if sim.mode == SimInputMode::Insert && !sim.report_mode { + let inner = area.inner(ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + let text_before_cursor = &sim.input_buffer[..sim.input_cursor]; + // Calculate cursor row/col accounting for wrapping + let width = inner.width as usize; + let mut row: u16 = 0; + let mut col: u16 = 0; + if width > 0 { + for ch in text_before_cursor.chars() { + if ch == '\n' { + row += 1; + col = 0; + } else { + col += 1; + if col >= inner.width { + row += 1; + col = 0; + } + } + } + } + frame.set_cursor_position((inner.x + col, inner.y + row)); + } } fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { @@ -270,8 +298,12 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { Style::default().fg(Color::Red), )); } else if !sim.processing { + let hint = match sim.mode { + SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [Esc] Exit", + SimInputMode::Insert => "[←→] Move [Home/End] Jump [Ctrl+S] Send [Esc] Normal", + }; spans.push(Span::styled( - "[i] Insert [Tab] Channel [F5] Layout [r] Report [Esc] Exit", + hint, Style::default().fg(Color::DarkGray), )); } From 8393986ebfd5553363930d9c2efefb053e503243 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:34:41 +1100 Subject: [PATCH 038/100] feat: add scenario input screen to simulation launch flow Adds an optional scenario description step between channel picker and simulation start. Users can describe the starting state and ongoing conditions (e.g. "other nodes are sending ACK messages") to customize the simulation context instead of always starting from a blank state. --- crates/spec-forest-tui/src/action.rs | 7 +++ crates/spec-forest-tui/src/app.rs | 51 ++++++++++++++++++- crates/spec-forest-tui/src/commands.rs | 3 +- crates/spec-forest-tui/src/input.rs | 16 ++++++ crates/spec-forest-tui/src/ui.rs | 2 + crates/spec-forest-tui/src/ui/sim_scenario.rs | 47 +++++++++++++++++ crates/spec-forest/src/simulation/prompt.rs | 21 ++++++-- crates/spec-forest/src/simulation/session.rs | 4 ++ 8 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 crates/spec-forest-tui/src/ui/sim_scenario.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index c5ee3c1..c1d3144 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -104,6 +104,13 @@ pub enum Action { SimChannelConfirm, SimChannelCancel, + // Simulation - scenario input + SimScenarioChar(char), + SimScenarioBackspace, + SimScenarioNewline, + SimScenarioConfirm, + SimScenarioCancel, + // Simulation - screen SimEnterInsert, SimExitToNormal, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 776645b..4621fc8 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -88,6 +88,7 @@ pub struct App { pub sim_state: Option, pub sim_channel_selected: usize, pub sim_channel_selection: std::collections::HashSet, + pub sim_scenario_input: String, } #[derive(Clone)] @@ -105,6 +106,7 @@ pub enum Screen { Config, UsernameInput, SimChannelPicker { spec_id: String, node_id: String }, + SimScenario { spec_id: String, node_id: String }, Simulation { spec_id: String, session_id: String }, } @@ -178,6 +180,7 @@ impl App { sim_state: None, sim_channel_selected: 0, sim_channel_selection: std::collections::HashSet::new(), + sim_scenario_input: String::new(), } } @@ -213,6 +216,12 @@ impl App { } pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { + // Scenario input screen needs modifiers for Shift+Enter + if matches!(self.screen, Screen::SimScenario { .. }) { + let action = input::map_sim_scenario_key(key, modifiers); + self.execute_action(action).await; + return; + } // Simulation screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::Simulation { .. }) { let mode = self @@ -532,7 +541,8 @@ impl App { } else if let Screen::SimChannelPicker { ref spec_id, ref node_id } = self.screen { let spec_id = spec_id.clone(); let node_id = node_id.clone(); - self.start_simulation(spec_id, node_id).await; + self.sim_scenario_input.clear(); + self.screen = Screen::SimScenario { spec_id, node_id }; } } Action::SimChannelCancel => { @@ -542,6 +552,36 @@ impl App { } } + // Simulation - scenario input + Action::SimScenarioChar(c) => { + self.sim_scenario_input.push(c); + } + Action::SimScenarioBackspace => { + self.sim_scenario_input.pop(); + } + Action::SimScenarioNewline => { + self.sim_scenario_input.push('\n'); + } + Action::SimScenarioConfirm => { + if let Screen::SimScenario { ref spec_id, ref node_id } = self.screen { + let spec_id = spec_id.clone(); + let node_id = node_id.clone(); + let scenario = if self.sim_scenario_input.trim().is_empty() { + None + } else { + Some(self.sim_scenario_input.clone()) + }; + self.start_simulation(spec_id, node_id, scenario).await; + } + } + Action::SimScenarioCancel => { + if let Screen::SimScenario { ref spec_id, ref node_id } = self.screen { + let spec_id = spec_id.clone(); + let node_id = node_id.clone(); + self.screen = Screen::SimChannelPicker { spec_id, node_id }; + } + } + // Simulation - screen Action::SimEnterInsert => { if let Some(ref mut sim) = self.sim_state { @@ -1604,7 +1644,12 @@ impl App { } } - async fn start_simulation(&mut self, spec_id: String, focus_node_id: String) { + async fn start_simulation( + &mut self, + spec_id: String, + focus_node_id: String, + scenario: Option, + ) { use spec_forest::simulation::{SimChannel, SimSession}; let channels: Vec = self @@ -1620,6 +1665,7 @@ impl App { Some(focus_node_id.clone()), self.model.clone(), channels.clone(), + scenario.clone(), ); self.state.set_sim_session(session); @@ -1658,6 +1704,7 @@ impl App { model, channels_for_task, focus_node_for_task, + scenario, ) .await; }); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index f8f7876..632ccf2 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -235,6 +235,7 @@ pub async fn run_sim_initial_turn( model: String, channels: Vec, focus_node_id: String, + scenario: Option, ) { // Load focus node let focus_node = match spec_forest::api::get_node(&state, &focus_node_id) { @@ -284,7 +285,7 @@ pub async fn run_sim_initial_turn( &summary, &other_roots, ); - let initial_prompt = simulation::build_initial_prompt(&channels); + let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 3861080..748e4c9 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -28,10 +28,26 @@ pub fn map_key( Screen::Config => map_config_key(key, config_selected), Screen::UsernameInput => map_input_key(key), Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), + Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key } } +/// Maps keys for the simulation scenario input screen. Needs modifiers for Shift+Enter. +pub fn map_sim_scenario_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::SimScenarioCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimScenarioConfirm, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => { + Action::SimScenarioConfirm + } + KeyCode::Backspace => Action::SimScenarioBackspace, + KeyCode::Char(c) => Action::SimScenarioChar(c), + KeyCode::Enter => Action::SimScenarioNewline, + _ => Action::Noop, + } +} + /// Maps keys for the simulation screen. Needs modifiers for Shift+Enter. pub fn map_sim_key(key: KeyCode, modifiers: KeyModifiers, mode: SimInputMode) -> Action { match mode { diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index ed6d9d3..0bf6ce5 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -6,6 +6,7 @@ mod input_screen; pub(crate) mod log_panel; mod model_config; mod sim_channel_picker; +mod sim_scenario; mod simulation; mod spec_list; mod spec_options_picker; @@ -32,6 +33,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::Config => config::render(app, frame), Screen::UsernameInput => input_screen::render_input(app, frame, "Username:"), Screen::SimChannelPicker { .. } => sim_channel_picker::render(app, frame), + Screen::SimScenario { .. } => sim_scenario::render(app, frame), Screen::Simulation { .. } => simulation::render(app, frame), } } diff --git a/crates/spec-forest-tui/src/ui/sim_scenario.rs b/crates/spec-forest-tui/src/ui/sim_scenario.rs new file mode 100644 index 0000000..aa67a85 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/sim_scenario.rs @@ -0,0 +1,47 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::app::App; + +pub fn render(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // title + Constraint::Min(5), // text area + Constraint::Length(3), // footer + ]) + .split(frame.area()); + + let title = Paragraph::new(Line::from(vec![Span::styled( + " Describe the simulation scenario (optional) ", + Style::default().fg(Color::Cyan), + )])) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(title, chunks[0]); + + let text = if app.sim_scenario_input.is_empty() { + Paragraph::new(Line::from(Span::styled( + "e.g. \"User is logged in, other nodes are sending ACK messages every 2s\"", + Style::default().fg(Color::DarkGray), + ))) + } else { + Paragraph::new(app.sim_scenario_input.as_str()) + }; + let text = text + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(" Scenario ")); + frame.render_widget(text, chunks[1]); + + let footer = Paragraph::new(Line::from(vec![Span::styled( + "[Enter] Newline [Shift+Enter/Ctrl+S] Start [Esc] Back", + Style::default().fg(Color::DarkGray), + )])) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[2]); +} diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index b21f860..0fbf650 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -188,15 +188,26 @@ Simulate how the application would respond to these inputs based on the spec."#, } /// Build the initial prompt for the first simulation turn. -pub fn build_initial_prompt(channels: &[SimChannel]) -> String { +/// +/// When `scenario` is provided, the simulation starts from the described state +/// and conditions instead of the default initial state. +pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> String { let channel_list = channels .iter() .map(|c| c.key()) .collect::>() .join(", "); - format!( - "Initialize the simulation. Show the application's starting state across channels: {channel_list}. \ - Render the initial UI and any startup events in the appropriate channels." - ) + match scenario { + Some(desc) if !desc.trim().is_empty() => format!( + "Initialize the simulation with the following scenario:\n\n\ + {desc}\n\n\ + Show the application state as described above across channels: {channel_list}. \ + Render the UI and any events in the appropriate channels." + ), + _ => format!( + "Initialize the simulation. Show the application's starting state across channels: {channel_list}. \ + Render the initial UI and any startup events in the appropriate channels." + ), + } } diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 8526c5c..e04c455 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -65,6 +65,8 @@ pub struct SimSession { pub status: SimStatus, /// Latest channel content from the most recent agent response. pub channel_contents: HashMap, + /// Optional scenario description for the simulation. + pub scenario: Option, } impl SimSession { @@ -74,6 +76,7 @@ impl SimSession { root_node_id: Option, model: String, channels: Vec, + scenario: Option, ) -> Self { Self { id, @@ -84,6 +87,7 @@ impl SimSession { channels, status: SimStatus::Idle, channel_contents: HashMap::new(), + scenario, } } } From e7de8bccc82f92be8203ed8c38449b44cd3a944b Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:43:01 +1100 Subject: [PATCH 039/100] feat: make simulation reports show explanation overlay instead of replacing channels Reports now display a magenta-bordered overlay with the LLM's explanation of why the simulation behaves a certain way, citing spec nodes with [^N] markers. Channel contents are preserved so the user doesn't lose the current simulation state and node references. --- crates/spec-forest-tui/src/app.rs | 43 ++++++-- crates/spec-forest-tui/src/commands.rs | 31 ++++++ crates/spec-forest-tui/src/simulation.rs | 7 ++ crates/spec-forest-tui/src/ui/simulation.rs | 40 +++++++ crates/spec-forest/src/simulation.rs | 2 +- crates/spec-forest/src/simulation/runner.rs | 105 ++++++++++++++++++- crates/spec-forest/src/simulation/session.rs | 5 +- crates/spec-forest/src/simulation/types.rs | 12 +++ crates/spec-forest/src/state.rs | 11 ++ 9 files changed, 243 insertions(+), 13 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 4621fc8..d5e26ac 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -600,7 +600,11 @@ impl App { } Action::SimExitSimulation => { if let Some(ref mut sim) = self.sim_state { - // Close overlay first if open + // Close overlays first if open + if sim.report_overlay.is_some() { + sim.report_overlay = None; + return; + } if sim.overlay.is_some() { sim.overlay = None; return; @@ -722,6 +726,7 @@ impl App { } Action::SimCloseOverlay => { if let Some(ref mut sim) = self.sim_state { + sim.report_overlay = None; sim.overlay = None; } } @@ -1621,11 +1626,22 @@ impl App { if sim.processing { // Transition from processing to idle means turn completed sim.processing = false; - // Pull latest channel contents - if let Some(contents) = - self.state.get_sim_channel_contents(&session_id) + // Check for pending report first (reports don't replace channels) + if let Some(report) = + self.state.take_sim_pending_report(&session_id) { - sim.channel_contents = contents; + sim.report_overlay = + Some(crate::simulation::ReportOverlay { + explanation: report.explanation, + refs: report.refs, + }); + } else { + // Normal turn: pull latest channel contents + if let Some(contents) = + self.state.get_sim_channel_contents(&session_id) + { + sim.channel_contents = contents; + } } } } @@ -1711,8 +1727,9 @@ impl App { } async fn submit_sim_input(&mut self) { - let (session_id, input_text) = match self.sim_state.as_mut() { + let (session_id, input_text, is_report) = match self.sim_state.as_mut() { Some(sim) if !sim.processing => { + let is_report = sim.report_mode; let text = if sim.report_mode { let report = spec_forest::simulation::SimReport { description: sim.report_input.clone(), @@ -1736,7 +1753,7 @@ impl App { serde_json::to_string(&input).unwrap_or_default() }; sim.processing = true; - (sim.session_id.clone(), text) + (sim.session_id.clone(), text, is_report) } _ => return, }; @@ -1748,9 +1765,15 @@ impl App { let state = self.state.clone(); let sid = session_id; - tokio::spawn(async move { - commands::run_sim_resume_turn(state, sid, input_text).await; - }); + if is_report { + tokio::spawn(async move { + commands::run_sim_report_turn(state, sid, input_text).await; + }); + } else { + tokio::spawn(async move { + commands::run_sim_resume_turn(state, sid, input_text).await; + }); + } } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 632ccf2..ee2e138 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -338,3 +338,34 @@ pub async fn run_sim_resume_turn(state: Arc, session_id: String, input } } +/// Resume a simulation turn with a user report. +/// Stores the report explanation without replacing channel contents. +pub async fn run_sim_report_turn(state: Arc, session_id: String, input: String) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + match simulation::runner::resume_sim_report_turn(&claude_sid, &input).await { + Ok(response) => { + state.update_sim_session(&session_id, |s| { + s.pending_report = Some(response); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation report turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index a8959de..3a6a2b3 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -12,6 +12,7 @@ pub struct SimulationState { pub input_cursor: usize, pub mode: SimInputMode, pub overlay: Option, + pub report_overlay: Option, pub report_mode: bool, pub report_input: String, pub channel_contents: HashMap, @@ -31,6 +32,7 @@ impl SimulationState { input_cursor: 0, mode: SimInputMode::Normal, overlay: None, + report_overlay: None, report_mode: false, report_input: String::new(), channel_contents: HashMap::new(), @@ -76,3 +78,8 @@ pub struct RefOverlay { pub question: String, pub answer: Option, } + +pub struct ReportOverlay { + pub explanation: String, + pub refs: Vec, +} diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 262e56c..bc0d644 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -35,6 +35,11 @@ pub fn render(app: &App, frame: &mut Frame) { if let Some(ref overlay) = sim.overlay { render_ref_overlay(overlay, frame, frame.area()); } + + // Render report overlay on top if present + if let Some(ref report) = sim.report_overlay { + render_report_overlay(report, frame, frame.area()); + } } fn render_tab_bar(app: &App, frame: &mut Frame, area: Rect) { @@ -369,3 +374,38 @@ fn render_ref_overlay( frame.render_widget(paragraph, overlay_area); } + +fn render_report_overlay( + report: &crate::simulation::ReportOverlay, + frame: &mut Frame, + area: Rect, +) { + // Centered overlay, 70% width, 50% height + let overlay_width = (area.width as f32 * 0.7) as u16; + let overlay_height = (area.height as f32 * 0.5) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = render_text_with_refs(&report.explanation); + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[Esc] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Report Explanation "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 2b9a2e4..20892c4 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -5,4 +5,4 @@ pub mod types; pub use prompt::{build_initial_prompt, build_system_prompt}; pub use session::{SimChannel, SimSession, SimStatus}; -pub use types::{ChannelContent, NodeRef, SimInput, SimReport, SimResponse}; +pub use types::{ChannelContent, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse}; diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 384c50c..b5d4262 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,4 +1,4 @@ -use super::types::SimResponse; +use super::types::{SimReportResponse, SimResponse}; use std::error::Error; use std::time::Duration; @@ -142,6 +142,61 @@ pub async fn resume_sim_turn( parse_sim_response(&response_text) } +/// Resume an existing simulation session with a user report. +/// +/// Unlike `resume_sim_turn`, this instructs the agent to explain why +/// the simulation behaves a certain way rather than updating channels. +pub async fn resume_sim_report_turn( + claude_session_id: &str, + input: &str, +) -> Result> { + let prompt = format!( + "{}\n\n\ + The user is reporting unexpected behavior in the simulation. \ + Do NOT update the simulation channels. Instead, respond ONLY with a JSON object:\n\ + {{\"explanation\": \"your explanation here with [^N] markers\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ + Explain WHY the simulation behaves this way, citing spec nodes with [^N] markers \ + and corresponding entries in the refs array. If the user found a genuine spec gap, \ + acknowledge it and explain what is missing from the spec.", + input + ); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("text") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(&prompt); + + tracing::info!( + session_id = %claude_session_id, + input_chars = input.len(), + "Resuming simulation report turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let response_text = String::from_utf8(output.stdout)?; + tracing::info!( + response_chars = response_text.len(), + "Simulation report turn complete" + ); + + parse_sim_report_response(&response_text) +} + /// Parse the agent's text response into a SimResponse JSON envelope. /// /// The agent should return valid JSON, but we try to extract it from @@ -191,6 +246,54 @@ fn parse_sim_response(text: &str) -> Result Result> { + let trimmed = text.trim(); + + // Try direct parse first + if let Ok(response) = serde_json::from_str::(trimmed) { + return Ok(response); + } + + // Try extracting from markdown code fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = &trimmed[start + 7..start + 7 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try extracting from plain code fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = &trimmed[start + 4..start + 4 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try finding first { to last } + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + if start < end { + let json_str = &trimmed[start..=end]; + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + Err(format!( + "Failed to parse simulation report response as JSON. Raw response:\n{}", + &trimmed[..trimmed.len().min(500)] + ) + .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 e04c455..736cb1a 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,4 +1,4 @@ -use super::types::ChannelContent; +use super::types::{ChannelContent, SimReportResponse}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -65,6 +65,8 @@ pub struct SimSession { pub status: SimStatus, /// Latest channel content from the most recent agent response. pub channel_contents: HashMap, + /// Pending report explanation from the most recent report turn. + pub pending_report: Option, /// Optional scenario description for the simulation. pub scenario: Option, } @@ -87,6 +89,7 @@ impl SimSession { channels, status: SimStatus::Idle, channel_contents: HashMap::new(), + pending_report: None, scenario, } } diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index 7761c5c..5c3acb1 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -51,3 +51,15 @@ pub struct SimInput { pub struct SimReport { pub description: String, } + +/// Response envelope for a report query. +/// +/// Unlike `SimResponse`, this does NOT replace channel contents. +/// It provides an explanation of why the simulation behaves a certain way, +/// with spec node references. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimReportResponse { + pub explanation: String, + #[serde(default)] + pub refs: Vec, +} diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index d7c2727..b296aca 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -454,6 +454,17 @@ impl AppState { .map(|s| s.channel_contents.clone()) } + /// Take the pending report from a simulation session (returns and clears it). + pub fn take_sim_pending_report( + &self, + session_id: &str, + ) -> Option { + self.sim_sessions + .lock() + .get_mut(session_id) + .and_then(|s| s.pending_report.take()) + } + // --- MCP URL --- pub fn mcp_url(&self) -> Option { From d042d23de68721bba93349c6c5fe654eb9e87028 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:51:04 +1100 Subject: [PATCH 040/100] fix: stop continuous candidate polling in TUI while background tasks are active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate reload was being forced every 250ms tick while any background operation was running. Now only reloads once on the busy→idle transition. --- crates/spec-forest-tui/src/app.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index d5e26ac..b4e8f34 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1590,10 +1590,14 @@ impl App { } } - if prev_busy || self.is_busy() { + let now_busy = self.is_busy(); + if prev_busy || now_busy { self.refresh_nodes(&spec_id); self.rebuild_tree_if_visible(&spec_id); - self.candidate_node_id = None; // force candidate reload + } + // Only force candidate reload when transitioning from busy to idle + if prev_busy && !now_busy { + self.candidate_node_id = None; } } From ecdc9f6d324fb7ccc54007ff00cdae7c5d73a120 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 14:52:23 +1100 Subject: [PATCH 041/100] fix: include spec_gap field in simulation prompt JSON schema so LLM actually generates it --- crates/spec-forest/src/simulation/prompt.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 0fbf650..75893be 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -120,7 +120,8 @@ Every response must be a JSON object with this schema: "channels": {{ "": {{ "text": "content with optional [^N] references", - "refs": [{{"marker": "[^1]", "node_id": "uuid"}}] + "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], + "spec_gap": "optional: describe any behavior not grounded in a spec node" }} }} }} From b61cf2e405c0659bb0d412ea1e1cdc745a909adf Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:03:00 +1100 Subject: [PATCH 042/100] feat: allow updating simulation scenario mid-session and add log panel focus cycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add [S] keybinding in simulation Normal mode to edit the scenario at any point during a running simulation. The input area shows a [Scenario] prefix, pre-populates with the current scenario, and on submit sends a scenario update to the LLM while persisting it on the session. Also adds log panel focus support with arrow key line scrolling and Tab cycling through main → tree → log panels. --- crates/spec-forest-tui/src/action.rs | 3 + crates/spec-forest-tui/src/app.rs | 164 ++++++++++++++++---- crates/spec-forest-tui/src/input.rs | 10 +- crates/spec-forest-tui/src/simulation.rs | 4 + crates/spec-forest-tui/src/ui/log_panel.rs | 7 +- crates/spec-forest-tui/src/ui/simulation.rs | 6 +- crates/spec-forest-tui/src/ui/spec_view.rs | 4 +- crates/spec-forest-tui/tests/tui_tests.rs | 62 ++++---- 8 files changed, 195 insertions(+), 65 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index c1d3144..cd5c58d 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -90,6 +90,8 @@ pub enum Action { ToggleLog, LogScrollUp, LogScrollDown, + LogScrollLineUp, + LogScrollLineDown, // Shadow answers GenerateShadow, @@ -125,6 +127,7 @@ pub enum Action { SimCycleChannel, SimCycleLayout, SimEnterReport, + SimEditScenario, SimOpenRef(String), SimCloseOverlay, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index b4e8f34..b85fd7e 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -52,6 +52,7 @@ pub struct App { pub tree_state: TreeState, pub tree_visible: bool, pub tree_focused: bool, + pub log_focused: bool, pub sync_register: bool, pub sync_connected: bool, pub needs_redraw: bool, @@ -146,6 +147,7 @@ impl App { tree_state: TreeState::new(), tree_visible: false, tree_focused: false, + log_focused: false, sync_register: false, sync_connected: false, needs_redraw: false, @@ -241,6 +243,7 @@ impl App { self.tree_focused, has_sync_url, self.log_visible, + self.log_focused, self.config_selected, ); self.execute_action(action).await; @@ -291,7 +294,29 @@ impl App { // Spec view Action::ToggleTree => self.toggle_tree(), - Action::SwitchFocus => self.tree_focused = !self.tree_focused, + Action::SwitchFocus => { + // Cycle focus: main → tree (if visible) → log (if visible) → main + if self.log_focused { + // log → main + self.log_focused = false; + self.tree_focused = false; + } else if self.tree_focused { + if self.log_visible { + // tree → log + self.tree_focused = false; + self.log_focused = true; + } else { + // tree → main + self.tree_focused = false; + } + } else if self.tree_visible { + // main → tree + self.tree_focused = true; + } else if self.log_visible { + // main → log (no tree) + self.log_focused = true; + } + } Action::AiAnswer => self.trigger_ai_answer(), Action::ExploreNode => self.trigger_explore_node(), Action::FullExplore => self.trigger_full_explore(), @@ -307,6 +332,9 @@ impl App { Action::ToggleLog => { self.log_visible = !self.log_visible; self.log_scroll_offset = 0; + if !self.log_visible { + self.log_focused = false; + } } Action::LogScrollUp => { let max = self.log_buffer.lock().unwrap().len().saturating_sub(1); @@ -315,6 +343,13 @@ impl App { Action::LogScrollDown => { self.log_scroll_offset = self.log_scroll_offset.saturating_sub(5); } + Action::LogScrollLineUp => { + let max = self.log_buffer.lock().unwrap().len().saturating_sub(1); + self.log_scroll_offset = (self.log_scroll_offset + 1).min(max); + } + Action::LogScrollLineDown => { + self.log_scroll_offset = self.log_scroll_offset.saturating_sub(1); + } // Tree Action::TreeUp => self.tree_state.select_up(), @@ -593,6 +628,9 @@ impl App { if sim.report_mode { sim.report_mode = false; sim.report_input.clear(); + } else if sim.scenario_mode { + sim.scenario_mode = false; + sim.scenario_input.clear(); } else { sim.mode = crate::simulation::SimInputMode::Normal; } @@ -622,6 +660,8 @@ impl App { if let Some(ref mut sim) = self.sim_state { if sim.report_mode { sim.report_input.push(c); + } else if sim.scenario_mode { + sim.scenario_input.push(c); } else { sim.input_buffer.insert(sim.input_cursor, c); sim.input_cursor += c.len_utf8(); @@ -632,6 +672,8 @@ impl App { if let Some(ref mut sim) = self.sim_state { if sim.report_mode { sim.report_input.pop(); + } else if sim.scenario_mode { + sim.scenario_input.pop(); } else if sim.input_cursor > 0 { // Find the char boundary before the cursor let prev = sim.input_buffer[..sim.input_cursor] @@ -696,6 +738,20 @@ impl App { sim.mode = crate::simulation::SimInputMode::Insert; } } + Action::SimEditScenario => { + if let Some(ref mut sim) = self.sim_state { + // Pre-populate with current scenario from the session + if let Screen::Simulation { ref session_id, .. } = self.screen { + let mut current = String::new(); + self.state.update_sim_session(session_id, |s| { + current = s.scenario.clone().unwrap_or_default(); + }); + sim.scenario_input = current; + } + sim.scenario_mode = true; + sim.mode = crate::simulation::SimInputMode::Insert; + } + } Action::SimOpenRef(marker) => { if let Some(ref mut sim) = self.sim_state { // Find the node_id for this marker in current channel contents @@ -883,6 +939,7 @@ impl App { self.tree_state = TreeState::new(); self.tree_visible = true; self.tree_focused = true; + self.log_focused = false; if let Err(e) = self.tree_state.rebuild(&self.state, spec_id) { tracing::error!("Tree rebuild failed: {e}"); self.message = Some(e.to_string()); @@ -1041,6 +1098,7 @@ impl App { self.message = Some(e.to_string()); } self.tree_focused = true; + self.log_focused = false; } else { self.tree_focused = false; } @@ -1731,33 +1789,56 @@ impl App { } async fn submit_sim_input(&mut self) { - let (session_id, input_text, is_report) = match self.sim_state.as_mut() { + #[derive(PartialEq)] + enum SimSubmitKind { + Report, + Scenario, + Input, + } + + let (session_id, input_text, kind) = match self.sim_state.as_mut() { Some(sim) if !sim.processing => { - let is_report = sim.report_mode; - let text = if sim.report_mode { - let report = spec_forest::simulation::SimReport { - description: sim.report_input.clone(), - }; - sim.report_mode = false; - sim.report_input.clear(); - sim.mode = crate::simulation::SimInputMode::Normal; - serde_json::to_string(&report).unwrap_or_default() + let kind = if sim.report_mode { + SimSubmitKind::Report + } else if sim.scenario_mode { + SimSubmitKind::Scenario } else { - let input = spec_forest::simulation::SimInput { - keys: sim - .input_buffer - .chars() - .map(|c| c.to_string()) - .collect(), - raw_text: sim.input_buffer.clone(), - }; - sim.input_buffer.clear(); - sim.input_cursor = 0; - sim.mode = crate::simulation::SimInputMode::Normal; - serde_json::to_string(&input).unwrap_or_default() + SimSubmitKind::Input + }; + let text = match kind { + SimSubmitKind::Report => { + let report = spec_forest::simulation::SimReport { + description: sim.report_input.clone(), + }; + sim.report_mode = false; + sim.report_input.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&report).unwrap_or_default() + } + SimSubmitKind::Scenario => { + let scenario_text = sim.scenario_input.clone(); + sim.scenario_mode = false; + sim.scenario_input.clear(); + sim.mode = crate::simulation::SimInputMode::Normal; + scenario_text + } + SimSubmitKind::Input => { + let input = spec_forest::simulation::SimInput { + keys: sim + .input_buffer + .chars() + .map(|c| c.to_string()) + .collect(), + raw_text: sim.input_buffer.clone(), + }; + sim.input_buffer.clear(); + sim.input_cursor = 0; + sim.mode = crate::simulation::SimInputMode::Normal; + serde_json::to_string(&input).unwrap_or_default() + } }; sim.processing = true; - (sim.session_id.clone(), text, is_report) + (sim.session_id.clone(), text, kind) } _ => return, }; @@ -1767,13 +1848,42 @@ impl App { s.status = spec_forest::simulation::SimStatus::Processing; }); - let state = self.state.clone(); - let sid = session_id; - if is_report { + // For scenario updates, also persist the new scenario on the session + if kind == SimSubmitKind::Scenario { + let scenario = if input_text.trim().is_empty() { + None + } else { + Some(input_text.clone()) + }; + let scenario_for_session = scenario.clone(); + self.state.update_sim_session(&session_id, |s| { + s.scenario = scenario_for_session; + }); + // Build the resume message that tells the LLM the scenario changed + let resume_text = match scenario { + Some(desc) => format!( + "SCENARIO UPDATE: The simulation scenario has changed. \ + The new scenario is:\n\n{desc}\n\n\ + Update all channels to reflect this new scenario." + ), + None => "SCENARIO UPDATE: The scenario has been cleared. \ + Continue with no specific scenario context." + .to_string(), + }; + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + commands::run_sim_resume_turn(state, sid, resume_text).await; + }); + } else if kind == SimSubmitKind::Report { + let state = self.state.clone(); + let sid = session_id; tokio::spawn(async move { commands::run_sim_report_turn(state, sid, input_text).await; }); } else { + let state = self.state.clone(); + let sid = session_id; tokio::spawn(async move { commands::run_sim_resume_turn(state, sid, input_text).await; }); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 748e4c9..b8bf186 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -13,6 +13,7 @@ pub fn map_key( tree_focused: bool, has_sync_url: bool, log_visible: bool, + log_focused: bool, config_selected: usize, ) -> Action { match screen { @@ -21,7 +22,7 @@ pub fn map_key( Screen::DirBrowser => map_dir_browser_key(key), Screen::DepthPicker => map_depth_picker_key(key), Screen::SpecOptionsPicker => map_spec_options_key(key), - Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused, log_visible), + Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused, log_visible, log_focused), Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -63,6 +64,7 @@ fn map_sim_normal_key(key: KeyCode) -> Action { KeyCode::Tab => Action::SimCycleChannel, KeyCode::F(5) => Action::SimCycleLayout, KeyCode::Char('r') => Action::SimEnterReport, + KeyCode::Char('S') => Action::SimEditScenario, // Number keys open spec reference overlays [^1] through [^9] KeyCode::Char(c @ '1'..='9') => { Action::SimOpenRef(format!("[^{}]", c)) @@ -173,16 +175,18 @@ fn map_spec_options_key(key: KeyCode) -> Action { } } -fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool, log_visible: bool) -> Action { +fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool, log_visible: bool, log_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Backspace => Action::GoBack, KeyCode::Char('t') => Action::ToggleTree, KeyCode::Char('l') => Action::ToggleLog, KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::Up if log_focused && log_visible => Action::LogScrollLineUp, + KeyCode::Down if log_focused && log_visible => Action::LogScrollLineDown, KeyCode::PageUp if log_visible => Action::LogScrollUp, KeyCode::PageDown if log_visible => Action::LogScrollDown, - KeyCode::Tab if tree_visible => Action::SwitchFocus, + KeyCode::Tab if tree_visible || log_visible => Action::SwitchFocus, _ if tree_focused && tree_visible => map_tree_key(key), _ => map_flat_list_key(key), } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index 3a6a2b3..e8f5dd0 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -15,6 +15,8 @@ pub struct SimulationState { pub report_overlay: Option, pub report_mode: bool, pub report_input: String, + pub scenario_mode: bool, + pub scenario_input: String, pub channel_contents: HashMap, pub processing: bool, pub tick: u64, @@ -35,6 +37,8 @@ impl SimulationState { report_overlay: None, report_mode: false, report_input: String::new(), + scenario_mode: false, + scenario_input: String::new(), channel_contents: HashMap::new(), processing: false, tick: 0, diff --git a/crates/spec-forest-tui/src/ui/log_panel.rs b/crates/spec-forest-tui/src/ui/log_panel.rs index 56ca2f0..dd013cc 100644 --- a/crates/spec-forest-tui/src/ui/log_panel.rs +++ b/crates/spec-forest-tui/src/ui/log_panel.rs @@ -10,7 +10,12 @@ use tracing::Level; use crate::app::App; pub fn render(app: &App, frame: &mut Frame, area: Rect) { - let block = Block::default().borders(Borders::ALL).title(" Logs "); + let border_style = if app.log_focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let block = Block::default().borders(Borders::ALL).title(" Logs ").border_style(border_style); let inner_height = area.height.saturating_sub(2) as usize; let buf = app.log_buffer.lock().unwrap(); diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index bc0d644..fa0dc84 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -229,6 +229,8 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { let display_text = if sim.report_mode { format!("[Report] {}", sim.report_input) + } else if sim.scenario_mode { + format!("[Scenario] {}", sim.scenario_input) } else if sim.input_buffer.is_empty() && sim.mode == SimInputMode::Normal { String::new() } else { @@ -242,7 +244,7 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(paragraph, area); // Show blinking cursor in insert mode - if sim.mode == SimInputMode::Insert && !sim.report_mode { + if sim.mode == SimInputMode::Insert && !sim.report_mode && !sim.scenario_mode { let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1, @@ -304,7 +306,7 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { )); } else if !sim.processing { let hint = match sim.mode { - SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [Esc] Exit", + SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [Esc] Exit", SimInputMode::Insert => "[←→] Move [Home/End] Jump [Ctrl+S] Send [Esc] Normal", }; spans.push(Span::styled( diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 59be022..65657a9 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -75,7 +75,9 @@ pub fn render(app: &App, frame: &mut Frame) { msg.clone() } else if !app.candidates.is_empty() { "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() - } else if app.tree_visible { + } else if app.log_focused { + "[↑↓] Scroll [PgUp/PgDn] Page [Tab] Focus [l] Log [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() + } else if app.tree_visible || app.log_visible { "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index c457244..203c022 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -545,28 +545,28 @@ async fn test_flat_list_and_tree_node_count_consistency() { #[test] fn test_input_map_spec_list_quit() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false, 0); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false, false, 0); assert_eq!(action, Action::Quit); } #[test] fn test_input_map_spec_list_create() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false, 0); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false, false, 0); assert_eq!(action, Action::OpenCreateSpec); } #[test] fn test_input_map_spec_list_navigate() { assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false, false, 0), Action::NavigateDown ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false, false, 0), Action::Select ); } @@ -576,19 +576,19 @@ fn test_input_map_shared_text_input() { // InputName and SyncPasswordInput share the same mapping for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( - input::map_key(&screen, KeyCode::Esc, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Esc, false, false, false, false, false, 0), Action::Cancel ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Enter, false, false, false, false, false, 0), Action::Submit ); assert_eq!( - input::map_key(&screen, KeyCode::Backspace, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Backspace, false, false, false, false, false, 0), Action::DeleteChar ); assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, false, 0), Action::TypeChar('a') ); } @@ -601,15 +601,15 @@ fn test_input_map_spec_view_tree_focused() { }; // With tree visible and focused, keys go to tree handlers assert_eq!( - input::map_key(&screen, KeyCode::Up, true, true, false, false, 0), + input::map_key(&screen, KeyCode::Up, true, true, false, false, false, 0), Action::TreeUp ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, true, true, false, false, 0), + input::map_key(&screen, KeyCode::Enter, true, true, false, false, false, 0), Action::ExpandOrCollapseTreeNode ); assert_eq!( - input::map_key(&screen, KeyCode::Left, true, true, false, false, 0), + input::map_key(&screen, KeyCode::Left, true, true, false, false, false, 0), Action::CollapseTreeNode ); } @@ -621,11 +621,11 @@ fn test_input_map_spec_view_flat_list() { }; // Without tree, keys go to flat list handlers assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, false, 0), Action::AiAnswer ); assert_eq!( - input::map_key(&screen, KeyCode::Char('e'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('e'), false, false, false, false, false, 0), Action::EditNextQuestion ); } @@ -636,7 +636,7 @@ fn test_input_map_spec_view_toggle_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('t'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('t'), false, false, false, false, false, 0), Action::ToggleTree ); } @@ -648,12 +648,12 @@ fn test_input_map_spec_view_tab_focus() { }; // Tab only works when tree is visible assert_eq!( - input::map_key(&screen, KeyCode::Tab, true, true, false, false, 0), + input::map_key(&screen, KeyCode::Tab, true, true, false, false, false, 0), Action::SwitchFocus ); // Tab when tree not visible goes to flat list (noop) assert_eq!( - input::map_key(&screen, KeyCode::Tab, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Tab, false, false, false, false, false, 0), Action::Noop ); } @@ -661,11 +661,11 @@ fn test_input_map_spec_view_tab_focus() { #[test] fn test_input_map_sync_config_with_url() { assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false, false, 0), Action::SyncLogin ); assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false, false, 0), Action::SyncRegister ); } @@ -674,7 +674,7 @@ fn test_input_map_sync_config_with_url() { fn test_input_map_sync_config_without_url() { // Without URL, login/register are noop assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false, false, 0), Action::Noop ); } @@ -682,15 +682,15 @@ fn test_input_map_sync_config_without_url() { #[test] fn test_input_map_model_config() { assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false, false, 0), Action::SelectModel ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false, false, 0), Action::Cancel ); } @@ -790,11 +790,11 @@ fn test_accept_candidate_key_mapping() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, false, 0), Action::AcceptCandidate ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, false, 0), Action::AcceptCandidate ); } @@ -821,15 +821,15 @@ fn test_input_map_candidate_keys_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), true, true, false, false, 0), + input::map_key(&screen, KeyCode::Char(']'), true, true, false, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), true, true, false, false, 0), + input::map_key(&screen, KeyCode::Char('['), true, true, false, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, false, 0), Action::AcceptCandidate ); } @@ -840,15 +840,15 @@ fn test_input_map_candidate_keys_flat_list() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char(']'), false, false, false, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('['), false, false, false, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, false, 0), Action::AcceptCandidate ); } From e974f5369fd3cfcd34af1aba7457e40c1ecdea55 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:14:59 +1100 Subject: [PATCH 043/100] feat: add explicit decisions list, multiple spec gaps, and multi-digit ref input to simulation - LLM now must list every discrete decision in a "decisions" array with refs and spec_gaps - Changed spec_gap from single optional string to spec_gaps array (multiple per channel) - Number keys buffer with ~500ms delay for multi-digit refs (e.g. "11" for [^11]) - Decisions panel renders below channels in magenta with inline ref markers - SimOpenRef searches both channel refs and decision refs --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 31 ++++- crates/spec-forest-tui/src/commands.rs | 2 + crates/spec-forest-tui/src/input.rs | 6 +- crates/spec-forest-tui/src/simulation.rs | 6 + crates/spec-forest-tui/src/ui/simulation.rs | 119 +++++++++++++++---- crates/spec-forest/src/simulation.rs | 4 +- crates/spec-forest/src/simulation/prompt.rs | 39 ++++-- crates/spec-forest/src/simulation/runner.rs | 31 ++++- crates/spec-forest/src/simulation/session.rs | 5 +- crates/spec-forest/src/simulation/types.rs | 20 +++- crates/spec-forest/src/state.rs | 12 ++ 12 files changed, 234 insertions(+), 42 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index cd5c58d..deefd50 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -129,6 +129,7 @@ pub enum Action { SimEnterReport, SimEditScenario, SimOpenRef(String), + SimRefDigit(char), SimCloseOverlay, Noop, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index b85fd7e..8b45d6d 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -212,6 +212,22 @@ impl App { if let Some(ref mut sim) = self.sim_state { sim.tick = self.tick; } + // Flush buffered digit input for multi-digit ref lookup + if let Some(ref sim) = self.sim_state { + if let Some(start_tick) = sim.ref_digit_start_tick { + if self.tick.saturating_sub(start_tick) >= 2 + && !sim.ref_digit_buffer.is_empty() + { + let marker = format!("[^{}]", sim.ref_digit_buffer); + // Clear buffer before dispatching (need mutable access) + if let Some(ref mut sim) = self.sim_state { + sim.ref_digit_buffer.clear(); + sim.ref_digit_start_tick = None; + } + self.execute_action(Action::SimOpenRef(marker)).await; + } + } + } self.poll_background_status(); } Ok(()) @@ -754,11 +770,12 @@ impl App { } Action::SimOpenRef(marker) => { if let Some(ref mut sim) = self.sim_state { - // Find the node_id for this marker in current channel contents + // Find the node_id for this marker in channel contents or decisions let node_id = sim .channel_contents .values() .flat_map(|c| c.refs.iter()) + .chain(sim.decisions.iter().flat_map(|d| d.refs.iter())) .find(|r| r.marker == marker) .map(|r| r.node_id.clone()); @@ -780,6 +797,14 @@ impl App { } } } + Action::SimRefDigit(c) => { + if let Some(ref mut sim) = self.sim_state { + sim.ref_digit_buffer.push(c); + if sim.ref_digit_start_tick.is_none() { + sim.ref_digit_start_tick = Some(sim.tick); + } + } + } Action::SimCloseOverlay => { if let Some(ref mut sim) = self.sim_state { sim.report_overlay = None; @@ -1698,12 +1723,14 @@ impl App { refs: report.refs, }); } else { - // Normal turn: pull latest channel contents + // Normal turn: pull latest channel contents and decisions if let Some(contents) = self.state.get_sim_channel_contents(&session_id) { sim.channel_contents = contents; } + sim.decisions = + self.state.get_sim_decisions(&session_id); } } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index ee2e138..536a426 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -295,6 +295,7 @@ pub async fn run_sim_initial_turn( state.update_sim_session(&session_id, |s| { s.claude_session_id = Some(claude_session_id); s.channel_contents = response.channels; + s.decisions = response.decisions; s.status = simulation::SimStatus::Idle; }); } @@ -326,6 +327,7 @@ pub async fn run_sim_resume_turn(state: Arc, session_id: String, input Ok(response) => { state.update_sim_session(&session_id, |s| { s.channel_contents = response.channels; + s.decisions = response.decisions; s.status = simulation::SimStatus::Idle; }); } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index b8bf186..7ee2625 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -65,10 +65,8 @@ fn map_sim_normal_key(key: KeyCode) -> Action { KeyCode::F(5) => Action::SimCycleLayout, KeyCode::Char('r') => Action::SimEnterReport, KeyCode::Char('S') => Action::SimEditScenario, - // Number keys open spec reference overlays [^1] through [^9] - KeyCode::Char(c @ '1'..='9') => { - Action::SimOpenRef(format!("[^{}]", c)) - } + // Number keys buffer for multi-digit ref lookup (e.g. "11" for [^11]) + KeyCode::Char(c @ '0'..='9') => Action::SimRefDigit(c), _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index e8f5dd0..79732db 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -18,6 +18,9 @@ pub struct SimulationState { pub scenario_mode: bool, pub scenario_input: String, pub channel_contents: HashMap, + pub decisions: Vec, + pub ref_digit_buffer: String, + pub ref_digit_start_tick: Option, pub processing: bool, pub tick: u64, } @@ -40,6 +43,9 @@ impl SimulationState { scenario_mode: false, scenario_input: String::new(), channel_contents: HashMap::new(), + decisions: Vec::new(), + ref_digit_buffer: String::new(), + ref_digit_start_tick: None, processing: false, tick: 0, } diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index fa0dc84..372a158 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -16,20 +16,46 @@ pub fn render(app: &App, frame: &mut Frame) { None => return, }; + let has_decisions = !sim.decisions.is_empty(); + let decisions_height = if has_decisions { + // 1 line per decision + extra for spec_gaps, capped at 8 content lines + 2 border + let content_lines: usize = sim + .decisions + .iter() + .map(|d| 1 + d.spec_gaps.len()) + .sum(); + (content_lines.min(8) + 2) as u16 + } else { + 0 + }; + + let mut constraints = vec![ + Constraint::Length(1), // tab bar + Constraint::Min(3), // channel content + ]; + if has_decisions { + constraints.push(Constraint::Length(decisions_height)); // decisions panel + } + constraints.push(Constraint::Length(3)); // input area + constraints.push(Constraint::Length(1)); // status bar + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // tab bar - Constraint::Min(3), // channel content - Constraint::Length(3), // input area - Constraint::Length(1), // status bar - ]) + .constraints(constraints) .split(frame.area()); - render_tab_bar(app, frame, chunks[0]); - render_channel_content(app, frame, chunks[1]); - render_input_area(app, frame, chunks[2]); - render_status_bar(app, frame, chunks[3]); + let mut idx = 0; + render_tab_bar(app, frame, chunks[idx]); + idx += 1; + render_channel_content(app, frame, chunks[idx]); + idx += 1; + if has_decisions { + render_decisions_panel(sim, frame, chunks[idx]); + idx += 1; + } + render_input_area(app, frame, chunks[idx]); + idx += 1; + render_status_bar(app, frame, chunks[idx]); // Render overlay on top if present if let Some(ref overlay) = sim.overlay { @@ -146,15 +172,17 @@ fn render_single_channel( render_text_with_refs(content) }; - // Show spec gap warning if present - if let Some(gap) = channel_content.and_then(|c| c.spec_gap.as_deref()) { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - format!(" SPEC GAP: {gap}"), - Style::default() - .fg(Color::Red) - .add_modifier(Modifier::BOLD), - ))); + // Show spec gap warnings if present + if let Some(content) = channel_content { + for gap in &content.spec_gaps { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" SPEC GAP: {gap}"), + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ))); + } } let block = Block::default() @@ -306,7 +334,7 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { )); } else if !sim.processing { let hint = match sim.mode { - SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [Esc] Exit", + SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [1-99] Ref [Esc] Exit", SimInputMode::Insert => "[←→] Move [Home/End] Jump [Ctrl+S] Send [Esc] Normal", }; spans.push(Span::styled( @@ -318,6 +346,57 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(Line::from(spans)), area); } +fn render_decisions_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + for (i, decision) in sim.decisions.iter().enumerate() { + let mut spans = vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(decision.description.clone()), + ]; + + // Show ref markers inline in magenta + if !decision.refs.is_empty() { + let markers: Vec = decision.refs.iter().map(|r| r.marker.clone()).collect(); + spans.push(Span::styled( + format!(" {}", markers.join(" ")), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )); + } + + lines.push(Line::from(spans)); + + // Show spec gaps for this decision in red + for gap in &decision.spec_gaps { + lines.push(Line::from(Span::styled( + format!(" SPEC GAP: {gap}"), + Style::default().fg(Color::Red), + ))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Decisions "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + fn render_ref_overlay( overlay: &crate::simulation::RefOverlay, frame: &mut Frame, diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 20892c4..ae173eb 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -5,4 +5,6 @@ pub mod types; pub use prompt::{build_initial_prompt, build_system_prompt}; pub use session::{SimChannel, SimSession, SimStatus}; -pub use types::{ChannelContent, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse}; +pub use types::{ + ChannelContent, Decision, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse, +}; diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 75893be..cb96d80 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -121,9 +121,16 @@ Every response must be a JSON object with this schema: "": {{ "text": "content with optional [^N] references", "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gap": "optional: describe any behavior not grounded in a spec node" + "spec_gaps": ["one entry per ungrounded assumption in this channel"] }} - }} + }}, + "decisions": [ + {{ + "description": "What you decided to do and why", + "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], + "spec_gaps": ["any ungrounded assumption behind this decision"] + }} + ] }} Active channels: {channel_list} @@ -146,18 +153,36 @@ Every simulated behavior MUST be traceable to the spec. This is the primary purp in the refs array. 2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — do not just cite one. -3. If ANY behavior is not grounded in a spec node, you MUST add a "spec_gap" field to that - channel describing exactly what is ungrounded and why you chose that behavior. - Never leave behavior unattributed — either cite a node or declare a spec_gap. +3. If ANY behavior is not grounded in a spec node, you MUST add an entry to the "spec_gaps" + array on that channel describing exactly what is ungrounded and why you chose that behavior. + One entry per ungrounded assumption. Never leave behavior unattributed — either cite a node + or declare a spec_gaps entry. 4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. -5. The refs array must NEVER be empty unless the channel also has a spec_gap explaining why. +5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. ### Example Good: "+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[^3] [Submit]" with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}},{{"marker":"[^3]","node_id":"..."}}] Bad: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" -with refs: [] and no spec_gap — this is NEVER acceptable. +with refs: [] and no spec_gaps — this is NEVER acceptable. + +## Decisions (CRITICAL) +Every response MUST include a "decisions" array listing every discrete decision you made this turn. + +### Rules +1. Each decision describes ONE specific action or behavior choice you made + (e.g., "Displayed login form with email and password fields", + "Returned 401 status on unauthenticated request", + "Played notification sound on message arrival"). +2. Each decision MUST cite at least one spec node in its refs array, + OR declare at least one entry in its spec_gaps array explaining the ungrounded assumption. +3. Be granular: if you made 5 decisions this turn, list all 5. Do NOT combine unrelated decisions. +4. Decision refs use the SAME [^N] marker namespace as channel text refs. + Reuse markers that already appear in channel text where applicable. +5. If you assumed behavior that NO spec node covers, you MUST add an entry + to that decision's spec_gaps array. This is non-negotiable. +6. The decisions array must NEVER be empty. Every turn involves at least one decision. ## User Input The user sends batched keypresses as structured JSON input. Each message contains: diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index b5d4262..c94290c 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -102,7 +102,7 @@ pub async fn resume_sim_turn( input: &str, ) -> Result> { let prompt = format!( - "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Every behavior MUST have [^N] spec refs or a spec_gap. Do not break character.", + "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Every behavior MUST have [^N] spec refs or spec_gaps. Include a decisions array listing every decision with refs or spec_gaps. Do not break character.", input ); @@ -326,13 +326,34 @@ Hope that helps!"#; } #[test] - fn parse_with_spec_gap() { + fn parse_with_spec_gaps() { let input = - r#"{"channels": {"ui": {"text": "Gap", "refs": [], "spec_gap": "No spec for this"}}}"#; + r#"{"channels": {"ui": {"text": "Gap", "refs": [], "spec_gaps": ["No spec for this", "Another assumption"]}}}"#; let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].spec_gaps.len(), 2); + assert_eq!(response.channels["ui"].spec_gaps[0], "No spec for this"); assert_eq!( - response.channels["ui"].spec_gap.as_deref(), - Some("No spec for this") + response.channels["ui"].spec_gaps[1], + "Another assumption" ); } + + #[test] + fn parse_with_decisions() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}, "decisions": [{"description": "Displayed greeting", "refs": [{"marker": "[^1]", "node_id": "abc"}], "spec_gaps": []}, {"description": "Assumed dark theme", "refs": [], "spec_gaps": ["No spec defines the color theme"]}]}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.decisions.len(), 2); + assert_eq!(response.decisions[0].description, "Displayed greeting"); + assert_eq!(response.decisions[0].refs.len(), 1); + assert!(response.decisions[0].spec_gaps.is_empty()); + assert_eq!(response.decisions[1].description, "Assumed dark theme"); + assert_eq!(response.decisions[1].spec_gaps.len(), 1); + } + + #[test] + fn parse_without_decisions_defaults_empty() { + let input = r#"{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert!(response.decisions.is_empty()); + } } diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 736cb1a..2a8280a 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,4 +1,4 @@ -use super::types::{ChannelContent, SimReportResponse}; +use super::types::{ChannelContent, Decision, SimReportResponse}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -67,6 +67,8 @@ pub struct SimSession { pub channel_contents: HashMap, /// Pending report explanation from the most recent report turn. pub pending_report: Option, + /// Decisions from the most recent agent response. + pub decisions: Vec, /// Optional scenario description for the simulation. pub scenario: Option, } @@ -90,6 +92,7 @@ impl SimSession { status: SimStatus::Idle, channel_contents: HashMap::new(), pending_report: None, + decisions: Vec::new(), scenario, } } diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index 5c3acb1..d10b0af 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -21,6 +21,8 @@ use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SimResponse { pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,9 +30,9 @@ pub struct ChannelContent { pub text: String, #[serde(default)] pub refs: Vec, - /// Present when the agent's behavior is not grounded in any spec node. + /// One entry per ungrounded assumption in this channel. #[serde(default)] - pub spec_gap: Option, + pub spec_gaps: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,6 +41,20 @@ pub struct NodeRef { pub node_id: String, } +/// A discrete decision the AI made during a simulation turn. +/// +/// Each decision describes one specific action or behavior choice, +/// with references to the spec nodes that justify it, or spec_gaps +/// for ungrounded assumptions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision { + pub description: String, + #[serde(default)] + pub refs: Vec, + #[serde(default)] + pub spec_gaps: Vec, +} + /// Structured input sent to the agent for each user interaction turn. #[derive(Debug, Clone, Serialize)] pub struct SimInput { diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index b296aca..3892cb3 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -465,6 +465,18 @@ impl AppState { .and_then(|s| s.pending_report.take()) } + /// Get the decisions from the most recent simulation turn. + pub fn get_sim_decisions( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.decisions.clone()) + .unwrap_or_default() + } + // --- MCP URL --- pub fn mcp_url(&self) -> Option { From 92e60a5931cf146e11ae1a62920950b41c497e04 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:21:31 +1100 Subject: [PATCH 044/100] feat: add regenerate_feature MCP tool to update feature descriptions from directory state Adds UpdateFeature op to the append-only log, a regeneration pipeline that uses directory context and child Q&A nodes to produce an updated description via Claude, and triggers cascade review on children after update. --- crates/spec-forest-db/src/op_apply.rs | 19 +++ crates/spec-forest-protocol/src/lib.rs | 6 + crates/spec-forest/src/generate.rs | 2 +- crates/spec-forest/src/generate/features.rs | 174 ++++++++++++++++++++ crates/spec-forest/src/op_loop/op_name.rs | 1 + crates/spec-forest/src/tool_types.rs | 8 + crates/spec-forest/src/tools.rs | 29 ++++ 7 files changed, 238 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-db/src/op_apply.rs b/crates/spec-forest-db/src/op_apply.rs index 387f8ea..96d3745 100644 --- a/crates/spec-forest-db/src/op_apply.rs +++ b/crates/spec-forest-db/src/op_apply.rs @@ -134,6 +134,16 @@ pub fn apply_op_to_conn(conn: &Connection, op: &SpecOp) -> Result<()> { Ok(()) } + SpecOp::UpdateFeature { node_id, content, embedding: emb } => { + let now = crate::util::now_iso8601(); + conn.execute( + "UPDATE nodes SET question = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, now, node_id], + )?; + embedding::upsert_embedding(conn, node_id, emb)?; + Ok(()) + } + SpecOp::CreateContextNode { spec_id, node_id, question, embedding } => { let now = crate::util::now_iso8601(); conn.execute( @@ -343,6 +353,15 @@ pub fn apply_op_to_conn_no_embed(conn: &Connection, op: &SpecOp) -> Result<()> { Ok(()) } + SpecOp::UpdateFeature { node_id, content, .. } => { + let now = crate::util::now_iso8601(); + conn.execute( + "UPDATE nodes SET question = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, now, node_id], + )?; + Ok(()) + } + SpecOp::CreateContextNode { spec_id, node_id, question, .. } => { let now = crate::util::now_iso8601(); conn.execute( diff --git a/crates/spec-forest-protocol/src/lib.rs b/crates/spec-forest-protocol/src/lib.rs index b3f6736..2496319 100644 --- a/crates/spec-forest-protocol/src/lib.rs +++ b/crates/spec-forest-protocol/src/lib.rs @@ -70,6 +70,11 @@ pub enum SpecOp { RemoveFeature { node_id: String, }, + UpdateFeature { + node_id: String, + content: String, + embedding: Vec, + }, CreateContextNode { spec_id: String, node_id: String, @@ -170,6 +175,7 @@ impl SpecOp { | SpecOp::UpdateQuestion { node_id, .. } | SpecOp::AddFeature { node_id, .. } | SpecOp::RemoveFeature { node_id, .. } + | SpecOp::UpdateFeature { node_id, .. } | SpecOp::CreateContextNode { node_id, .. } | SpecOp::UpdateResidualEntropy { node_id, .. } | SpecOp::ClearCandidates { node_id, .. } diff --git a/crates/spec-forest/src/generate.rs b/crates/spec-forest/src/generate.rs index 7c417eb..784d424 100644 --- a/crates/spec-forest/src/generate.rs +++ b/crates/spec-forest/src/generate.rs @@ -18,7 +18,7 @@ pub use children::{run_generation_in_dir_public, run_generation_public, spawn_ge pub use claude_runner::{run_claude_in_dir_cached, run_claude_public}; pub use dir_context::{get_relevant_dir_context, CODEBASE_CONTEXT_OUTPUT_INSTRUCTION}; pub use entropy::spawn_entropy_evaluation; -pub use features::spawn_feature_extraction; +pub use features::{spawn_feature_extraction, spawn_feature_regeneration}; pub use output::{spawn_output_generation, spawn_spec_output_generation}; pub use review::spawn_review; pub use summary::spawn_summary_regeneration; diff --git a/crates/spec-forest/src/generate/features.rs b/crates/spec-forest/src/generate/features.rs index 40f135c..eaae565 100644 --- a/crates/spec-forest/src/generate/features.rs +++ b/crates/spec-forest/src/generate/features.rs @@ -139,6 +139,180 @@ async fn run_feature_extraction( Ok(()) } +// --- Feature regeneration --- + +pub fn spawn_feature_regeneration( + state: Arc, + node_id: String, + model: String, +) { + state.set_generation_status(&node_id, GenerationStatus::Generating); + + tokio::spawn(async move { + let result = run_feature_regeneration(&state, &node_id, &model).await; + match result { + Ok(regenerated) => { + if regenerated { + // Fetch the node to get spec_id for summary regeneration + if let Ok(node) = state.db().get_node(&node_id) { + spawn_summary_regeneration( + state.clone(), + node.spec_id, + model, + ); + } + } + state.clear_generation_status(&node_id); + } + Err(e) => { + tracing::error!(node_id = %node_id, "Feature regeneration failed: {e}"); + state.set_generation_status( + &node_id, + GenerationStatus::Failed { + error: e.to_string(), + }, + ); + } + } + }); +} + +async fn run_feature_regeneration( + state: &AppState, + node_id: &str, + model: &str, +) -> Result> { + let (node, spec, children) = { + let db = state.db(); + let node = db.get_node(node_id)?; + let spec = db.get_spec(&node.spec_id)?; + let children = db.get_children(node_id)?; + (node, spec, children) + }; + + let existing_description = &node.question; + + // Build child Q&A context + let mut child_context = String::new(); + for child in &children { + child_context.push_str(&format!("Q: {}\n", child.question)); + if let Some(ref answer) = child.answer { + child_context.push_str(&format!("A: {}\n", answer)); + } + child_context.push('\n'); + } + + // Get directory context if available + let dir_context = if let Some(ref dir) = spec.directory { + // Clear cache to force fresh retrieval on next ingest + state.dir_context_cache().clear_dir(&node.spec_id, dir); + super::dir_context::get_relevant_dir_context(state, &node.spec_id, dir, existing_description) + } else { + None + }; + + let prompt = build_feature_regeneration_prompt( + &spec, + existing_description, + &child_context, + dir_context.as_deref(), + ); + + let prompt_log = state.prompt_log().map(|l| l.as_ref()); + let response = run_claude(&prompt, model, "feature_regeneration", prompt_log).await?; + let new_description = response.trim().to_string(); + + if new_description.is_empty() { + return Err("Regeneration produced empty description".into()); + } + + // Suppress no-op if description unchanged + if new_description == *existing_description { + tracing::info!(node_id, "Feature regeneration produced identical description, skipping update"); + return Ok(false); + } + + // Embed the new content + let embedding = state + .embed(&new_description) + .map_err(|e| -> Box { e.to_string().into() })?; + + // Submit UpdateFeature op + let op = spec_forest_protocol::SpecOp::UpdateFeature { + node_id: node_id.to_string(), + content: new_description, + embedding, + }; + state + .submit_op(&node.spec_id, op) + .await + .map_err(|e| -> Box { e.to_string().into() })?; + + // Trigger cascade review on children + if !children.is_empty() { + let review_op = spec_forest_protocol::SpecOp::TriggerReview { + node_id: node_id.to_string(), + }; + state + .submit_op(&node.spec_id, review_op) + .await + .map_err(|e| -> Box { e.to_string().into() })?; + } + + Ok(true) +} + +fn build_feature_regeneration_prompt( + spec: &spec_forest_db::Spec, + existing_description: &str, + child_qa_context: &str, + dir_context: Option<&str>, +) -> String { + let mut prompt = String::new(); + + prompt.push_str( + "You are updating a feature description to reflect the current state of a codebase. \ + Your goal is to produce a revised description that accurately reflects what the code \ + currently implements while preserving the original spec intent.\n\n", + ); + + prompt.push_str(&format!( + "## Project: {}\n\n", + spec.name, + )); + + prompt.push_str(&format!( + "## Current Feature Description\n\n{}\n\n", + existing_description, + )); + + if !child_qa_context.is_empty() { + prompt.push_str(&format!( + "## Feature Specification Details\n\n\ + The following questions and answers further define this feature:\n\n{}\n", + child_qa_context, + )); + } + + if let Some(ctx) = dir_context { + prompt.push_str(ctx); + prompt.push('\n'); + } + + prompt.push_str( + "## Instructions\n\n\ + Based on the codebase context above and the existing feature description, write an \ + updated feature description (2-4 sentences) that:\n\ + 1. Reflects what the code currently implements for this feature\n\ + 2. Preserves the original spec intent where applicable\n\ + 3. Notes any significant additions or changes from the original description\n\n\ + Respond with ONLY the updated description text, no JSON, no markdown headers, no \ + additional commentary.\n", + ); + + prompt +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/spec-forest/src/op_loop/op_name.rs b/crates/spec-forest/src/op_loop/op_name.rs index e3c349d..7360a6c 100644 --- a/crates/spec-forest/src/op_loop/op_name.rs +++ b/crates/spec-forest/src/op_loop/op_name.rs @@ -15,6 +15,7 @@ pub fn spec_op_type_name(op: &SpecOp) -> &'static str { SpecOp::UpdateQuestion { .. } => "UpdateQuestion", SpecOp::AddFeature { .. } => "AddFeature", SpecOp::RemoveFeature { .. } => "RemoveFeature", + SpecOp::UpdateFeature { .. } => "UpdateFeature", SpecOp::CreateContextNode { .. } => "CreateContextNode", SpecOp::UpdateContextSummary { .. } => "UpdateContextSummary", SpecOp::DeleteSpec { .. } => "DeleteSpec", diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index a1ec854..d455395 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -105,6 +105,14 @@ pub struct AddFeatureParams { pub model: Option, } +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct RegenerateFeatureParams { + #[schemars(description = "ID of the feature node to regenerate")] + pub node_id: String, + #[schemars(description = "AI model to use: 'opus', 'sonnet', or 'haiku' (default: opus)")] + pub model: Option, +} + // -- Mutation -- #[derive(Debug, Default, Deserialize, JsonSchema)] diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 7b7c913..6ddee0c 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -600,6 +600,35 @@ impl SpecForestServer { )])) } + #[tool(description = "Regenerate a feature's description based on current directory state. Updates the feature in-place, preserving all descendants, and triggers cascade review on children.")] + fn regenerate_feature( + &self, + Parameters(params): Parameters, + ) -> Result { + let node = self + .state + .db() + .get_node(¶ms.node_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let spec_id = node.spec_id.clone(); + let model = params.model.unwrap_or_else(|| "opus".to_string()); + + crate::generate::spawn_feature_regeneration( + self.state.clone(), + params.node_id.clone(), + model, + ); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "node_id": params.node_id, + "spec_id": spec_id, + "status": "regenerating" + })) + .unwrap(), + )])) + } + // -- Directory tools -- #[tool(description = "Set or clear the project directory for a spec. When set on development specs, AI generation will explore this directory for codebase context. Pass null or omit directory to clear.")] From 1d23b9c7497098e149cdff2544dfdad31840ed32 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:28:15 +1100 Subject: [PATCH 045/100] feat: add [R] Regen keypress in TUI to regenerate a feature from directory state Adds RegenerateFeature action bound to Shift+R in both tree and flat-list modes, with an API layer that validates the selected node is a root feature before spawning regeneration. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 18 ++++++++++++++++ crates/spec-forest-tui/src/commands.rs | 9 ++++++++ crates/spec-forest-tui/src/input.rs | 2 ++ crates/spec-forest-tui/src/ui/spec_view.rs | 6 +++--- crates/spec-forest/src/api/features.rs | 25 ++++++++++++++++++++++ 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index deefd50..ddc7e9f 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -30,6 +30,7 @@ pub enum Action { CancelExplore, EditNextQuestion, AddFeature, + RegenerateFeature, AddQuestion, DeleteNode, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 8b45d6d..30248a9 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -341,6 +341,7 @@ impl App { Action::CancelExplore => self.cancel_explore_session(), Action::EditNextQuestion => self.edit_tree_node().await, Action::AddFeature => self.add_feature().await, + Action::RegenerateFeature => self.regenerate_feature(), Action::AddQuestion => self.add_question().await, Action::DeleteNode => self.delete_node().await, @@ -1395,6 +1396,23 @@ impl App { } } + fn regenerate_feature(&mut self) { + let Some(node) = self.get_selected_node().cloned() else { + self.message = Some("No node selected".to_string()); + return; + }; + + match commands::regenerate_feature(&self.state, &node.id, self.model.clone()) { + Ok(()) => { + let label = truncate_str(&node.question, 40); + self.message = Some(format!("Regenerating feature: {label}...")); + } + Err(e) => { + self.message = Some(e.to_string()); + } + } + } + async fn add_question(&mut self) { let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 536a426..538cee5 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -164,6 +164,15 @@ pub fn update_directory( .map_err(|e| TuiError::Api(e.to_string())) } +pub fn regenerate_feature( + state: &Arc, + node_id: &str, + model: String, +) -> Result<(), TuiError> { + spec_forest::api::regenerate_feature(state, node_id, model) + .map_err(|e| TuiError::Api(e.to_string())) +} + pub async fn delete_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 7ee2625..9a96780 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -208,6 +208,7 @@ fn map_tree_key(key: KeyCode) -> Action { KeyCode::Char('y') => Action::AcceptCandidate, KeyCode::Char('E') => Action::EditCandidate, KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('R') => Action::RegenerateFeature, KeyCode::Char('n') => Action::AddQuestion, KeyCode::Char('d') => Action::DeleteNode, KeyCode::Char('S') => Action::GenerateShadow, @@ -231,6 +232,7 @@ fn map_flat_list_key(key: KeyCode) -> Action { KeyCode::Char('E') => Action::EditCandidate, KeyCode::Char('y') => Action::AcceptCandidate, KeyCode::Char('f') => Action::AddFeature, + KeyCode::Char('R') => Action::RegenerateFeature, KeyCode::Char('n') => Action::AddQuestion, KeyCode::Char('d') => Action::DeleteNode, KeyCode::Char('S') => Action::GenerateShadow, diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 65657a9..898f479 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -74,13 +74,13 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.log_focused { "[↑↓] Scroll [PgUp/PgDn] Page [Tab] Focus [l] Log [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible || app.log_visible { - "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { diff --git a/crates/spec-forest/src/api/features.rs b/crates/spec-forest/src/api/features.rs index faf9a62..b2ad10a 100644 --- a/crates/spec-forest/src/api/features.rs +++ b/crates/spec-forest/src/api/features.rs @@ -31,6 +31,31 @@ pub async fn create_feature( Ok(node) } +pub fn regenerate_feature( + state: &Arc, + node_id: &str, + model: String, +) -> Result<(), ApiError> { + // Verify the node exists and is a root (feature) node + let db = state.db(); + let _node = db.get_node(node_id)?; + let is_root = db.get_ancestors(node_id)?.is_empty(); + drop(db); + + if !is_root { + return Err(ApiError::InvalidInput( + "Only root feature nodes can be regenerated".to_string(), + )); + } + + crate::generate::spawn_feature_regeneration( + state.clone(), + node_id.to_string(), + model, + ); + Ok(()) +} + pub async fn seed_spec( state: &Arc, spec_id: &str, From 30a64ee2b4ef4212ca13c1ad2181b2082ed98bc7 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:39:02 +1100 Subject: [PATCH 046/100] fix: run feature regeneration in the project directory so Claude can access codebase Previously run_claude was called without a directory, so Claude had no file access. Now uses run_claude_in_dir_cached with the spec's directory, includes project directory instructions in the prompt, and appends the codebase context output instruction so responses build the dir context cache. --- crates/spec-forest/src/generate/features.rs | 41 ++++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/spec-forest/src/generate/features.rs b/crates/spec-forest/src/generate/features.rs index eaae565..3c47ea7 100644 --- a/crates/spec-forest/src/generate/features.rs +++ b/crates/spec-forest/src/generate/features.rs @@ -202,24 +202,27 @@ async fn run_feature_regeneration( child_context.push('\n'); } - // Get directory context if available - let dir_context = if let Some(ref dir) = spec.directory { - // Clear cache to force fresh retrieval on next ingest - state.dir_context_cache().clear_dir(&node.spec_id, dir); + // Get cached directory context if available + let dir_context = spec.directory.as_deref().and_then(|dir| { super::dir_context::get_relevant_dir_context(state, &node.spec_id, dir, existing_description) - } else { - None - }; + }); let prompt = build_feature_regeneration_prompt( &spec, existing_description, &child_context, dir_context.as_deref(), + spec.directory.is_some(), ); - let prompt_log = state.prompt_log().map(|l| l.as_ref()); - let response = run_claude(&prompt, model, "feature_regeneration", prompt_log).await?; + let response = if let Some(ref dir) = spec.directory { + super::claude_runner::run_claude_in_dir_cached( + state, &node.spec_id, &prompt, model, dir, "feature_regeneration", + ).await? + } else { + let prompt_log = state.prompt_log().map(|l| l.as_ref()); + run_claude(&prompt, model, "feature_regeneration", prompt_log).await? + }; let new_description = response.trim().to_string(); if new_description.is_empty() { @@ -267,6 +270,7 @@ fn build_feature_regeneration_prompt( existing_description: &str, child_qa_context: &str, dir_context: Option<&str>, + has_directory: bool, ) -> String { let mut prompt = String::new(); @@ -299,9 +303,22 @@ fn build_feature_regeneration_prompt( prompt.push('\n'); } + if has_directory { + if let Some(ref dir) = spec.directory { + prompt.push_str(&format!( + "## Project Directory\n\n\ + This specification is for a software project located at: {}\n\n\ + Before generating your response, use the Read, Glob, and Grep tools to explore the \ + project directory and gather relevant context about this feature. Look at source code, \ + configuration files, and any implementation related to the feature described above.\n\n", + dir + )); + } + } + prompt.push_str( "## Instructions\n\n\ - Based on the codebase context above and the existing feature description, write an \ + Based on the codebase context and the existing feature description, write an \ updated feature description (2-4 sentences) that:\n\ 1. Reflects what the code currently implements for this feature\n\ 2. Preserves the original spec intent where applicable\n\ @@ -310,6 +327,10 @@ fn build_feature_regeneration_prompt( additional commentary.\n", ); + if has_directory { + prompt.push_str(super::dir_context::CODEBASE_CONTEXT_OUTPUT_INSTRUCTION); + } + prompt } From 87161f7849afe34c4d3ee0c5aa149bf9aff73826 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 15:47:36 +1100 Subject: [PATCH 047/100] feat: add depth picker modal for full exploration and end on answer Full explore (X key) now shows a depth selection modal instead of immediately starting with hardcoded depth 3. Exploration also runs with end_on_answer=true so it stops after answering leaf nodes. --- crates/spec-forest-tui/src/app.rs | 33 ++++++++++++++----- crates/spec-forest-tui/src/commands.rs | 4 ++- crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/ui.rs | 1 + crates/spec-forest-tui/src/ui/depth_picker.rs | 19 +++++++---- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 30248a9..185f771 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -109,6 +109,7 @@ pub enum Screen { SimChannelPicker { spec_id: String, node_id: String }, SimScenario { spec_id: String, node_id: String }, Simulation { spec_id: String, session_id: String }, + ExploreDepthPicker { spec_id: String }, } #[derive(Clone)] @@ -335,7 +336,12 @@ impl App { } Action::AiAnswer => self.trigger_ai_answer(), Action::ExploreNode => self.trigger_explore_node(), - Action::FullExplore => self.trigger_full_explore(), + Action::FullExplore => { + if let Screen::SpecView { spec_id } = &self.screen { + self.depth_selected = 2; // default to depth 3 + self.screen = Screen::ExploreDepthPicker { spec_id: spec_id.clone() }; + } + } Action::GenerateShadow => self.trigger_shadow_generation(), Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), @@ -451,13 +457,24 @@ impl App { } } Action::DepthPickerConfirm => { - self.spec_options_selected = 0; - self.spec_options_source = SpecOptionsSource::SeedFromDir; - self.screen = Screen::SpecOptionsPicker; + if let Screen::ExploreDepthPicker { spec_id } = &self.screen { + let spec_id = spec_id.clone(); + let depth = (self.depth_selected + 1) as u32; + self.screen = Screen::SpecView { spec_id }; + self.trigger_full_explore(depth); + } else { + self.spec_options_selected = 0; + self.spec_options_source = SpecOptionsSource::SeedFromDir; + self.screen = Screen::SpecOptionsPicker; + } } Action::DepthPickerCancel => { - self.depth_picker_dir = None; - self.screen = Screen::DirBrowser; + if let Screen::ExploreDepthPicker { spec_id } = &self.screen { + self.screen = Screen::SpecView { spec_id: spec_id.clone() }; + } else { + self.depth_picker_dir = None; + self.screen = Screen::DirBrowser; + } } // Sync @@ -1194,7 +1211,7 @@ impl App { } } - fn trigger_full_explore(&mut self) { + fn trigger_full_explore(&mut self, depth: u32) { let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, @@ -1204,7 +1221,7 @@ impl App { return; } let root_node_id = self.get_selected_node().map(|n| n.id.clone()); - match commands::start_full_explore(&self.state, &spec_id, root_node_id, self.model.clone()) + match commands::start_full_explore(&self.state, &spec_id, root_node_id, depth, self.model.clone(), true) { Ok(status) => { self.explore_session_id = Some(status.session_id.clone()); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 538cee5..750f929 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -87,9 +87,11 @@ pub fn start_full_explore( state: &Arc, spec_id: &str, root_node_id: Option, + depth: u32, model: String, + end_on_answer: bool, ) -> Result { - spec_forest::api::start_explore(state, spec_id, root_node_id, 3, model, false) + spec_forest::api::start_explore(state, spec_id, root_node_id, depth, model, end_on_answer) .map_err(|e| TuiError::Api(e.to_string())) } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 9a96780..556b58e 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -29,6 +29,7 @@ pub fn map_key( Screen::Config => map_config_key(key, config_selected), Screen::UsernameInput => map_input_key(key), Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), + Screen::ExploreDepthPicker { .. } => map_depth_picker_key(key), Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key } diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 0bf6ce5..b8a38d5 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -35,5 +35,6 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SimChannelPicker { .. } => sim_channel_picker::render(app, frame), Screen::SimScenario { .. } => sim_scenario::render(app, frame), Screen::Simulation { .. } => simulation::render(app, frame), + Screen::ExploreDepthPicker { .. } => depth_picker::render(app, frame), } } diff --git a/crates/spec-forest-tui/src/ui/depth_picker.rs b/crates/spec-forest-tui/src/ui/depth_picker.rs index e8bee69..e95177a 100644 --- a/crates/spec-forest-tui/src/ui/depth_picker.rs +++ b/crates/spec-forest-tui/src/ui/depth_picker.rs @@ -6,14 +6,19 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; -use crate::app::{App, DEPTH_OPTIONS}; +use crate::app::{App, Screen, DEPTH_OPTIONS}; pub fn render(app: &App, frame: &mut Frame) { - let dir_name = app - .depth_picker_dir - .as_ref() - .map(|(_, name)| name.as_str()) - .unwrap_or("directory"); + let title = if matches!(&app.screen, Screen::ExploreDepthPicker { .. }) { + " Full exploration depth ".to_string() + } else { + let dir_name = app + .depth_picker_dir + .as_ref() + .map(|(_, name)| name.as_str()) + .unwrap_or("directory"); + format!(" Exploration depth for: {dir_name} ") + }; let chunks = Layout::default() .direction(Direction::Vertical) @@ -42,7 +47,7 @@ pub fn render(app: &App, frame: &mut Frame) { .block( Block::default() .borders(Borders::ALL) - .title(format!(" Exploration depth for: {dir_name} ")), + .title(title), ) .highlight_style( Style::default() From 7a53a49c0c12f62310e0ab3540fdc7aee6556522 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 16:32:51 +1100 Subject: [PATCH 048/100] fix: use JSON output format for Claude CLI to prevent tool output contaminating sim responses With --output-format text, intermediate MCP tool call/result content was concatenated into stdout, causing JSON parse failures when the model used tools during resumed sessions (e.g., scenario updates). Switching to --output-format json isolates the final assistant text in a result field. --- crates/spec-forest/src/simulation/runner.rs | 41 ++++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index c94290c..8bcd706 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -4,6 +4,28 @@ use std::time::Duration; const CLAUDE_TIMEOUT: Duration = Duration::from_secs(600); +/// Wrapper for the Claude CLI `--output-format json` envelope. +/// +/// Using JSON output format ensures we get only the final assistant text +/// in the `result` field, excluding intermediate tool call/result content +/// that would otherwise be concatenated in `--output-format text`. +#[derive(serde::Deserialize)] +struct ClaudeCliOutput { + result: String, + session_id: String, +} + +/// Extract the assistant's final text and session ID from the CLI JSON envelope. +fn extract_cli_result(raw: &str) -> Result<(String, String), Box> { + let output: ClaudeCliOutput = serde_json::from_str(raw).map_err(|e| { + format!( + "Failed to parse Claude CLI JSON output: {e}. Raw:\n{}", + &raw[..raw.len().min(500)] + ) + })?; + Ok((output.result, output.session_id)) +} + /// Configuration for starting a simulation turn. pub struct SimConfig { pub model: String, @@ -49,18 +71,14 @@ pub async fn start_sim_turn( } }); - let session_id = uuid::Uuid::new_v4().to_string(); - let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("text") + .arg("json") .arg("--model") .arg(&config.model) .arg("--system-prompt") .arg(&config.system_prompt) - .arg("--session-id") - .arg(&session_id) .arg("--mcp-config") .arg(mcp_config.to_string()) .arg("--allowedTools") @@ -86,7 +104,8 @@ pub async fn start_sim_turn( return Err(format!("claude CLI failed: {}", stderr).into()); } - let response_text = String::from_utf8(output.stdout)?; + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, session_id) = extract_cli_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation initial turn complete" @@ -109,7 +128,7 @@ pub async fn resume_sim_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("text") + .arg("json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -133,7 +152,8 @@ pub async fn resume_sim_turn( return Err(format!("claude CLI failed: {}", stderr).into()); } - let response_text = String::from_utf8(output.stdout)?; + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, _) = extract_cli_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation resume turn complete" @@ -164,7 +184,7 @@ pub async fn resume_sim_report_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("text") + .arg("json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -188,7 +208,8 @@ pub async fn resume_sim_report_turn( return Err(format!("claude CLI failed: {}", stderr).into()); } - let response_text = String::from_utf8(output.stdout)?; + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, _) = extract_cli_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation report turn complete" From 1ee7038ff70743d230c942a8340860ee3f0ca66e Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 16:52:06 +1100 Subject: [PATCH 049/100] fix: rewrite simulation prompts to prevent AI from hallucinating unspecified behavior Reframe the AI's role from "simulate a working app" to "spec-simulation tool" that renders ONLY what spec nodes explicitly describe. Key changes: - Add CARDINAL RULE preamble making grounding the #1 priority - Strengthen "do not guess" into explicit rules against loosely-related citations - Remove "realistic TUI" language that pushed AI to invent UI elements - Reframe spec_gaps as warning flags, not permission slips - Add "prefer omission over invention" rule with concrete examples - Fix initial/resume prompts to stop requesting a "starting state" --- crates/spec-forest/src/simulation/prompt.rs | 56 +++++++++++++++------ crates/spec-forest/src/simulation/runner.rs | 7 ++- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index cb96d80..bb2cb3c 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -79,8 +79,16 @@ pub fn build_system_prompt( } format!( - r#"You are simulating an interactive software application based on specification nodes. -You are rendering a terminal UI simulation. Your responses MUST be valid JSON. + r#"## CARDINAL RULE: SHOW ONLY WHAT THE SPEC DEFINES +You are a spec-simulation tool. You render ONLY behaviors, UI elements, interactions, audio, +network events, and logs that are explicitly described in specification nodes. If a spec node +does not describe it, you do NOT render it — you mark the absence as a spec_gap instead. + +You are NOT building a working application. You are NOT trying to make something look complete +or realistic. You are simulating exactly what the spec covers — nothing more, nothing less. +Omitting something is ALWAYS better than inventing something. + +Your responses MUST be valid JSON. ## Focus Node (Primary Context) This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. @@ -112,7 +120,11 @@ Available tools: - **get_descendants**: Get a node's subtree — use this to explore an area in depth - **get_spec_summary**: Get an overview of a spec -Do NOT guess or fabricate behavior. If unsure, query the spec first. +NEVER render behavior you cannot cite to a SPECIFIC spec node. If a tool search returns no +relevant nodes, that means the spec does not cover it — add a spec_gap, do NOT invent the behavior. +Finding a "loosely related" node is NOT sufficient justification to render something. +The node must SPECIFICALLY describe the exact element or behavior you are rendering. +If unsure, query the spec first. If the spec still doesn't cover it, OMIT and add a spec_gap. ## Output Format Every response must be a JSON object with this schema: @@ -138,7 +150,7 @@ Active channels: {channel_list} You MUST include an entry for each active channel in every response. ## Channel Semantics -- "ui": Unicode/ASCII art rendering of the simulated interface. Replace entirely each turn. This should be a realistic TUI representation using box-drawing characters, borders, and layout. +- "ui": Unicode/ASCII art rendering of spec-defined interface elements ONLY. Replace entirely each turn. Use box-drawing characters for layout. Render ONLY elements that a spec node explicitly describes. Show "[not specified]" placeholders for parts of the interface the spec does not cover. Do NOT fill in UI elements to make the interface look "complete" or "realistic." - "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' - "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages and warnings from the simulated application @@ -153,18 +165,28 @@ Every simulated behavior MUST be traceable to the spec. This is the primary purp in the refs array. 2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — do not just cite one. -3. If ANY behavior is not grounded in a spec node, you MUST add an entry to the "spec_gaps" - array on that channel describing exactly what is ungrounded and why you chose that behavior. - One entry per ungrounded assumption. Never leave behavior unattributed — either cite a node - or declare a spec_gaps entry. +3. spec_gaps are WARNING FLAGS, not permission slips. If behavior is not grounded in a spec + node, you SHOULD OMIT THAT BEHAVIOR ENTIRELY when possible. Only render ungrounded behavior + when it is structurally necessary to display grounded behavior (e.g., a container layout + needed to show a specified element). In that case, add a spec_gaps entry describing EXACTLY + what you invented and why it was structurally required. Having many spec_gaps means you are + rendering too much — reduce what you show. 4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. 5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. +6. PREFER OMISSION OVER INVENTION. If the spec says "login form with email and password" + but does NOT mention a submit button, do NOT render a submit button. Add a spec_gap: + "No submit mechanism specified for login form." Let the user see what is missing. ### Example -Good: "+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[^3] [Submit]" -with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}},{{"marker":"[^3]","node_id":"..."}}] +Good (spec says "login form with email and password fields"): +"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[not specified: submit mechanism]" +with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}}] +and spec_gaps: ["No submit mechanism specified for login form"] + +BAD: Adding a [Submit] button because "login forms usually have one" — this is hallucination +even if you cite the login form node. The node must SPECIFICALLY mention a submit button. -Bad: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" +BAD: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" with refs: [] and no spec_gaps — this is NEVER acceptable. ## Decisions (CRITICAL) @@ -228,12 +250,16 @@ pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> Some(desc) if !desc.trim().is_empty() => format!( "Initialize the simulation with the following scenario:\n\n\ {desc}\n\n\ - Show the application state as described above across channels: {channel_list}. \ - Render the UI and any events in the appropriate channels." + Render ONLY the elements explicitly described in spec nodes across channels: {channel_list}. \ + For any aspect of the scenario not covered by a spec node, add a spec_gap instead of inventing it. \ + It is fine if large parts of the UI are empty or show \"[not specified]\" placeholders." ), _ => format!( - "Initialize the simulation. Show the application's starting state across channels: {channel_list}. \ - Render the initial UI and any startup events in the appropriate channels." + "Initialize the simulation. Examine the spec nodes provided and render ONLY what they \ + explicitly describe across channels: {channel_list}. \ + Do NOT invent a \"starting state\" — show only elements that spec nodes define. \ + If the spec does not describe an initial screen, render a minimal placeholder and note \ + the spec_gap. Empty channels are acceptable." ), } } diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 8bcd706..88689a0 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -121,7 +121,12 @@ pub async fn resume_sim_turn( input: &str, ) -> Result> { let prompt = format!( - "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. Every behavior MUST have [^N] spec refs or spec_gaps. Include a decisions array listing every decision with refs or spec_gaps. Do not break character.", + "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. \ + CARDINAL RULE: render ONLY what spec nodes explicitly describe. Every element MUST cite a \ + specific spec node that describes THAT EXACT element — not a loosely related node. \ + If the user's input triggers behavior the spec does not cover, do NOT invent the behavior. \ + Instead show what IS specified and add spec_gaps for what is not. \ + Include a decisions array listing every decision with refs or spec_gaps.", input ); From 8df5e46659e1807c41011ad41f390e53b62490fd Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 16:59:01 +1100 Subject: [PATCH 050/100] feat: add consume whole spec toggle to simulation channel picker Adds a [Tab] toggle on the channel picker screen that loads all spec nodes into the simulation system prompt instead of just the focus node's ancestors and descendants. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 8 + crates/spec-forest-tui/src/commands.rs | 62 ++++--- crates/spec-forest-tui/src/input.rs | 1 + .../src/ui/sim_channel_picker.rs | 23 ++- crates/spec-forest/src/simulation.rs | 2 +- crates/spec-forest/src/simulation/prompt.rs | 168 ++++++++++++++++++ 7 files changed, 238 insertions(+), 27 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index ddc7e9f..34fffb0 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -104,6 +104,7 @@ pub enum Action { SimChannelUp, SimChannelDown, SimChannelToggle, + SimChannelToggleWholeSpec, SimChannelConfirm, SimChannelCancel, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 185f771..06b5478 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -89,6 +89,7 @@ pub struct App { pub sim_state: Option, pub sim_channel_selected: usize, pub sim_channel_selection: std::collections::HashSet, + pub sim_consume_whole_spec: bool, pub sim_scenario_input: String, } @@ -183,6 +184,7 @@ impl App { sim_state: None, sim_channel_selected: 0, sim_channel_selection: std::collections::HashSet::new(), + sim_consume_whole_spec: false, sim_scenario_input: String::new(), } } @@ -581,6 +583,7 @@ impl App { let spec_id = spec_id.clone(); self.sim_channel_selected = 0; self.sim_channel_selection = [0].into(); // UI channel selected by default + self.sim_consume_whole_spec = false; self.screen = Screen::SimChannelPicker { spec_id, node_id }; } else { self.message = Some("Select a node to simulate".to_string()); @@ -604,6 +607,9 @@ impl App { self.sim_channel_selection.insert(idx); } } + Action::SimChannelToggleWholeSpec => { + self.sim_consume_whole_spec = !self.sim_consume_whole_spec; + } Action::SimChannelConfirm => { if self.sim_channel_selection.is_empty() { self.message = Some("Select at least one channel".to_string()); @@ -1827,6 +1833,7 @@ impl App { let spec_id_for_task = spec_id.clone(); let channels_for_task = channels; let focus_node_for_task = focus_node_id; + let consume_whole_spec = self.sim_consume_whole_spec; // Mark session as processing self.state.update_sim_session(&session_id, |s| { @@ -1845,6 +1852,7 @@ impl App { channels_for_task, focus_node_for_task, scenario, + consume_whole_spec, ) .await; }); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 750f929..ebeb0d1 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -247,6 +247,7 @@ pub async fn run_sim_initial_turn( channels: Vec, focus_node_id: String, scenario: Option, + consume_whole_spec: bool, ) { // Load focus node let focus_node = match spec_forest::api::get_node(&state, &focus_node_id) { @@ -260,9 +261,6 @@ pub async fn run_sim_initial_turn( } }; - // Load ancestors, descendants, summary, and roots - let ancestors = spec_forest::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); - let descendants = spec_forest::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); let summary = match spec_forest::api::get_spec(&state, &spec_id) { Ok(s) => s, Err(e) => { @@ -275,27 +273,43 @@ pub async fn run_sim_initial_turn( } }; - // Get root nodes, excluding any already in ancestors/descendants/focus - 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 = spec_forest::api::get_spec_roots(&state, &spec_id) - .unwrap_or_default() - .into_iter() - .filter(|n| !context_ids.contains(n.id.as_str())) - .collect::>(); - - let system_prompt = simulation::build_system_prompt( - &channels, - &focus_node, - &ancestors, - &descendants, - &summary, - &other_roots, - ); + let system_prompt = if consume_whole_spec { + let all_nodes = spec_forest::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + simulation::build_system_prompt_whole_spec( + &channels, + &focus_node, + &all_nodes, + &summary, + ) + } else { + // Load ancestors, descendants, and roots for focused context + let ancestors = + spec_forest::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = + spec_forest::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + // Get root nodes, excluding any already in ancestors/descendants/focus + 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 = spec_forest::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + simulation::build_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + ) + }; let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 556b58e..88f8906 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -93,6 +93,7 @@ fn map_sim_channel_picker_key(key: KeyCode) -> Action { KeyCode::Up => Action::SimChannelUp, KeyCode::Down => Action::SimChannelDown, KeyCode::Char(' ') => Action::SimChannelToggle, + KeyCode::Tab => Action::SimChannelToggleWholeSpec, KeyCode::Enter => Action::SimChannelConfirm, KeyCode::Esc => Action::SimChannelCancel, _ => Action::Noop, 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 7346456..890d32c 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -15,6 +15,7 @@ pub fn render(app: &App, frame: &mut Frame) { .direction(Direction::Vertical) .constraints([ Constraint::Min(3), // channel list + Constraint::Length(3), // whole spec toggle Constraint::Length(3), // footer ]) .split(frame.area()); @@ -49,6 +50,24 @@ pub fn render(app: &App, frame: &mut Frame) { let list = List::new(items).block(block); frame.render_widget(list, chunks[0]); + // Whole spec toggle + let whole_spec_checkbox = if app.sim_consume_whole_spec { + "[x]" + } else { + "[ ]" + }; + let whole_spec_style = if app.sim_consume_whole_spec { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let whole_spec = Paragraph::new(Line::from(Span::styled( + format!(" {whole_spec_checkbox} Consume Whole Spec — include all spec nodes in system prompt"), + whole_spec_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(whole_spec, chunks[1]); + let selected_count = app.sim_channel_selection.len(); let footer = Paragraph::new(Line::from(vec![ Span::styled( @@ -56,11 +75,11 @@ pub fn render(app: &App, frame: &mut Frame) { Style::default().fg(Color::Cyan), ), Span::styled( - "[Space] Toggle [Enter] Start [Esc] Cancel", + "[Space] Toggle [Tab] Whole Spec [Enter] Start [Esc] Cancel", Style::default().fg(Color::DarkGray), ), ])) .block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[1]); + frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index ae173eb..d33fcb6 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -3,7 +3,7 @@ pub mod runner; pub mod session; pub mod types; -pub use prompt::{build_initial_prompt, build_system_prompt}; +pub use prompt::{build_initial_prompt, build_system_prompt, build_system_prompt_whole_spec}; pub use session::{SimChannel, SimSession, SimStatus}; pub use types::{ ChannelContent, Decision, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse, diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index bb2cb3c..17af5bc 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -235,6 +235,174 @@ Simulate how the application would respond to these inputs based on the spec."#, ) } +/// Build the system prompt with the entire spec loaded. +/// +/// Similar to `build_system_prompt` but includes ALL nodes from the spec +/// instead of just the focus node's ancestors and descendants. +pub fn build_system_prompt_whole_spec( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, +) -> String { + let channel_list = channels + .iter() + .map(|c| c.key()) + .collect::>() + .join(", "); + + // Build focus node section + let mut focus_section = String::new(); + focus_section.push_str(&format!("### Focus Node (ID: {})\n", focus_node.id)); + focus_section.push_str(&format!("**Q:** {}\n", focus_node.question)); + if let Some(ref answer) = focus_node.answer { + focus_section.push_str(&format!("**A:** {}\n", answer)); + } else { + focus_section.push_str("**A:** _(unanswered)_\n"); + } + + // Build complete spec section with all nodes + let mut all_nodes_section = String::new(); + for node in all_nodes { + if node.id == focus_node.id { + continue; + } + all_nodes_section.push_str(&format!("#### Node {}\n", node.id)); + all_nodes_section.push_str(&format!("**Q:** {}\n", node.question)); + if let Some(ref answer) = node.answer { + all_nodes_section.push_str(&format!("**A:** {}\n", answer)); + } else { + all_nodes_section.push_str("**A:** _(unanswered)_\n"); + } + all_nodes_section.push('\n'); + } + + format!( + r#"## CARDINAL RULE: SHOW ONLY WHAT THE SPEC DEFINES +You are a spec-simulation tool. You render ONLY behaviors, UI elements, interactions, audio, +network events, and logs that are explicitly described in specification nodes. If a spec node +does not describe it, you do NOT render it — you mark the absence as a spec_gap instead. + +You are NOT building a working application. You are NOT trying to make something look complete +or realistic. You are simulating exactly what the spec covers — nothing more, nothing less. +Omitting something is ALWAYS better than inventing something. + +Your responses MUST be valid JSON. + +## Focus Node (Primary Context) +This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. + +{focus_section} +## Complete Specification (All Nodes) +The entire spec has been loaded. All nodes are listed below: + +{all_nodes_section} +## Spec Overview +The full spec "{spec_name}" has {answered} answered nodes, {unanswered} unanswered nodes, and {needs_review} nodes needing review. + +## Additional Tools +You have access to spec-forest MCP tools if you need to explore relationships between nodes: + +Available tools: +- **search_nodes**: Search for nodes by text — use this to find relevant spec nodes +- **get_node**: Get a specific node by ID — use this when you have a node ID +- **get_descendants**: Get a node's subtree — use this to explore an area in depth +- **get_spec_summary**: Get an overview of a spec + +NEVER render behavior you cannot cite to a SPECIFIC spec node. If a tool search returns no +relevant nodes, that means the spec does not cover it — add a spec_gap, do NOT invent the behavior. +Finding a "loosely related" node is NOT sufficient justification to render something. +The node must SPECIFICALLY describe the exact element or behavior you are rendering. + +## Output Format +Every response must be a JSON object with this schema: +{{ + "channels": {{ + "": {{ + "text": "content with optional [^N] references", + "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], + "spec_gaps": ["one entry per ungrounded assumption in this channel"] + }} + }}, + "decisions": [ + {{ + "description": "What you decided to do and why", + "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], + "spec_gaps": ["any ungrounded assumption behind this decision"] + }} + ] +}} + +Active channels: {channel_list} + +You MUST include an entry for each active channel in every response. + +## Channel Semantics +- "ui": Unicode/ASCII art rendering of spec-defined interface elements ONLY. Replace entirely each turn. Use box-drawing characters for layout. Render ONLY elements that a spec node explicitly describes. Show "[not specified]" placeholders for parts of the interface the spec does not cover. Do NOT fill in UI elements to make the interface look "complete" or "realistic." +- "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' +- "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' +- "errors": Error messages and warnings from the simulated application +- "logs": Application log output from the simulated application + +## Spec References (CRITICAL) +Every simulated behavior MUST be traceable to the spec. This is the primary purpose of the simulation. + +### Rules +1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry + MUST cite at least one spec node using [^N] markers in the text, with corresponding entries + in the refs array. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — + do not just cite one. +3. If ANY behavior is not grounded in a spec node, you MUST add an entry to the "spec_gaps" + array on that channel describing exactly what is ungrounded and why you chose that behavior. + One entry per ungrounded assumption. Never leave behavior unattributed — either cite a node + or declare a spec_gaps entry. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. +5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. + +### Example +Good: "+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[^3] [Submit]" +with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}},{{"marker":"[^3]","node_id":"..."}}] + +Bad: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" +with refs: [] and no spec_gaps — this is NEVER acceptable. + +## Decisions (CRITICAL) +Every response MUST include a "decisions" array listing every discrete decision you made this turn. + +### Rules +1. Each decision describes ONE specific action or behavior choice you made + (e.g., "Displayed login form with email and password fields", + "Returned 401 status on unauthenticated request", + "Played notification sound on message arrival"). +2. Each decision MUST cite at least one spec node in its refs array, + OR declare at least one entry in its spec_gaps array explaining the ungrounded assumption. +3. Be granular: if you made 5 decisions this turn, list all 5. Do NOT combine unrelated decisions. +4. Decision refs use the SAME [^N] marker namespace as channel text refs. + Reuse markers that already appear in channel text where applicable. +5. If you assumed behavior that NO spec node covers, you MUST add an entry + to that decision's spec_gaps array. This is non-negotiable. +6. The decisions array must NEVER be empty. Every turn involves at least one decision. + +## User Input +The user sends batched keypresses as structured JSON input. Each message contains: +{{"keys": ["a", "b", "Enter"], "raw_text": "ab\n"}} + +Simulate how the application would respond to these inputs based on the spec."#, + focus_section = focus_section, + all_nodes_section = if all_nodes_section.is_empty() { + "_(no other nodes)_\n".to_string() + } else { + all_nodes_section + }, + spec_name = summary.spec.name, + answered = summary.answered_count, + unanswered = summary.unanswered_count, + needs_review = summary.needs_review_count, + channel_list = channel_list, + ) +} + /// Build the initial prompt for the first simulation turn. /// /// When `scenario` is provided, the simulation starts from the described state From 99145b42dfc1d1ae4f1100f7ccf279df49effc2b Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 17:01:07 +1100 Subject: [PATCH 051/100] =?UTF-8?q?fix:=20rebalance=20simulation=20prompts?= =?UTF-8?q?=20=E2=80=94=20render=20interactively=20but=20flag=20all=20inve?= =?UTF-8?q?nted=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous prompt revision was too strict, causing the AI to just list spec coverage instead of rendering an interactive simulation. Rebalance: - AI MUST render the feature as it would look if implemented - Everything grounded in a spec node gets cited with [^N] markers - Everything invented to make the simulation interactive gets flagged as a spec_gap — never silently blended with grounded content - Loose citations (citing a parent/related node for something it doesn't specifically describe) are explicitly called out as the wrong pattern - Updated both build_system_prompt and build_system_prompt_whole_spec --- crates/spec-forest/src/simulation/prompt.rs | 144 +++++++++++--------- crates/spec-forest/src/simulation/runner.rs | 7 +- 2 files changed, 86 insertions(+), 65 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 17af5bc..fb44451 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -79,14 +79,21 @@ pub fn build_system_prompt( } format!( - r#"## CARDINAL RULE: SHOW ONLY WHAT THE SPEC DEFINES -You are a spec-simulation tool. You render ONLY behaviors, UI elements, interactions, audio, -network events, and logs that are explicitly described in specification nodes. If a spec node -does not describe it, you do NOT render it — you mark the absence as a spec_gap instead. - -You are NOT building a working application. You are NOT trying to make something look complete -or realistic. You are simulating exactly what the spec covers — nothing more, nothing less. -Omitting something is ALWAYS better than inventing something. + r#"## CARDINAL RULE: GROUND EVERYTHING IN THE SPEC +You are a spec-simulation tool. You simulate the feature described by the spec nodes as an +interactive application. You MUST render a working, interactive simulation — but you must be +rigorously honest about what comes from the spec vs what you had to invent. + +- Behavior that IS described by a spec node: render it and cite the node with [^N] markers. +- Behavior you MUST INVENT to make the simulation interactive (e.g., a submit button the spec + doesn't mention, layout choices, default states): render it BUT flag every invented element + as a spec_gap. The user needs to see what the spec is missing. +- NEVER cite a loosely related node to justify invented behavior. If a node doesn't SPECIFICALLY + describe the element, it is not a valid citation. Cite it as a spec_gap instead. + +The simulation should look and feel like the real feature would if implemented. But every piece +of it must be either grounded in a specific spec node OR explicitly flagged as a spec_gap. +NEVER blend grounded and invented content without marking the invented parts. Your responses MUST be valid JSON. @@ -120,11 +127,8 @@ Available tools: - **get_descendants**: Get a node's subtree — use this to explore an area in depth - **get_spec_summary**: Get an overview of a spec -NEVER render behavior you cannot cite to a SPECIFIC spec node. If a tool search returns no -relevant nodes, that means the spec does not cover it — add a spec_gap, do NOT invent the behavior. -Finding a "loosely related" node is NOT sufficient justification to render something. -The node must SPECIFICALLY describe the exact element or behavior you are rendering. -If unsure, query the spec first. If the spec still doesn't cover it, OMIT and add a spec_gap. +If unsure whether the spec covers something, query the spec with tools first. If it doesn't +cover it and you need the element to make the simulation interactive, render it and add a spec_gap. ## Output Format Every response must be a JSON object with this schema: @@ -150,44 +154,46 @@ Active channels: {channel_list} You MUST include an entry for each active channel in every response. ## Channel Semantics -- "ui": Unicode/ASCII art rendering of spec-defined interface elements ONLY. Replace entirely each turn. Use box-drawing characters for layout. Render ONLY elements that a spec node explicitly describes. Show "[not specified]" placeholders for parts of the interface the spec does not cover. Do NOT fill in UI elements to make the interface look "complete" or "realistic." +- "ui": Unicode/ASCII art rendering of the simulated interface as it would appear if the feature were implemented. Replace entirely each turn. Use box-drawing characters, borders, and layout. Render the feature realistically, but EVERY element must either cite a spec node or be flagged as a spec_gap. Do NOT render elements and silently pretend they are specified — if you invented it, flag it. - "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' - "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages and warnings from the simulated application - "logs": Application log output from the simulated application ## Spec References (CRITICAL) -Every simulated behavior MUST be traceable to the spec. This is the primary purpose of the simulation. +Every simulated behavior MUST be traceable. This is the primary purpose of the simulation — +to show the user what the spec covers and what it is missing. ### Rules 1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry MUST cite at least one spec node using [^N] markers in the text, with corresponding entries - in the refs array. + in the refs array — OR be flagged as a spec_gap. 2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — do not just cite one. -3. spec_gaps are WARNING FLAGS, not permission slips. If behavior is not grounded in a spec - node, you SHOULD OMIT THAT BEHAVIOR ENTIRELY when possible. Only render ungrounded behavior - when it is structurally necessary to display grounded behavior (e.g., a container layout - needed to show a specified element). In that case, add a spec_gaps entry describing EXACTLY - what you invented and why it was structurally required. Having many spec_gaps means you are - rendering too much — reduce what you show. +3. A citation must be SPECIFIC: the cited node must describe the EXACT element or behavior you + are rendering. Do NOT cite a loosely related or parent node to justify something it doesn't + specifically mention. If the node says "login form" that does NOT justify rendering a + "forgot password" link — that link needs its own spec node or must be a spec_gap. 4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. 5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. -6. PREFER OMISSION OVER INVENTION. If the spec says "login form with email and password" - but does NOT mention a submit button, do NOT render a submit button. Add a spec_gap: - "No submit mechanism specified for login form." Let the user see what is missing. +6. Anything you invent to make the simulation work (layout, buttons, default states, transitions, + error messages, etc.) that no spec node specifically describes MUST be a spec_gap entry. + Never silently blend invented behavior with spec-grounded behavior. ### Example Good (spec says "login form with email and password fields"): -"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[not specified: submit mechanism]" +"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[Submit]" with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}}] -and spec_gaps: ["No submit mechanism specified for login form"] +and spec_gaps: ["Submit button not specified — added to make form interactive"] + +Note: The submit button IS rendered to make the form usable, but it is flagged as a spec_gap +because no spec node specifically mentions it. -BAD: Adding a [Submit] button because "login forms usually have one" — this is hallucination -even if you cite the login form node. The node must SPECIFICALLY mention a submit button. +BAD: Citing the "login form" node [^1] for the submit button — the node describes the form, +not the submit button. This is a loose citation that hides a spec gap. BAD: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" -with refs: [] and no spec_gaps — this is NEVER acceptable. +with refs: [] and no spec_gaps — this is NEVER acceptable. Nothing is attributed. ## Decisions (CRITICAL) Every response MUST include a "decisions" array listing every discrete decision you made this turn. @@ -278,14 +284,21 @@ pub fn build_system_prompt_whole_spec( } format!( - r#"## CARDINAL RULE: SHOW ONLY WHAT THE SPEC DEFINES -You are a spec-simulation tool. You render ONLY behaviors, UI elements, interactions, audio, -network events, and logs that are explicitly described in specification nodes. If a spec node -does not describe it, you do NOT render it — you mark the absence as a spec_gap instead. - -You are NOT building a working application. You are NOT trying to make something look complete -or realistic. You are simulating exactly what the spec covers — nothing more, nothing less. -Omitting something is ALWAYS better than inventing something. + r#"## CARDINAL RULE: GROUND EVERYTHING IN THE SPEC +You are a spec-simulation tool. You simulate the feature described by the spec nodes as an +interactive application. You MUST render a working, interactive simulation — but you must be +rigorously honest about what comes from the spec vs what you had to invent. + +- Behavior that IS described by a spec node: render it and cite the node with [^N] markers. +- Behavior you MUST INVENT to make the simulation interactive (e.g., a submit button the spec + doesn't mention, layout choices, default states): render it BUT flag every invented element + as a spec_gap. The user needs to see what the spec is missing. +- NEVER cite a loosely related node to justify invented behavior. If a node doesn't SPECIFICALLY + describe the element, it is not a valid citation. Cite it as a spec_gap instead. + +The simulation should look and feel like the real feature would if implemented. But every piece +of it must be either grounded in a specific spec node OR explicitly flagged as a spec_gap. +NEVER blend grounded and invented content without marking the invented parts. Your responses MUST be valid JSON. @@ -309,10 +322,8 @@ Available tools: - **get_descendants**: Get a node's subtree — use this to explore an area in depth - **get_spec_summary**: Get an overview of a spec -NEVER render behavior you cannot cite to a SPECIFIC spec node. If a tool search returns no -relevant nodes, that means the spec does not cover it — add a spec_gap, do NOT invent the behavior. -Finding a "loosely related" node is NOT sufficient justification to render something. -The node must SPECIFICALLY describe the exact element or behavior you are rendering. +If unsure whether the spec covers something, query the spec with tools first. If it doesn't +cover it and you need the element to make the simulation interactive, render it and add a spec_gap. ## Output Format Every response must be a JSON object with this schema: @@ -338,34 +349,46 @@ Active channels: {channel_list} You MUST include an entry for each active channel in every response. ## Channel Semantics -- "ui": Unicode/ASCII art rendering of spec-defined interface elements ONLY. Replace entirely each turn. Use box-drawing characters for layout. Render ONLY elements that a spec node explicitly describes. Show "[not specified]" placeholders for parts of the interface the spec does not cover. Do NOT fill in UI elements to make the interface look "complete" or "realistic." +- "ui": Unicode/ASCII art rendering of the simulated interface as it would appear if the feature were implemented. Replace entirely each turn. Use box-drawing characters, borders, and layout. Render the feature realistically, but EVERY element must either cite a spec node or be flagged as a spec_gap. Do NOT render elements and silently pretend they are specified — if you invented it, flag it. - "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' - "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages and warnings from the simulated application - "logs": Application log output from the simulated application ## Spec References (CRITICAL) -Every simulated behavior MUST be traceable to the spec. This is the primary purpose of the simulation. +Every simulated behavior MUST be traceable. This is the primary purpose of the simulation — +to show the user what the spec covers and what it is missing. ### Rules 1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry MUST cite at least one spec node using [^N] markers in the text, with corresponding entries - in the refs array. + in the refs array — OR be flagged as a spec_gap. 2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — do not just cite one. -3. If ANY behavior is not grounded in a spec node, you MUST add an entry to the "spec_gaps" - array on that channel describing exactly what is ungrounded and why you chose that behavior. - One entry per ungrounded assumption. Never leave behavior unattributed — either cite a node - or declare a spec_gaps entry. +3. A citation must be SPECIFIC: the cited node must describe the EXACT element or behavior you + are rendering. Do NOT cite a loosely related or parent node to justify something it doesn't + specifically mention. If the node says "login form" that does NOT justify rendering a + "forgot password" link — that link needs its own spec node or must be a spec_gap. 4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. 5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. +6. Anything you invent to make the simulation work (layout, buttons, default states, transitions, + error messages, etc.) that no spec node specifically describes MUST be a spec_gap entry. + Never silently blend invented behavior with spec-grounded behavior. ### Example -Good: "+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[^3] [Submit]" -with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}},{{"marker":"[^3]","node_id":"..."}}] +Good (spec says "login form with email and password fields"): +"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[Submit]" +with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}}] +and spec_gaps: ["Submit button not specified — added to make form interactive"] + +Note: The submit button IS rendered to make the form usable, but it is flagged as a spec_gap +because no spec node specifically mentions it. -Bad: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" -with refs: [] and no spec_gaps — this is NEVER acceptable. +BAD: Citing the "login form" node [^1] for the submit button — the node describes the form, +not the submit button. This is a loose citation that hides a spec gap. + +BAD: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" +with refs: [] and no spec_gaps — this is NEVER acceptable. Nothing is attributed. ## Decisions (CRITICAL) Every response MUST include a "decisions" array listing every discrete decision you made this turn. @@ -418,16 +441,15 @@ pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> Some(desc) if !desc.trim().is_empty() => format!( "Initialize the simulation with the following scenario:\n\n\ {desc}\n\n\ - Render ONLY the elements explicitly described in spec nodes across channels: {channel_list}. \ - For any aspect of the scenario not covered by a spec node, add a spec_gap instead of inventing it. \ - It is fine if large parts of the UI are empty or show \"[not specified]\" placeholders." + Render the application state as it would appear if the feature were implemented, across channels: {channel_list}. \ + Cite spec nodes for everything they cover. For anything you must invent to make \ + the simulation interactive, flag it as a spec_gap." ), _ => format!( - "Initialize the simulation. Examine the spec nodes provided and render ONLY what they \ - explicitly describe across channels: {channel_list}. \ - Do NOT invent a \"starting state\" — show only elements that spec nodes define. \ - If the spec does not describe an initial screen, render a minimal placeholder and note \ - the spec_gap. Empty channels are acceptable." + "Initialize the simulation. Render the application's starting state as it would appear \ + if the feature were implemented, across channels: {channel_list}. \ + Cite spec nodes for everything they cover. For anything you must invent to make \ + the simulation interactive, flag it as a spec_gap." ), } } diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 88689a0..969593a 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -122,10 +122,9 @@ pub async fn resume_sim_turn( ) -> Result> { let prompt = format!( "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. \ - CARDINAL RULE: render ONLY what spec nodes explicitly describe. Every element MUST cite a \ - specific spec node that describes THAT EXACT element — not a loosely related node. \ - If the user's input triggers behavior the spec does not cover, do NOT invent the behavior. \ - Instead show what IS specified and add spec_gaps for what is not. \ + Render the simulation as the feature would behave if implemented. Every element must \ + either cite a SPECIFIC spec node (not a loosely related one) or be flagged as a spec_gap. \ + Never silently blend invented behavior with spec-grounded behavior. \ Include a decisions array listing every decision with refs or spec_gaps.", input ); From d5fbd19640ae55bb3d683a00a782871772c680d0 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 21:00:40 +1100 Subject: [PATCH 052/100] fix: reframe simulation prompts from spec audit to faithful implementation prediction Use entropy-based gap detection: only flag spec_gaps for high-entropy decisions where different implementers would diverge. Low-entropy choices (submit buttons, standard layout, obvious defaults) are rendered naturally without flagging, producing cleaner simulation output. --- crates/spec-forest/src/simulation/prompt.rs | 320 +++++++++++--------- crates/spec-forest/src/simulation/runner.rs | 8 +- 2 files changed, 187 insertions(+), 141 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index fb44451..0d3f4d5 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -79,21 +79,30 @@ pub fn build_system_prompt( } format!( - r#"## CARDINAL RULE: GROUND EVERYTHING IN THE SPEC -You are a spec-simulation tool. You simulate the feature described by the spec nodes as an -interactive application. You MUST render a working, interactive simulation — but you must be -rigorously honest about what comes from the spec vs what you had to invent. - -- Behavior that IS described by a spec node: render it and cite the node with [^N] markers. -- Behavior you MUST INVENT to make the simulation interactive (e.g., a submit button the spec - doesn't mention, layout choices, default states): render it BUT flag every invented element - as a spec_gap. The user needs to see what the spec is missing. -- NEVER cite a loosely related node to justify invented behavior. If a node doesn't SPECIFICALLY - describe the element, it is not a valid citation. Cite it as a spec_gap instead. - -The simulation should look and feel like the real feature would if implemented. But every piece -of it must be either grounded in a specific spec node OR explicitly flagged as a spec_gap. -NEVER blend grounded and invented content without marking the invented parts. + r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD +You are simulating the program that an AI developer would build from this specification. +Your job is to predict the implementation — not to audit the spec. + +Mental model: +1. An implementing AI will eventually build this software from the spec. +2. You predict what that implementer would create. +3. The user interacts with your simulation to preview what the spec leads to. +4. This helps the user refine the spec BEFORE actual implementation. + +When simulating, you will inevitably make decisions the spec does not explicitly cover. +Apply an ENTROPY test to each such decision: + +- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag + it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, + a close button on a modal, standard HTTP status codes for obvious cases. +- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different + choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. + Examples: what happens on auth failure (redirect vs inline error vs modal), pagination + strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution + strategy. + +The goal is NOT to flag every invented detail — it is to surface the decisions that actually +matter and that the spec should clarify. Your responses MUST be valid JSON. @@ -127,8 +136,8 @@ Available tools: - **get_descendants**: Get a node's subtree — use this to explore an area in depth - **get_spec_summary**: Get an overview of a spec -If unsure whether the spec covers something, query the spec with tools first. If it doesn't -cover it and you need the element to make the simulation interactive, render it and add a spec_gap. +If unsure whether the spec covers something, search the spec with tools first. If the spec +does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. ## Output Format Every response must be a JSON object with this schema: @@ -137,14 +146,14 @@ Every response must be a JSON object with this schema: "": {{ "text": "content with optional [^N] references", "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["one entry per ungrounded assumption in this channel"] + "spec_gaps": ["one entry per high-entropy decision in this channel"] }} }}, "decisions": [ {{ "description": "What you decided to do and why", "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["any ungrounded assumption behind this decision"] + "spec_gaps": ["any high-entropy decision behind this choice"] }} ] }} @@ -154,63 +163,76 @@ Active channels: {channel_list} You MUST include an entry for each active channel in every response. ## Channel Semantics -- "ui": Unicode/ASCII art rendering of the simulated interface as it would appear if the feature were implemented. Replace entirely each turn. Use box-drawing characters, borders, and layout. Render the feature realistically, but EVERY element must either cite a spec node or be flagged as a spec_gap. Do NOT render elements and silently pretend they are specified — if you invented it, flag it. +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout. + Render the feature realistically and completely. Cite spec nodes with [^N] markers where + the spec drives a specific element. Do not clutter the output by flagging obvious, + low-entropy UI elements (buttons, scroll bars, standard layout) — only flag genuinely + ambiguous choices as spec_gaps. - "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' - "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages and warnings from the simulated application - "logs": Application log output from the simulated application -## Spec References (CRITICAL) -Every simulated behavior MUST be traceable. This is the primary purpose of the simulation — -to show the user what the spec covers and what it is missing. +## Spec References +Use [^N] markers to show which spec nodes drive your simulation behavior. This helps the +user see which parts of the spec are active and effective. ### Rules -1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry - MUST cite at least one spec node using [^N] markers in the text, with corresponding entries - in the refs array — OR be flagged as a spec_gap. -2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — - do not just cite one. -3. A citation must be SPECIFIC: the cited node must describe the EXACT element or behavior you - are rendering. Do NOT cite a loosely related or parent node to justify something it doesn't - specifically mention. If the node says "login form" that does NOT justify rendering a - "forgot password" link — that link needs its own spec node or must be a spec_gap. -4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. -5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. -6. Anything you invent to make the simulation work (layout, buttons, default states, transitions, - error messages, etc.) that no spec node specifically describes MUST be a spec_gap entry. - Never silently blend invented behavior with spec-grounded behavior. - -### Example -Good (spec says "login form with email and password fields"): -"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[Submit]" -with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}}] -and spec_gaps: ["Submit button not specified — added to make form interactive"] - -Note: The submit button IS rendered to make the form usable, but it is flagged as a spec_gap -because no spec node specifically mentions it. - -BAD: Citing the "login form" node [^1] for the submit button — the node describes the form, -not the submit button. This is a loose citation that hides a spec gap. - -BAD: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" -with refs: [] and no spec_gaps — this is NEVER acceptable. Nothing is attributed. - -## Decisions (CRITICAL) -Every response MUST include a "decisions" array listing every discrete decision you made this turn. +1. When a simulated behavior IS grounded in a spec node, cite it with [^N] markers in the + text and include corresponding entries in the refs array. This is how the user confirms + the spec is producing the intended result. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3. +3. A citation must be SPECIFIC: the cited node must describe the element or behavior you + are rendering. Do NOT cite a loosely related or parent node to cover something it does + not specifically mention. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite + the node. +5. Low-entropy implementation details that any developer would add (submit buttons, standard + layout, obvious defaults) do NOT need citations or spec_gap entries. Just render them. +6. The refs array may be empty for a channel if that channel's content is entirely standard + implementation detail not driven by any specific spec node. + +## Spec Gaps (High-Entropy Decisions Only) +A spec_gap entry signals a decision where the spec is genuinely ambiguous and different +implementers could reasonably go different ways. The user needs to see these so they can +refine the spec. + +### When to flag a spec_gap +- You chose between multiple reasonable approaches and the spec does not indicate which + (e.g., error display strategy, navigation flow, data handling policy) +- The spec is silent on a user-facing behavior that would be visible and could surprise + the user if implemented differently +- A business rule or policy decision is implied but not stated + +### When NOT to flag a spec_gap +- Standard UI affordances (submit buttons, close buttons, scroll bars) +- Obvious default states (empty form fields, loading spinners) +- Conventional layout choices (header at top, navigation on left) +- Standard HTTP methods and status codes +- Any decision where there is clearly one right answer + +Keep spec_gap entries concise and actionable. Each one should suggest what the spec could +say to resolve the ambiguity. + +## Decisions +Each response MUST include a "decisions" array listing the significant decisions you made +this turn. ### Rules -1. Each decision describes ONE specific action or behavior choice you made +1. Each decision describes a meaningful action or behavior choice you made (e.g., "Displayed login form with email and password fields", - "Returned 401 status on unauthenticated request", - "Played notification sound on message arrival"). -2. Each decision MUST cite at least one spec node in its refs array, - OR declare at least one entry in its spec_gaps array explaining the ungrounded assumption. -3. Be granular: if you made 5 decisions this turn, list all 5. Do NOT combine unrelated decisions. -4. Decision refs use the SAME [^N] marker namespace as channel text refs. + "Chose inline error display for failed authentication", + "Returned 401 status on unauthenticated request"). +2. When a decision is grounded in a spec node, cite it in the refs array. +3. When a decision involves a high-entropy choice the spec does not cover, add a spec_gap + entry explaining the ambiguity and what you chose. +4. Low-entropy decisions (obvious implementation choices) can have empty refs and empty + spec_gaps — they do not need justification. +5. Decision refs use the SAME [^N] marker namespace as channel text refs. Reuse markers that already appear in channel text where applicable. -5. If you assumed behavior that NO spec node covers, you MUST add an entry - to that decision's spec_gaps array. This is non-negotiable. -6. The decisions array must NEVER be empty. Every turn involves at least one decision. +6. Be granular: list each distinct decision separately. +7. The decisions array must NEVER be empty. Every turn involves at least one decision. ## User Input The user sends batched keypresses as structured JSON input. Each message contains: @@ -284,21 +306,30 @@ pub fn build_system_prompt_whole_spec( } format!( - r#"## CARDINAL RULE: GROUND EVERYTHING IN THE SPEC -You are a spec-simulation tool. You simulate the feature described by the spec nodes as an -interactive application. You MUST render a working, interactive simulation — but you must be -rigorously honest about what comes from the spec vs what you had to invent. - -- Behavior that IS described by a spec node: render it and cite the node with [^N] markers. -- Behavior you MUST INVENT to make the simulation interactive (e.g., a submit button the spec - doesn't mention, layout choices, default states): render it BUT flag every invented element - as a spec_gap. The user needs to see what the spec is missing. -- NEVER cite a loosely related node to justify invented behavior. If a node doesn't SPECIFICALLY - describe the element, it is not a valid citation. Cite it as a spec_gap instead. - -The simulation should look and feel like the real feature would if implemented. But every piece -of it must be either grounded in a specific spec node OR explicitly flagged as a spec_gap. -NEVER blend grounded and invented content without marking the invented parts. + r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD +You are simulating the program that an AI developer would build from this specification. +Your job is to predict the implementation — not to audit the spec. + +Mental model: +1. An implementing AI will eventually build this software from the spec. +2. You predict what that implementer would create. +3. The user interacts with your simulation to preview what the spec leads to. +4. This helps the user refine the spec BEFORE actual implementation. + +When simulating, you will inevitably make decisions the spec does not explicitly cover. +Apply an ENTROPY test to each such decision: + +- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag + it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, + a close button on a modal, standard HTTP status codes for obvious cases. +- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different + choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. + Examples: what happens on auth failure (redirect vs inline error vs modal), pagination + strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution + strategy. + +The goal is NOT to flag every invented detail — it is to surface the decisions that actually +matter and that the spec should clarify. Your responses MUST be valid JSON. @@ -322,8 +353,8 @@ Available tools: - **get_descendants**: Get a node's subtree — use this to explore an area in depth - **get_spec_summary**: Get an overview of a spec -If unsure whether the spec covers something, query the spec with tools first. If it doesn't -cover it and you need the element to make the simulation interactive, render it and add a spec_gap. +If unsure whether the spec covers something, search the spec with tools first. If the spec +does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. ## Output Format Every response must be a JSON object with this schema: @@ -332,14 +363,14 @@ Every response must be a JSON object with this schema: "": {{ "text": "content with optional [^N] references", "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["one entry per ungrounded assumption in this channel"] + "spec_gaps": ["one entry per high-entropy decision in this channel"] }} }}, "decisions": [ {{ "description": "What you decided to do and why", "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["any ungrounded assumption behind this decision"] + "spec_gaps": ["any high-entropy decision behind this choice"] }} ] }} @@ -349,63 +380,76 @@ Active channels: {channel_list} You MUST include an entry for each active channel in every response. ## Channel Semantics -- "ui": Unicode/ASCII art rendering of the simulated interface as it would appear if the feature were implemented. Replace entirely each turn. Use box-drawing characters, borders, and layout. Render the feature realistically, but EVERY element must either cite a spec node or be flagged as a spec_gap. Do NOT render elements and silently pretend they are specified — if you invented it, flag it. +- "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would + build it. Replace entirely each turn. Use box-drawing characters, borders, and layout. + Render the feature realistically and completely. Cite spec nodes with [^N] markers where + the spec drives a specific element. Do not clutter the output by flagging obvious, + low-entropy UI elements (buttons, scroll bars, standard layout) — only flag genuinely + ambiguous choices as spec_gaps. - "audio": Timestamped audio event descriptions, e.g. '[AUDIO] Button click sound played' - "network": Timestamped network event log, e.g. '[NET] POST /api/users -> 201' - "errors": Error messages and warnings from the simulated application - "logs": Application log output from the simulated application -## Spec References (CRITICAL) -Every simulated behavior MUST be traceable. This is the primary purpose of the simulation — -to show the user what the spec covers and what it is missing. +## Spec References +Use [^N] markers to show which spec nodes drive your simulation behavior. This helps the +user see which parts of the spec are active and effective. ### Rules -1. EVERY visible behavior, UI element, interaction response, network call, sound, or log entry - MUST cite at least one spec node using [^N] markers in the text, with corresponding entries - in the refs array — OR be flagged as a spec_gap. -2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3 — - do not just cite one. -3. A citation must be SPECIFIC: the cited node must describe the EXACT element or behavior you - are rendering. Do NOT cite a loosely related or parent node to justify something it doesn't - specifically mention. If the node says "login form" that does NOT justify rendering a - "forgot password" link — that link needs its own spec node or must be a spec_gap. -4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite the node. -5. The refs array must NEVER be empty unless the channel also has a spec_gaps entry explaining why. -6. Anything you invent to make the simulation work (layout, buttons, default states, transitions, - error messages, etc.) that no spec node specifically describes MUST be a spec_gap entry. - Never silently blend invented behavior with spec-grounded behavior. - -### Example -Good (spec says "login form with email and password fields"): -"+---------+\n| Login [^1] |\n+---------+\nEmail: [^2] ___\nPassword: [^2] ___\n[Submit]" -with refs: [{{"marker":"[^1]","node_id":"..."}},{{"marker":"[^2]","node_id":"..."}}] -and spec_gaps: ["Submit button not specified — added to make form interactive"] - -Note: The submit button IS rendered to make the form usable, but it is flagged as a spec_gap -because no spec node specifically mentions it. - -BAD: Citing the "login form" node [^1] for the submit button — the node describes the form, -not the submit button. This is a loose citation that hides a spec gap. - -BAD: "+---------+\n| Login |\n+---------+\nEmail: ___\nPassword: ___\n[Submit]" -with refs: [] and no spec_gaps — this is NEVER acceptable. Nothing is attributed. - -## Decisions (CRITICAL) -Every response MUST include a "decisions" array listing every discrete decision you made this turn. +1. When a simulated behavior IS grounded in a spec node, cite it with [^N] markers in the + text and include corresponding entries in the refs array. This is how the user confirms + the spec is producing the intended result. +2. Prefer granular references: if a UI screen draws on 3 different spec nodes, cite all 3. +3. A citation must be SPECIFIC: the cited node must describe the element or behavior you + are rendering. Do NOT cite a loosely related or parent node to cover something it does + not specifically mention. +4. For unanswered or review-flagged nodes, prefix the behavior with [SPECULATIVE] and cite + the node. +5. Low-entropy implementation details that any developer would add (submit buttons, standard + layout, obvious defaults) do NOT need citations or spec_gap entries. Just render them. +6. The refs array may be empty for a channel if that channel's content is entirely standard + implementation detail not driven by any specific spec node. + +## Spec Gaps (High-Entropy Decisions Only) +A spec_gap entry signals a decision where the spec is genuinely ambiguous and different +implementers could reasonably go different ways. The user needs to see these so they can +refine the spec. + +### When to flag a spec_gap +- You chose between multiple reasonable approaches and the spec does not indicate which + (e.g., error display strategy, navigation flow, data handling policy) +- The spec is silent on a user-facing behavior that would be visible and could surprise + the user if implemented differently +- A business rule or policy decision is implied but not stated + +### When NOT to flag a spec_gap +- Standard UI affordances (submit buttons, close buttons, scroll bars) +- Obvious default states (empty form fields, loading spinners) +- Conventional layout choices (header at top, navigation on left) +- Standard HTTP methods and status codes +- Any decision where there is clearly one right answer + +Keep spec_gap entries concise and actionable. Each one should suggest what the spec could +say to resolve the ambiguity. + +## Decisions +Each response MUST include a "decisions" array listing the significant decisions you made +this turn. ### Rules -1. Each decision describes ONE specific action or behavior choice you made +1. Each decision describes a meaningful action or behavior choice you made (e.g., "Displayed login form with email and password fields", - "Returned 401 status on unauthenticated request", - "Played notification sound on message arrival"). -2. Each decision MUST cite at least one spec node in its refs array, - OR declare at least one entry in its spec_gaps array explaining the ungrounded assumption. -3. Be granular: if you made 5 decisions this turn, list all 5. Do NOT combine unrelated decisions. -4. Decision refs use the SAME [^N] marker namespace as channel text refs. + "Chose inline error display for failed authentication", + "Returned 401 status on unauthenticated request"). +2. When a decision is grounded in a spec node, cite it in the refs array. +3. When a decision involves a high-entropy choice the spec does not cover, add a spec_gap + entry explaining the ambiguity and what you chose. +4. Low-entropy decisions (obvious implementation choices) can have empty refs and empty + spec_gaps — they do not need justification. +5. Decision refs use the SAME [^N] marker namespace as channel text refs. Reuse markers that already appear in channel text where applicable. -5. If you assumed behavior that NO spec node covers, you MUST add an entry - to that decision's spec_gaps array. This is non-negotiable. -6. The decisions array must NEVER be empty. Every turn involves at least one decision. +6. Be granular: list each distinct decision separately. +7. The decisions array must NEVER be empty. Every turn involves at least one decision. ## User Input The user sends batched keypresses as structured JSON input. Each message contains: @@ -441,15 +485,17 @@ pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> Some(desc) if !desc.trim().is_empty() => format!( "Initialize the simulation with the following scenario:\n\n\ {desc}\n\n\ - Render the application state as it would appear if the feature were implemented, across channels: {channel_list}. \ - Cite spec nodes for everything they cover. For anything you must invent to make \ - the simulation interactive, flag it as a spec_gap." + Render the application state as an implementer would build it, across channels: {channel_list}. \ + Cite spec nodes that drive specific behaviors. For high-entropy decisions where the \ + spec is ambiguous, flag them as spec_gaps. Low-entropy implementation details need \ + no special treatment — just render them naturally." ), _ => format!( - "Initialize the simulation. Render the application's starting state as it would appear \ - if the feature were implemented, across channels: {channel_list}. \ - Cite spec nodes for everything they cover. For anything you must invent to make \ - the simulation interactive, flag it as a spec_gap." + "Initialize the simulation. Render the application's starting state as an implementer \ + would build it from the spec, across channels: {channel_list}. \ + Cite spec nodes that drive specific behaviors. For high-entropy decisions where the \ + spec is ambiguous, flag them as spec_gaps. Low-entropy implementation details need \ + no special treatment — just render them naturally." ), } } diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 969593a..d2a9a04 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -122,10 +122,10 @@ pub async fn resume_sim_turn( ) -> Result> { let prompt = format!( "{}\n\nRemember: respond ONLY with a valid JSON object matching the output format. \ - Render the simulation as the feature would behave if implemented. Every element must \ - either cite a SPECIFIC spec node (not a loosely related one) or be flagged as a spec_gap. \ - Never silently blend invented behavior with spec-grounded behavior. \ - Include a decisions array listing every decision with refs or spec_gaps.", + Simulate the application as an implementer would build it from the spec. \ + Cite spec nodes that drive specific behaviors. Only flag spec_gaps for high-entropy \ + decisions where the spec is genuinely ambiguous — not for obvious implementation details. \ + Include a decisions array listing significant decisions with refs or spec_gaps as appropriate.", input ); From 1c0e1238a1fc3b8e01ee71c3ff5c02245e1028ae Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 21:03:37 +1100 Subject: [PATCH 053/100] feat: display captured key representations in simulation input Replace string buffer with Vec so simulation insert mode captures all keystrokes as discrete tokens. Special keys render as {enter}, {up}, {down}, {tab}, etc. for readable input display. --- crates/spec-forest-tui/src/action.rs | 6 +- crates/spec-forest-tui/src/app.rs | 77 +++++++-------------- crates/spec-forest-tui/src/input.rs | 21 ++++-- crates/spec-forest-tui/src/simulation.rs | 55 +++++++++++++-- crates/spec-forest-tui/src/ui/simulation.rs | 19 ++--- 5 files changed, 98 insertions(+), 80 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 34fffb0..370fb62 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -119,12 +119,8 @@ pub enum Action { SimEnterInsert, SimExitToNormal, SimExitSimulation, - SimTypeChar(char), + SimCaptureKey(crate::simulation::CapturedKey), SimDeleteChar, - SimCursorLeft, - SimCursorRight, - SimCursorHome, - SimCursorEnd, SimSubmitInput, SimCycleChannel, SimCycleLayout, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 06b5478..a6d947c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -696,15 +696,18 @@ impl App { self.screen = Screen::SpecView { spec_id }; } } - Action::SimTypeChar(c) => { + Action::SimCaptureKey(key) => { if let Some(ref mut sim) = self.sim_state { if sim.report_mode { - sim.report_input.push(c); + if let crate::simulation::CapturedKey::Char(c) = key { + sim.report_input.push(c); + } } else if sim.scenario_mode { - sim.scenario_input.push(c); + if let crate::simulation::CapturedKey::Char(c) = key { + sim.scenario_input.push(c); + } } else { - sim.input_buffer.insert(sim.input_cursor, c); - sim.input_cursor += c.len_utf8(); + sim.captured_keys.push(key); } } } @@ -714,50 +717,11 @@ impl App { sim.report_input.pop(); } else if sim.scenario_mode { sim.scenario_input.pop(); - } else if sim.input_cursor > 0 { - // Find the char boundary before the cursor - let prev = sim.input_buffer[..sim.input_cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - sim.input_buffer.remove(prev); - sim.input_cursor = prev; - } - } - } - Action::SimCursorLeft => { - if let Some(ref mut sim) = self.sim_state { - if sim.input_cursor > 0 { - sim.input_cursor = sim.input_buffer[..sim.input_cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } - } - } - Action::SimCursorRight => { - if let Some(ref mut sim) = self.sim_state { - if sim.input_cursor < sim.input_buffer.len() { - sim.input_cursor += sim.input_buffer[sim.input_cursor..] - .chars() - .next() - .map(|c| c.len_utf8()) - .unwrap_or(0); + } else { + sim.captured_keys.pop(); } } } - Action::SimCursorHome => { - if let Some(ref mut sim) = self.sim_state { - sim.input_cursor = 0; - } - } - Action::SimCursorEnd => { - if let Some(ref mut sim) = self.sim_state { - sim.input_cursor = sim.input_buffer.len(); - } - } Action::SimSubmitInput => { self.submit_sim_input().await; } @@ -1893,16 +1857,25 @@ impl App { scenario_text } SimSubmitKind::Input => { + let raw_text: String = sim + .captured_keys + .iter() + .map(|k| match k { + crate::simulation::CapturedKey::Char(c) => c.to_string(), + crate::simulation::CapturedKey::Enter => "\n".into(), + crate::simulation::CapturedKey::Tab => "\t".into(), + _ => k.to_key_string(), + }) + .collect(); let input = spec_forest::simulation::SimInput { keys: sim - .input_buffer - .chars() - .map(|c| c.to_string()) + .captured_keys + .iter() + .map(|k| k.to_key_string()) .collect(), - raw_text: sim.input_buffer.clone(), + raw_text, }; - sim.input_buffer.clear(); - sim.input_cursor = 0; + sim.captured_keys.clear(); sim.mode = crate::simulation::SimInputMode::Normal; serde_json::to_string(&input).unwrap_or_default() } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 88f8906..5ff0236 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -73,17 +73,26 @@ fn map_sim_normal_key(key: KeyCode) -> Action { } fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + use crate::simulation::CapturedKey; match key { KeyCode::Esc => Action::SimExitToNormal, KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::SimSubmitInput, KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => Action::SimSubmitInput, KeyCode::Backspace => Action::SimDeleteChar, - KeyCode::Left => Action::SimCursorLeft, - KeyCode::Right => Action::SimCursorRight, - KeyCode::Home => Action::SimCursorHome, - KeyCode::End => Action::SimCursorEnd, - KeyCode::Char(c) => Action::SimTypeChar(c), - KeyCode::Enter => Action::SimTypeChar('\n'), + KeyCode::Char(c) => Action::SimCaptureKey(CapturedKey::Char(c)), + KeyCode::Enter => Action::SimCaptureKey(CapturedKey::Enter), + KeyCode::Tab => Action::SimCaptureKey(CapturedKey::Tab), + KeyCode::Up => Action::SimCaptureKey(CapturedKey::Up), + KeyCode::Down => Action::SimCaptureKey(CapturedKey::Down), + KeyCode::Left => Action::SimCaptureKey(CapturedKey::Left), + KeyCode::Right => Action::SimCaptureKey(CapturedKey::Right), + KeyCode::Home => Action::SimCaptureKey(CapturedKey::Home), + KeyCode::End => Action::SimCaptureKey(CapturedKey::End), + KeyCode::Delete => Action::SimCaptureKey(CapturedKey::Delete), + KeyCode::Insert => Action::SimCaptureKey(CapturedKey::Insert), + KeyCode::PageUp => Action::SimCaptureKey(CapturedKey::PageUp), + KeyCode::PageDown => Action::SimCaptureKey(CapturedKey::PageDown), + KeyCode::F(n) => Action::SimCaptureKey(CapturedKey::F(n)), _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index 79732db..bc180a2 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -1,6 +1,47 @@ use spec_forest::simulation::{ChannelContent, SimChannel}; use std::collections::HashMap; +/// A captured keystroke in simulation insert mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapturedKey { + Char(char), + Enter, + Tab, + Up, + Down, + Left, + Right, + Home, + End, + Delete, + Insert, + PageUp, + PageDown, + F(u8), +} + +impl CapturedKey { + /// Machine-readable key string for SimInput.keys. + pub fn to_key_string(&self) -> String { + match self { + Self::Char(c) => c.to_string(), + Self::Enter => "{enter}".into(), + Self::Tab => "{tab}".into(), + Self::Up => "{up}".into(), + Self::Down => "{down}".into(), + Self::Left => "{left}".into(), + Self::Right => "{right}".into(), + Self::Home => "{home}".into(), + Self::End => "{end}".into(), + Self::Delete => "{delete}".into(), + Self::Insert => "{insert}".into(), + Self::PageUp => "{pageup}".into(), + Self::PageDown => "{pagedown}".into(), + Self::F(n) => format!("{{f{n}}}"), + } + } +} + /// TUI-side state for the simulation screen. pub struct SimulationState { pub session_id: String, @@ -8,8 +49,7 @@ pub struct SimulationState { pub channels: Vec, pub active_channel: usize, pub layout: SimLayout, - pub input_buffer: String, - pub input_cursor: usize, + pub captured_keys: Vec, pub mode: SimInputMode, pub overlay: Option, pub report_overlay: Option, @@ -33,8 +73,7 @@ impl SimulationState { channels, active_channel: 0, layout: SimLayout::Tabs, - input_buffer: String::new(), - input_cursor: 0, + captured_keys: Vec::new(), mode: SimInputMode::Normal, overlay: None, report_overlay: None, @@ -51,6 +90,14 @@ impl SimulationState { } } + /// Display string for the captured key sequence. + pub fn display_captured_input(&self) -> String { + self.captured_keys + .iter() + .map(|k| k.to_key_string()) + .collect::() + } + pub fn active_channel_key(&self) -> Option<&str> { self.channels.get(self.active_channel).map(|c| c.key()) } diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 372a158..e53f332 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -259,10 +259,10 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { format!("[Report] {}", sim.report_input) } else if sim.scenario_mode { format!("[Scenario] {}", sim.scenario_input) - } else if sim.input_buffer.is_empty() && sim.mode == SimInputMode::Normal { + } else if sim.captured_keys.is_empty() && sim.mode == SimInputMode::Normal { String::new() } else { - sim.input_buffer.clone() + sim.display_captured_input() }; let paragraph = Paragraph::new(display_text.clone()) @@ -271,28 +271,21 @@ fn render_input_area(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(paragraph, area); - // Show blinking cursor in insert mode + // Show cursor in insert mode (always at end) if sim.mode == SimInputMode::Insert && !sim.report_mode && !sim.scenario_mode { let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1, }); - let text_before_cursor = &sim.input_buffer[..sim.input_cursor]; - // Calculate cursor row/col accounting for wrapping let width = inner.width as usize; let mut row: u16 = 0; let mut col: u16 = 0; if width > 0 { - for ch in text_before_cursor.chars() { - if ch == '\n' { + for _ch in display_text.chars() { + col += 1; + if col >= inner.width { row += 1; col = 0; - } else { - col += 1; - if col >= inner.width { - row += 1; - col = 0; - } } } } From 8c209feee53976f5c85e62852ddca37449ba9450 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 21:13:54 +1100 Subject: [PATCH 054/100] feat: capture mouse left-clicks as input tokens in simulation insert mode Enables mouse capture in the terminal and records left-clicks within the channel content area as {left-click:X,Y} tokens in the captured key sequence, allowing the AI to interpret simulated UI interactions. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 60 +++++++++++++++++++-- crates/spec-forest-tui/src/main.rs | 7 +-- crates/spec-forest-tui/src/simulation.rs | 2 + crates/spec-forest-tui/src/ui/simulation.rs | 2 + 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 370fb62..2f61ad8 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -129,6 +129,7 @@ pub enum Action { SimOpenRef(String), SimRefDigit(char), SimCloseOverlay, + SimMouseClick { column: u16, row: u16 }, Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a6d947c..f9839a6 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -86,6 +86,7 @@ pub struct App { pub shadow_answers: Vec, pub shadow_node_id: Option, // Simulation + pub sim_content_area: std::cell::Cell>, pub sim_state: Option, pub sim_channel_selected: usize, pub sim_channel_selection: std::collections::HashSet, @@ -181,6 +182,7 @@ impl App { implementation_statuses: HashMap::new(), shadow_answers: Vec::new(), shadow_node_id: None, + sim_content_area: std::cell::Cell::new(None), sim_state: None, sim_channel_selected: 0, sim_channel_selection: std::collections::HashSet::new(), @@ -204,12 +206,20 @@ impl App { terminal.draw(|frame| ui::render(self, frame))?; let event = tokio::time::timeout(Duration::from_millis(250), reader.next()).await; - if let Ok(Some(Ok(Event::Key(key)))) = event { - if key.kind != KeyEventKind::Press { - continue; + if let Ok(Some(Ok(event))) = event { + match event { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + self.message = None; + self.handle_key(key.code, key.modifiers).await; + } + Event::Mouse(mouse) => { + self.handle_mouse(mouse).await; + } + _ => {} } - self.message = None; - self.handle_key(key.code, key.modifiers).await; } self.tick += 1; if let Some(ref mut sim) = self.sim_state { @@ -268,6 +278,22 @@ impl App { self.execute_action(action).await; } + pub async fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) { + use crossterm::event::{MouseButton, MouseEventKind}; + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if !matches!(self.screen, Screen::Simulation { .. }) { + return; + } + self.message = None; + self.execute_action(Action::SimMouseClick { + column: mouse.column, + row: mouse.row, + }) + .await; + } + async fn execute_action(&mut self, action: Action) { if action != Action::DeleteNode && action != Action::Noop { self.pending_delete = None; @@ -799,6 +825,30 @@ impl App { sim.overlay = None; } } + Action::SimMouseClick { column, row } => { + if let Some(ref mut sim) = self.sim_state { + if sim.mode != crate::simulation::SimInputMode::Insert { + return; + } + if let Some(content_area) = self.sim_content_area.get() { + // Only handle clicks within the channel content area + if column >= content_area.x + && column < content_area.x + content_area.width + && row >= content_area.y + && row < content_area.y + content_area.height + { + let rel_col = column - content_area.x; + let rel_row = row - content_area.y; + sim.captured_keys.push( + crate::simulation::CapturedKey::MouseClick { + column: rel_col, + row: rel_row, + }, + ); + } + } + } + } } } diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index 307e9ef..73807fe 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -2,6 +2,7 @@ use std::io; use clap::Parser; use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, execute, }; @@ -113,13 +114,13 @@ async fn main() -> Result<(), Box> { let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); original_hook(panic_info); })); // Set up terminal enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen)?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; @@ -129,7 +130,7 @@ async fn main() -> Result<(), Box> { // Restore terminal disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen)?; + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; // Shut down the HTTP server ct.cancel(); diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index bc180a2..744c2c7 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -18,6 +18,7 @@ pub enum CapturedKey { PageUp, PageDown, F(u8), + MouseClick { column: u16, row: u16 }, } impl CapturedKey { @@ -38,6 +39,7 @@ impl CapturedKey { Self::PageUp => "{pageup}".into(), Self::PageDown => "{pagedown}".into(), Self::F(n) => format!("{{f{n}}}"), + Self::MouseClick { column, row } => format!("{{left-click:{column},{row}}}"), } } } diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index e53f332..15816f6 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -47,6 +47,8 @@ pub fn render(app: &App, frame: &mut Frame) { let mut idx = 0; render_tab_bar(app, frame, chunks[idx]); idx += 1; + // Store channel content area for mouse click coordinate mapping + app.sim_content_area.set(Some(chunks[idx])); render_channel_content(app, frame, chunks[idx]); idx += 1; if has_decisions { From cfa61c8018956b4446318b93bb505cd72fc27573 Mon Sep 17 00:00:00 2001 From: freesig Date: Tue, 31 Mar 2026 21:28:40 +1100 Subject: [PATCH 055/100] feat: add explore code toggle to simulation channel picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, sets the claude CLI working directory to the spec's project directory and appends code-aware instructions to the system prompt — letting the agent consult the actual codebase for unspecified details while treating the spec as the source of truth. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 25 +++++++++++ crates/spec-forest-tui/src/commands.rs | 8 +++- crates/spec-forest-tui/src/input.rs | 1 + .../src/ui/sim_channel_picker.rs | 44 +++++++++++++++++-- crates/spec-forest/src/simulation.rs | 5 ++- crates/spec-forest/src/simulation/prompt.rs | 31 +++++++++++++ crates/spec-forest/src/simulation/runner.rs | 8 +++- 8 files changed, 117 insertions(+), 6 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 2f61ad8..5b878cd 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -105,6 +105,7 @@ pub enum Action { SimChannelDown, SimChannelToggle, SimChannelToggleWholeSpec, + SimChannelToggleExploreCode, SimChannelConfirm, SimChannelCancel, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index f9839a6..460cf68 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -91,6 +91,7 @@ pub struct App { pub sim_channel_selected: usize, pub sim_channel_selection: std::collections::HashSet, pub sim_consume_whole_spec: bool, + pub sim_explore_code: bool, pub sim_scenario_input: String, } @@ -187,6 +188,7 @@ impl App { sim_channel_selected: 0, sim_channel_selection: std::collections::HashSet::new(), sim_consume_whole_spec: false, + sim_explore_code: false, sim_scenario_input: String::new(), } } @@ -610,6 +612,7 @@ impl App { self.sim_channel_selected = 0; self.sim_channel_selection = [0].into(); // UI channel selected by default self.sim_consume_whole_spec = false; + self.sim_explore_code = false; self.screen = Screen::SimChannelPicker { spec_id, node_id }; } else { self.message = Some("Select a node to simulate".to_string()); @@ -636,6 +639,19 @@ impl App { Action::SimChannelToggleWholeSpec => { self.sim_consume_whole_spec = !self.sim_consume_whole_spec; } + Action::SimChannelToggleExploreCode => { + if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { + let has_dir = self + .specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_ref()) + .is_some(); + if has_dir { + self.sim_explore_code = !self.sim_explore_code; + } + } + } Action::SimChannelConfirm => { if self.sim_channel_selection.is_empty() { self.message = Some("Select at least one channel".to_string()); @@ -1848,6 +1864,14 @@ impl App { let channels_for_task = channels; let focus_node_for_task = focus_node_id; let consume_whole_spec = self.sim_consume_whole_spec; + let directory = if self.sim_explore_code { + self.specs + .iter() + .find(|s| s.id == spec_id) + .and_then(|s| s.directory.clone()) + } else { + None + }; // Mark session as processing self.state.update_sim_session(&session_id, |s| { @@ -1867,6 +1891,7 @@ impl App { focus_node_for_task, scenario, consume_whole_spec, + directory, ) .await; }); diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index ebeb0d1..9037d8f 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -248,6 +248,7 @@ pub async fn run_sim_initial_turn( focus_node_id: String, scenario: Option, consume_whole_spec: bool, + directory: Option, ) { // Load focus node let focus_node = match spec_forest::api::get_node(&state, &focus_node_id) { @@ -310,10 +311,15 @@ pub async fn run_sim_initial_turn( &other_roots, ) }; + let system_prompt = if directory.is_some() { + simulation::append_code_aware_section(&system_prompt) + } else { + system_prompt + }; let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); match simulation::runner::start_sim_turn(&config, &initial_prompt).await { Ok((claude_session_id, response)) => { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5ff0236..5b6a021 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -103,6 +103,7 @@ fn map_sim_channel_picker_key(key: KeyCode) -> Action { KeyCode::Down => Action::SimChannelDown, KeyCode::Char(' ') => Action::SimChannelToggle, KeyCode::Tab => Action::SimChannelToggleWholeSpec, + KeyCode::BackTab => Action::SimChannelToggleExploreCode, KeyCode::Enter => Action::SimChannelConfirm, KeyCode::Esc => Action::SimChannelCancel, _ => Action::Noop, 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 890d32c..15a5663 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -8,7 +8,7 @@ use ratatui::{ use spec_forest::simulation::SimChannel; -use crate::app::App; +use crate::app::{App, Screen}; pub fn render(app: &App, frame: &mut Frame) { let chunks = Layout::default() @@ -16,6 +16,7 @@ pub fn render(app: &App, frame: &mut Frame) { .constraints([ Constraint::Min(3), // channel list Constraint::Length(3), // whole spec toggle + Constraint::Length(3), // explore code toggle Constraint::Length(3), // footer ]) .split(frame.area()); @@ -68,6 +69,43 @@ pub fn render(app: &App, frame: &mut Frame) { .block(Block::default().borders(Borders::ALL)); frame.render_widget(whole_spec, chunks[1]); + // Explore code toggle + let has_directory = if let Screen::SimChannelPicker { ref spec_id, .. } = app.screen { + app.specs + .iter() + .find(|s| s.id == *spec_id) + .and_then(|s| s.directory.as_ref()) + .is_some() + } else { + false + }; + + let (explore_checkbox, explore_label, explore_style) = if !has_directory { + ( + "[ ]", + "Explore Code — requires directory to be set", + Style::default().fg(Color::DarkGray), + ) + } else if app.sim_explore_code { + ( + "[x]", + "Explore Code — let agent read the actual codebase", + Style::default().fg(Color::Green), + ) + } else { + ( + "[ ]", + "Explore Code — let agent read the actual codebase", + Style::default(), + ) + }; + let explore_code = Paragraph::new(Line::from(Span::styled( + format!(" {explore_checkbox} {explore_label}"), + explore_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(explore_code, chunks[2]); + let selected_count = app.sim_channel_selection.len(); let footer = Paragraph::new(Line::from(vec![ Span::styled( @@ -75,11 +113,11 @@ pub fn render(app: &App, frame: &mut Frame) { Style::default().fg(Color::Cyan), ), Span::styled( - "[Space] Toggle [Tab] Whole Spec [Enter] Start [Esc] Cancel", + "[Space] Toggle [Tab] Whole Spec [Shift+Tab] Explore Code [Enter] Start [Esc] Cancel", Style::default().fg(Color::DarkGray), ), ])) .block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[2]); + frame.render_widget(footer, chunks[3]); } diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index d33fcb6..6bbc9e1 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -3,7 +3,10 @@ pub mod runner; pub mod session; pub mod types; -pub use prompt::{build_initial_prompt, build_system_prompt, build_system_prompt_whole_spec}; +pub use prompt::{ + append_code_aware_section, build_initial_prompt, build_system_prompt, + build_system_prompt_whole_spec, +}; pub use session::{SimChannel, SimSession, SimStatus}; pub use types::{ ChannelContent, Decision, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse, diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 0d3f4d5..a86ace6 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -499,3 +499,34 @@ pub fn build_initial_prompt(channels: &[SimChannel], scenario: Option<&str>) -> ), } } + +/// Append code-aware instructions to a simulation system prompt. +/// +/// When the user enables "Explore Code", this tells the agent it can +/// read the actual codebase as a reference while treating the spec as +/// the source of truth. +pub fn append_code_aware_section(base_prompt: &str) -> String { + format!( + r#"{base_prompt} + +## Code-Aware Mode +You have access to the actual codebase via Read, Glob, and Grep tools. Your working +directory is set to the project root. + +### Rules for code consultation +1. **The spec is the source of truth.** When the spec and the code conflict, follow the spec. + If you notice a discrepancy, mention it as an observation but simulate according to the spec. +2. **Use code to fill gaps.** When the spec is silent on an implementation detail, consult the + code to see how it was actually built. This reduces speculation and produces a more accurate + simulation. +3. **Still flag high-entropy decisions as spec_gaps.** Even if the code makes a particular + choice, if the spec does not cover that decision and it is high-entropy (different + implementers could reasonably choose differently), flag it as a spec_gap. The code's choice + is informative but does not substitute for a spec decision. +4. **Cite code findings briefly.** When you consult the code, you may mention what you found + (e.g., "the codebase uses X approach for this") but keep it concise. The simulation output + should still focus on what the spec produces, not on documenting the code. +5. **Do not read the entire codebase.** Only consult files relevant to the current simulation + context. Use Glob and Grep to find relevant files efficiently."# + ) +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index d2a9a04..f387267 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -32,14 +32,16 @@ pub struct SimConfig { pub system_prompt: String, pub mcp_url: String, pub allowed_tools: String, + pub directory: Option, } impl SimConfig { - pub fn new(model: String, system_prompt: String, mcp_url: String) -> Self { + pub fn new(model: String, system_prompt: String, mcp_url: String, directory: Option) -> Self { Self { model, system_prompt, mcp_url, + directory, allowed_tools: [ "mcp__spec-forest__search_nodes", "mcp__spec-forest__get_node", @@ -86,6 +88,10 @@ pub async fn start_sim_turn( .arg("-p") .arg(prompt); + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + tracing::info!( model = %config.model, prompt_chars = prompt.len(), From 793694a803182247dded83c5275ee14dff304233 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:01:08 +1100 Subject: [PATCH 056/100] refactor: unify MCP answer/update tools with api::answer_node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP answer_question and update_answer were reimplementing embed + submit_op directly, skipping the post-answer pipeline (entropy evaluation, child generation, descendant review, summary regeneration). Now both route through api::answer_node — the same path used by the web UI and TUI. - Extend answer_node to accept optional residual_entropy; skip background AI evaluation when caller provides it - Remove redundant update_answer MCP tool (answer_question handles both) - MCP now returns full Node instead of just {node_id, status} --- crates/spec-forest-tui/src/commands.rs | 2 +- crates/spec-forest/src/api/nodes.rs | 16 ++++-- crates/spec-forest/src/http.rs | 2 +- crates/spec-forest/src/tool_types.rs | 14 ----- crates/spec-forest/src/tools.rs | 80 +++++--------------------- 5 files changed, 26 insertions(+), 88 deletions(-) diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 9037d8f..4a92f03 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -47,7 +47,7 @@ pub async fn submit_answer( model: String, generate: bool, ) -> Result<(), TuiError> { - spec_forest::api::answer_node(state, node_id, answer, model, generate) + spec_forest::api::answer_node(state, node_id, answer, model, generate, None, None) .await .map(|_| ()) .map_err(|e| TuiError::Api(e.to_string())) diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index 54bdf6b..deaa2d6 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -38,6 +38,8 @@ pub async fn answer_node( answer: String, model: String, generate: bool, + residual_entropy: Option, + residual_entropy_reasoning: Option, ) -> Result { // Read node info with a short-lived lock, then release before embedding let (question, was_unanswered, old_answer, is_root, spec_id) = { @@ -64,16 +66,16 @@ pub async fn answer_node( node_id: id.to_string(), answer: answer.clone(), embedding, - residual_entropy: None, - residual_entropy_reasoning: None, + residual_entropy, + residual_entropy_reasoning, } } else { spec_forest_protocol::SpecOp::UpdateAnswer { node_id: id.to_string(), answer: answer.clone(), embedding, - residual_entropy: None, - residual_entropy_reasoning: None, + residual_entropy, + residual_entropy_reasoning, } }; let submit_result = state.submit_op(&spec_id, op).await; @@ -104,8 +106,10 @@ pub async fn answer_node( Err(e) => return Err(ApiError::Internal(e.to_string())), }; - // Evaluate residual entropy in the background - crate::generate::spawn_entropy_evaluation(state.clone(), node.id.clone(), model.clone()); + // Evaluate residual entropy in the background (skip if caller already provided it) + if residual_entropy.is_none() { + crate::generate::spawn_entropy_evaluation(state.clone(), node.id.clone(), model.clone()); + } if generate { if was_unanswered { diff --git a/crates/spec-forest/src/http.rs b/crates/spec-forest/src/http.rs index 9465e95..f8b21cb 100644 --- a/crates/spec-forest/src/http.rs +++ b/crates/spec-forest/src/http.rs @@ -444,7 +444,7 @@ async fn answer_node( ) -> Result, AppError> { let model = body.model.unwrap_or_else(|| "opus".to_string()); let generate = body.generate.unwrap_or(true); - Ok(Json(crate::api::answer_node(&state, &id, body.answer, model, generate).await?)) + Ok(Json(crate::api::answer_node(&state, &id, body.answer, model, generate, None, None).await?)) } async fn update_question_handler( diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index d455395..d71b5b3 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -113,20 +113,6 @@ pub struct RegenerateFeatureParams { pub model: Option, } -// -- Mutation -- - -#[derive(Debug, Default, Deserialize, JsonSchema)] -pub struct UpdateAnswerParams { - #[schemars(description = "ID of the node to update")] - pub node_id: String, - #[schemars(description = "New answer text")] - pub answer_text: String, - #[schemars(description = "Residual uncertainty after this answer (0.0 = fully resolved, 1.0 = not resolved at all)")] - pub residual_entropy: Option, - #[schemars(description = "Reasoning for the residual entropy score")] - pub residual_entropy_reasoning: Option, -} - // -- Directory -- #[derive(Debug, Default, Deserialize, JsonSchema)] diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 6ddee0c..8341322 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -206,42 +206,28 @@ impl SpecForestServer { } #[tool( - description = "Answer an unanswered question node. Generates and stores the embedding. You MUST also provide residual_entropy (0.0 = answer fully resolves the question, 1.0 = answer doesn't resolve it at all) and residual_entropy_reasoning to score how much uncertainty the answer leaves." + description = "Answer or update the answer on a question node. Works for both unanswered nodes (spawns child question generation) and already-answered nodes (spawns descendant review). Generates embedding and evaluates entropy in the background. You MAY provide residual_entropy (0.0 = answer fully resolves the question, 1.0 = answer doesn't resolve it at all) and residual_entropy_reasoning to skip automatic entropy evaluation." )] fn answer_question( &self, Parameters(params): Parameters, ) -> Result { - let node_before = self - .state - .db() - .get_node(¶ms.node_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let embed_text = format!("{}\n{}", node_before.question, params.answer_text); - let embedding = self - .state - .embed(&embed_text) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - - let op = SpecOp::AnswerQuestion { - node_id: params.node_id.clone(), - answer: params.answer_text, - embedding, - residual_entropy: params.residual_entropy, - residual_entropy_reasoning: params.residual_entropy_reasoning, - }; - + let model = "opus".to_string(); let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node_before.spec_id, op)) - .map_err(op_err)?; + let node = rt + .block_on(crate::api::answer_node( + &self.state, + ¶ms.node_id, + params.answer_text, + model, + true, + params.residual_entropy, + params.residual_entropy_reasoning, + )) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&serde_json::json!({ - "node_id": params.node_id, - "status": "applied" - })) - .unwrap(), + serde_json::to_string_pretty(&node).unwrap(), )])) } @@ -417,44 +403,6 @@ impl SpecForestServer { // -- Mutation & review tools -- - #[tool(description = "Update the answer on an existing answered node. Re-generates the embedding. Does NOT mark descendants — call trigger_review separately. You MUST also provide residual_entropy (0.0 = fully resolved, 1.0 = not resolved) and residual_entropy_reasoning.")] - fn update_answer( - &self, - Parameters(params): Parameters, - ) -> Result { - let node_before = self - .state - .db() - .get_node(¶ms.node_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let embed_text = format!("{}\n{}", node_before.question, params.answer_text); - let embedding = self - .state - .embed(&embed_text) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - - let op = SpecOp::UpdateAnswer { - node_id: params.node_id.clone(), - answer: params.answer_text, - embedding, - residual_entropy: params.residual_entropy, - residual_entropy_reasoning: params.residual_entropy_reasoning, - }; - - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node_before.spec_id, op)) - .map_err(op_err)?; - - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&serde_json::json!({ - "node_id": params.node_id, - "status": "applied" - })) - .unwrap(), - )])) - } - #[tool(description = "Mark all descendants of a node as needs_review. Returns the count of affected nodes.")] fn trigger_review( &self, From 2bf4fcc15cec50c2146ca48f1f141a43a0eee569 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:09:09 +1100 Subject: [PATCH 057/100] fix: scope mouse capture to simulation insert mode only Global EnableMouseCapture was preventing text selection/copy across the entire TUI. Now mouse capture is toggled on only when entering simulation insert mode and off when leaving. --- crates/spec-forest-tui/src/app.rs | 14 ++++++++++++++ crates/spec-forest-tui/src/main.rs | 7 +++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 460cf68..8d37bb2 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -703,6 +703,10 @@ impl App { Action::SimEnterInsert => { if let Some(ref mut sim) = self.sim_state { sim.mode = crate::simulation::SimInputMode::Insert; + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::EnableMouseCapture + ); } } Action::SimExitToNormal => { @@ -715,6 +719,10 @@ impl App { sim.scenario_input.clear(); } else { sim.mode = crate::simulation::SimInputMode::Normal; + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); } } } @@ -729,6 +737,12 @@ impl App { sim.overlay = None; return; } + if sim.mode == crate::simulation::SimInputMode::Insert { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } } if let Screen::Simulation { ref spec_id, ref session_id } = self.screen { let spec_id = spec_id.clone(); diff --git a/crates/spec-forest-tui/src/main.rs b/crates/spec-forest-tui/src/main.rs index 73807fe..307e9ef 100644 --- a/crates/spec-forest-tui/src/main.rs +++ b/crates/spec-forest-tui/src/main.rs @@ -2,7 +2,6 @@ use std::io; use clap::Parser; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, execute, }; @@ -114,13 +113,13 @@ async fn main() -> Result<(), Box> { let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!(io::stdout(), LeaveAlternateScreen); original_hook(panic_info); })); // Set up terminal enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + execute!(io::stdout(), EnterAlternateScreen)?; let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; @@ -130,7 +129,7 @@ async fn main() -> Result<(), Box> { // Restore terminal disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!(io::stdout(), LeaveAlternateScreen)?; // Shut down the HTTP server ct.cancel(); From b65a81af2e4b8416227506f78720efeb8bdc14bd Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:20:55 +1100 Subject: [PATCH 058/100] feat: auto-refresh TUI when ops are applied via MCP or sync Add a broadcast channel to AppState so the op_loop notifies all subscribers after each committed operation. The TUI subscribes and refreshes the relevant view (spec list or node list) immediately, replacing the previous pull-only model that required user navigation to see background changes. --- crates/spec-forest-tui/src/app.rs | 103 +++++++++++++++++++++++---- crates/spec-forest/src/op_channel.rs | 9 +++ crates/spec-forest/src/op_loop.rs | 17 ++++- crates/spec-forest/src/state.rs | 19 ++++- 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 8d37bb2..a73f83c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -198,6 +198,9 @@ impl App { terminal: &mut Terminal>, ) -> std::io::Result<()> { let mut reader = EventStream::new(); + let mut op_notify_rx = self.state.subscribe_op_notifications(); + let mut tick_interval = tokio::time::interval(Duration::from_millis(250)); + while !self.should_quit { if self.needs_redraw { terminal.clear()?; @@ -207,22 +210,30 @@ impl App { self.refresh_shadow_answers_if_needed(); terminal.draw(|frame| ui::render(self, frame))?; - let event = tokio::time::timeout(Duration::from_millis(250), reader.next()).await; - if let Ok(Some(Ok(event))) = event { - match event { - Event::Key(key) => { - if key.kind != KeyEventKind::Press { - continue; + tokio::select! { + event = reader.next() => { + if let Some(Ok(event)) = event { + match event { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + self.message = None; + self.handle_key(key.code, key.modifiers).await; + } + Event::Mouse(mouse) => { + self.handle_mouse(mouse).await; + } + _ => {} } - self.message = None; - self.handle_key(key.code, key.modifiers).await; } - Event::Mouse(mouse) => { - self.handle_mouse(mouse).await; - } - _ => {} } + notification = op_notify_rx.recv() => { + self.handle_op_notification(notification); + } + _ = tick_interval.tick() => {} } + self.tick += 1; if let Some(ref mut sim) = self.sim_state { sim.tick = self.tick; @@ -1779,6 +1790,74 @@ impl App { || self.shadow_session_id.is_some() } + fn handle_op_notification( + &mut self, + result: Result< + spec_forest::op_channel::OpNotification, + tokio::sync::broadcast::error::RecvError, + >, + ) { + match result { + Ok(notification) => { + if matches!(self.screen, Screen::SpecList) { + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + return; + } + if let Screen::SpecView { ref spec_id } = self.screen { + if notification.spec_list_changed && notification.spec_id == *spec_id { + // Deleted the spec we're viewing — go back to list. + if notification.op_type == "DeleteSpec" { + self.screen = Screen::SpecList; + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + return; + } + } + if notification.spec_id == *spec_id { + let sid = spec_id.clone(); + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + self.candidate_node_id = None; + self.shadow_node_id = None; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("TUI missed {n} op notifications, doing full refresh"); + match &self.screen { + Screen::SpecView { spec_id } => { + let sid = spec_id.clone(); + self.refresh_nodes(&sid); + self.rebuild_tree_if_visible(&sid); + self.candidate_node_id = None; + self.shadow_node_id = None; + } + Screen::SpecList => { + if let Some(specs) = handle_result( + commands::refresh_spec_list(&self.state), + &mut self.message, + ) { + self.specs = specs; + } + } + _ => {} + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::warn!("Op notification channel closed"); + } + } + } + fn refresh_nodes(&mut self, spec_id: &str) { if let Some(nodes) = handle_result( commands::load_spec_nodes(&self.state, spec_id), diff --git a/crates/spec-forest/src/op_channel.rs b/crates/spec-forest/src/op_channel.rs index a6d30e0..062f3f7 100644 --- a/crates/spec-forest/src/op_channel.rs +++ b/crates/spec-forest/src/op_channel.rs @@ -53,3 +53,12 @@ impl std::fmt::Display for OpError { } impl std::error::Error for OpError {} + +/// Notification sent after an op is successfully committed. +#[derive(Clone, Debug)] +pub struct OpNotification { + pub spec_id: String, + pub op_type: &'static str, + /// True when the spec list changed (CreateSpec / DeleteSpec). + pub spec_list_changed: bool, +} diff --git a/crates/spec-forest/src/op_loop.rs b/crates/spec-forest/src/op_loop.rs index e1cb654..772ff5b 100644 --- a/crates/spec-forest/src/op_loop.rs +++ b/crates/spec-forest/src/op_loop.rs @@ -7,7 +7,7 @@ mod side_effects; pub use op_name::spec_op_type_name; -use crate::op_channel::{OpError, OpRequest, OpSource}; +use crate::op_channel::{OpError, OpNotification, OpRequest, OpSource}; use crate::state::AppState; use spec_forest_protocol::SpecOp; use std::sync::Arc; @@ -34,7 +34,15 @@ fn apply_and_log(state: &AppState, req: &OpRequest) -> Result { // DeleteSpec has its own internal transaction and removes all data — nothing to log. if matches!(req.op, SpecOp::DeleteSpec { .. }) { - return side_effects::apply_delete_spec(conn, &req.op); + let result = side_effects::apply_delete_spec(conn, &req.op); + if result.is_ok() { + state.notify_op(OpNotification { + spec_id: req.spec_id.clone(), + op_type: "DeleteSpec", + spec_list_changed: true, + }); + } + return result; } // Wrap all mutations in a transaction so last_seq stays in sync. @@ -84,6 +92,11 @@ fn apply_and_log(state: &AppState, req: &OpRequest) -> Result { )?; tx.commit().map_err(|e| OpError::Database(e.to_string()))?; + state.notify_op(OpNotification { + spec_id: req.spec_id.clone(), + op_type: op_name::spec_op_type_name(&req.op), + spec_list_changed: matches!(req.op, SpecOp::CreateSpec { .. }), + }); Ok(seq) } diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 3892cb3..6eb30e9 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -1,7 +1,7 @@ use crate::dir_context::DirectoryContextCache; use crate::explore::{ExploreSession, ExploreStatusResponse}; use crate::ingest::{IngestSession, IngestStatusResponse}; -use crate::op_channel::{OpError, OpRequest, OpSource}; +use crate::op_channel::{OpError, OpNotification, OpRequest, OpSource}; use crate::prompt_log::PromptLog; use crate::simulation::SimSession; use crate::sync::{OpReceiver, SyncHandle}; @@ -11,7 +11,7 @@ use serde::Serialize; use spec_forest_db::Database; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{broadcast, mpsc}; use tokio::sync::Mutex as TokioMutex; #[derive(Debug, Clone, Serialize)] @@ -41,6 +41,7 @@ pub struct AppState { user_name: Mutex, undo_state: Mutex, op_tx: Option>, + op_notify_tx: broadcast::Sender, dir_context_cache: DirectoryContextCache, } @@ -55,6 +56,7 @@ impl AppState { let embedder = TextEmbedding::try_new( InitOptions::new(EmbeddingModel::BGESmallENV15).with_show_download_progress(true), )?; + let (op_notify_tx, _) = broadcast::channel(64); Ok(Self { db: Mutex::new(db), embedder, @@ -71,6 +73,7 @@ impl AppState { user_name: Mutex::new("anonymous".to_string()), undo_state: Mutex::new(spec_forest_db::undo::UndoState::new()), op_tx: None, + op_notify_tx, dir_context_cache: DirectoryContextCache::new(), }) } @@ -81,6 +84,7 @@ impl AppState { let embedder = TextEmbedding::try_new( InitOptions::new(EmbeddingModel::BGESmallENV15).with_show_download_progress(false), )?; + let (op_notify_tx, _) = broadcast::channel(64); Ok(Self { db: Mutex::new(db), embedder, @@ -97,6 +101,7 @@ impl AppState { user_name: Mutex::new("anonymous".to_string()), undo_state: Mutex::new(spec_forest_db::undo::UndoState::new()), op_tx: None, + op_notify_tx, dir_context_cache: DirectoryContextCache::new(), }) } @@ -231,6 +236,16 @@ impl AppState { self.op_tx.as_ref() } + /// Subscribe to op commit notifications. + pub fn subscribe_op_notifications(&self) -> broadcast::Receiver { + self.op_notify_tx.subscribe() + } + + /// Send an op notification (called by op_loop after commit). + pub fn notify_op(&self, notification: OpNotification) { + let _ = self.op_notify_tx.send(notification); + } + pub fn dir_context_cache(&self) -> &DirectoryContextCache { &self.dir_context_cache } From ab3469137201a11f42a25d9955715e2eb73faf21 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:25:47 +1100 Subject: [PATCH 059/100] fix: convert MCP tool handlers to async to prevent runtime deadlock Sync tool handlers were calling Handle::block_on() from within the tokio async runtime, causing hangs on every MCP tool call. Converted all 10 affected handlers to async fn and replaced block_on with .await. --- crates/spec-forest/src/tools.rs | 109 ++++++++++++++------------------ 1 file changed, 46 insertions(+), 63 deletions(-) diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 8341322..ced6ff6 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -63,7 +63,7 @@ impl SpecForestServer { // -- Spec lifecycle tools -- #[tool(description = "Create a new specification project")] - fn create_spec( + async fn create_spec( &self, Parameters(params): Parameters, ) -> Result { @@ -95,8 +95,7 @@ impl SpecForestServer { locality: Some(locality.to_string()), }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&spec_id, op)) + self.state.submit_op(&spec_id, op).await .map_err(op_err)?; // Directory is local-only — set it directly, not via the op log @@ -119,7 +118,7 @@ impl SpecForestServer { } #[tool(description = "Seed a spec with a root document. Creates the root node with a mode-appropriate root question and the document as the answer.")] - fn seed_spec( + async fn seed_spec( &self, Parameters(params): Parameters, ) -> Result { @@ -146,9 +145,7 @@ impl SpecForestServer { embedding, }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(¶ms.spec_id, op)) + let _seq = self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -208,14 +205,12 @@ impl SpecForestServer { #[tool( description = "Answer or update the answer on a question node. Works for both unanswered nodes (spawns child question generation) and already-answered nodes (spawns descendant review). Generates embedding and evaluates entropy in the background. You MAY provide residual_entropy (0.0 = answer fully resolves the question, 1.0 = answer doesn't resolve it at all) and residual_entropy_reasoning to skip automatic entropy evaluation." )] - fn answer_question( + async fn answer_question( &self, Parameters(params): Parameters, ) -> Result { let model = "opus".to_string(); - let rt = tokio::runtime::Handle::current(); - let node = rt - .block_on(crate::api::answer_node( + let node = crate::api::answer_node( &self.state, ¶ms.node_id, params.answer_text, @@ -223,7 +218,7 @@ impl SpecForestServer { true, params.residual_entropy, params.residual_entropy_reasoning, - )) + ).await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( @@ -234,7 +229,7 @@ impl SpecForestServer { #[tool( description = "Create child question nodes linked to a parent. Each child starts as unanswered." )] - fn add_children( + async fn add_children( &self, Parameters(params): Parameters, ) -> Result { @@ -270,9 +265,7 @@ impl SpecForestServer { children: child_ops, }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&parent_node.spec_id, op)) + let _seq = self.state.submit_op(&parent_node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -404,7 +397,7 @@ impl SpecForestServer { // -- Mutation & review tools -- #[tool(description = "Mark all descendants of a node as needs_review. Returns the count of affected nodes.")] - fn trigger_review( + async fn trigger_review( &self, Parameters(params): Parameters, ) -> Result { @@ -418,9 +411,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node.spec_id, op)) + let _seq = self.state.submit_op(&node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -448,7 +439,7 @@ impl SpecForestServer { } #[tool(description = "Soft-delete a node and all its descendants")] - fn delete_node( + async fn delete_node( &self, Parameters(params): Parameters, ) -> Result { @@ -462,9 +453,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&node.spec_id, op)) + let _seq = self.state.submit_op(&node.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( @@ -479,7 +468,7 @@ impl SpecForestServer { // -- Feature tools -- #[tool(description = "Add a new feature root node to a spec. Creates a root with the given content.")] - fn add_feature( + async fn add_feature( &self, Parameters(params): Parameters, ) -> Result { @@ -490,9 +479,7 @@ impl SpecForestServer { content: params.content.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(¶ms.spec_id, op)) + let _seq = self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; // Spawn summary regeneration after successful apply @@ -512,7 +499,7 @@ impl SpecForestServer { } #[tool(description = "Remove a feature root and garbage-collect exclusive descendants. Shared nodes survive.")] - fn remove_feature( + async fn remove_feature( &self, Parameters(params): Parameters, ) -> Result { @@ -527,9 +514,7 @@ impl SpecForestServer { node_id: params.node_id.clone(), }; - let rt = tokio::runtime::Handle::current(); - let _seq = rt - .block_on(self.state.submit_op(&spec_id, op)) + let _seq = self.state.submit_op(&spec_id, op).await .map_err(op_err)?; // Spawn summary regeneration after successful apply @@ -767,14 +752,16 @@ impl SpecForestServer { // -- Annotation tools -- #[tool(description = "Add an annotation (review comment) to a node for a specific commit range")] - fn add_annotation( + async fn add_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let branch_id = resolve_branch_id(&db, ¶ms.spec_id, params.branch.as_deref())?; - let annotation_id = uuid::Uuid::new_v4().to_string(); - drop(db); + let (branch_id, annotation_id) = { + let db = self.state.db(); + let branch_id = resolve_branch_id(&db, ¶ms.spec_id, params.branch.as_deref())?; + let annotation_id = uuid::Uuid::new_v4().to_string(); + (branch_id, annotation_id) + }; let op = SpecOp::AddAnnotation { annotation_id: annotation_id.clone(), @@ -788,8 +775,7 @@ impl SpecForestServer { seq_end: params.seq_end, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(¶ms.spec_id, op)) + self.state.submit_op(¶ms.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -830,23 +816,22 @@ impl SpecForestServer { } #[tool(description = "Update the content of an existing annotation")] - fn update_annotation( + async fn update_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let op = SpecOp::UpdateAnnotation { annotation_id: params.annotation_id.clone(), content: params.content, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -860,15 +845,15 @@ impl SpecForestServer { } #[tool(description = "Resolve or unresolve an annotation")] - fn resolve_annotation( + async fn resolve_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let resolved = params.resolved.unwrap_or(true); let op = SpecOp::ResolveAnnotation { @@ -876,8 +861,7 @@ impl SpecForestServer { resolved, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; let db = self.state.db(); @@ -891,22 +875,21 @@ impl SpecForestServer { } #[tool(description = "Soft-delete an annotation")] - fn delete_annotation( + async fn delete_annotation( &self, Parameters(params): Parameters, ) -> Result { - let db = self.state.db(); - let annotation = db - .get_annotation(¶ms.annotation_id) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - drop(db); + let annotation = { + let db = self.state.db(); + db.get_annotation(¶ms.annotation_id) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))? + }; let op = SpecOp::DeleteAnnotation { annotation_id: params.annotation_id, }; - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.state.submit_op(&annotation.spec_id, op)) + self.state.submit_op(&annotation.spec_id, op).await .map_err(op_err)?; Ok(CallToolResult::success(vec![Content::text( From 9a421ccc1e10ea60dc84823e8a615a1bc815af44 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:30:29 +1100 Subject: [PATCH 060/100] debug: add tracing to MCP tool call path to diagnose hang Instruments: tool handler entry/exit, answer_node stages, submit_op flow, op_loop receive/apply, and MCP handler creation. --- crates/spec-forest/src/api/nodes.rs | 5 +++++ crates/spec-forest/src/lib.rs | 5 ++++- crates/spec-forest/src/op_loop.rs | 4 ++++ crates/spec-forest/src/state.rs | 13 +++++++++++-- crates/spec-forest/src/tools.rs | 4 ++++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index deaa2d6..52839cd 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -41,6 +41,7 @@ pub async fn answer_node( residual_entropy: Option, residual_entropy_reasoning: Option, ) -> Result { + tracing::info!(node_id = %id, "answer_node: reading node info"); // Read node info with a short-lived lock, then release before embedding let (question, was_unanswered, old_answer, is_root, spec_id) = { let db = state.db(); @@ -55,11 +56,13 @@ pub async fn answer_node( ) }; + tracing::info!(node_id = %id, "answer_node: embedding"); // Embed OUTSIDE the DB lock — this is CPU-intensive and must not block other requests let embed_text = format!("{}\n{}", question, answer); let embedding = state .embed(&embed_text) .map_err(|e| ApiError::Internal(e.to_string()))?; + tracing::info!(node_id = %id, "answer_node: embedding done"); let op = if was_unanswered { spec_forest_protocol::SpecOp::AnswerQuestion { @@ -78,7 +81,9 @@ pub async fn answer_node( residual_entropy_reasoning, } }; + tracing::info!(node_id = %id, was_unanswered, "answer_node: submitting op"); let submit_result = state.submit_op(&spec_id, op).await; + tracing::info!(node_id = %id, success = submit_result.is_ok(), "answer_node: submit_op returned"); // For remote specs, the op may have been sent to the sync server but the local // broadcast timed out. The answer will still be applied eventually, so we should diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 3f21133..7cc4837 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -100,7 +100,10 @@ pub async fn build_server( let mcp_service = StreamableHttpService::new( { let state = state.clone(); - move || Ok(SpecForestServer::new(state.clone())) + move || { + tracing::info!("mcp: creating new SpecForestServer handler for request"); + Ok(SpecForestServer::new(state.clone())) + } }, Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig { diff --git a/crates/spec-forest/src/op_loop.rs b/crates/spec-forest/src/op_loop.rs index 772ff5b..6de0207 100644 --- a/crates/spec-forest/src/op_loop.rs +++ b/crates/spec-forest/src/op_loop.rs @@ -15,8 +15,12 @@ use tokio::sync::mpsc; /// Run the unified op apply loop. All database mutations flow through here. pub async fn run_op_loop(state: Arc, mut op_rx: mpsc::Receiver) { + tracing::info!("op_loop: started, waiting for ops"); while let Some(req) = op_rx.recv().await { + let op_type = spec_op_type_name(&req.op); + tracing::info!(spec_id = %req.spec_id, op_type, "op_loop: received op, applying"); let result = apply_and_log(&state, &req); + tracing::info!(spec_id = %req.spec_id, op_type, success = result.is_ok(), "op_loop: applied, sending response"); let _ = req.response.send(result); } tracing::warn!("Op apply loop ended — channel closed"); diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 6eb30e9..4c63caa 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -251,9 +251,13 @@ impl AppState { } pub async fn submit_op(&self, spec_id: &str, op: spec_forest_protocol::SpecOp) -> Result { + tracing::info!(spec_id, op_type = crate::op_loop::spec_op_type_name(&op), "submit_op: start"); let branch_id = self.db().get_active_branch_id(spec_id) .map_err(|e| OpError::Database(format!("Failed to get active branch: {e}")))?; - self.submit_op_with_branch(spec_id, op, branch_id).await + tracing::info!(spec_id, ?branch_id, "submit_op: got branch, calling submit_op_with_branch"); + let result = self.submit_op_with_branch(spec_id, op, branch_id).await; + tracing::info!(spec_id, success = result.is_ok(), "submit_op: done"); + result } pub async fn submit_op_with_branch(&self, spec_id: &str, op: spec_forest_protocol::SpecOp, branch_id: Option) -> Result { @@ -267,6 +271,7 @@ impl AppState { .map_err(|e| OpError::Database(format!("Cannot determine spec locality: {e}")))?, }; + tracing::info!(spec_id, is_remote, "submit_op_with_branch: locality determined"); if is_remote { let handle = self.get_sync_handle().await .ok_or(OpError::NoSyncServer)?; @@ -325,10 +330,14 @@ impl AppState { }, response: resp_tx, }; + tracing::info!(spec_id, "submit_op_with_branch: sending to op channel"); tx.send(request) .await .map_err(|_| OpError::ChannelClosed)?; - resp_rx.await.map_err(|_| OpError::ChannelClosed)? + tracing::info!(spec_id, "submit_op_with_branch: sent, waiting for op_loop response"); + let result = resp_rx.await.map_err(|_| OpError::ChannelClosed)?; + tracing::info!(spec_id, success = result.is_ok(), "submit_op_with_branch: got response from op_loop"); + result } pub fn db(&self) -> parking_lot::MutexGuard<'_, Database> { diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index ced6ff6..d0811ba 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -67,6 +67,7 @@ impl SpecForestServer { &self, Parameters(params): Parameters, ) -> Result { + tracing::info!(name = %params.name, "mcp: create_spec started"); let mode: spec_forest_db::SpecMode = params .mode .as_deref() @@ -209,6 +210,7 @@ impl SpecForestServer { &self, Parameters(params): Parameters, ) -> Result { + tracing::info!(node_id = %params.node_id, "mcp: answer_question started"); let model = "opus".to_string(); let node = crate::api::answer_node( &self.state, @@ -221,6 +223,7 @@ impl SpecForestServer { ).await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + tracing::info!(node_id = %node.id, "mcp: answer_question completed"); Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&node).unwrap(), )])) @@ -901,6 +904,7 @@ impl SpecForestServer { #[tool_handler] impl ServerHandler for SpecForestServer { fn get_info(&self) -> ServerInfo { + tracing::info!("mcp: get_info called"); ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_server_info(Implementation::new( From 5f7a65210fddcb7c50864adeccc0c9bff7e9c363 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:37:26 +1100 Subject: [PATCH 061/100] chore: reduce debug tracing to lightweight debug-level logs --- crates/spec-forest/src/api/nodes.rs | 6 +----- crates/spec-forest/src/lib.rs | 2 +- crates/spec-forest/src/op_loop.rs | 5 ++--- crates/spec-forest/src/state.rs | 14 +++----------- crates/spec-forest/src/tools.rs | 7 +++---- 5 files changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/spec-forest/src/api/nodes.rs b/crates/spec-forest/src/api/nodes.rs index 52839cd..b22e2e5 100644 --- a/crates/spec-forest/src/api/nodes.rs +++ b/crates/spec-forest/src/api/nodes.rs @@ -41,7 +41,7 @@ pub async fn answer_node( residual_entropy: Option, residual_entropy_reasoning: Option, ) -> Result { - tracing::info!(node_id = %id, "answer_node: reading node info"); + tracing::debug!(node_id = %id, "answer_node: start"); // Read node info with a short-lived lock, then release before embedding let (question, was_unanswered, old_answer, is_root, spec_id) = { let db = state.db(); @@ -56,13 +56,11 @@ pub async fn answer_node( ) }; - tracing::info!(node_id = %id, "answer_node: embedding"); // Embed OUTSIDE the DB lock — this is CPU-intensive and must not block other requests let embed_text = format!("{}\n{}", question, answer); let embedding = state .embed(&embed_text) .map_err(|e| ApiError::Internal(e.to_string()))?; - tracing::info!(node_id = %id, "answer_node: embedding done"); let op = if was_unanswered { spec_forest_protocol::SpecOp::AnswerQuestion { @@ -81,9 +79,7 @@ pub async fn answer_node( residual_entropy_reasoning, } }; - tracing::info!(node_id = %id, was_unanswered, "answer_node: submitting op"); let submit_result = state.submit_op(&spec_id, op).await; - tracing::info!(node_id = %id, success = submit_result.is_ok(), "answer_node: submit_op returned"); // For remote specs, the op may have been sent to the sync server but the local // broadcast timed out. The answer will still be applied eventually, so we should diff --git a/crates/spec-forest/src/lib.rs b/crates/spec-forest/src/lib.rs index 7cc4837..becc63c 100644 --- a/crates/spec-forest/src/lib.rs +++ b/crates/spec-forest/src/lib.rs @@ -101,7 +101,7 @@ pub async fn build_server( { let state = state.clone(); move || { - tracing::info!("mcp: creating new SpecForestServer handler for request"); + tracing::debug!("mcp: new handler"); Ok(SpecForestServer::new(state.clone())) } }, diff --git a/crates/spec-forest/src/op_loop.rs b/crates/spec-forest/src/op_loop.rs index 6de0207..4a5d61b 100644 --- a/crates/spec-forest/src/op_loop.rs +++ b/crates/spec-forest/src/op_loop.rs @@ -15,12 +15,11 @@ use tokio::sync::mpsc; /// Run the unified op apply loop. All database mutations flow through here. pub async fn run_op_loop(state: Arc, mut op_rx: mpsc::Receiver) { - tracing::info!("op_loop: started, waiting for ops"); + tracing::info!("op_loop: started"); while let Some(req) = op_rx.recv().await { let op_type = spec_op_type_name(&req.op); - tracing::info!(spec_id = %req.spec_id, op_type, "op_loop: received op, applying"); + tracing::debug!(spec_id = %req.spec_id, op_type, "op_loop: applying"); let result = apply_and_log(&state, &req); - tracing::info!(spec_id = %req.spec_id, op_type, success = result.is_ok(), "op_loop: applied, sending response"); let _ = req.response.send(result); } tracing::warn!("Op apply loop ended — channel closed"); diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 4c63caa..09ab542 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -251,13 +251,10 @@ impl AppState { } pub async fn submit_op(&self, spec_id: &str, op: spec_forest_protocol::SpecOp) -> Result { - tracing::info!(spec_id, op_type = crate::op_loop::spec_op_type_name(&op), "submit_op: start"); + tracing::debug!(spec_id, op_type = crate::op_loop::spec_op_type_name(&op), "submit_op"); let branch_id = self.db().get_active_branch_id(spec_id) .map_err(|e| OpError::Database(format!("Failed to get active branch: {e}")))?; - tracing::info!(spec_id, ?branch_id, "submit_op: got branch, calling submit_op_with_branch"); - let result = self.submit_op_with_branch(spec_id, op, branch_id).await; - tracing::info!(spec_id, success = result.is_ok(), "submit_op: done"); - result + self.submit_op_with_branch(spec_id, op, branch_id).await } pub async fn submit_op_with_branch(&self, spec_id: &str, op: spec_forest_protocol::SpecOp, branch_id: Option) -> Result { @@ -271,7 +268,6 @@ impl AppState { .map_err(|e| OpError::Database(format!("Cannot determine spec locality: {e}")))?, }; - tracing::info!(spec_id, is_remote, "submit_op_with_branch: locality determined"); if is_remote { let handle = self.get_sync_handle().await .ok_or(OpError::NoSyncServer)?; @@ -330,14 +326,10 @@ impl AppState { }, response: resp_tx, }; - tracing::info!(spec_id, "submit_op_with_branch: sending to op channel"); tx.send(request) .await .map_err(|_| OpError::ChannelClosed)?; - tracing::info!(spec_id, "submit_op_with_branch: sent, waiting for op_loop response"); - let result = resp_rx.await.map_err(|_| OpError::ChannelClosed)?; - tracing::info!(spec_id, success = result.is_ok(), "submit_op_with_branch: got response from op_loop"); - result + resp_rx.await.map_err(|_| OpError::ChannelClosed)? } pub fn db(&self) -> parking_lot::MutexGuard<'_, Database> { diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index d0811ba..80b1960 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -67,7 +67,7 @@ impl SpecForestServer { &self, Parameters(params): Parameters, ) -> Result { - tracing::info!(name = %params.name, "mcp: create_spec started"); + tracing::debug!(name = %params.name, "mcp: create_spec"); let mode: spec_forest_db::SpecMode = params .mode .as_deref() @@ -210,7 +210,7 @@ impl SpecForestServer { &self, Parameters(params): Parameters, ) -> Result { - tracing::info!(node_id = %params.node_id, "mcp: answer_question started"); + tracing::debug!(node_id = %params.node_id, "mcp: answer_question"); let model = "opus".to_string(); let node = crate::api::answer_node( &self.state, @@ -223,7 +223,6 @@ impl SpecForestServer { ).await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - tracing::info!(node_id = %node.id, "mcp: answer_question completed"); Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&node).unwrap(), )])) @@ -904,7 +903,7 @@ impl SpecForestServer { #[tool_handler] impl ServerHandler for SpecForestServer { fn get_info(&self) -> ServerInfo { - tracing::info!("mcp: get_info called"); + tracing::debug!("mcp: get_info"); ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_server_info(Implementation::new( From 1cf0c3dca2ee294e56a92152e6b782caea5c4873 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 08:53:36 +1100 Subject: [PATCH 062/100] fix: use floor_char_boundary to prevent UTF-8 slicing panic in error messages --- crates/spec-forest/src/simulation/runner.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index f387267..d443587 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -272,7 +272,7 @@ fn parse_sim_response(text: &str) -> Result Date: Wed, 1 Apr 2026 09:02:07 +1100 Subject: [PATCH 063/100] feat: add persistent simulation notification system with session backgrounding Simulations can now be backgrounded (Esc) instead of destroyed, with a global notification bar showing when responses arrive on any screen. Ctrl+s opens a session picker overlay to switch between background sessions. Q permanently ends a simulation. --- crates/spec-forest-tui/src/action.rs | 11 +- crates/spec-forest-tui/src/app.rs | 237 +++++++++++++++++- crates/spec-forest-tui/src/input.rs | 3 +- crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/notification.rs | 30 +++ crates/spec-forest-tui/src/ui.rs | 10 + .../src/ui/notification_bar.rs | 61 +++++ .../spec-forest-tui/src/ui/session_picker.rs | 103 ++++++++ crates/spec-forest-tui/src/ui/simulation.rs | 2 +- crates/spec-forest/src/simulation/session.rs | 1 + crates/spec-forest/src/state.rs | 14 ++ 11 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 crates/spec-forest-tui/src/notification.rs create mode 100644 crates/spec-forest-tui/src/ui/notification_bar.rs create mode 100644 crates/spec-forest-tui/src/ui/session_picker.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 5b878cd..93b7c8d 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -119,7 +119,8 @@ pub enum Action { // Simulation - screen SimEnterInsert, SimExitToNormal, - SimExitSimulation, + SimBackgroundSimulation, + SimEndSimulation, SimCaptureKey(crate::simulation::CapturedKey), SimDeleteChar, SimSubmitInput, @@ -132,5 +133,13 @@ pub enum Action { SimCloseOverlay, SimMouseClick { column: u16, row: u16 }, + // Notification / session picker + OpenSessionPicker, + SessionPickerUp, + SessionPickerDown, + SessionPickerSelect, + SessionPickerDismiss, + DismissNotification, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a73f83c..7a05c32 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -93,6 +93,10 @@ pub struct App { pub sim_consume_whole_spec: bool, pub sim_explore_code: bool, pub sim_scenario_input: String, + // Background simulation notifications + pub background_sims: Vec, + pub sim_notifications: Vec, + pub session_picker: Option, } #[derive(Clone)] @@ -190,6 +194,9 @@ impl App { sim_consume_whole_spec: false, sim_explore_code: false, sim_scenario_input: String::new(), + background_sims: Vec::new(), + sim_notifications: Vec::new(), + session_picker: None, } } @@ -260,6 +267,35 @@ impl App { } pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { + use crossterm::event::KeyModifiers; + + // Session picker overlay takes priority when open + if self.session_picker.is_some() { + let action = match key { + KeyCode::Up | KeyCode::Char('k') => Action::SessionPickerUp, + KeyCode::Down | KeyCode::Char('j') => Action::SessionPickerDown, + KeyCode::Enter => Action::SessionPickerSelect, + KeyCode::Esc => Action::SessionPickerDismiss, + _ => Action::Noop, + }; + self.execute_action(action).await; + return; + } + + // Global Ctrl+s opens session picker (except in SimScenario and Simulation insert mode) + if key == KeyCode::Char('s') && modifiers.contains(KeyModifiers::CONTROL) { + let is_sim_scenario = matches!(self.screen, Screen::SimScenario { .. }); + let is_sim_insert = matches!(self.screen, Screen::Simulation { .. }) + && self + .sim_state + .as_ref() + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert); + if !is_sim_scenario && !is_sim_insert && !self.background_sims.is_empty() { + self.execute_action(Action::OpenSessionPicker).await; + return; + } + } + // Scenario input screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::SimScenario { .. }) { let action = input::map_sim_scenario_key(key, modifiers); @@ -737,7 +773,44 @@ impl App { } } } - Action::SimExitSimulation => { + Action::SimBackgroundSimulation => { + if let Some(ref mut sim) = self.sim_state { + // Close overlays first if open + if sim.report_overlay.is_some() { + sim.report_overlay = None; + return; + } + if sim.overlay.is_some() { + sim.overlay = None; + return; + } + if sim.mode == crate::simulation::SimInputMode::Insert { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } + } + if let Screen::Simulation { ref spec_id, ref session_id } = self.screen { + let spec_id = spec_id.clone(); + let session_id = session_id.clone(); + let label = self.state.get_sim_session(&session_id) + .and_then(|s| s.scenario.clone()) + .unwrap_or_else(|| session_id[..8.min(session_id.len())].to_string()); + let was_processing = self.sim_state.as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims.push(crate::notification::BackgroundSimEntry { + session_id, + spec_id: spec_id.clone(), + label, + was_processing, + }); + self.sim_state = None; + self.screen = Screen::SpecView { spec_id }; + } + } + Action::SimEndSimulation => { if let Some(ref mut sim) = self.sim_state { // Close overlays first if open if sim.report_overlay.is_some() { @@ -890,6 +963,165 @@ impl App { } } } + + // ── Notification / session picker ── + Action::OpenSessionPicker => { + let entries: Vec = self + .background_sims + .iter() + .map(|bg| { + let ready = self + .state + .get_sim_session_status(&bg.session_id) + .map(|s| matches!(s, spec_forest::simulation::SimStatus::Idle)) + .unwrap_or(false); + crate::notification::SessionPickerEntry { + session_id: bg.session_id.clone(), + spec_id: bg.spec_id.clone(), + label: bg.label.clone(), + ready, + } + }) + .collect(); + if !entries.is_empty() { + self.session_picker = Some(crate::notification::SessionPickerState { + selected: 0, + entries, + }); + } + } + Action::SessionPickerUp => { + if let Some(ref mut picker) = self.session_picker { + picker.selected = picker.selected.saturating_sub(1); + } + } + Action::SessionPickerDown => { + if let Some(ref mut picker) = self.session_picker { + if picker.selected + 1 < picker.entries.len() { + picker.selected += 1; + } + } + } + Action::SessionPickerSelect => { + if let Some(picker) = self.session_picker.take() { + if let Some(entry) = picker.entries.get(picker.selected) { + let session_id = entry.session_id.clone(); + self.enter_sim_session(&session_id); + } + } + } + Action::SessionPickerDismiss => { + self.session_picker = None; + } + Action::DismissNotification => { + if !self.sim_notifications.is_empty() { + self.sim_notifications.remove(0); + } + } + } + } + + // ── Simulation session management ─────────────────────────── + + /// Switch to a backgrounded simulation session, restoring it as the active sim. + fn enter_sim_session(&mut self, session_id: &str) { + // Background the current active sim if we're viewing one + if let Screen::Simulation { + ref spec_id, + ref session_id, + } = self.screen + { + let spec_id = spec_id.clone(); + let sid = session_id.clone(); + let label = self + .sim_state + .as_ref() + .and_then(|_| { + self.state + .get_sim_session(&sid) + .and_then(|s| s.scenario.clone()) + }) + .unwrap_or_else(|| sid[..8.min(sid.len())].to_string()); + let was_processing = self + .sim_state + .as_ref() + .map(|s| s.processing) + .unwrap_or(false); + self.background_sims + .push(crate::notification::BackgroundSimEntry { + session_id: sid, + spec_id, + label, + was_processing, + }); + self.sim_state = None; + } + + // Remove from background list + self.background_sims + .retain(|bg| bg.session_id != session_id); + + // Remove matching notifications + self.sim_notifications + .retain(|n| n.session_id != session_id); + + // Reconstruct SimulationState 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.processing = + matches!(session.status, spec_forest::simulation::SimStatus::Processing); + sim_state.scenario_input = session.scenario.clone().unwrap_or_default(); + self.screen = Screen::Simulation { + spec_id: session.spec_id.clone(), + session_id: session.id.clone(), + }; + self.sim_state = Some(sim_state); + } + } + + /// Poll all backgrounded simulation sessions for status transitions. + fn poll_background_sims(&mut self) { + let mut new_notifications = Vec::new(); + self.background_sims.retain_mut(|bg| { + let status = self.state.get_sim_session_status(&bg.session_id); + match status { + Some(spec_forest::simulation::SimStatus::Idle) => { + if bg.was_processing { + // Transitioned from Processing → Idle: response is ready + new_notifications.push(crate::notification::SimNotification { + session_id: bg.session_id.clone(), + spec_id: bg.spec_id.clone(), + label: bg.label.clone(), + created_at: std::time::Instant::now(), + }); + bg.was_processing = false; + } + true + } + Some(spec_forest::simulation::SimStatus::Processing) => { + bg.was_processing = true; + true + } + Some(spec_forest::simulation::SimStatus::Error(_)) => false, + Some(spec_forest::simulation::SimStatus::Ended) => false, + None => false, // session was removed externally + } + }); + // Avoid duplicate notifications for the same session + for notif in new_notifications { + if !self + .sim_notifications + .iter() + .any(|n| n.session_id == notif.session_id) + { + self.sim_notifications.push(notif); + } } } @@ -1699,6 +1931,9 @@ impl App { self.poll_sim_status(session_id.clone()); } + // Always poll background simulation sessions for notifications + self.poll_background_sims(); + let spec_id = match &self.screen { Screen::SpecView { spec_id } => spec_id.clone(), _ => return, diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 5b6a021..d441683 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -61,7 +61,8 @@ pub fn map_sim_key(key: KeyCode, modifiers: KeyModifiers, mode: SimInputMode) -> fn map_sim_normal_key(key: KeyCode) -> Action { match key { KeyCode::Char('i') => Action::SimEnterInsert, - KeyCode::Esc => Action::SimExitSimulation, + KeyCode::Esc => Action::SimBackgroundSimulation, + KeyCode::Char('Q') => Action::SimEndSimulation, KeyCode::Tab => Action::SimCycleChannel, KeyCode::F(5) => Action::SimCycleLayout, KeyCode::Char('r') => Action::SimEnterReport, diff --git a/crates/spec-forest-tui/src/lib.rs b/crates/spec-forest-tui/src/lib.rs index 4b126ae..6c72a02 100644 --- a/crates/spec-forest-tui/src/lib.rs +++ b/crates/spec-forest-tui/src/lib.rs @@ -6,6 +6,7 @@ pub mod editor; pub mod error; pub mod input; pub mod log_buffer; +pub mod notification; pub mod simulation; pub mod tree_state; pub mod ui; diff --git a/crates/spec-forest-tui/src/notification.rs b/crates/spec-forest-tui/src/notification.rs new file mode 100644 index 0000000..f341d73 --- /dev/null +++ b/crates/spec-forest-tui/src/notification.rs @@ -0,0 +1,30 @@ +/// A persistent notification for a background simulation session that has a response ready. +pub struct SimNotification { + pub session_id: String, + pub spec_id: String, + /// Human-readable label (spec name or truncated scenario). + pub label: String, + pub created_at: std::time::Instant, +} + +/// Lightweight tracker for a simulation session running in the background. +pub struct BackgroundSimEntry { + pub session_id: String, + pub spec_id: String, + pub label: String, + /// Whether the session was processing on the last poll (used to detect Idle transitions). + pub was_processing: bool, +} + +/// State for the Ctrl+s session picker overlay. +pub struct SessionPickerState { + pub selected: usize, + pub entries: Vec, +} + +pub struct SessionPickerEntry { + pub session_id: String, + pub spec_id: String, + pub label: String, + pub ready: bool, +} diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index b8a38d5..864569c 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -5,6 +5,8 @@ mod dir_browser; mod input_screen; pub(crate) mod log_panel; mod model_config; +mod notification_bar; +mod session_picker; mod sim_channel_picker; mod sim_scenario; mod simulation; @@ -37,4 +39,12 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::Simulation { .. } => simulation::render(app, frame), Screen::ExploreDepthPicker { .. } => depth_picker::render(app, frame), } + + // Global overlays (drawn last = on top via painter's order) + if !app.sim_notifications.is_empty() { + notification_bar::render(app, frame); + } + if app.session_picker.is_some() { + session_picker::render(app, frame); + } } diff --git a/crates/spec-forest-tui/src/ui/notification_bar.rs b/crates/spec-forest-tui/src/ui/notification_bar.rs new file mode 100644 index 0000000..2f57355 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/notification_bar.rs @@ -0,0 +1,61 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::App; + +/// Render a 1-line notification bar at the bottom of the screen. +/// Drawn last (painter's order) so it overlays the current screen's footer. +pub fn render(app: &App, frame: &mut Frame) { + if app.sim_notifications.is_empty() { + return; + } + + let area = frame.area(); + let bar_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(1), + width: area.width, + height: 1, + }; + + let style = Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD); + + let first = &app.sim_notifications[0]; + let remaining = app.sim_notifications.len().saturating_sub(1); + + // Build label, truncating if needed + let prefix = "[SIM] "; + let suffix_ready = " ready"; + let more_suffix = if remaining > 0 { + format!(" [+{remaining} more]") + } else { + String::new() + }; + + let available = bar_area.width as usize; + let fixed_len = prefix.len() + suffix_ready.len() + more_suffix.len() + 2; // 2 for quotes + let max_label = available.saturating_sub(fixed_len); + + let label = if first.label.len() > max_label { + format!( + "\"{}...\"", + &first.label[..max_label.saturating_sub(3)] + ) + } else { + format!("\"{}\"", first.label) + }; + + let text = format!("{prefix}{label}{suffix_ready}{more_suffix}"); + + let line = Line::from(vec![Span::styled(text, style)]); + let bar = Paragraph::new(line).style(style); + frame.render_widget(bar, bar_area); +} diff --git a/crates/spec-forest-tui/src/ui/session_picker.rs b/crates/spec-forest-tui/src/ui/session_picker.rs new file mode 100644 index 0000000..1012c31 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/session_picker.rs @@ -0,0 +1,103 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem}, + Frame, +}; + +use crate::app::App; + +/// Render a centered popup overlay listing backgrounded simulation sessions. +pub fn render(app: &App, frame: &mut Frame) { + let picker = match &app.session_picker { + Some(p) => p, + None => return, + }; + + let area = frame.area(); + + // Size the popup: ~60% width, height fits entries + 2 (border) + let popup_width = (area.width * 3 / 5).max(30).min(area.width); + let popup_height = ((picker.entries.len() as u16) + 2) + .max(4) + .min(area.height); + + let popup_area = centered_rect(popup_width, popup_height, area); + + // Clear the background + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Sessions (Ctrl+s) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let items: Vec = picker + .entries + .iter() + .enumerate() + .map(|(i, entry)| { + let (icon, icon_color) = if entry.ready { + ("✓", Color::Green) + } else { + ("●", Color::Yellow) + }; + let status_text = if entry.ready { "ready" } else { "processing" }; + + let style = if i == picker.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let line = Line::from(vec![ + Span::styled(format!(" {icon} "), Style::default().fg(icon_color)), + Span::styled( + truncate_label(&entry.label, popup_area.width.saturating_sub(20) as usize), + style, + ), + Span::styled(format!(" ({status_text})"), style.fg(Color::DarkGray)), + ]); + ListItem::new(line).style(style) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, popup_area); +} + +fn truncate_label(label: &str, max_len: usize) -> String { + if label.len() <= max_len { + label.to_string() + } else if max_len > 3 { + format!("{}...", &label[..max_len - 3]) + } else { + label[..max_len].to_string() + } +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vertical[1]); + + horizontal[1] +} diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 15816f6..2575bdd 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -329,7 +329,7 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { )); } else if !sim.processing { let hint = match sim.mode { - SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [1-99] Ref [Esc] Exit", + SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [1-99] Ref [Esc] Background [Q] End", SimInputMode::Insert => "[←→] Move [Home/End] Jump [Ctrl+S] Send [Esc] Normal", }; spans.push(Span::styled( diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 2a8280a..b2a399f 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -54,6 +54,7 @@ pub enum SimStatus { Ended, } +#[derive(Clone)] pub struct SimSession { pub id: String, pub spec_id: String, diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 09ab542..1d0f508 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -453,6 +453,20 @@ impl AppState { self.sim_sessions.lock().get(session_id).map(|s| s.status.clone()) } + /// List all simulation sessions with their ID, status, and scenario. + pub fn list_sim_sessions(&self) -> Vec<(String, crate::simulation::SimStatus, Option)> { + self.sim_sessions + .lock() + .iter() + .map(|(id, s)| (id.clone(), s.status.clone(), s.scenario.clone())) + .collect() + } + + /// Get a clone of a simulation session by ID. + pub fn get_sim_session(&self, session_id: &str) -> Option { + self.sim_sessions.lock().get(session_id).cloned() + } + pub fn get_sim_claude_session_id(&self, session_id: &str) -> Option { self.sim_sessions .lock() From aec53538b45e8f3df66cd9a42a8ac0b8002b4f97 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 09:06:18 +1100 Subject: [PATCH 064/100] fix: show notification bar whenever background sims exist, not just on ready The bar now always displays when any simulation is backgrounded, showing the count and status (ready/running) with a Ctrl+s hint. Previously it only appeared when a response notification fired. --- crates/spec-forest-tui/src/ui.rs | 2 +- .../src/ui/notification_bar.rs | 103 ++++++++++++------ 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 864569c..d2ea8be 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -41,7 +41,7 @@ pub fn render(app: &App, frame: &mut Frame) { } // Global overlays (drawn last = on top via painter's order) - if !app.sim_notifications.is_empty() { + if notification_bar::should_render(app) { notification_bar::render(app, frame); } if app.session_picker.is_some() { diff --git a/crates/spec-forest-tui/src/ui/notification_bar.rs b/crates/spec-forest-tui/src/ui/notification_bar.rs index 2f57355..8fe3df6 100644 --- a/crates/spec-forest-tui/src/ui/notification_bar.rs +++ b/crates/spec-forest-tui/src/ui/notification_bar.rs @@ -8,13 +8,14 @@ use ratatui::{ use crate::app::App; +/// Returns true when the notification bar should be rendered. +pub fn should_render(app: &App) -> bool { + !app.background_sims.is_empty() || !app.sim_notifications.is_empty() +} + /// Render a 1-line notification bar at the bottom of the screen. -/// Drawn last (painter's order) so it overlays the current screen's footer. +/// Shows background sim count + any ready notifications. Drawn last (painter's order). pub fn render(app: &App, frame: &mut Frame) { - if app.sim_notifications.is_empty() { - return; - } - let area = frame.area(); let bar_area = Rect { x: area.x, @@ -23,39 +24,77 @@ pub fn render(app: &App, frame: &mut Frame) { height: 1, }; - let style = Style::default() - .fg(Color::Black) - .bg(Color::Yellow) - .add_modifier(Modifier::BOLD); + let mut spans = Vec::new(); - let first = &app.sim_notifications[0]; - let remaining = app.sim_notifications.len().saturating_sub(1); + let ready_count = app.sim_notifications.len(); + let total_bg = app.background_sims.len(); - // Build label, truncating if needed - let prefix = "[SIM] "; - let suffix_ready = " ready"; - let more_suffix = if remaining > 0 { - format!(" [+{remaining} more]") - } else { - String::new() - }; + if ready_count > 0 { + // Highlighted notification for ready sessions + let notif_style = Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD); + + let first = &app.sim_notifications[0]; + let label = truncate_label(&first.label, 30); - let available = bar_area.width as usize; - let fixed_len = prefix.len() + suffix_ready.len() + more_suffix.len() + 2; // 2 for quotes - let max_label = available.saturating_sub(fixed_len); + if ready_count == 1 { + spans.push(Span::styled( + format!(" [SIM] \"{label}\" ready "), + notif_style, + )); + } else { + spans.push(Span::styled( + format!(" [SIM] \"{label}\" ready [+{} more] ", ready_count - 1), + notif_style, + )); + } + + spans.push(Span::raw(" ")); + } - let label = if first.label.len() > max_label { - format!( - "\"{}...\"", - &first.label[..max_label.saturating_sub(3)] - ) + // Always show background sim count with Ctrl+s hint + let info_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let hint_style = Style::default().fg(Color::DarkGray); + + let processing = total_bg.saturating_sub(ready_count); + if total_bg > 0 { + let mut parts = Vec::new(); + if ready_count > 0 { + parts.push(format!("{ready_count} ready")); + } + if processing > 0 { + parts.push(format!("{processing} running")); + } + let summary = parts.join(", "); + spans.push(Span::styled( + format!("[{total_bg} sim{}] ", if total_bg == 1 { "" } else { "s" }), + info_style, + )); + spans.push(Span::styled(summary, hint_style)); + spans.push(Span::styled(" │ Ctrl+s switch", hint_style)); + } + + let bg_style = if ready_count > 0 { + Style::default().bg(Color::DarkGray) } else { - format!("\"{}\"", first.label) + Style::default() }; - let text = format!("{prefix}{label}{suffix_ready}{more_suffix}"); - - let line = Line::from(vec![Span::styled(text, style)]); - let bar = Paragraph::new(line).style(style); + let line = Line::from(spans); + let bar = Paragraph::new(line).style(bg_style); frame.render_widget(bar, bar_area); } + +fn truncate_label(label: &str, max_len: usize) -> String { + if label.len() <= max_len { + label.to_string() + } else if max_len > 3 { + format!("{}...", &label[..max_len - 3]) + } else { + label[..max_len].to_string() + } +} From 4b06bc02602bd0c4e4c2e8145c89fa3102994832 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 09:17:18 +1100 Subject: [PATCH 065/100] feat: add Alt+S keybinding to regenerate shadow answers from TUI --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 42 +++++++++++++++ crates/spec-forest-tui/src/input.rs | 6 ++- crates/spec-forest-tui/src/ui/spec_view.rs | 6 +-- crates/spec-forest-tui/tests/tui_tests.rs | 62 +++++++++++----------- 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 93b7c8d..ca5d62a 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -96,6 +96,7 @@ pub enum Action { // Shadow answers GenerateShadow, + RegenerateShadow, // Simulation - launch LaunchSimulation, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7a05c32..99ec0a6 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -317,6 +317,7 @@ impl App { let action = input::map_key( &self.screen, key, + modifiers, self.tree_visible, self.tree_focused, has_sync_url, @@ -420,6 +421,7 @@ impl App { } } Action::GenerateShadow => self.trigger_shadow_generation(), + Action::RegenerateShadow => self.trigger_shadow_regeneration(), Action::TogglePause => self.toggle_explore_pause(), Action::CancelExplore => self.cancel_explore_session(), Action::EditNextQuestion => self.edit_tree_node().await, @@ -1568,6 +1570,46 @@ impl App { } } + fn trigger_shadow_regeneration(&mut self) { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } => spec_id.clone(), + _ => return, + }; + let spec = match self.specs.get(self.selected) { + Some(s) => s, + None => return, + }; + let dir_path = match &spec.directory { + Some(d) => d.clone(), + None => { + self.message = Some("No directory set. Use [g] Settings to set one.".to_string()); + return; + } + }; + if self.shadow_session_id.is_some() { + self.message = Some("Shadow regeneration already running".to_string()); + return; + } + let focus_node_id = self.get_selected_node().map(|n| n.id.clone()); + match commands::start_shadow_regenerate( + &self.state, + spec_id, + dir_path, + self.model.clone(), + focus_node_id, + ) { + Ok(resp) => { + self.shadow_session_id = Some(resp.session_id.clone()); + self.shadow_status = Some(resp); + self.message = Some("Shadow regeneration started...".to_string()); + } + Err(e) => { + tracing::error!("Shadow regeneration failed: {e}"); + self.message = Some(format!("Shadow regeneration failed: {e}")); + } + } + } + fn toggle_explore_pause(&mut self) { if let Some(ref sid) = self.explore_session_id && let Some(ref status) = self.explore_status diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index d441683..1526664 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -9,6 +9,7 @@ use crate::simulation::SimInputMode; pub fn map_key( screen: &Screen, key: KeyCode, + modifiers: KeyModifiers, tree_visible: bool, tree_focused: bool, has_sync_url: bool, @@ -22,7 +23,7 @@ pub fn map_key( Screen::DirBrowser => map_dir_browser_key(key), Screen::DepthPicker => map_depth_picker_key(key), Screen::SpecOptionsPicker => map_spec_options_key(key), - Screen::SpecView { .. } => map_spec_view_key(key, tree_visible, tree_focused, log_visible, log_focused), + Screen::SpecView { .. } => map_spec_view_key(key, modifiers, tree_visible, tree_focused, log_visible, log_focused), Screen::SpecSettings { .. } => map_spec_settings_key(key), Screen::SyncConfig => map_sync_config_key(key, has_sync_url), Screen::ModelConfig => map_model_config_key(key), @@ -186,13 +187,14 @@ fn map_spec_options_key(key: KeyCode) -> Action { } } -fn map_spec_view_key(key: KeyCode, tree_visible: bool, tree_focused: bool, log_visible: bool, log_focused: bool) -> Action { +fn map_spec_view_key(key: KeyCode, modifiers: KeyModifiers, tree_visible: bool, tree_focused: bool, log_visible: bool, log_focused: bool) -> Action { match key { KeyCode::Char('q') => Action::Quit, KeyCode::Backspace => Action::GoBack, KeyCode::Char('t') => Action::ToggleTree, KeyCode::Char('l') => Action::ToggleLog, KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::ALT) => Action::RegenerateShadow, KeyCode::Up if log_focused && log_visible => Action::LogScrollLineUp, KeyCode::Down if log_focused && log_visible => Action::LogScrollLineDown, KeyCode::PageUp if log_visible => Action::LogScrollUp, diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 898f479..4476080 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -74,13 +74,13 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_text = if let Some(ref msg) = app.message { msg.clone() } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.log_focused { "[↑↓] Scroll [PgUp/PgDn] Page [Tab] Focus [l] Log [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() } else if app.tree_visible || app.log_visible { - "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() + "[a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() } else { - "[a] AI [x] Explore [X] Full [S] Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" + "[a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" .to_string() }; let footer_line = if let Some(label) = app.sync_disconnect_indicator() { diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 203c022..7fdb4b3 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -545,28 +545,28 @@ async fn test_flat_list_and_tree_node_count_consistency() { #[test] fn test_input_map_spec_list_quit() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), false, false, false, false, false, 0); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('q'), KeyModifiers::NONE, false, false, false, false, false, 0); assert_eq!(action, Action::Quit); } #[test] fn test_input_map_spec_list_create() { - let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), false, false, false, false, false, 0); + let action = input::map_key(&Screen::SpecList, KeyCode::Char('c'), KeyModifiers::NONE, false, false, false, false, false, 0); assert_eq!(action, Action::OpenCreateSpec); } #[test] fn test_input_map_spec_list_navigate() { assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Up, false, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Up, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Down, false, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Down, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateDown ); assert_eq!( - input::map_key(&Screen::SpecList, KeyCode::Enter, false, false, false, false, false, 0), + input::map_key(&Screen::SpecList, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Select ); } @@ -576,19 +576,19 @@ fn test_input_map_shared_text_input() { // InputName and SyncPasswordInput share the same mapping for screen in [Screen::InputName, Screen::SyncPasswordInput] { assert_eq!( - input::map_key(&screen, KeyCode::Esc, false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Esc, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Cancel ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Submit ); assert_eq!( - input::map_key(&screen, KeyCode::Backspace, false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Backspace, KeyModifiers::NONE, false, false, false, false, false, 0), Action::DeleteChar ); assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('a'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::TypeChar('a') ); } @@ -601,15 +601,15 @@ fn test_input_map_spec_view_tree_focused() { }; // With tree visible and focused, keys go to tree handlers assert_eq!( - input::map_key(&screen, KeyCode::Up, true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Up, KeyModifiers::NONE, true, true, false, false, false, 0), Action::TreeUp ); assert_eq!( - input::map_key(&screen, KeyCode::Enter, true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Enter, KeyModifiers::NONE, true, true, false, false, false, 0), Action::ExpandOrCollapseTreeNode ); assert_eq!( - input::map_key(&screen, KeyCode::Left, true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Left, KeyModifiers::NONE, true, true, false, false, false, 0), Action::CollapseTreeNode ); } @@ -621,11 +621,11 @@ fn test_input_map_spec_view_flat_list() { }; // Without tree, keys go to flat list handlers assert_eq!( - input::map_key(&screen, KeyCode::Char('a'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('a'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::AiAnswer ); assert_eq!( - input::map_key(&screen, KeyCode::Char('e'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('e'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::EditNextQuestion ); } @@ -636,7 +636,7 @@ fn test_input_map_spec_view_toggle_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('t'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('t'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::ToggleTree ); } @@ -648,12 +648,12 @@ fn test_input_map_spec_view_tab_focus() { }; // Tab only works when tree is visible assert_eq!( - input::map_key(&screen, KeyCode::Tab, true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Tab, KeyModifiers::NONE, true, true, false, false, false, 0), Action::SwitchFocus ); // Tab when tree not visible goes to flat list (noop) assert_eq!( - input::map_key(&screen, KeyCode::Tab, false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Tab, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Noop ); } @@ -661,11 +661,11 @@ fn test_input_map_spec_view_tab_focus() { #[test] fn test_input_map_sync_config_with_url() { assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, true, false, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), KeyModifiers::NONE, false, false, true, false, false, 0), Action::SyncLogin ); assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), false, false, true, false, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('r'), KeyModifiers::NONE, false, false, true, false, false, 0), Action::SyncRegister ); } @@ -674,7 +674,7 @@ fn test_input_map_sync_config_with_url() { fn test_input_map_sync_config_without_url() { // Without URL, login/register are noop assert_eq!( - input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), false, false, false, false, false, 0), + input::map_key(&Screen::SyncConfig, KeyCode::Char('l'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::Noop ); } @@ -682,15 +682,15 @@ fn test_input_map_sync_config_without_url() { #[test] fn test_input_map_model_config() { assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Up, false, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Up, KeyModifiers::NONE, false, false, false, false, false, 0), Action::NavigateUp ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Enter, false, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Enter, KeyModifiers::NONE, false, false, false, false, false, 0), Action::SelectModel ); assert_eq!( - input::map_key(&Screen::ModelConfig, KeyCode::Esc, false, false, false, false, false, 0), + input::map_key(&Screen::ModelConfig, KeyCode::Esc, KeyModifiers::NONE, false, false, false, false, false, 0), Action::Cancel ); } @@ -790,11 +790,11 @@ fn test_accept_candidate_key_mapping() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, true, true, false, false, false, 0), Action::AcceptCandidate ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::AcceptCandidate ); } @@ -821,15 +821,15 @@ fn test_input_map_candidate_keys_tree() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Char(']'), KeyModifiers::NONE, true, true, false, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('['), KeyModifiers::NONE, true, true, false, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), true, true, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, true, true, false, false, false, 0), Action::AcceptCandidate ); } @@ -840,15 +840,15 @@ fn test_input_map_candidate_keys_flat_list() { spec_id: "s".to_string(), }; assert_eq!( - input::map_key(&screen, KeyCode::Char(']'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char(']'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::CandidateNext ); assert_eq!( - input::map_key(&screen, KeyCode::Char('['), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('['), KeyModifiers::NONE, false, false, false, false, false, 0), Action::CandidatePrev ); assert_eq!( - input::map_key(&screen, KeyCode::Char('y'), false, false, false, false, false, 0), + input::map_key(&screen, KeyCode::Char('y'), KeyModifiers::NONE, false, false, false, false, false, 0), Action::AcceptCandidate ); } From 8a337ba70d57c6a73a644f4f5e5f3c6bd1915e9f Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 09:33:24 +1100 Subject: [PATCH 066/100] feat: add ? help popup and clean up footer key hints Replace verbose footer bars (15+ keys per line) with minimal badge-styled hints showing only 3-5 essential keys. Press ? on any non-text-input screen to open a context-aware help popup listing all available key bindings. --- crates/spec-forest-tui/src/action.rs | 3 + crates/spec-forest-tui/src/app.rs | 30 ++ crates/spec-forest-tui/src/ui.rs | 4 + crates/spec-forest-tui/src/ui/common.rs | 53 ++- crates/spec-forest-tui/src/ui/config.rs | 14 +- crates/spec-forest-tui/src/ui/depth_picker.rs | 7 +- crates/spec-forest-tui/src/ui/dir_browser.rs | 7 +- crates/spec-forest-tui/src/ui/help_popup.rs | 355 ++++++++++++++++++ crates/spec-forest-tui/src/ui/input_screen.rs | 14 +- crates/spec-forest-tui/src/ui/model_config.rs | 14 +- .../spec-forest-tui/src/ui/session_picker.rs | 24 +- .../src/ui/sim_channel_picker.rs | 16 +- crates/spec-forest-tui/src/ui/sim_scenario.rs | 10 +- crates/spec-forest-tui/src/ui/simulation.rs | 12 +- crates/spec-forest-tui/src/ui/spec_list.rs | 16 +- .../src/ui/spec_options_picker.rs | 7 +- .../spec-forest-tui/src/ui/spec_settings.rs | 14 +- crates/spec-forest-tui/src/ui/spec_view.rs | 30 +- crates/spec-forest-tui/src/ui/sync_config.rs | 16 +- crates/spec-forest-tui/tests/tui_tests.rs | 2 +- 20 files changed, 548 insertions(+), 100 deletions(-) create mode 100644 crates/spec-forest-tui/src/ui/help_popup.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index ca5d62a..c712f0f 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -142,5 +142,8 @@ pub enum Action { SessionPickerDismiss, DismissNotification, + // Help + ToggleHelp, + Noop, } diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 99ec0a6..a75746c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -97,6 +97,7 @@ pub struct App { pub background_sims: Vec, pub sim_notifications: Vec, pub session_picker: Option, + pub show_help: bool, } #[derive(Clone)] @@ -197,6 +198,7 @@ impl App { background_sims: Vec::new(), sim_notifications: Vec::new(), session_picker: None, + show_help: false, } } @@ -269,6 +271,12 @@ impl App { pub async fn handle_key(&mut self, key: KeyCode, modifiers: crossterm::event::KeyModifiers) { use crossterm::event::KeyModifiers; + // Help overlay: dismiss on any key + if self.show_help { + self.show_help = false; + return; + } + // Session picker overlay takes priority when open if self.session_picker.is_some() { let action = match key { @@ -296,6 +304,25 @@ impl App { } } + // Global ? opens help (skip text-input screens) + if key == KeyCode::Char('?') { + let is_text_input = matches!( + self.screen, + Screen::InputName + | Screen::SyncPasswordInput + | Screen::UsernameInput + | Screen::SimScenario { .. } + ) || (matches!(self.screen, Screen::Simulation { .. }) + && self + .sim_state + .as_ref() + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)); + if !is_text_input { + self.show_help = true; + return; + } + } + // Scenario input screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::SimScenario { .. }) { let action = input::map_sim_scenario_key(key, modifiers); @@ -1020,6 +1047,9 @@ impl App { self.sim_notifications.remove(0); } } + Action::ToggleHelp => { + self.show_help = !self.show_help; + } } } diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index d2ea8be..8257a88 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -1,5 +1,6 @@ mod common; mod config; +mod help_popup; mod depth_picker; mod dir_browser; mod input_screen; @@ -47,4 +48,7 @@ pub fn render(app: &App, frame: &mut Frame) { if app.session_picker.is_some() { session_picker::render(app, frame); } + if app.show_help { + help_popup::render(app, frame); + } } diff --git a/crates/spec-forest-tui/src/ui/common.rs b/crates/spec-forest-tui/src/ui/common.rs index a2b8b17..5e7b186 100644 --- a/crates/spec-forest-tui/src/ui/common.rs +++ b/crates/spec-forest-tui/src/ui/common.rs @@ -1,6 +1,57 @@ -use ratatui::style::Color; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + use spec_forest::NodeState; +/// Render a footer line with badge-styled keys: ` key ` in dark-gray bg, then description. +pub fn render_footer_line<'a>(items: &[(&str, &str)], sync_label: Option<&str>) -> Line<'a> { + let mut spans: Vec> = Vec::new(); + if let Some(label) = sync_label { + spans.push(Span::styled( + format!(" {label} "), + Style::default().fg(Color::Red), + )); + } + for (i, (key, desc)) in items.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!(" {key} "), + Style::default() + .bg(Color::DarkGray) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(format!(" {desc}"))); + } + Line::from(spans) +} + +/// Build a centered rectangle within `area`. +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vertical[1]); + + horizontal[1] +} + pub fn spinner_char(tick: u64) -> char { const FRAMES: &[char] = &[ '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', diff --git a/crates/spec-forest-tui/src/ui/config.rs b/crates/spec-forest-tui/src/ui/config.rs index c11e850..e1dab73 100644 --- a/crates/spec-forest-tui/src/ui/config.rs +++ b/crates/spec-forest-tui/src/ui/config.rs @@ -44,10 +44,14 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.config_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer_text = app - .message - .as_deref() - .unwrap_or("[Up/Down] Select [Enter] Edit [Esc] Back"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Edit"), ("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/depth_picker.rs b/crates/spec-forest-tui/src/ui/depth_picker.rs index e95177a..fe0b3a8 100644 --- a/crates/spec-forest-tui/src/ui/depth_picker.rs +++ b/crates/spec-forest-tui/src/ui/depth_picker.rs @@ -60,7 +60,10 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.depth_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer = Paragraph::new("[Up/Down] Select [Enter] Confirm [Esc] Back") - .block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Back"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/dir_browser.rs b/crates/spec-forest-tui/src/ui/dir_browser.rs index e7881d1..84b9fd0 100644 --- a/crates/spec-forest-tui/src/ui/dir_browser.rs +++ b/crates/spec-forest-tui/src/ui/dir_browser.rs @@ -63,7 +63,10 @@ pub fn render(app: &App, frame: &mut Frame) { Paragraph::new(path_text).block(Block::default().borders(Borders::ALL)); frame.render_widget(path_display, chunks[1]); - let footer = Paragraph::new("[S] Select [Enter/\u{2192}] Expand [\u{2190}] Collapse [Bksp] Parent [Esc] Cancel") - .block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("S", "Select"), ("Enter", "Expand"), ("Bksp", "Parent"), ("Esc", "Cancel"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs new file mode 100644 index 0000000..c8811c1 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -0,0 +1,355 @@ +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use crate::app::{App, Screen}; + +pub struct HelpSection { + pub title: &'static str, + pub bindings: Vec<(&'static str, &'static str)>, +} + +fn help_sections(app: &App) -> Vec { + match &app.screen { + Screen::SpecList => vec![ + HelpSection { + title: "Navigation", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Open spec"), + ], + }, + HelpSection { + title: "Actions", + bindings: vec![ + ("c", "Create spec"), + ("s", "Seed from directory"), + ("m", "Model config"), + ("y", "Sync config"), + ("g", "Global config"), + ], + }, + HelpSection { + title: "General", + bindings: vec![("q", "Quit")], + }, + ], + Screen::SpecView { .. } => { + let mut sections = vec![]; + + sections.push(HelpSection { + title: "Navigation", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Tab", "Switch focus"), + ("Bksp", "Back to spec list"), + ], + }); + + sections.push(HelpSection { + title: "AI Actions", + bindings: vec![ + ("a", "AI answer"), + ("x", "Explore node"), + ("X", "Full explore"), + ("S", "Shadow answers"), + ("Alt+S", "Regen shadow"), + ("s", "Simulate"), + ], + }); + + sections.push(HelpSection { + title: "Edit", + bindings: vec![ + ("e", "Edit node"), + ("f", "Add feature"), + ("R", "Regenerate"), + ("n", "Add question"), + ("d", "Delete node"), + ], + }); + + sections.push(HelpSection { + title: "Panels", + bindings: vec![ + ("t", "Toggle tree"), + ("l", "Toggle log"), + ("g", "Spec settings"), + ], + }); + + if app.tree_focused && app.tree_visible { + sections.push(HelpSection { + title: "Tree", + bindings: vec![ + ("Left", "Collapse node"), + ("Right/Enter", "Expand node"), + ("K/J", "Sibling up/down"), + ], + }); + } + + if app.log_focused && app.log_visible { + sections.push(HelpSection { + title: "Log", + bindings: vec![ + ("Up/Down", "Scroll line"), + ("PgUp/PgDn", "Scroll page"), + ], + }); + } + + if !app.candidates.is_empty() { + sections.push(HelpSection { + title: "Candidates", + bindings: vec![ + ("[", "Previous candidate"), + ("]", "Next candidate"), + ("y", "Accept candidate"), + ("E", "Edit candidate"), + ], + }); + } + + if app + .explore_status + .as_ref() + .is_some_and(|s| { + matches!( + s.status, + spec_forest::explore::ExploreStatus::Running + | spec_forest::explore::ExploreStatus::Paused + ) + }) + { + sections.push(HelpSection { + title: "Explore", + bindings: vec![("p", "Pause/Resume"), ("c", "Cancel")], + }); + } + + sections.push(HelpSection { + title: "General", + bindings: vec![("q", "Quit")], + }); + + sections + } + Screen::Simulation { .. } => { + let mode = app + .sim_state + .as_ref() + .map(|s| s.mode) + .unwrap_or(crate::simulation::SimInputMode::Normal); + match mode { + crate::simulation::SimInputMode::Normal => vec![ + HelpSection { + title: "Mode", + bindings: vec![("i", "Enter insert mode")], + }, + HelpSection { + title: "Channels & Layout", + bindings: vec![ + ("Tab", "Cycle channel"), + ("F5", "Cycle layout"), + ], + }, + HelpSection { + title: "Actions", + bindings: vec![ + ("r", "Show report"), + ("S", "Edit scenario"), + ("1-99", "View ref"), + ], + }, + HelpSection { + title: "Session", + bindings: vec![ + ("Esc", "Background simulation"), + ("Q", "End simulation"), + ], + }, + ], + crate::simulation::SimInputMode::Insert => vec![HelpSection { + title: "Insert Mode", + bindings: vec![ + ("Left/Right", "Move cursor"), + ("Home/End", "Jump to start/end"), + ("Ctrl+S", "Send message"), + ("Shift+Enter", "Send message"), + ("Esc", "Back to normal mode"), + ], + }], + } + } + Screen::SimChannelPicker { .. } => vec![HelpSection { + title: "Channel Picker", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Space", "Toggle channel"), + ("Tab", "Toggle whole spec"), + ("Shift+Tab", "Toggle explore code"), + ("Enter", "Start simulation"), + ("Esc", "Cancel"), + ], + }], + Screen::SimScenario { .. } => vec![HelpSection { + title: "Scenario Input", + bindings: vec![ + ("Enter", "New line"), + ("Shift+Enter", "Start simulation"), + ("Ctrl+S", "Start simulation"), + ("Esc", "Back"), + ], + }], + Screen::Config => vec![HelpSection { + title: "Config", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Edit selected"), + ("Esc", "Back"), + ], + }], + Screen::ModelConfig => vec![HelpSection { + title: "Model Config", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm model"), + ("Esc", "Cancel"), + ], + }], + Screen::SpecSettings { .. } => vec![HelpSection { + title: "Spec Settings", + bindings: vec![ + ("Enter", "Change directory"), + ("d", "Clear directory"), + ("Esc", "Back"), + ], + }], + Screen::SyncConfig => { + let has_url = app.state.sync_url().is_some(); + if has_url { + vec![HelpSection { + title: "Sync Config", + bindings: vec![ + ("l", "Login"), + ("r", "Register"), + ("Esc", "Back"), + ], + }] + } else { + vec![HelpSection { + title: "Sync Config", + bindings: vec![("Esc", "Back")], + }] + } + } + Screen::DirBrowser => vec![HelpSection { + title: "Directory Browser", + bindings: vec![ + ("Up/Down", "Navigate"), + ("Enter/Right", "Expand directory"), + ("Left", "Collapse"), + ("Bksp", "Go to parent"), + ("S", "Select directory"), + ("Esc", "Cancel"), + ], + }], + Screen::DepthPicker | Screen::ExploreDepthPicker { .. } => vec![HelpSection { + title: "Depth Picker", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm"), + ("Esc", "Back"), + ], + }], + Screen::SpecOptionsPicker => vec![HelpSection { + title: "Spec Options", + bindings: vec![ + ("Up/Down", "Move selection"), + ("Enter", "Confirm"), + ("Esc", "Cancel"), + ], + }], + Screen::InputName | Screen::SyncPasswordInput | Screen::UsernameInput => { + vec![HelpSection { + title: "Text Input", + bindings: vec![ + ("Enter", "Submit"), + ("Esc", "Cancel"), + ], + }] + } + } +} + +pub fn render(app: &App, frame: &mut Frame) { + if !app.show_help { + return; + } + + let area = frame.area(); + let sections = help_sections(app); + + // Calculate content height: section titles + bindings + spacing + let content_lines: u16 = sections + .iter() + .map(|s| 1 + s.bindings.len() as u16 + 1) // title + bindings + blank line + .sum::() + + 1; // footer hint + + let popup_width = (area.width * 3 / 5).max(40).min(area.width); + let popup_height = (content_lines + 2) // +2 for border + .max(6) + .min(area.height.saturating_sub(4)); + + let popup_area = super::common::centered_rect(popup_width, popup_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let mut lines: Vec = Vec::new(); + + for section in §ions { + lines.push(Line::from(Span::styled( + format!(" {}", section.title), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + + for (key, desc) in §ion.bindings { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!(" {key} "), + Style::default() + .bg(Color::DarkGray) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {desc}")), + ])); + } + + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + " Press any key to close", + Style::default().fg(Color::DarkGray), + ))); + + let content = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(content, inner); +} diff --git a/crates/spec-forest-tui/src/ui/input_screen.rs b/crates/spec-forest-tui/src/ui/input_screen.rs index 31c7a3f..67eb359 100644 --- a/crates/spec-forest-tui/src/ui/input_screen.rs +++ b/crates/spec-forest-tui/src/ui/input_screen.rs @@ -23,8 +23,11 @@ pub fn render_input(app: &App, frame: &mut Frame, prompt: &str) { let input = Paragraph::new(input_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(input, chunks[1]); - let footer = - Paragraph::new("[Enter] Submit [Esc] Cancel").block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Submit"), ("Esc", "Cancel")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } @@ -46,7 +49,10 @@ pub fn render_password(app: &App, frame: &mut Frame) { let input = Paragraph::new(input_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(input, chunks[1]); - let footer = - Paragraph::new("[Enter] Submit [Esc] Cancel").block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Submit"), ("Esc", "Cancel")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/model_config.rs b/crates/spec-forest-tui/src/ui/model_config.rs index 3dce837..eda1333 100644 --- a/crates/spec-forest-tui/src/ui/model_config.rs +++ b/crates/spec-forest-tui/src/ui/model_config.rs @@ -50,10 +50,14 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.model_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer_text = app - .message - .as_deref() - .unwrap_or("[Up/Down] Select [Enter] Confirm [Esc] Cancel"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Cancel"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/session_picker.rs b/crates/spec-forest-tui/src/ui/session_picker.rs index 1012c31..24bb781 100644 --- a/crates/spec-forest-tui/src/ui/session_picker.rs +++ b/crates/spec-forest-tui/src/ui/session_picker.rs @@ -1,5 +1,4 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem}, @@ -23,7 +22,7 @@ pub fn render(app: &App, frame: &mut Frame) { .max(4) .min(area.height); - let popup_area = centered_rect(popup_width, popup_height, area); + let popup_area = super::common::centered_rect(popup_width, popup_height, area); // Clear the background frame.render_widget(Clear, popup_area); @@ -80,24 +79,3 @@ fn truncate_label(label: &str, max_len: usize) -> String { } } -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let vertical = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Length((area.height.saturating_sub(height)) / 2), - Constraint::Length(height), - Constraint::Min(0), - ]) - .split(area); - - let horizontal = Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Length((area.width.saturating_sub(width)) / 2), - Constraint::Length(width), - Constraint::Min(0), - ]) - .split(vertical[1]); - - horizontal[1] -} 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 15a5663..d4f7e99 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -107,17 +107,19 @@ pub fn render(app: &App, frame: &mut Frame) { frame.render_widget(explore_code, chunks[2]); let selected_count = app.sim_channel_selection.len(); - let footer = Paragraph::new(Line::from(vec![ + let mut footer_spans = vec![ Span::styled( format!(" {selected_count} selected "), Style::default().fg(Color::Cyan), ), - Span::styled( - "[Space] Toggle [Tab] Whole Spec [Shift+Tab] Explore Code [Enter] Start [Esc] Cancel", - Style::default().fg(Color::DarkGray), - ), - ])) - .block(Block::default().borders(Borders::ALL)); + ]; + let badge_line = super::common::render_footer_line( + &[("Space", "Toggle"), ("Enter", "Start"), ("Esc", "Cancel"), ("?", "Help")], + 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[3]); } diff --git a/crates/spec-forest-tui/src/ui/sim_scenario.rs b/crates/spec-forest-tui/src/ui/sim_scenario.rs index aa67a85..728b7b4 100644 --- a/crates/spec-forest-tui/src/ui/sim_scenario.rs +++ b/crates/spec-forest-tui/src/ui/sim_scenario.rs @@ -38,10 +38,10 @@ pub fn render(app: &App, frame: &mut Frame) { .block(Block::default().borders(Borders::ALL).title(" Scenario ")); frame.render_widget(text, chunks[1]); - let footer = Paragraph::new(Line::from(vec![Span::styled( - "[Enter] Newline [Shift+Enter/Ctrl+S] Start [Esc] Back", - Style::default().fg(Color::DarkGray), - )])) - .block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Shift+Enter", "Start"), ("Esc", "Back")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); } diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 2575bdd..54d2908 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -328,14 +328,12 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { Style::default().fg(Color::Red), )); } else if !sim.processing { - let hint = match sim.mode { - SimInputMode::Normal => "[i] Insert [Tab] Channel [F5] Layout [r] Report [S] Scenario [1-99] Ref [Esc] Background [Q] End", - SimInputMode::Insert => "[←→] Move [Home/End] Jump [Ctrl+S] Send [Esc] Normal", + let hint_items: &[(&str, &str)] = match sim.mode { + SimInputMode::Normal => &[("i", "Insert"), ("Tab", "Channel"), ("Esc", "Background"), ("?", "Help")], + SimInputMode::Insert => &[("Ctrl+S", "Send"), ("Esc", "Normal")], }; - spans.push(Span::styled( - hint, - Style::default().fg(Color::DarkGray), - )); + let badge_line = super::common::render_footer_line(hint_items, None); + spans.extend(badge_line.spans); } frame.render_widget(Paragraph::new(Line::from(spans)), area); diff --git a/crates/spec-forest-tui/src/ui/spec_list.rs b/crates/spec-forest-tui/src/ui/spec_list.rs index 60f2e22..2e450dc 100644 --- a/crates/spec-forest-tui/src/ui/spec_list.rs +++ b/crates/spec-forest-tui/src/ui/spec_list.rs @@ -46,17 +46,13 @@ pub fn render(app: &App, frame: &mut Frame) { )); frame.render_widget(id_line, chunks[1]); - let footer_text = app - .message - .as_deref() - .unwrap_or("[c] Create [s] Seed from dir [m] Model [y] Sync [g] Config [Enter] Open [q] Quit"); - let footer_line = if let Some(label) = app.sync_disconnect_indicator() { - Line::from(vec![ - Span::styled(format!(" {label} "), Style::default().fg(Color::Red)), - Span::raw(footer_text), - ]) + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) } else { - Line::from(footer_text) + super::common::render_footer_line( + &[("Enter", "Open"), ("c", "Create"), ("q", "Quit"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) }; let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[2]); diff --git a/crates/spec-forest-tui/src/ui/spec_options_picker.rs b/crates/spec-forest-tui/src/ui/spec_options_picker.rs index 83e2036..202f0c2 100644 --- a/crates/spec-forest-tui/src/ui/spec_options_picker.rs +++ b/crates/spec-forest-tui/src/ui/spec_options_picker.rs @@ -46,7 +46,10 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.spec_options_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer = Paragraph::new("[Up/Down] Select [Enter] Confirm [Esc] Back") - .block(Block::default().borders(Borders::ALL)); + let footer_line = super::common::render_footer_line( + &[("Enter", "Confirm"), ("Esc", "Cancel"), ("?", "Help")], + None, + ); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/spec_settings.rs b/crates/spec-forest-tui/src/ui/spec_settings.rs index 7711839..9b7f17d 100644 --- a/crates/spec-forest-tui/src/ui/spec_settings.rs +++ b/crates/spec-forest-tui/src/ui/spec_settings.rs @@ -48,10 +48,14 @@ pub fn render(app: &App, frame: &mut Frame) { state.select(Some(app.spec_settings_selected)); frame.render_stateful_widget(list, chunks[0], &mut state); - let footer_text = app - .message - .as_deref() - .unwrap_or("[Enter] Change Directory [d] Clear Directory [Esc] Back"); - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else { + super::common::render_footer_line( + &[("Enter", "Set Dir"), ("d", "Clear Dir"), ("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/src/ui/spec_view.rs b/crates/spec-forest-tui/src/ui/spec_view.rs index 4476080..adfdec0 100644 --- a/crates/spec-forest-tui/src/ui/spec_view.rs +++ b/crates/spec-forest-tui/src/ui/spec_view.rs @@ -71,25 +71,23 @@ pub fn render(app: &App, frame: &mut Frame) { let footer_chunk = chunks[idx]; - let footer_text = if let Some(ref msg) = app.message { - msg.clone() + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) } else if !app.candidates.is_empty() { - "[[] prev []] next [y] accept [E] Edit candidate [a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit".to_string() + super::common::render_footer_line( + &[("y", "Accept"), ("[]", "Navigate"), ("Bksp", "Back"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) } else if app.log_focused { - "[↑↓] Scroll [PgUp/PgDn] Page [Tab] Focus [l] Log [t] Tree [g] Settings [Bksp] Back [q] Quit".to_string() - } else if app.tree_visible || app.log_visible { - "[a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [Tab] Focus [g] Settings [Bksp] Back [q] Quit".to_string() - } else { - "[a] AI [x] Explore [X] Full [S] Shadow [Alt+S] Regen Shadow [s] Sim [e] Edit [f] Feature [R] Regen [n] Question [d] Delete [t] Tree [l] Log [g] Settings [Bksp] Back [q] Quit" - .to_string() - }; - let footer_line = if let Some(label) = app.sync_disconnect_indicator() { - Line::from(vec![ - Span::styled(format!(" {label} "), Style::default().fg(Color::Red)), - Span::raw(footer_text), - ]) + super::common::render_footer_line( + &[("↑↓", "Scroll"), ("Tab", "Focus"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) } else { - Line::from(footer_text) + super::common::render_footer_line( + &[("a", "AI"), ("x", "Explore"), ("Bksp", "Back"), ("q", "Quit"), ("?", "Help")], + app.sync_disconnect_indicator(), + ) }; let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, footer_chunk); diff --git a/crates/spec-forest-tui/src/ui/sync_config.rs b/crates/spec-forest-tui/src/ui/sync_config.rs index 593d3b4..f424883 100644 --- a/crates/spec-forest-tui/src/ui/sync_config.rs +++ b/crates/spec-forest-tui/src/ui/sync_config.rs @@ -36,13 +36,19 @@ pub fn render(app: &App, frame: &mut Frame) { frame.render_widget(info, chunks[0]); let has_url = app.state.sync_url().is_some(); - let footer_text = if let Some(ref msg) = app.message { - msg.as_str().to_string() + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) } else if has_url { - "[l] Login [r] Register [Esc] Back".to_string() + super::common::render_footer_line( + &[("l", "Login"), ("r", "Register"), ("Esc", "Back"), ("?", "Help")], + None, + ) } else { - "Set --sync-url to enable sync [Esc] Back".to_string() + super::common::render_footer_line( + &[("Esc", "Back"), ("?", "Help")], + None, + ) }; - let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)); + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, chunks[1]); } diff --git a/crates/spec-forest-tui/tests/tui_tests.rs b/crates/spec-forest-tui/tests/tui_tests.rs index 7fdb4b3..e86442f 100644 --- a/crates/spec-forest-tui/tests/tui_tests.rs +++ b/crates/spec-forest-tui/tests/tui_tests.rs @@ -92,7 +92,7 @@ async fn test_spec_list_shows_footer() { let output = render_to_string_sized(&app, 120, 24); assert!(output.contains("Quit"), "footer should show Quit"); assert!(output.contains("Create"), "footer should show Create"); - assert!(output.contains("Seed"), "footer should show Seed"); + assert!(output.contains("Help"), "footer should show Help"); } #[tokio::test] From f0fed6e30e5a2d4c87a5bafa47ef0477b5978ffd Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 09:45:25 +1100 Subject: [PATCH 067/100] feat: expose simulation mode via 10 MCP tools for agent-driven interaction Extract shared orchestration logic from TUI into spec-forest lib so both TUI and MCP can drive simulations. Add fire-and-poll MCP tools: sim_create_session, sim_start, sim_send_input, sim_ask_report, sim_update_scenario, sim_get_status, sim_get_channels, sim_get_report, sim_list_sessions, sim_end. --- crates/spec-forest-tui/src/commands.rs | 159 +------ crates/spec-forest/src/simulation.rs | 1 + .../spec-forest/src/simulation/orchestrate.rs | 187 ++++++++ crates/spec-forest/src/simulation/session.rs | 17 + crates/spec-forest/src/state.rs | 22 +- crates/spec-forest/src/tool_types.rs | 79 ++++ crates/spec-forest/src/tools.rs | 441 ++++++++++++++++++ 7 files changed, 758 insertions(+), 148 deletions(-) create mode 100644 crates/spec-forest/src/simulation/orchestrate.rs diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 4a92f03..64e8d61 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -242,163 +242,32 @@ pub fn load_implementation_statuses( pub async fn run_sim_initial_turn( state: Arc, session_id: String, - spec_id: String, - model: String, - channels: Vec, - focus_node_id: String, - scenario: Option, + _spec_id: String, + _model: String, + _channels: Vec, + _focus_node_id: String, + _scenario: Option, consume_whole_spec: bool, directory: Option, ) { - // Load focus node - let focus_node = match spec_forest::api::get_node(&state, &focus_node_id) { - Ok(node) => node, - Err(e) => { - tracing::error!("Failed to load focus node for simulation: {e}"); - state.update_sim_session(&session_id, |s| { - s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); - }); - return; - } - }; - - let summary = match spec_forest::api::get_spec(&state, &spec_id) { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to load spec summary for simulation: {e}"); - state.update_sim_session(&session_id, |s| { - s.status = - simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); - }); - return; - } - }; - - let system_prompt = if consume_whole_spec { - let all_nodes = spec_forest::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); - simulation::build_system_prompt_whole_spec( - &channels, - &focus_node, - &all_nodes, - &summary, - ) - } else { - // Load ancestors, descendants, and roots for focused context - let ancestors = - spec_forest::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); - let descendants = - spec_forest::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); - - // Get root nodes, excluding any already in ancestors/descendants/focus - 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 = spec_forest::api::get_spec_roots(&state, &spec_id) - .unwrap_or_default() - .into_iter() - .filter(|n| !context_ids.contains(n.id.as_str())) - .collect::>(); - - simulation::build_system_prompt( - &channels, - &focus_node, - &ancestors, - &descendants, - &summary, - &other_roots, - ) - }; - let system_prompt = if directory.is_some() { - simulation::append_code_aware_section(&system_prompt) - } else { - system_prompt - }; - let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); - - let mcp_url = state.mcp_url().unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); - - match simulation::runner::start_sim_turn(&config, &initial_prompt).await { - Ok((claude_session_id, response)) => { - state.update_sim_session(&session_id, |s| { - s.claude_session_id = Some(claude_session_id); - s.channel_contents = response.channels; - s.decisions = response.decisions; - s.status = simulation::SimStatus::Idle; - }); - } - Err(e) => { - tracing::error!("Simulation initial turn failed: {e}"); - state.update_sim_session(&session_id, |s| { - s.status = simulation::SimStatus::Error(e.to_string()); - }); - } - } + simulation::orchestrate::orchestrate_initial_turn( + state, + session_id, + consume_whole_spec, + directory, + ) + .await; } /// Resume a simulation turn with user input. /// Updates the sim session in AppState when complete. pub async fn run_sim_resume_turn(state: Arc, session_id: String, input: String) { - let claude_sid = state - .get_sim_claude_session_id(&session_id) - .unwrap_or_default(); - - if claude_sid.is_empty() { - state.update_sim_session(&session_id, |s| { - s.status = - simulation::SimStatus::Error("No claude session ID for resume".to_string()); - }); - return; - } - - match simulation::runner::resume_sim_turn(&claude_sid, &input).await { - Ok(response) => { - state.update_sim_session(&session_id, |s| { - s.channel_contents = response.channels; - s.decisions = response.decisions; - s.status = simulation::SimStatus::Idle; - }); - } - Err(e) => { - tracing::error!("Simulation resume turn failed: {e}"); - state.update_sim_session(&session_id, |s| { - s.status = simulation::SimStatus::Error(e.to_string()); - }); - } - } + simulation::orchestrate::orchestrate_resume_turn(state, session_id, input).await; } /// Resume a simulation turn with a user report. /// Stores the report explanation without replacing channel contents. pub async fn run_sim_report_turn(state: Arc, session_id: String, input: String) { - let claude_sid = state - .get_sim_claude_session_id(&session_id) - .unwrap_or_default(); - - if claude_sid.is_empty() { - state.update_sim_session(&session_id, |s| { - s.status = - simulation::SimStatus::Error("No claude session ID for resume".to_string()); - }); - return; - } - - match simulation::runner::resume_sim_report_turn(&claude_sid, &input).await { - Ok(response) => { - state.update_sim_session(&session_id, |s| { - s.pending_report = Some(response); - s.status = simulation::SimStatus::Idle; - }); - } - Err(e) => { - tracing::error!("Simulation report turn failed: {e}"); - state.update_sim_session(&session_id, |s| { - s.status = simulation::SimStatus::Error(e.to_string()); - }); - } - } + simulation::orchestrate::orchestrate_report_turn(state, session_id, input).await; } diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 6bbc9e1..5ec727c 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -1,4 +1,5 @@ mod prompt; +pub mod orchestrate; pub mod runner; pub mod session; pub mod types; diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs new file mode 100644 index 0000000..3245d66 --- /dev/null +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -0,0 +1,187 @@ +use std::sync::Arc; + +use crate::simulation; +use crate::state::AppState; + +/// Run the initial simulation turn. +/// Reads session config from AppState, builds prompts, calls the runner, +/// and updates the session with results or error. +pub async fn orchestrate_initial_turn( + state: Arc, + session_id: String, + consume_whole_spec: bool, + directory: Option, +) { + let (spec_id, model, channels, focus_node_id, scenario) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.spec_id.clone(), + session.model.clone(), + session.channels.clone(), + session.root_node_id.clone().unwrap_or_default(), + session.scenario.clone(), + ) + }; + + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to load focus node for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); + }); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to load spec summary for simulation: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); + }); + return; + } + }; + + let system_prompt = if consume_whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + simulation::build_system_prompt_whole_spec(&channels, &focus_node, &all_nodes, &summary) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + simulation::build_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + ) + }; + + let system_prompt = if directory.is_some() { + simulation::append_code_aware_section(&system_prompt) + } else { + system_prompt + }; + let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); + + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); + + match simulation::runner::start_sim_turn(&config, &initial_prompt).await { + Ok((claude_session_id, response)) => { + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + s.channel_contents = response.channels; + s.decisions = response.decisions; + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation initial turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Resume a simulation turn with user input. +pub async fn orchestrate_resume_turn(state: Arc, session_id: String, input: String) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + match simulation::runner::resume_sim_turn(&claude_sid, &input).await { + Ok(response) => { + state.update_sim_session(&session_id, |s| { + s.channel_contents = response.channels; + s.decisions = response.decisions; + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation resume turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Resume a simulation turn with a report question. +/// Stores the report explanation without replacing channel contents. +pub async fn orchestrate_report_turn(state: Arc, session_id: String, question: String) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + match simulation::runner::resume_sim_report_turn(&claude_sid, &question).await { + Ok(response) => { + state.update_sim_session(&session_id, |s| { + s.pending_report = Some(response); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Simulation report turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Update the scenario and resume with scenario-update framing. +pub async fn orchestrate_scenario_update( + state: Arc, + session_id: String, + scenario: String, +) { + state.update_sim_session(&session_id, |s| { + s.scenario = Some(scenario.clone()); + }); + + let input = format!( + "SCENARIO UPDATE: The simulation scenario has changed. The new scenario is: {scenario}" + ); + orchestrate_resume_turn(state, session_id, input).await; +} diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index b2a399f..1dc491a 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -31,6 +31,17 @@ impl SimChannel { SimChannel::Logs => "logs", } } + + pub fn from_key(s: &str) -> Option { + match s { + "ui" => Some(SimChannel::Ui), + "audio" => Some(SimChannel::Audio), + "network" => Some(SimChannel::Network), + "errors" => Some(SimChannel::Errors), + "logs" => Some(SimChannel::Logs), + _ => None, + } + } } impl fmt::Display for SimChannel { @@ -72,6 +83,10 @@ pub struct SimSession { pub decisions: Vec, /// Optional scenario description for the simulation. pub scenario: Option, + /// Whether to load the entire spec into context. + pub whole_spec: bool, + /// Optional project directory for code-aware simulation. + pub directory: Option, } impl SimSession { @@ -95,6 +110,8 @@ impl SimSession { pending_report: None, decisions: Vec::new(), scenario, + whole_spec: false, + directory: None, } } } diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 1d0f508..aab1d57 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -453,12 +453,28 @@ impl AppState { self.sim_sessions.lock().get(session_id).map(|s| s.status.clone()) } - /// List all simulation sessions with their ID, status, and scenario. - pub fn list_sim_sessions(&self) -> Vec<(String, crate::simulation::SimStatus, Option)> { + /// List all simulation sessions with their ID, spec_id, status, scenario, and channels. + pub fn list_sim_sessions( + &self, + ) -> Vec<( + String, + String, + crate::simulation::SimStatus, + Option, + Vec, + )> { self.sim_sessions .lock() .iter() - .map(|(id, s)| (id.clone(), s.status.clone(), s.scenario.clone())) + .map(|(id, s)| { + ( + id.clone(), + s.spec_id.clone(), + s.status.clone(), + s.scenario.clone(), + s.channels.clone(), + ) + }) .collect() } diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index d71b5b3..ce63151 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -224,3 +224,82 @@ pub struct DeleteAnnotationParams { #[schemars(description = "ID of the annotation to delete")] pub annotation_id: String, } + +// -- Simulation tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimCreateSessionParams { + #[schemars(description = "ID of the specification to simulate")] + pub spec_id: String, + #[schemars( + description = "Node ID to focus the simulation on. Determines which subtree of the spec is loaded into the simulation agent's context." + )] + pub focus_node_id: String, + #[schemars( + description = "Which output channels to activate. Array of: 'ui', 'audio', 'network', 'errors', 'logs'. Default: ['ui']" + )] + pub channels: Option>, + #[schemars( + description = "Optional scenario description. The simulation starts from the described state instead of the default initial state. Example: 'The user has already logged in and navigated to the settings page.'" + )] + pub scenario: Option, + #[schemars(description = "AI model to use: 'opus', 'sonnet', or 'haiku' (default: 'sonnet')")] + pub model: Option, + #[schemars( + description = "If true, loads the entire spec into context instead of just the focus node's subtree. Useful for small specs. Default: false" + )] + pub whole_spec: Option, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimSessionIdParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimSendInputParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Array of key names to simulate (e.g. ['a', 'b', 'Enter', 'Tab', 'Escape']). Use standard key names." + )] + pub keys: Vec, + #[schemars( + description = "Raw text equivalent of the key input (e.g. 'ab\\n'). This is what the simulated application receives as text input." + )] + pub raw_text: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimAskReportParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Question about observed simulation behavior. Example: 'Why does the login form show an inline error instead of a modal?'" + )] + pub question: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimUpdateScenarioParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "New scenario description. The simulation agent will re-render all channels as if starting from this scenario." + )] + pub scenario: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimGetChannelsParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Optional: only return these channels (e.g. ['ui']). Default: return all active channels." + )] + pub channels: Option>, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimListSessionsParams {} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 80b1960..d5520eb 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -898,6 +898,447 @@ impl SpecForestServer { "Annotation deleted".to_string(), )])) } + + // --- Simulation tools --- + + #[tool(description = "Create a new simulation session for a spec. Does not start the simulation — call sim_start after creation. Returns the session ID.")] + fn sim_create_session( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::{SimChannel, SimSession}; + + let channels: Vec = match ¶ms.channels { + Some(names) => { + let mut chs = Vec::new(); + for name in names { + match SimChannel::from_key(name) { + Some(ch) => chs.push(ch), + None => { + return Err(ErrorData::invalid_params( + format!("Invalid channel: '{name}'. Valid: ui, audio, network, errors, logs"), + None, + )); + } + } + } + chs + } + None => vec![SimChannel::Ui], + }; + + let model = params.model.unwrap_or_else(|| "sonnet".to_string()); + let whole_spec = params.whole_spec.unwrap_or(false); + let session_id = uuid::Uuid::new_v4().to_string(); + + let mut session = SimSession::new( + session_id.clone(), + params.spec_id.clone(), + Some(params.focus_node_id.clone()), + model, + channels.clone(), + params.scenario, + ); + session.whole_spec = whole_spec; + + self.state.set_sim_session(session); + + let channel_keys: Vec<&str> = channels.iter().map(|c| c.key()).collect(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": session_id, + "spec_id": params.spec_id, + "focus_node_id": params.focus_node_id, + "channels": channel_keys, + "status": "idle" + })) + .unwrap(), + )])) + } + + #[tool(description = "Start the initial simulation turn. The session must be in 'idle' status. Sets status to 'processing' and spawns the AI simulation in the background. Poll sim_get_status until status returns to 'idle', then read results with sim_get_channels.")] + fn sim_start( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + match &session.status { + SimStatus::Idle | SimStatus::Error(_) => {} + SimStatus::Processing => { + return Err(ErrorData::invalid_params("Session is already processing", None)); + } + SimStatus::Ended => { + return Err(ErrorData::invalid_params("Session has ended", None)); + } + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let whole_spec = session.whole_spec; + // Use session directory if set, otherwise check the spec's directory + let directory = session.directory.or_else(|| { + self.state + .db() + .get_spec(&session.spec_id) + .ok() + .and_then(|s| s.directory) + }); + + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_initial_turn( + state, + sid, + whole_spec, + directory, + ) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Send user input to a running simulation. Simulates keypresses and text input as if a user were interacting with the simulated application. The session must be 'idle' (previous turn complete). Sets status to 'processing'. Poll sim_get_status until idle, then read results with sim_get_channels.")] + fn sim_send_input( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::{SimInput, SimStatus}; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to send input", + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let input = SimInput { + keys: params.keys, + raw_text: params.raw_text, + }; + let input_json = serde_json::to_string(&input).unwrap(); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Ask the simulation agent to explain a specific behavior without changing the simulation state. Use this when you observe something unexpected and want to understand WHY it happens. The explanation will include spec node references. Session must be 'idle'. Poll sim_get_status until idle, then read the explanation with sim_get_report.")] + fn sim_ask_report( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to ask a report", + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let question = params.question; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_report_turn(state, sid, question).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Update the scenario for an existing simulation session. The simulation agent will re-render all channels based on the new scenario. Session must be 'idle'. Poll sim_get_status until idle, then read results with sim_get_channels.")] + fn sim_update_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if session.status != SimStatus::Idle { + return Err(ErrorData::invalid_params( + "Session must be idle to update scenario", + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let scenario = params.scenario; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_scenario_update(state, sid, scenario).await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing" + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the current status of a simulation session. Use this to poll until an async operation completes. Status values: 'idle' (ready for input or results available), 'processing' (turn in progress), 'error' (something went wrong), 'ended' (session terminated).")] + fn sim_get_status( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let (status_str, error) = match &session.status { + crate::simulation::SimStatus::Idle => ("idle", None), + crate::simulation::SimStatus::Processing => ("processing", None), + crate::simulation::SimStatus::Error(e) => ("error", Some(e.clone())), + crate::simulation::SimStatus::Ended => ("ended", None), + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "spec_id": session.spec_id, + "status": status_str, + "error": error, + "scenario": session.scenario, + "has_channel_contents": !session.channel_contents.is_empty(), + "has_pending_report": session.pending_report.is_some(), + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the current channel contents, decisions, and spec gaps from the most recent simulation turn. Returns all active channels at once. Each channel includes rendered text, spec node references, and spec gaps (ambiguities the spec should address). Also includes the decisions array and a deduplicated list of all spec gaps.")] + fn sim_get_channels( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimChannel; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + // Filter channels if requested + let filter: Option> = params.channels.map(|names| { + names + .iter() + .filter_map(|n| SimChannel::from_key(n).map(|c| c.key().to_string())) + .collect() + }); + + let mut channels_json = serde_json::Map::new(); + let mut all_spec_gaps: Vec = Vec::new(); + + for (key, content) in &session.channel_contents { + if let Some(ref f) = filter { + if !f.contains(key) { + continue; + } + } + all_spec_gaps.extend(content.spec_gaps.clone()); + channels_json.insert( + key.clone(), + serde_json::to_value(content).unwrap_or_default(), + ); + } + + // Collect spec gaps from decisions too + for decision in &session.decisions { + all_spec_gaps.extend(decision.spec_gaps.clone()); + } + all_spec_gaps.sort(); + all_spec_gaps.dedup(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": match &session.status { + crate::simulation::SimStatus::Idle => "idle", + crate::simulation::SimStatus::Processing => "processing", + crate::simulation::SimStatus::Error(_) => "error", + crate::simulation::SimStatus::Ended => "ended", + }, + "channels": channels_json, + "decisions": session.decisions, + "all_spec_gaps": all_spec_gaps, + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the pending report explanation from the most recent sim_ask_report call. Consumes the report (subsequent calls return null until a new report is requested). Returns the explanation with spec node references.")] + fn sim_get_report( + &self, + Parameters(params): Parameters, + ) -> Result { + // Verify session exists + if self.state.get_sim_session(¶ms.session_id).is_none() { + return Err(ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + )); + } + + let report = self.state.take_sim_pending_report(¶ms.session_id); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "report": report, + })) + .unwrap(), + )])) + } + + #[tool(description = "List all active simulation sessions with their status, scenario, and channels.")] + fn sim_list_sessions( + &self, + Parameters(_params): Parameters, + ) -> Result { + let sessions = self.state.list_sim_sessions(); + let list: Vec = sessions + .into_iter() + .map(|(id, spec_id, status, scenario, channels)| { + let status_str = match &status { + crate::simulation::SimStatus::Idle => "idle", + crate::simulation::SimStatus::Processing => "processing", + crate::simulation::SimStatus::Error(_) => "error", + crate::simulation::SimStatus::Ended => "ended", + }; + let channel_keys: Vec<&str> = channels.iter().map(|c| c.key()).collect(); + serde_json::json!({ + "session_id": id, + "spec_id": spec_id, + "status": status_str, + "scenario": scenario, + "channels": channel_keys, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "sessions": list, + })) + .unwrap(), + )])) + } + + #[tool(description = "End a simulation session and free its resources.")] + fn sim_end( + &self, + Parameters(params): Parameters, + ) -> Result { + if self.state.get_sim_session(¶ms.session_id).is_none() { + return Err(ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + )); + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = crate::simulation::SimStatus::Ended; + }); + self.state.remove_sim_session(¶ms.session_id); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "ended" + })) + .unwrap(), + )])) + } } #[tool_handler] From eb242647080d40f0b256222fc3166e6c6a29682b Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 09:51:22 +1100 Subject: [PATCH 068/100] feat: add explore_code param to sim_start for code-aware simulation mode When explore_code is true, sim_start loads the spec's project directory from the database so the simulation agent can read the codebase. Errors if the spec has no directory set. --- crates/spec-forest/src/tool_types.rs | 10 +++++++++ crates/spec-forest/src/tools.rs | 31 +++++++++++++++++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index ce63151..18839a5 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -257,6 +257,16 @@ pub struct SimSessionIdParams { pub session_id: String, } +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimStartParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "If true, enables code-aware mode by loading the project directory from the spec. The simulation agent will be able to read the codebase for context. Default: false" + )] + pub explore_code: Option, +} + #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct SimSendInputParams { #[schemars(description = "Simulation session ID")] diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index d5520eb..4ffdb7a 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -956,10 +956,10 @@ impl SpecForestServer { )])) } - #[tool(description = "Start the initial simulation turn. The session must be in 'idle' status. Sets status to 'processing' and spawns the AI simulation in the background. Poll sim_get_status until status returns to 'idle', then read results with sim_get_channels.")] + #[tool(description = "Start the initial simulation turn. The session must be in 'idle' status. Sets status to 'processing' and spawns the AI simulation in the background. Poll sim_get_status until status returns to 'idle', then read results with sim_get_channels. Set explore_code to true to enable code-aware mode (loads the spec's project directory so the agent can read the codebase).")] fn sim_start( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { use crate::simulation::SimStatus; @@ -983,6 +983,25 @@ impl SpecForestServer { } } + // If explore_code is true, look up the spec's directory from the db + let directory = if params.explore_code.unwrap_or(false) { + let dir = self + .state + .db() + .get_spec(&session.spec_id) + .ok() + .and_then(|s| s.directory); + if dir.is_none() { + return Err(ErrorData::invalid_params( + "explore_code is true but the spec has no directory set. Use set_directory first.", + None, + )); + } + dir + } else { + None + }; + self.state.update_sim_session(¶ms.session_id, |s| { s.status = SimStatus::Processing; }); @@ -990,14 +1009,6 @@ impl SpecForestServer { let state = self.state.clone(); let sid = params.session_id.clone(); let whole_spec = session.whole_spec; - // Use session directory if set, otherwise check the spec's directory - let directory = session.directory.or_else(|| { - self.state - .db() - .get_spec(&session.spec_id) - .ok() - .and_then(|s| s.directory) - }); tokio::spawn(async move { crate::simulation::orchestrate::orchestrate_initial_turn( From 7d4c707cd5d58bc101d1d45ad8fe5e229af9c11b Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 14:31:06 +1100 Subject: [PATCH 069/100] feat: pre-computed interaction tree for instant simulation responses Replace sequential request-response simulation with a pre-computed interaction tree. The AI now generates a tree of likely user interactions and their resulting outputs in a single call, so picking a predicted interaction is instant (no AI latency). Custom input falls back to AI generation. When the user reaches a leaf node, the next tree is pre-generated in the background. New MCP tool: sim_get_interactions returns predicted choices at the current position. sim_send_input now returns tree_hit/at_leaf fields. sim_create_session accepts tree_depth (1-3) and tree_branching (2-4). --- crates/spec-forest/src/simulation.rs | 7 +- .../spec-forest/src/simulation/orchestrate.rs | 204 ++++++++++++-- crates/spec-forest/src/simulation/prompt.rs | 263 ++++++++++++++---- crates/spec-forest/src/simulation/runner.rs | 168 ++++++++++- crates/spec-forest/src/simulation/session.rs | 17 +- crates/spec-forest/src/simulation/tree.rs | 239 ++++++++++++++++ crates/spec-forest/src/simulation/types.rs | 38 ++- crates/spec-forest/src/tool_types.rs | 8 + crates/spec-forest/src/tools.rs | 175 ++++++++++-- 9 files changed, 1032 insertions(+), 87 deletions(-) create mode 100644 crates/spec-forest/src/simulation/tree.rs diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 5ec727c..5c07a52 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -2,13 +2,16 @@ mod prompt; pub mod orchestrate; pub mod runner; pub mod session; +pub mod tree; pub mod types; pub use prompt::{ append_code_aware_section, build_initial_prompt, build_system_prompt, - build_system_prompt_whole_spec, + build_system_prompt_whole_spec, build_system_prompt_whole_spec_with_tree, + build_system_prompt_with_tree, build_tree_output_format, build_tree_resume_prompt, }; pub use session::{SimChannel, SimSession, SimStatus}; pub use types::{ - ChannelContent, Decision, NodeRef, SimInput, SimReport, SimReportResponse, SimResponse, + ChannelContent, Decision, NodeRef, PredictedInteraction, SimInput, SimReport, + SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, }; diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs index 3245d66..c03c1f5 100644 --- a/crates/spec-forest/src/simulation/orchestrate.rs +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -1,18 +1,20 @@ use std::sync::Arc; use crate::simulation; +use crate::simulation::tree; +use crate::simulation::types::SimInput; use crate::state::AppState; /// Run the initial simulation turn. /// Reads session config from AppState, builds prompts, calls the runner, -/// and updates the session with results or error. +/// and updates the session with the interaction tree or error. pub async fn orchestrate_initial_turn( state: Arc, session_id: String, consume_whole_spec: bool, directory: Option, ) { - let (spec_id, model, channels, focus_node_id, scenario) = { + let (spec_id, model, channels, focus_node_id, scenario, tree_depth, tree_branching) = { let session = match state.get_sim_session(&session_id) { Some(s) => s, None => return, @@ -23,6 +25,8 @@ pub async fn orchestrate_initial_turn( session.channels.clone(), session.root_node_id.clone().unwrap_or_default(), session.scenario.clone(), + session.tree_depth, + session.tree_branching, ) }; @@ -51,7 +55,14 @@ pub async fn orchestrate_initial_turn( let system_prompt = if consume_whole_spec { let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); - simulation::build_system_prompt_whole_spec(&channels, &focus_node, &all_nodes, &summary) + simulation::build_system_prompt_whole_spec_with_tree( + &channels, + &focus_node, + &all_nodes, + &summary, + tree_depth, + tree_branching, + ) } else { let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); @@ -68,13 +79,15 @@ pub async fn orchestrate_initial_turn( .filter(|n| !context_ids.contains(n.id.as_str())) .collect::>(); - simulation::build_system_prompt( + simulation::build_system_prompt_with_tree( &channels, &focus_node, &ancestors, &descendants, &summary, &other_roots, + tree_depth, + tree_branching, ) }; @@ -90,17 +103,23 @@ pub async fn orchestrate_initial_turn( .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); - match simulation::runner::start_sim_turn(&config, &initial_prompt).await { - Ok((claude_session_id, response)) => { + match simulation::runner::start_sim_tree_turn(&config, &initial_prompt).await { + Ok((claude_session_id, mut response)) => { + tree::assign_node_ids(&mut response.root); + let root_id = response.root.node_id.clone(); state.update_sim_session(&session_id, |s| { s.claude_session_id = Some(claude_session_id); - s.channel_contents = response.channels; - s.decisions = response.decisions; + // Populate channel_contents/decisions from root for backward compat + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.interaction_tree = Some(response.root); + s.current_node_id = Some(root_id.clone()); + s.navigation_path = vec![root_id]; s.status = simulation::SimStatus::Idle; }); } Err(e) => { - tracing::error!("Simulation initial turn failed: {e}"); + tracing::error!("Simulation initial tree turn failed: {e}"); state.update_sim_session(&session_id, |s| { s.status = simulation::SimStatus::Error(e.to_string()); }); @@ -109,10 +128,91 @@ pub async fn orchestrate_initial_turn( } /// Resume a simulation turn with user input. +/// +/// First checks the interaction tree for a matching predicted interaction. +/// If found, navigates instantly without calling the AI. +/// If not found, falls back to AI generation with path history replay. pub async fn orchestrate_resume_turn(state: Arc, session_id: String, input: String) { - let claude_sid = state - .get_sim_claude_session_id(&session_id) - .unwrap_or_default(); + // Parse the input as SimInput + let sim_input: SimInput = match serde_json::from_str(&input) { + Ok(i) => i, + Err(_) => { + // If it's not valid SimInput JSON (e.g. scenario update), fall through to AI + orchestrate_ai_resume(state, session_id, input).await; + return; + } + }; + + // Check tree for matching interaction + let tree_match = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + if let (Some(interaction_tree), Some(current_id)) = + (&session.interaction_tree, &session.current_node_id) + { + if let Some(current_node) = tree::find_node(interaction_tree, current_id) { + if let Some(idx) = tree::find_matching_interaction(current_node, &sim_input) { + let child = ¤t_node.interactions[idx].result; + Some(( + child.node_id.clone(), + child.channels.clone(), + child.decisions.clone(), + tree::is_leaf(child), + )) + } else { + None + } + } else { + None + } + } else { + None + } + }; + + match tree_match { + Some((child_id, channels, decisions, is_leaf)) => { + // Tree hit - navigate instantly + state.update_sim_session(&session_id, |s| { + s.channel_contents = channels; + s.decisions = decisions; + s.current_node_id = Some(child_id.clone()); + s.navigation_path.push(child_id); + s.status = simulation::SimStatus::Idle; + }); + + // If we reached a leaf, start pre-generating the next tree + if is_leaf { + let state_clone = state.clone(); + let sid_clone = session_id.clone(); + tokio::spawn(async move { + orchestrate_pregeneration(state_clone, sid_clone).await; + }); + } + } + None => { + // Tree miss - fall back to AI generation + orchestrate_ai_resume(state, session_id, input).await; + } + } +} + +/// Fall back to AI generation when input doesn't match the tree. +/// Replays path history and generates a new tree. +async fn orchestrate_ai_resume(state: Arc, session_id: String, input: String) { + let (claude_sid, tree_data) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.claude_session_id.clone().unwrap_or_default(), + session.interaction_tree.clone().zip(Some(session.navigation_path.clone())), + ) + }; if claude_sid.is_empty() { state.update_sim_session(&session_id, |s| { @@ -122,16 +222,34 @@ pub async fn orchestrate_resume_turn(state: Arc, session_id: String, i return; } - match simulation::runner::resume_sim_turn(&claude_sid, &input).await { - Ok(response) => { + // Build resume prompt with path history + let prompt = if let Some((ref interaction_tree, ref path)) = tree_data { + let history = tree::collect_path_history(interaction_tree, path); + simulation::build_tree_resume_prompt(&history, Some(&input)) + } else { + // No tree available, use simple resume + format!( + "{}\n\nRespond with a valid JSON interaction tree. \ + Simulate the application as an implementer would build it from the spec.", + input + ) + }; + + match simulation::runner::resume_sim_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_node_ids(&mut response.root); + let root_id = response.root.node_id.clone(); state.update_sim_session(&session_id, |s| { - s.channel_contents = response.channels; - s.decisions = response.decisions; + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.interaction_tree = Some(response.root); + s.current_node_id = Some(root_id.clone()); + s.navigation_path = vec![root_id]; s.status = simulation::SimStatus::Idle; }); } Err(e) => { - tracing::error!("Simulation resume turn failed: {e}"); + tracing::error!("Simulation AI resume turn failed: {e}"); state.update_sim_session(&session_id, |s| { s.status = simulation::SimStatus::Error(e.to_string()); }); @@ -139,6 +257,56 @@ pub async fn orchestrate_resume_turn(state: Arc, session_id: String, i } } +/// Pre-generate the next interaction tree when the user reaches a leaf node. +/// Runs as a background task. +async fn orchestrate_pregeneration(state: Arc, session_id: String) { + let (claude_sid, tree_data) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.claude_session_id.clone().unwrap_or_default(), + session.interaction_tree.clone().zip(Some(session.navigation_path.clone())), + ) + }; + + if claude_sid.is_empty() { + return; + } + + let prompt = if let Some((ref interaction_tree, ref path)) = tree_data { + let history = tree::collect_path_history(interaction_tree, path); + simulation::build_tree_resume_prompt(&history, None) + } else { + return; + }; + + tracing::info!(session_id = %session_id, "Starting tree pre-generation"); + + match simulation::runner::resume_sim_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_node_ids(&mut response.root); + let root_id = response.root.node_id.clone(); + state.update_sim_session(&session_id, |s| { + // Only update if user is still at the leaf (hasn't sent new input) + if s.status == simulation::SimStatus::Idle { + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.interaction_tree = Some(response.root); + s.current_node_id = Some(root_id.clone()); + s.navigation_path = vec![root_id]; + } + }); + tracing::info!(session_id = %session_id, "Tree pre-generation complete"); + } + Err(e) => { + tracing::warn!(session_id = %session_id, "Tree pre-generation failed: {e}"); + // Don't set error status - pre-generation failure is non-fatal + } + } +} + /// Resume a simulation turn with a report question. /// Stores the report explanation without replacing channel contents. pub async fn orchestrate_report_turn(state: Arc, session_id: String, question: String) { @@ -183,5 +351,5 @@ pub async fn orchestrate_scenario_update( let input = format!( "SCENARIO UPDATE: The simulation scenario has changed. The new scenario is: {scenario}" ); - orchestrate_resume_turn(state, session_id, input).await; + orchestrate_ai_resume(state, session_id, input).await; } diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index a86ace6..3ebc5d4 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -1,15 +1,19 @@ use super::session::SimChannel; +use super::types::{SimInput, SimTreeNode}; use crate::Node; use spec_forest_db::SpecSummary; /// Build the system prompt for a simulation agent. /// /// The prompt instructs the agent on: -/// - JSON envelope output format +/// - JSON envelope output format (flat or tree based on tree_config) /// - Channel semantics and which channels are active /// - Spec node referencing conventions /// - Input format (batched keypresses) /// - Focus node context with ancestors and descendants +/// +/// When `tree_config` is Some((depth, branching)), the output format switches +/// to the interaction tree format instead of flat JSON. pub fn build_system_prompt( channels: &[SimChannel], focus_node: &Node, @@ -17,6 +21,32 @@ pub fn build_system_prompt( descendants: &[Node], summary: &SpecSummary, other_roots: &[Node], +) -> String { + build_system_prompt_inner(channels, focus_node, ancestors, descendants, summary, other_roots, None) +} + +/// Build the system prompt with tree output format enabled. +pub fn build_system_prompt_with_tree( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_depth: u8, + tree_branching: u8, +) -> String { + build_system_prompt_inner(channels, focus_node, ancestors, descendants, summary, other_roots, Some((tree_depth, tree_branching))) +} + +fn build_system_prompt_inner( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_config: Option<(u8, u8)>, ) -> String { let channel_list = channels .iter() @@ -139,28 +169,7 @@ Available tools: If unsure whether the spec covers something, search the spec with tools first. If the spec does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. -## Output Format -Every response must be a JSON object with this schema: -{{ - "channels": {{ - "": {{ - "text": "content with optional [^N] references", - "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["one entry per high-entropy decision in this channel"] - }} - }}, - "decisions": [ - {{ - "description": "What you decided to do and why", - "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["any high-entropy decision behind this choice"] - }} - ] -}} - -Active channels: {channel_list} - -You MUST include an entry for each active channel in every response. +{output_format} ## Channel Semantics - "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would @@ -259,7 +268,10 @@ Simulate how the application would respond to these inputs based on the spec."#, } else { other_roots_section }, - channel_list = channel_list, + output_format = match tree_config { + Some((depth, branching)) => build_tree_output_format(depth, branching, &channel_list), + None => build_flat_output_format(&channel_list), + }, ) } @@ -272,6 +284,28 @@ pub fn build_system_prompt_whole_spec( focus_node: &Node, all_nodes: &[Node], summary: &SpecSummary, +) -> String { + build_system_prompt_whole_spec_inner(channels, focus_node, all_nodes, summary, None) +} + +/// Build the whole-spec system prompt with tree output format enabled. +pub fn build_system_prompt_whole_spec_with_tree( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_depth: u8, + tree_branching: u8, +) -> String { + build_system_prompt_whole_spec_inner(channels, focus_node, all_nodes, summary, Some((tree_depth, tree_branching))) +} + +fn build_system_prompt_whole_spec_inner( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_config: Option<(u8, u8)>, ) -> String { let channel_list = channels .iter() @@ -356,28 +390,7 @@ Available tools: If unsure whether the spec covers something, search the spec with tools first. If the spec does cover it, cite the node. If it does not and the decision is high-entropy, add a spec_gap. -## Output Format -Every response must be a JSON object with this schema: -{{ - "channels": {{ - "": {{ - "text": "content with optional [^N] references", - "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["one entry per high-entropy decision in this channel"] - }} - }}, - "decisions": [ - {{ - "description": "What you decided to do and why", - "refs": [{{"marker": "[^1]", "node_id": "uuid"}}], - "spec_gaps": ["any high-entropy decision behind this choice"] - }} - ] -}} - -Active channels: {channel_list} - -You MUST include an entry for each active channel in every response. +{output_format} ## Channel Semantics - "ui": Unicode/ASCII art rendering of the simulated interface as a real implementer would @@ -466,7 +479,10 @@ Simulate how the application would respond to these inputs based on the spec."#, answered = summary.answered_count, unanswered = summary.unanswered_count, needs_review = summary.needs_review_count, - channel_list = channel_list, + output_format = match tree_config { + Some((depth, branching)) => build_tree_output_format(depth, branching, &channel_list), + None => build_flat_output_format(&channel_list), + }, ) } @@ -530,3 +546,156 @@ directory is set to the project root. context. Use Glob and Grep to find relevant files efficiently."# ) } + +/// Build the flat (non-tree) output format section for system prompts. +fn build_flat_output_format(channel_list: &str) -> String { + format!( + r#"## Output Format +Every response must be a JSON object with this schema: +{{{{ + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ] +}}}} + +Active channels: {channel_list} + +You MUST include an entry for each active channel in every response."#, + channel_list = channel_list, + ) +} + +/// Build the tree output format section for system prompts. +/// +/// Instructs the AI to return a tree of interaction nodes instead of a flat response. +pub fn build_tree_output_format(depth: u8, branching: u8, channel_list: &str) -> String { + format!( + r#"## Output Format — Interaction Tree +Every response must be a JSON object containing an interaction tree. The tree pre-computes +the most likely user interactions and their resulting simulation states. + +Schema: +{{{{ + "root": {{{{ + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ], + "interactions": [ + {{{{ + "label": "Short description of user action (e.g., Click Login)", + "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}, + "result": {{{{ + "channels": {{{{ ... }}}}, + "decisions": [...], + "interactions": [...] + }}}} + }}}} + ] + }}}} +}}}} + +Active channels: {channel_list} + +## Interaction Tree Rules +1. Generate {depth} levels of interactions (the root is level 0, its children are level 1, etc.). +2. Each non-leaf node should have {branching} predicted interactions — the most likely user actions. +3. Leaf nodes (at maximum depth) MUST have an empty "interactions" array. +4. Each predicted interaction must include: + - **label**: A concise, human-readable description of the action (shown as a choice in the UI). + - **input**: The exact keys and raw_text the user would send for this action. + - **result**: The complete simulation state after that interaction — with all active channels. +5. Choose interactions that represent the MOST LIKELY user actions at each state. + Prioritize actions that exercise different parts of the spec and cover distinct user paths. +6. Every node (root and all children) must include entries for ALL active channels. +7. Every node must include a non-empty decisions array. +8. The [^N] marker namespace is PER NODE — each node's refs are self-contained."#, + channel_list = channel_list, + depth = depth, + branching = branching, + ) +} + +/// Build a resume prompt that replays the user's path through the previous tree +/// and optionally includes a custom input for a tree miss. +pub fn build_tree_resume_prompt( + history: &[(&SimInput, &SimTreeNode)], + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !history.is_empty() { + prompt.push_str( + "The user navigated through these interactions since the last generation:\n\n", + ); + for (i, (input, node)) in history.iter().enumerate() { + // Summarize what the user did and what state resulted + let ui_summary = node + .channels + .get("ui") + .map(|c| { + // Take first 200 chars of UI for context + let text = &c.text; + if text.len() > 200 { + format!("{}...", &text[..text.floor_char_boundary(200)]) + } else { + text.clone() + } + }) + .unwrap_or_default(); + + prompt.push_str(&format!( + "{}. User input: keys={:?}, raw_text={:?}\n Resulting UI state: {}\n\n", + i + 1, + input.keys, + input.raw_text, + ui_summary, + )); + } + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "Now the user provided a custom input that was NOT one of the predicted interactions:\n{}\n\n", + input + )); + } + None => { + prompt.push_str( + "The user has reached the end of the predicted interaction tree.\n\n", + ); + } + } + + prompt.push_str( + "Generate the next interaction tree from the current state. \ + Respond ONLY with a valid JSON object matching the interaction tree format. \ + Simulate the application as an implementer would build it from the spec. \ + Cite spec nodes that drive specific behaviors. Only flag spec_gaps for high-entropy \ + decisions where the spec is genuinely ambiguous — not for obvious implementation details.", + ); + + prompt +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index d443587..8b9eb6e 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,4 +1,4 @@ -use super::types::{SimReportResponse, SimResponse}; +use super::types::{SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse}; use std::error::Error; use std::time::Duration; @@ -228,6 +228,172 @@ pub async fn resume_sim_report_turn( parse_sim_report_response(&response_text) } +/// Start the first simulation turn with tree output. Returns (claude_session_id, tree_response). +pub async fn start_sim_tree_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, SimTreeResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("json") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting simulation tree turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, session_id) = extract_cli_result(&raw_output)?; + tracing::info!( + response_chars = response_text.len(), + "Simulation initial tree turn complete" + ); + + let response = parse_sim_tree_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing simulation session with tree output. +pub async fn resume_sim_tree_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("json") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming simulation tree turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, _) = extract_cli_result(&raw_output)?; + tracing::info!( + response_chars = response_text.len(), + "Simulation resume tree turn complete" + ); + + parse_sim_tree_response(&response_text) +} + +/// Parse the agent's text response into a SimTreeResponse. +/// +/// Falls back to wrapping a flat SimResponse in a single-node tree +/// if tree parsing fails but flat parsing succeeds. +fn parse_sim_tree_response(text: &str) -> Result> { + let trimmed = text.trim(); + + // Try direct tree parse first + if let Ok(response) = serde_json::from_str::(trimmed) { + return Ok(response); + } + + // Try extracting from markdown code fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = &trimmed[start + 7..start + 7 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try extracting from plain code fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = &trimmed[start + 4..start + 4 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try finding first { to last } + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + if start < end { + let json_str = &trimmed[start..=end]; + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Fallback: try parsing as flat SimResponse and wrap in single-node tree + if let Ok(flat) = parse_sim_response(trimmed) { + tracing::warn!("Tree parse failed, fell back to flat SimResponse"); + return Ok(SimTreeResponse { + root: SimTreeNode { + node_id: String::new(), + channels: flat.channels, + decisions: flat.decisions, + interactions: vec![], + }, + }); + } + + Err(format!( + "Failed to parse simulation tree response as JSON. Raw response:\n{}", + &trimmed[..trimmed.floor_char_boundary(500)] + ) + .into()) +} + /// Parse the agent's text response into a SimResponse JSON envelope. /// /// The agent should return valid JSON, but we try to extract it from diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 1dc491a..87a4c46 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,4 +1,4 @@ -use super::types::{ChannelContent, Decision, SimReportResponse}; +use super::types::{ChannelContent, Decision, SimReportResponse, SimTreeNode}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -87,6 +87,16 @@ pub struct SimSession { pub whole_spec: bool, /// Optional project directory for code-aware simulation. pub directory: Option, + /// The current interaction tree (set after each AI generation). + pub interaction_tree: Option, + /// Current position in the tree (node_id of the node the user is viewing). + pub current_node_id: Option, + /// Path of node_ids from root to current position (for history replay). + pub navigation_path: Vec, + /// Tree depth to request from AI (number of interaction levels). + pub tree_depth: u8, + /// Number of predicted interactions per node. + pub tree_branching: u8, } impl SimSession { @@ -112,6 +122,11 @@ impl SimSession { scenario, whole_spec: false, directory: None, + interaction_tree: None, + current_node_id: None, + navigation_path: Vec::new(), + tree_depth: 2, + tree_branching: 3, } } } diff --git a/crates/spec-forest/src/simulation/tree.rs b/crates/spec-forest/src/simulation/tree.rs new file mode 100644 index 0000000..de83c80 --- /dev/null +++ b/crates/spec-forest/src/simulation/tree.rs @@ -0,0 +1,239 @@ +use super::types::{SimInput, SimTreeNode}; +use uuid::Uuid; + +/// Recursively assign unique node IDs to every node in the tree. +/// Called after parsing the AI response (the AI does not produce IDs). +pub fn assign_node_ids(node: &mut SimTreeNode) { + node.node_id = Uuid::new_v4().to_string(); + for interaction in &mut node.interactions { + assign_node_ids(&mut interaction.result); + } +} + +/// Find a node by ID anywhere in the tree. +pub fn find_node<'a>(tree: &'a SimTreeNode, node_id: &str) -> Option<&'a SimTreeNode> { + if tree.node_id == node_id { + return Some(tree); + } + for interaction in &tree.interactions { + if let Some(found) = find_node(&interaction.result, node_id) { + return Some(found); + } + } + None +} + +/// Find which predicted interaction matches the given user input. +/// Returns the index into `node.interactions` if found. +/// +/// Matching strategy: +/// 1. Exact `raw_text` match (after trimming) +/// 2. Exact `keys` match +/// 3. Case-insensitive `raw_text` match +pub fn find_matching_interaction(node: &SimTreeNode, input: &SimInput) -> Option { + let input_text = input.raw_text.trim(); + + // Exact raw_text match + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.raw_text.trim() == input_text { + return Some(i); + } + } + + // Exact keys match + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.keys == input.keys { + return Some(i); + } + } + + // Case-insensitive raw_text match + let input_lower = input_text.to_lowercase(); + for (i, interaction) in node.interactions.iter().enumerate() { + if interaction.input.raw_text.trim().to_lowercase() == input_lower { + return Some(i); + } + } + + None +} + +/// Check if a node is a leaf (no predicted interactions). +pub fn is_leaf(node: &SimTreeNode) -> bool { + node.interactions.is_empty() +} + +/// Reconstruct the user's journey through the tree for replaying to the AI. +/// Given the tree and the path of node_ids from root to current position, +/// returns the sequence of (input, resulting_node) pairs. +pub fn collect_path_history<'a>( + tree: &'a SimTreeNode, + path: &[String], +) -> Vec<(&'a SimInput, &'a SimTreeNode)> { + let mut history = Vec::new(); + let mut current = tree; + + // path[0] is the root, so we start from path[1..] which are child node IDs + for target_id in path.iter().skip(1) { + let mut found = false; + for interaction in ¤t.interactions { + if interaction.result.node_id == *target_id { + history.push((&interaction.input, &interaction.result)); + current = &interaction.result; + found = true; + break; + } + } + if !found { + break; + } + } + + history +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::types::{ChannelContent, PredictedInteraction, SimTreeNode}; + use std::collections::HashMap; + + fn make_leaf(text: &str) -> SimTreeNode { + let mut channels = HashMap::new(); + channels.insert( + "ui".to_string(), + ChannelContent { + text: text.to_string(), + refs: vec![], + spec_gaps: vec![], + }, + ); + SimTreeNode { + node_id: String::new(), + channels, + decisions: vec![], + interactions: vec![], + } + } + + fn make_tree() -> SimTreeNode { + SimTreeNode { + node_id: String::new(), + channels: HashMap::new(), + decisions: vec![], + interactions: vec![ + PredictedInteraction { + label: "Click Login".to_string(), + input: SimInput { + keys: vec!["Enter".to_string()], + raw_text: "\n".to_string(), + }, + result: make_leaf("Login form"), + }, + PredictedInteraction { + label: "Type hello".to_string(), + input: SimInput { + keys: vec![ + "h".to_string(), + "e".to_string(), + "l".to_string(), + "l".to_string(), + "o".to_string(), + ], + raw_text: "hello".to_string(), + }, + result: make_leaf("Search results"), + }, + ], + } + } + + #[test] + fn test_assign_node_ids() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + assert!(!tree.node_id.is_empty()); + assert!(!tree.interactions[0].result.node_id.is_empty()); + assert!(!tree.interactions[1].result.node_id.is_empty()); + // All IDs should be unique + assert_ne!(tree.node_id, tree.interactions[0].result.node_id); + assert_ne!(tree.node_id, tree.interactions[1].result.node_id); + assert_ne!( + tree.interactions[0].result.node_id, + tree.interactions[1].result.node_id + ); + } + + #[test] + fn test_find_node() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let child_id = tree.interactions[1].result.node_id.clone(); + + let found = find_node(&tree, &child_id).unwrap(); + assert_eq!(found.channels["ui"].text, "Search results"); + + assert!(find_node(&tree, "nonexistent").is_none()); + } + + #[test] + fn test_find_matching_interaction_exact_text() { + let tree = make_tree(); + let input = SimInput { + keys: vec![], + raw_text: "hello".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(1)); + } + + #[test] + fn test_find_matching_interaction_exact_keys() { + let tree = make_tree(); + let input = SimInput { + keys: vec!["Enter".to_string()], + raw_text: "different".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(0)); + } + + #[test] + fn test_find_matching_interaction_case_insensitive() { + let tree = make_tree(); + let input = SimInput { + keys: vec![], + raw_text: "HELLO".to_string(), + }; + assert_eq!(find_matching_interaction(&tree, &input), Some(1)); + } + + #[test] + fn test_find_matching_interaction_no_match() { + let tree = make_tree(); + let input = SimInput { + keys: vec!["Tab".to_string()], + raw_text: "something else".to_string(), + }; + assert!(find_matching_interaction(&tree, &input).is_none()); + } + + #[test] + fn test_is_leaf() { + let tree = make_tree(); + assert!(!is_leaf(&tree)); + assert!(is_leaf(&tree.interactions[0].result)); + } + + #[test] + fn test_collect_path_history() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + let child_id = tree.interactions[0].result.node_id.clone(); + + let path = vec![root_id, child_id]; + let history = collect_path_history(&tree, &path); + assert_eq!(history.len(), 1); + assert_eq!(history[0].0.raw_text, "\n"); + assert_eq!(history[0].1.channels["ui"].text, "Login form"); + } +} diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index d10b0af..0fa1ebf 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -56,12 +56,48 @@ pub struct Decision { } /// Structured input sent to the agent for each user interaction turn. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SimInput { pub keys: Vec, pub raw_text: String, } +/// A node in the pre-computed interaction tree. +/// +/// Each node contains the same output as `SimResponse` (channels + decisions), +/// plus a set of predicted next interactions with pre-computed results. +/// Leaf nodes have an empty `interactions` vec. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimTreeNode { + /// Unique identifier assigned server-side after parsing (not produced by AI). + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the simulation. + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, + /// Predicted next interactions with pre-computed results. + #[serde(default)] + pub interactions: Vec, +} + +/// A predicted user interaction that leads to a child tree node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PredictedInteraction { + /// Human-readable label for the interaction (e.g., "Click Login", "Type email"). + pub label: String, + /// The input this interaction represents. + pub input: SimInput, + /// The pre-computed simulation output if the user takes this interaction. + pub result: SimTreeNode, +} + +/// The full tree response the AI produces in a single generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimTreeResponse { + pub root: SimTreeNode, +} + /// Structured input for behavior reporting. #[derive(Debug, Clone, Serialize)] pub struct SimReport { diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 18839a5..9a9ca9e 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -249,6 +249,14 @@ pub struct SimCreateSessionParams { description = "If true, loads the entire spec into context instead of just the focus node's subtree. Useful for small specs. Default: false" )] pub whole_spec: Option, + #[schemars( + description = "Interaction tree depth: how many levels of predicted interactions to pre-compute. Range 1-3. Default: 2" + )] + pub tree_depth: Option, + #[schemars( + description = "Interaction tree branching factor: how many predicted interactions per node. Range 2-4. Default: 3" + )] + pub tree_branching: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 4ffdb7a..afcfd73 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -929,6 +929,8 @@ impl SpecForestServer { let model = params.model.unwrap_or_else(|| "sonnet".to_string()); let whole_spec = params.whole_spec.unwrap_or(false); + let tree_depth = params.tree_depth.unwrap_or(2).clamp(1, 3); + let tree_branching = params.tree_branching.unwrap_or(3).clamp(2, 4); let session_id = uuid::Uuid::new_v4().to_string(); let mut session = SimSession::new( @@ -940,6 +942,8 @@ impl SpecForestServer { params.scenario, ); session.whole_spec = whole_spec; + session.tree_depth = tree_depth; + session.tree_branching = tree_branching; self.state.set_sim_session(session); @@ -1029,12 +1033,13 @@ impl SpecForestServer { )])) } - #[tool(description = "Send user input to a running simulation. Simulates keypresses and text input as if a user were interacting with the simulated application. The session must be 'idle' (previous turn complete). Sets status to 'processing'. Poll sim_get_status until idle, then read results with sim_get_channels.")] + #[tool(description = "Send user input to a running simulation. Simulates keypresses and text input. The session must be 'idle'. If the input matches a predicted interaction in the pre-computed tree, the response is instant (tree_hit=true, status=idle). Otherwise sets status to 'processing' for AI generation — poll sim_get_status until idle. Use sim_get_interactions first to see available predicted choices for instant response.")] fn sim_send_input( &self, Parameters(params): Parameters, ) -> Result { use crate::simulation::{SimInput, SimStatus}; + use crate::simulation::tree; let session = self .state @@ -1053,29 +1058,86 @@ impl SpecForestServer { )); } - self.state.update_sim_session(¶ms.session_id, |s| { - s.status = SimStatus::Processing; - }); - let input = SimInput { keys: params.keys, raw_text: params.raw_text, }; + + // Check tree for matching interaction first + let tree_hit = if let (Some(interaction_tree), Some(current_id)) = + (&session.interaction_tree, &session.current_node_id) + { + if let Some(current_node) = tree::find_node(interaction_tree, current_id) { + tree::find_matching_interaction(current_node, &input).is_some() + } else { + false + } + } else { + false + }; + let input_json = serde_json::to_string(&input).unwrap(); - let state = self.state.clone(); - let sid = params.session_id.clone(); - tokio::spawn(async move { - crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json).await; - }); + if tree_hit { + // The orchestrate_resume_turn will handle the tree navigation synchronously + // but we still spawn it to keep the interface consistent + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&serde_json::json!({ - "session_id": params.session_id, - "status": "processing" - })) - .unwrap(), - )])) + let state = self.state.clone(); + let sid = params.session_id.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json) + .await; + }); + + // Check if the matched child is a leaf + let updated_session = self.state.get_sim_session(¶ms.session_id); + let at_leaf = updated_session + .as_ref() + .and_then(|s| { + s.interaction_tree.as_ref().and_then(|t| { + s.current_node_id + .as_ref() + .and_then(|id| tree::find_node(t, id)) + .map(tree::is_leaf) + }) + }) + .unwrap_or(false); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "tree_hit": true, + "at_leaf": at_leaf, + })) + .unwrap(), + )])) + } else { + // Tree miss — need AI generation + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_resume_turn(state, sid, input_json) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "tree_hit": false, + "at_leaf": false, + })) + .unwrap(), + )])) + } } #[tool(description = "Ask the simulation agent to explain a specific behavior without changing the simulation state. Use this when you observe something unexpected and want to understand WHY it happens. The explanation will include spec node references. Session must be 'idle'. Poll sim_get_status until idle, then read the explanation with sim_get_report.")] @@ -1188,6 +1250,30 @@ impl SpecForestServer { crate::simulation::SimStatus::Ended => ("ended", None), }; + let at_leaf = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + .map(crate::simulation::tree::is_leaf) + }) + .unwrap_or(false); + + let has_interactions = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + .map(|n| !n.interactions.is_empty()) + }) + .unwrap_or(false); + Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&serde_json::json!({ "session_id": params.session_id, @@ -1197,6 +1283,8 @@ impl SpecForestServer { "scenario": session.scenario, "has_channel_contents": !session.channel_contents.is_empty(), "has_pending_report": session.pending_report.is_some(), + "at_leaf": at_leaf, + "has_interactions": has_interactions, })) .unwrap(), )])) @@ -1267,6 +1355,59 @@ impl SpecForestServer { )])) } + #[tool(description = "Get the predicted interactions available at the current position in the interaction tree. Returns labels and inputs for each predicted choice. Sending input matching one of these predictions will result in an instant response (no AI latency). Returns empty if at a leaf node or no tree is available.")] + fn sim_get_interactions( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let interactions: Vec = session + .interaction_tree + .as_ref() + .and_then(|t| { + session + .current_node_id + .as_ref() + .and_then(|id| crate::simulation::tree::find_node(t, id)) + }) + .map(|node| { + node.interactions + .iter() + .map(|i| { + serde_json::json!({ + "label": i.label, + "input": { + "keys": i.input.keys, + "raw_text": i.input.raw_text, + } + }) + }) + .collect() + }) + .unwrap_or_default(); + + let at_leaf = interactions.is_empty() + && session.interaction_tree.is_some(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "interactions": interactions, + "at_leaf": at_leaf, + })) + .unwrap(), + )])) + } + #[tool(description = "Get the pending report explanation from the most recent sim_ask_report call. Consumes the report (subsequent calls return null until a new report is requested). Returns the explanation with spec node references.")] fn sim_get_report( &self, From ed88960a0978c8d246f7a218d0feef9e17f590ee Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 15:52:40 +1100 Subject: [PATCH 070/100] feat: show predicted interactions in TUI simulation view Add an interactions panel between decisions and the input area that displays the pre-computed interaction choices from the tree. Users can navigate with arrow keys and press Enter to select, getting an instant response without AI latency. The 'i' key enters custom input mode for interactions not in the tree. --- crates/spec-forest-tui/src/action.rs | 4 ++ crates/spec-forest-tui/src/app.rs | 63 ++++++++++++++++++++- crates/spec-forest-tui/src/input.rs | 4 ++ crates/spec-forest-tui/src/simulation.rs | 7 ++- crates/spec-forest-tui/src/ui/help_popup.rs | 8 ++- crates/spec-forest-tui/src/ui/simulation.rs | 57 ++++++++++++++++++- crates/spec-forest/src/state.rs | 17 ++++++ 7 files changed, 155 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index c712f0f..73e4d54 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -131,6 +131,10 @@ pub enum Action { SimEditScenario, SimOpenRef(String), SimRefDigit(char), + SimSelectInteraction(usize), + SimInteractionUp, + SimInteractionDown, + SimConfirmInteraction, SimCloseOverlay, SimMouseClick { column: u16, row: u16 }, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a75746c..c1f2309 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -894,6 +894,9 @@ impl App { Action::SimSubmitInput => { self.submit_sim_input().await; } + Action::SimSelectInteraction(idx) => { + self.select_sim_interaction(idx).await; + } Action::SimCycleChannel => { if let Some(ref mut sim) = self.sim_state { sim.cycle_channel(); @@ -954,6 +957,30 @@ impl App { } } } + Action::SimInteractionUp => { + if let Some(ref mut sim) = self.sim_state { + if !sim.interactions.is_empty() && sim.selected_interaction > 0 { + sim.selected_interaction -= 1; + } + } + } + Action::SimInteractionDown => { + if let Some(ref mut sim) = self.sim_state { + if !sim.interactions.is_empty() + && sim.selected_interaction < sim.interactions.len() - 1 + { + sim.selected_interaction += 1; + } + } + } + Action::SimConfirmInteraction => { + let idx = self + .sim_state + .as_ref() + .map(|s| s.selected_interaction) + .unwrap_or(0); + self.select_sim_interaction(idx).await; + } Action::SimRefDigit(c) => { if let Some(ref mut sim) = self.sim_state { sim.ref_digit_buffer.push(c); @@ -1106,6 +1133,8 @@ impl App { ); 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(); @@ -2194,7 +2223,7 @@ impl App { refs: report.refs, }); } else { - // Normal turn: pull latest channel contents and decisions + // Normal turn: pull latest channel contents, decisions, and interactions if let Some(contents) = self.state.get_sim_channel_contents(&session_id) { @@ -2202,6 +2231,9 @@ impl App { } sim.decisions = self.state.get_sim_decisions(&session_id); + sim.interactions = + self.state.get_sim_interactions(&session_id); + sim.selected_interaction = 0; } } } @@ -2407,6 +2439,35 @@ impl App { }); } } + + async fn select_sim_interaction(&mut self, idx: usize) { + let (session_id, input_json) = match self.sim_state.as_mut() { + Some(sim) if !sim.processing => { + if idx >= sim.interactions.len() { + return; + } + let interaction = &sim.interactions[idx]; + let input = spec_forest::simulation::SimInput { + keys: interaction.input.keys.clone(), + raw_text: interaction.input.raw_text.clone(), + }; + let json = serde_json::to_string(&input).unwrap_or_default(); + sim.processing = true; + (sim.session_id.clone(), json) + } + _ => return, + }; + + self.state.update_sim_session(&session_id, |s| { + s.status = spec_forest::simulation::SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = session_id; + tokio::spawn(async move { + crate::commands::run_sim_resume_turn(state, sid, input_json).await; + }); + } } pub fn truncate_str(s: &str, max: usize) -> &str { diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 1526664..35ddc6a 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -70,6 +70,10 @@ fn map_sim_normal_key(key: KeyCode) -> Action { KeyCode::Char('S') => Action::SimEditScenario, // Number keys buffer for multi-digit ref lookup (e.g. "11" for [^11]) KeyCode::Char(c @ '0'..='9') => Action::SimRefDigit(c), + // Arrow keys navigate predicted interactions, Enter selects + KeyCode::Up => Action::SimInteractionUp, + KeyCode::Down => Action::SimInteractionDown, + KeyCode::Enter => Action::SimConfirmInteraction, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index 744c2c7..b940ae3 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -1,4 +1,4 @@ -use spec_forest::simulation::{ChannelContent, SimChannel}; +use spec_forest::simulation::{ChannelContent, PredictedInteraction, SimChannel}; use std::collections::HashMap; /// A captured keystroke in simulation insert mode. @@ -61,6 +61,9 @@ pub struct SimulationState { pub scenario_input: String, pub channel_contents: HashMap, pub decisions: Vec, + /// Predicted interactions available at the current tree position. + pub interactions: Vec, + pub selected_interaction: usize, pub ref_digit_buffer: String, pub ref_digit_start_tick: Option, pub processing: bool, @@ -85,6 +88,8 @@ impl SimulationState { scenario_input: String::new(), channel_contents: HashMap::new(), decisions: Vec::new(), + interactions: Vec::new(), + selected_interaction: 0, ref_digit_buffer: String::new(), ref_digit_start_tick: None, processing: false, diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index c8811c1..a19b42f 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -147,8 +147,12 @@ fn help_sections(app: &App) -> Vec { match mode { crate::simulation::SimInputMode::Normal => vec![ HelpSection { - title: "Mode", - bindings: vec![("i", "Enter insert mode")], + title: "Interactions", + bindings: vec![ + ("↑/↓", "Select interaction"), + ("Enter", "Confirm interaction"), + ("i", "Custom input"), + ], }, HelpSection { title: "Channels & Layout", diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 54d2908..7ad6332 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -29,6 +29,14 @@ pub fn render(app: &App, frame: &mut Frame) { 0 }; + let has_interactions = !sim.interactions.is_empty(); + let interactions_height = if has_interactions { + // 1 line per interaction + 2 for border, capped at 6 + 2 + (sim.interactions.len().min(6) + 2) as u16 + } else { + 0 + }; + let mut constraints = vec![ Constraint::Length(1), // tab bar Constraint::Min(3), // channel content @@ -36,6 +44,9 @@ pub fn render(app: &App, frame: &mut Frame) { if has_decisions { constraints.push(Constraint::Length(decisions_height)); // decisions panel } + if has_interactions { + constraints.push(Constraint::Length(interactions_height)); // interactions panel + } constraints.push(Constraint::Length(3)); // input area constraints.push(Constraint::Length(1)); // status bar @@ -55,6 +66,10 @@ pub fn render(app: &App, frame: &mut Frame) { render_decisions_panel(sim, frame, chunks[idx]); idx += 1; } + if has_interactions { + render_interactions_panel(sim, frame, chunks[idx]); + idx += 1; + } render_input_area(app, frame, chunks[idx]); idx += 1; render_status_bar(app, frame, chunks[idx]); @@ -329,7 +344,11 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { )); } else if !sim.processing { let hint_items: &[(&str, &str)] = match sim.mode { - SimInputMode::Normal => &[("i", "Insert"), ("Tab", "Channel"), ("Esc", "Background"), ("?", "Help")], + SimInputMode::Normal => if sim.interactions.is_empty() { + &[("i", "Insert"), ("Tab", "Channel"), ("Esc", "Background"), ("?", "Help")] + } else { + &[("↑↓", "Select"), ("Enter", "Confirm"), ("i", "Custom"), ("Tab", "Channel"), ("Esc", "Background")] + }, SimInputMode::Insert => &[("Ctrl+S", "Send"), ("Esc", "Normal")], }; let badge_line = super::common::render_footer_line(hint_items, None); @@ -390,6 +409,42 @@ fn render_decisions_panel( frame.render_widget(paragraph, area); } +fn render_interactions_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + for (i, interaction) in sim.interactions.iter().enumerate() { + let is_selected = i == sim.selected_interaction; + let prefix = if is_selected { "▸ " } else { " " }; + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::from(Span::styled( + format!("{prefix}{}: {}", i + 1, interaction.label), + style, + ))); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Interactions (↑↓ select, Enter confirm, i for custom input) "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + fn render_ref_overlay( overlay: &crate::simulation::RefOverlay, frame: &mut Frame, diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index aab1d57..e1a38f9 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -523,6 +523,23 @@ impl AppState { .unwrap_or_default() } + /// Get the predicted interactions at the current tree position. + pub fn get_sim_interactions( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| { + let tree = s.interaction_tree.as_ref()?; + let current_id = s.current_node_id.as_ref()?; + let node = crate::simulation::tree::find_node(tree, current_id)?; + Some(node.interactions.clone()) + }) + .unwrap_or_default() + } + // --- MCP URL --- pub fn mcp_url(&self) -> Option { From ae8ec652028012e160e9b4af21e9666ea7e858e0 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 16:30:33 +1100 Subject: [PATCH 071/100] feat: deeper interaction tree with back navigation, eager pregeneration, and leaf interactions - Make PredictedInteraction.result optional so leaf-depth nodes can suggest interactions without pre-computed results - Change defaults to depth=4, branching=2 for deeper exploration (31 nodes) - Add Backspace back-navigation through the interaction tree - Eagerly pre-generate subtrees when the most likely path leads to a dead end, grafting results onto the existing tree instead of replacing it - Show spinner + "Expanding tree..." during background pregeneration - Dim shallow interactions with "(generates)" suffix in the interactions panel --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 37 ++++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/simulation.rs | 6 + crates/spec-forest-tui/src/ui/help_popup.rs | 1 + crates/spec-forest-tui/src/ui/simulation.rs | 53 ++++- .../spec-forest/src/simulation/orchestrate.rs | 189 +++++++++++++++--- crates/spec-forest/src/simulation/prompt.rs | 6 +- crates/spec-forest/src/simulation/session.rs | 14 +- crates/spec-forest/src/simulation/tree.rs | 76 +++++-- crates/spec-forest/src/simulation/types.rs | 4 +- crates/spec-forest/src/state.rs | 42 ++++ crates/spec-forest/src/tools.rs | 10 +- 13 files changed, 365 insertions(+), 75 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 73e4d54..537fffc 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -135,6 +135,7 @@ pub enum Action { SimInteractionUp, SimInteractionDown, SimConfirmInteraction, + SimNavigateBack, SimCloseOverlay, SimMouseClick { column: u16, row: u16 }, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index c1f2309..136e62d 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -981,6 +981,29 @@ impl App { .unwrap_or(0); self.select_sim_interaction(idx).await; } + Action::SimNavigateBack => { + if let Some(ref mut sim) = self.sim_state { + if sim.can_go_back && !sim.processing { + if let Some((_channels, _decisions)) = + self.state.sim_navigate_back(&sim.session_id) + { + // Refresh TUI state from the updated session + if let Some(contents) = + self.state.get_sim_channel_contents(&sim.session_id) + { + sim.channel_contents = contents; + } + sim.decisions = + self.state.get_sim_decisions(&sim.session_id); + sim.interactions = + self.state.get_sim_interactions(&sim.session_id); + sim.selected_interaction = 0; + sim.can_go_back = + self.state.get_sim_nav_depth(&sim.session_id) > 1; + } + } + } + } Action::SimRefDigit(c) => { if let Some(ref mut sim) = self.sim_state { sim.ref_digit_buffer.push(c); @@ -2210,6 +2233,18 @@ impl App { if let Some(status) = self.state.get_sim_session_status(&session_id) { match status { spec_forest::simulation::SimStatus::Idle => { + // Always sync pregenerating status (it's a background process) + let was_pregenerating = sim.pregenerating; + sim.pregenerating = + self.state.get_sim_pregenerating(&session_id); + + // If pregeneration just finished, refresh interactions + // (new branches may have been grafted onto the tree) + if was_pregenerating && !sim.pregenerating { + sim.interactions = + self.state.get_sim_interactions(&session_id); + } + if sim.processing { // Transition from processing to idle means turn completed sim.processing = false; @@ -2234,6 +2269,8 @@ impl App { sim.interactions = self.state.get_sim_interactions(&session_id); sim.selected_interaction = 0; + sim.can_go_back = + self.state.get_sim_nav_depth(&session_id) > 1; } } } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 35ddc6a..bea7e96 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -74,6 +74,7 @@ fn map_sim_normal_key(key: KeyCode) -> Action { KeyCode::Up => Action::SimInteractionUp, KeyCode::Down => Action::SimInteractionDown, KeyCode::Enter => Action::SimConfirmInteraction, + KeyCode::Backspace => Action::SimNavigateBack, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index b940ae3..c225848 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -64,6 +64,10 @@ pub struct SimulationState { /// Predicted interactions available at the current tree position. pub interactions: Vec, pub selected_interaction: usize, + /// Whether the user can navigate back (not at root). + pub can_go_back: bool, + /// Whether background pregeneration is in progress. + pub pregenerating: bool, pub ref_digit_buffer: String, pub ref_digit_start_tick: Option, pub processing: bool, @@ -90,6 +94,8 @@ impl SimulationState { decisions: Vec::new(), interactions: Vec::new(), selected_interaction: 0, + can_go_back: false, + pregenerating: false, ref_digit_buffer: String::new(), ref_digit_start_tick: None, processing: false, diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index a19b42f..ae2ee44 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -151,6 +151,7 @@ fn help_sections(app: &App) -> Vec { bindings: vec![ ("↑/↓", "Select interaction"), ("Enter", "Confirm interaction"), + ("Backspace", "Go back"), ("i", "Custom input"), ], }, diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 7ad6332..2acfdcd 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -337,21 +337,40 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { spans.push(Span::raw(" ")); + if sim.pregenerating { + spans.push(Span::styled( + format!(" {} Expanding tree... ", spinner_char(sim.tick)), + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(" ")); + } + if let Some(ref msg) = app.message { spans.push(Span::styled( format!(" {msg} "), Style::default().fg(Color::Red), )); } else if !sim.processing { - let hint_items: &[(&str, &str)] = match sim.mode { - SimInputMode::Normal => if sim.interactions.is_empty() { - &[("i", "Insert"), ("Tab", "Channel"), ("Esc", "Background"), ("?", "Help")] - } else { - &[("↑↓", "Select"), ("Enter", "Confirm"), ("i", "Custom"), ("Tab", "Channel"), ("Esc", "Background")] - }, - SimInputMode::Insert => &[("Ctrl+S", "Send"), ("Esc", "Normal")], + let hint_items: Vec<(&str, &str)> = match sim.mode { + SimInputMode::Normal => { + let mut items = Vec::new(); + if !sim.interactions.is_empty() { + items.extend_from_slice(&[("↑↓", "Select"), ("Enter", "Confirm"), ("i", "Custom")]); + } else { + items.push(("i", "Insert")); + } + if sim.can_go_back { + items.push(("Bksp", "Back")); + } + items.extend_from_slice(&[("Tab", "Channel"), ("Esc", "Background")]); + if sim.interactions.is_empty() { + items.push(("?", "Help")); + } + items + } + SimInputMode::Insert => vec![("Ctrl+S", "Send"), ("Esc", "Normal")], }; - let badge_line = super::common::render_footer_line(hint_items, None); + let badge_line = super::common::render_footer_line(&hint_items, None); spans.extend(badge_line.spans); } @@ -417,20 +436,34 @@ fn render_interactions_panel( let mut lines = Vec::new(); for (i, interaction) in sim.interactions.iter().enumerate() { let is_selected = i == sim.selected_interaction; + let is_shallow = interaction.result.is_none(); let prefix = if is_selected { "▸ " } else { " " }; + + let mut spans_line = vec![]; let style = if is_selected { Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD) + } else if is_shallow { + Style::default().fg(Color::DarkGray) } else { Style::default().fg(Color::White) }; - lines.push(Line::from(Span::styled( + spans_line.push(Span::styled( format!("{prefix}{}: {}", i + 1, interaction.label), style, - ))); + )); + + if is_shallow && !is_selected { + spans_line.push(Span::styled( + " (generates)", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM), + )); + } + + lines.push(Line::from(spans_line)); } let block = Block::default() diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs index c03c1f5..0b3d92e 100644 --- a/crates/spec-forest/src/simulation/orchestrate.rs +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -115,8 +115,12 @@ pub async fn orchestrate_initial_turn( s.interaction_tree = Some(response.root); s.current_node_id = Some(root_id.clone()); s.navigation_path = vec![root_id]; + s.tree_generation += 1; s.status = simulation::SimStatus::Idle; }); + + // Check if eager pregeneration is needed at the root + maybe_eager_pregen(&state, &session_id); } Err(e) => { tracing::error!("Simulation initial tree turn failed: {e}"); @@ -155,13 +159,19 @@ pub async fn orchestrate_resume_turn(state: Arc, session_id: String, i { if let Some(current_node) = tree::find_node(interaction_tree, current_id) { if let Some(idx) = tree::find_matching_interaction(current_node, &sim_input) { - let child = ¤t_node.interactions[idx].result; - Some(( - child.node_id.clone(), - child.channels.clone(), - child.decisions.clone(), - tree::is_leaf(child), - )) + match ¤t_node.interactions[idx].result { + Some(child) => Some(( + child.node_id.clone(), + child.channels.clone(), + child.decisions.clone(), + tree::is_leaf(child), + )), + None => { + // Shallow interaction — matched label/input but no pre-computed result. + // Fall through to AI resume. + None + } + } } else { None } @@ -174,7 +184,7 @@ pub async fn orchestrate_resume_turn(state: Arc, session_id: String, i }; match tree_match { - Some((child_id, channels, decisions, is_leaf)) => { + Some((child_id, channels, decisions, _is_leaf)) => { // Tree hit - navigate instantly state.update_sim_session(&session_id, |s| { s.channel_contents = channels; @@ -184,14 +194,8 @@ pub async fn orchestrate_resume_turn(state: Arc, session_id: String, i s.status = simulation::SimStatus::Idle; }); - // If we reached a leaf, start pre-generating the next tree - if is_leaf { - let state_clone = state.clone(); - let sid_clone = session_id.clone(); - tokio::spawn(async move { - orchestrate_pregeneration(state_clone, sid_clone).await; - }); - } + // Check if eager pregeneration is needed at the new position + maybe_eager_pregen(&state, &session_id); } None => { // Tree miss - fall back to AI generation @@ -245,8 +249,12 @@ async fn orchestrate_ai_resume(state: Arc, session_id: String, input: s.interaction_tree = Some(response.root); s.current_node_id = Some(root_id.clone()); s.navigation_path = vec![root_id]; + s.tree_generation += 1; s.status = simulation::SimStatus::Idle; }); + + // Check if eager pregeneration is needed at the new root + maybe_eager_pregen(&state, &session_id); } Err(e) => { tracing::error!("Simulation AI resume turn failed: {e}"); @@ -257,9 +265,100 @@ async fn orchestrate_ai_resume(state: Arc, session_id: String, input: } } -/// Pre-generate the next interaction tree when the user reaches a leaf node. +/// Check if the current node's most-likely interaction leads to a dead end +/// (leaf or shallow node). If so, and no pregeneration is already running, +/// spawn eager pregeneration targeting that child node. +fn maybe_eager_pregen(state: &Arc, session_id: &str) { + let pregen_info = { + let session = match state.get_sim_session(session_id) { + Some(s) => s, + None => return, + }; + + // Skip if pregeneration is already running + if session.pregen_target.is_some() { + return; + } + + let interaction_tree = match &session.interaction_tree { + Some(t) => t, + None => return, + }; + let current_id = match &session.current_node_id { + Some(id) => id, + None => return, + }; + let current_node = match tree::find_node(interaction_tree, current_id) { + Some(n) => n, + None => return, + }; + + // Check the first (most likely) interaction + let first = match current_node.interactions.first() { + Some(i) => i, + None => return, // No interactions at all — nothing to pregen + }; + + match &first.result { + None => { + // The most likely interaction is shallow (no result) — pregen from current node + // We'll generate a new tree continuing from the current position + Some(( + session.claude_session_id.clone().unwrap_or_default(), + current_id.clone(), + session.navigation_path.clone(), + session.tree_generation, + )) + } + Some(child) if tree::is_leaf(child) => { + // The most likely interaction leads to a leaf — pregen from that child + let mut path = session.navigation_path.clone(); + path.push(child.node_id.clone()); + Some(( + session.claude_session_id.clone().unwrap_or_default(), + child.node_id.clone(), + path, + session.tree_generation, + )) + } + _ => None, // First interaction has deep children, no need to pregen + } + }; + + if let Some((claude_sid, target_node_id, path, generation)) = pregen_info { + if claude_sid.is_empty() { + return; + } + + state.update_sim_session(session_id, |s| { + s.pregen_target = Some(target_node_id.clone()); + s.pregenerating = true; + }); + + let state_clone = state.clone(); + let sid_clone = session_id.to_string(); + tokio::spawn(async move { + orchestrate_eager_pregeneration( + state_clone, + sid_clone, + target_node_id, + path, + generation, + ) + .await; + }); + } +} + +/// Eagerly pre-generate a subtree and graft it onto the existing tree. /// Runs as a background task. -async fn orchestrate_pregeneration(state: Arc, session_id: String) { +async fn orchestrate_eager_pregeneration( + state: Arc, + session_id: String, + target_node_id: String, + path_to_target: Vec, + generation: u64, +) { let (claude_sid, tree_data) = { let session = match state.get_sim_session(&session_id) { Some(s) => s, @@ -267,42 +366,66 @@ async fn orchestrate_pregeneration(state: Arc, session_id: String) { }; ( session.claude_session_id.clone().unwrap_or_default(), - session.interaction_tree.clone().zip(Some(session.navigation_path.clone())), + session.interaction_tree.clone(), ) }; if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); return; } - let prompt = if let Some((ref interaction_tree, ref path)) = tree_data { - let history = tree::collect_path_history(interaction_tree, path); + let prompt = if let Some(ref interaction_tree) = tree_data { + let history = tree::collect_path_history(interaction_tree, &path_to_target); simulation::build_tree_resume_prompt(&history, None) } else { + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); return; }; - tracing::info!(session_id = %session_id, "Starting tree pre-generation"); + tracing::info!( + session_id = %session_id, + target = %target_node_id, + "Starting eager tree pre-generation" + ); match simulation::runner::resume_sim_tree_turn(&claude_sid, &prompt).await { Ok(mut response) => { tree::assign_node_ids(&mut response.root); - let root_id = response.root.node_id.clone(); state.update_sim_session(&session_id, |s| { - // Only update if user is still at the leaf (hasn't sent new input) - if s.status == simulation::SimStatus::Idle { - s.channel_contents = response.root.channels.clone(); - s.decisions = response.root.decisions.clone(); - s.interaction_tree = Some(response.root); - s.current_node_id = Some(root_id.clone()); - s.navigation_path = vec![root_id]; + // Only graft if the tree hasn't been replaced since we started + if s.tree_generation == generation + && s.pregen_target.as_deref() == Some(&target_node_id) + { + if let Some(ref mut existing_tree) = s.interaction_tree { + tree::graft_tree(existing_tree, &target_node_id, response.root); + } } + s.pregen_target = None; + s.pregenerating = false; }); - tracing::info!(session_id = %session_id, "Tree pre-generation complete"); + tracing::info!( + session_id = %session_id, + target = %target_node_id, + "Eager tree pre-generation complete" + ); } Err(e) => { - tracing::warn!(session_id = %session_id, "Tree pre-generation failed: {e}"); - // Don't set error status - pre-generation failure is non-fatal + tracing::warn!( + session_id = %session_id, + target = %target_node_id, + "Eager tree pre-generation failed: {e}" + ); + state.update_sim_session(&session_id, |s| { + s.pregen_target = None; + s.pregenerating = false; + }); } } } diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 3ebc5d4..94138ed 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -620,12 +620,12 @@ Active channels: {channel_list} ## Interaction Tree Rules 1. Generate {depth} levels of interactions (the root is level 0, its children are level 1, etc.). -2. Each non-leaf node should have {branching} predicted interactions — the most likely user actions. -3. Leaf nodes (at maximum depth) MUST have an empty "interactions" array. +2. Every node (including nodes at maximum depth) should have {branching} predicted interactions — the most likely user actions. +3. Nodes at maximum depth should include interactions with "label" and "input" fields, but OMIT the "result" field. This gives the user action suggestions even at the tree boundary. 4. Each predicted interaction must include: - **label**: A concise, human-readable description of the action (shown as a choice in the UI). - **input**: The exact keys and raw_text the user would send for this action. - - **result**: The complete simulation state after that interaction — with all active channels. + - **result** (omit at maximum depth): The complete simulation state after that interaction — with all active channels. 5. Choose interactions that represent the MOST LIKELY user actions at each state. Prioritize actions that exercise different parts of the spec and cover distinct user paths. 6. Every node (root and all children) must include entries for ALL active channels. diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 87a4c46..6072373 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -97,6 +97,13 @@ pub struct SimSession { pub tree_depth: u8, /// Number of predicted interactions per node. pub tree_branching: u8, + /// Node ID where pregeneration is currently targeting, if any. + pub pregen_target: Option, + /// Generation counter, incremented on each full tree replacement. + /// Used to invalidate stale pregenerations. + pub tree_generation: u64, + /// Whether background pregeneration is in progress. + pub pregenerating: bool, } impl SimSession { @@ -125,8 +132,11 @@ impl SimSession { interaction_tree: None, current_node_id: None, navigation_path: Vec::new(), - tree_depth: 2, - tree_branching: 3, + tree_depth: 4, + tree_branching: 2, + pregen_target: None, + tree_generation: 0, + pregenerating: false, } } } diff --git a/crates/spec-forest/src/simulation/tree.rs b/crates/spec-forest/src/simulation/tree.rs index de83c80..38f7f15 100644 --- a/crates/spec-forest/src/simulation/tree.rs +++ b/crates/spec-forest/src/simulation/tree.rs @@ -6,7 +6,9 @@ use uuid::Uuid; pub fn assign_node_ids(node: &mut SimTreeNode) { node.node_id = Uuid::new_v4().to_string(); for interaction in &mut node.interactions { - assign_node_ids(&mut interaction.result); + if let Some(ref mut result) = interaction.result { + assign_node_ids(result); + } } } @@ -16,8 +18,25 @@ pub fn find_node<'a>(tree: &'a SimTreeNode, node_id: &str) -> Option<&'a SimTree return Some(tree); } for interaction in &tree.interactions { - if let Some(found) = find_node(&interaction.result, node_id) { - return Some(found); + if let Some(ref result) = interaction.result { + if let Some(found) = find_node(result, node_id) { + return Some(found); + } + } + } + None +} + +/// Find a node by ID anywhere in the tree (mutable reference). +pub fn find_node_mut<'a>(tree: &'a mut SimTreeNode, node_id: &str) -> Option<&'a mut SimTreeNode> { + if tree.node_id == node_id { + return Some(tree); + } + for interaction in &mut tree.interactions { + if let Some(ref mut result) = interaction.result { + if let Some(found) = find_node_mut(result, node_id) { + return Some(found); + } } } None @@ -58,9 +77,21 @@ pub fn find_matching_interaction(node: &SimTreeNode, input: &SimInput) -> Option None } -/// Check if a node is a leaf (no predicted interactions). +/// Check if a node is a leaf (no interactions, or all interactions are shallow with no result). pub fn is_leaf(node: &SimTreeNode) -> bool { node.interactions.is_empty() + || node.interactions.iter().all(|i| i.result.is_none()) +} + +/// Graft a generated subtree onto a target node. +/// Replaces the target node's interactions with the new subtree's interactions. +pub fn graft_tree(tree: &mut SimTreeNode, target_node_id: &str, new_subtree: SimTreeNode) -> bool { + if let Some(target) = find_node_mut(tree, target_node_id) { + target.interactions = new_subtree.interactions; + true + } else { + false + } } /// Reconstruct the user's journey through the tree for replaying to the AI. @@ -77,11 +108,13 @@ pub fn collect_path_history<'a>( for target_id in path.iter().skip(1) { let mut found = false; for interaction in ¤t.interactions { - if interaction.result.node_id == *target_id { - history.push((&interaction.input, &interaction.result)); - current = &interaction.result; - found = true; - break; + if let Some(ref result) = interaction.result { + if result.node_id == *target_id { + history.push((&interaction.input, result)); + current = result; + found = true; + break; + } } } if !found { @@ -128,7 +161,7 @@ mod tests { keys: vec!["Enter".to_string()], raw_text: "\n".to_string(), }, - result: make_leaf("Login form"), + result: Some(make_leaf("Login form")), }, PredictedInteraction { label: "Type hello".to_string(), @@ -142,7 +175,7 @@ mod tests { ], raw_text: "hello".to_string(), }, - result: make_leaf("Search results"), + result: Some(make_leaf("Search results")), }, ], } @@ -153,22 +186,21 @@ mod tests { let mut tree = make_tree(); assign_node_ids(&mut tree); assert!(!tree.node_id.is_empty()); - assert!(!tree.interactions[0].result.node_id.is_empty()); - assert!(!tree.interactions[1].result.node_id.is_empty()); + let r0 = tree.interactions[0].result.as_ref().unwrap(); + let r1 = tree.interactions[1].result.as_ref().unwrap(); + assert!(!r0.node_id.is_empty()); + assert!(!r1.node_id.is_empty()); // All IDs should be unique - assert_ne!(tree.node_id, tree.interactions[0].result.node_id); - assert_ne!(tree.node_id, tree.interactions[1].result.node_id); - assert_ne!( - tree.interactions[0].result.node_id, - tree.interactions[1].result.node_id - ); + assert_ne!(tree.node_id, r0.node_id); + assert_ne!(tree.node_id, r1.node_id); + assert_ne!(r0.node_id, r1.node_id); } #[test] fn test_find_node() { let mut tree = make_tree(); assign_node_ids(&mut tree); - let child_id = tree.interactions[1].result.node_id.clone(); + let child_id = tree.interactions[1].result.as_ref().unwrap().node_id.clone(); let found = find_node(&tree, &child_id).unwrap(); assert_eq!(found.channels["ui"].text, "Search results"); @@ -220,7 +252,7 @@ mod tests { fn test_is_leaf() { let tree = make_tree(); assert!(!is_leaf(&tree)); - assert!(is_leaf(&tree.interactions[0].result)); + assert!(is_leaf(tree.interactions[0].result.as_ref().unwrap())); } #[test] @@ -228,7 +260,7 @@ mod tests { let mut tree = make_tree(); assign_node_ids(&mut tree); let root_id = tree.node_id.clone(); - let child_id = tree.interactions[0].result.node_id.clone(); + let child_id = tree.interactions[0].result.as_ref().unwrap().node_id.clone(); let path = vec![root_id, child_id]; let history = collect_path_history(&tree, &path); diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index 0fa1ebf..ee8e8e0 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -89,7 +89,9 @@ pub struct PredictedInteraction { /// The input this interaction represents. pub input: SimInput, /// The pre-computed simulation output if the user takes this interaction. - pub result: SimTreeNode, + /// `None` for shallow interactions at the tree boundary (label + input only). + #[serde(default)] + pub result: Option, } /// The full tree response the AI produces in a single generation. diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index e1a38f9..16e0b54 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -540,6 +540,48 @@ impl AppState { .unwrap_or_default() } + /// Navigate back to the previous node in the simulation tree. + /// Returns the node data (channels, decisions) if successful, None if already at root. + pub fn sim_navigate_back( + &self, + session_id: &str, + ) -> Option<( + std::collections::HashMap, + Vec, + )> { + self.sim_sessions.lock().get_mut(session_id).and_then(|s| { + if s.navigation_path.len() <= 1 { + return None; + } + s.navigation_path.pop(); + let parent_id = s.navigation_path.last()?.clone(); + s.current_node_id = Some(parent_id.clone()); + let tree = s.interaction_tree.as_ref()?; + let node = crate::simulation::tree::find_node(tree, &parent_id)?; + s.channel_contents = node.channels.clone(); + s.decisions = node.decisions.clone(); + Some((node.channels.clone(), node.decisions.clone())) + }) + } + + /// Check if background pregeneration is running for a simulation session. + pub fn get_sim_pregenerating(&self, session_id: &str) -> bool { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.pregenerating) + .unwrap_or(false) + } + + /// Get the length of the navigation path for the simulation session. + pub fn get_sim_nav_depth(&self, session_id: &str) -> usize { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.navigation_path.len()) + .unwrap_or(0) + } + // --- MCP URL --- pub fn mcp_url(&self) -> Option { diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index afcfd73..3bfacb3 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -929,8 +929,8 @@ impl SpecForestServer { let model = params.model.unwrap_or_else(|| "sonnet".to_string()); let whole_spec = params.whole_spec.unwrap_or(false); - let tree_depth = params.tree_depth.unwrap_or(2).clamp(1, 3); - let tree_branching = params.tree_branching.unwrap_or(3).clamp(2, 4); + let tree_depth = params.tree_depth.unwrap_or(4).clamp(1, 6); + let tree_branching = params.tree_branching.unwrap_or(2).clamp(2, 4); let session_id = uuid::Uuid::new_v4().to_string(); let mut session = SimSession::new( @@ -1063,12 +1063,14 @@ impl SpecForestServer { raw_text: params.raw_text, }; - // Check tree for matching interaction first + // Check tree for matching interaction with a pre-computed result let tree_hit = if let (Some(interaction_tree), Some(current_id)) = (&session.interaction_tree, &session.current_node_id) { if let Some(current_node) = tree::find_node(interaction_tree, current_id) { - tree::find_matching_interaction(current_node, &input).is_some() + tree::find_matching_interaction(current_node, &input) + .map(|idx| current_node.interactions[idx].result.is_some()) + .unwrap_or(false) } else { false } From 1514550524efaad2f24502948608d24544e4bd8d Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:02:29 +1100 Subject: [PATCH 072/100] feat: add game mode for spec-updating play-through simulation Game mode lets users play through their spec by choosing interaction+outcome pairs. Each choice feeds back into the spec DAG, iteratively refining the specification through play. Players can also reject outcomes with corrections. - New data types: GameChoiceGroup, GameOutcome, GameTreeRoot, GameSpecUpdate - Game-specific AI prompts presenting alternative outcomes per interaction - Backend orchestration for game turns with background spec updates - 4 new MCP tools: game_get_choices, game_select_outcome, game_reject_outcome, game_get_spec_updates - TUI: grouped choices panel, reject overlay, spec update log overlay - Channel picker toggle (g key) to enable game mode --- crates/spec-forest-tui/src/action.rs | 14 + crates/spec-forest-tui/src/app.rs | 134 +++++- crates/spec-forest-tui/src/commands.rs | 34 ++ crates/spec-forest-tui/src/input.rs | 49 ++- crates/spec-forest-tui/src/simulation.rs | 29 +- .../src/ui/sim_channel_picker.rs | 19 +- crates/spec-forest-tui/src/ui/simulation.rs | 214 ++++++++- crates/spec-forest/src/simulation.rs | 9 +- .../spec-forest/src/simulation/orchestrate.rs | 413 ++++++++++++++++++ crates/spec-forest/src/simulation/prompt.rs | 385 +++++++++++++--- crates/spec-forest/src/simulation/runner.rs | 217 ++++++++- crates/spec-forest/src/simulation/session.rs | 13 +- crates/spec-forest/src/simulation/tree.rs | 14 +- crates/spec-forest/src/simulation/types.rs | 61 +++ crates/spec-forest/src/state.rs | 24 + crates/spec-forest/src/tool_types.rs | 30 ++ crates/spec-forest/src/tools.rs | 248 ++++++++++- 17 files changed, 1821 insertions(+), 86 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 537fffc..d4cf992 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -107,6 +107,7 @@ pub enum Action { SimChannelToggle, SimChannelToggleWholeSpec, SimChannelToggleExploreCode, + SimChannelToggleGameMode, SimChannelConfirm, SimChannelCancel, @@ -139,6 +140,19 @@ pub enum Action { SimCloseOverlay, SimMouseClick { column: u16, row: u16 }, + // Game mode + GameGroupUp, + GameGroupDown, + GameOutcomeLeft, + GameOutcomeRight, + GameConfirmChoice, + GameRejectOutcome, + GameRejectChar(char), + GameRejectBackspace, + GameRejectSubmit, + GameRejectCancel, + GameToggleUpdateLog, + // Notification / session picker OpenSessionPicker, SessionPickerUp, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 136e62d..c7b3e1a 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -92,6 +92,7 @@ pub struct App { pub sim_channel_selection: std::collections::HashSet, pub sim_consume_whole_spec: bool, pub sim_explore_code: bool, + pub sim_game_mode: bool, pub sim_scenario_input: String, // Background simulation notifications pub background_sims: Vec, @@ -194,6 +195,7 @@ impl App { sim_channel_selection: std::collections::HashSet::new(), sim_consume_whole_spec: false, sim_explore_code: false, + sim_game_mode: false, sim_scenario_input: String::new(), background_sims: Vec::new(), sim_notifications: Vec::new(), @@ -331,12 +333,12 @@ impl App { } // Simulation screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::Simulation { .. }) { - let mode = self + let (mode, game_mode, reject_mode) = self .sim_state .as_ref() - .map(|s| s.mode) - .unwrap_or(crate::simulation::SimInputMode::Normal); - let action = input::map_sim_key(key, modifiers, mode); + .map(|s| (s.mode, s.game_mode, s.reject_mode)) + .unwrap_or((crate::simulation::SimInputMode::Normal, false, false)); + let action = input::map_sim_key(key, modifiers, mode, game_mode, reject_mode); self.execute_action(action).await; return; } @@ -689,6 +691,7 @@ impl App { self.sim_channel_selection = [0].into(); // UI channel selected by default self.sim_consume_whole_spec = false; self.sim_explore_code = false; + self.sim_game_mode = false; self.screen = Screen::SimChannelPicker { spec_id, node_id }; } else { self.message = Some("Select a node to simulate".to_string()); @@ -715,6 +718,9 @@ impl App { Action::SimChannelToggleWholeSpec => { self.sim_consume_whole_spec = !self.sim_consume_whole_spec; } + Action::SimChannelToggleGameMode => { + self.sim_game_mode = !self.sim_game_mode; + } Action::SimChannelToggleExploreCode => { if let Screen::SimChannelPicker { ref spec_id, .. } = self.screen { let has_dir = self @@ -1097,6 +1103,105 @@ impl App { self.sim_notifications.remove(0); } } + // Game mode actions + Action::GameGroupUp => { + if let Some(ref mut sim) = self.sim_state { + if !sim.game_choice_groups.is_empty() && sim.selected_group > 0 { + sim.selected_group -= 1; + sim.selected_outcome = 0; + } + } + } + Action::GameGroupDown => { + if let Some(ref mut sim) = self.sim_state { + if !sim.game_choice_groups.is_empty() + && sim.selected_group < sim.game_choice_groups.len() - 1 + { + sim.selected_group += 1; + sim.selected_outcome = 0; + } + } + } + Action::GameOutcomeLeft => { + if let Some(ref mut sim) = self.sim_state { + if sim.selected_outcome > 0 { + sim.selected_outcome -= 1; + } + } + } + Action::GameOutcomeRight => { + if let Some(ref mut sim) = self.sim_state { + if let Some(group) = sim.game_choice_groups.get(sim.selected_group) { + if sim.selected_outcome < group.outcomes.len().saturating_sub(1) { + sim.selected_outcome += 1; + } + } + } + } + Action::GameConfirmChoice => { + if let Some(ref mut sim) = self.sim_state { + if !sim.processing && !sim.game_choice_groups.is_empty() { + let gi = sim.selected_group; + let oi = sim.selected_outcome; + sim.processing = true; + let state = self.state.clone(); + let sid = sim.session_id.clone(); + tokio::spawn(async move { + crate::commands::run_game_select_outcome(state, sid, gi, oi).await; + }); + } + } + } + Action::GameRejectOutcome => { + if let Some(ref mut sim) = self.sim_state { + if !sim.processing && !sim.game_choice_groups.is_empty() { + sim.reject_mode = true; + sim.reject_input.clear(); + } + } + } + Action::GameRejectChar(c) => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_input.push(c); + } + } + Action::GameRejectBackspace => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_input.pop(); + } + } + Action::GameRejectSubmit => { + if let Some(ref mut sim) = self.sim_state { + if !sim.reject_input.is_empty() { + let gi = sim.selected_group; + let oi = sim.selected_outcome; + let correction = sim.reject_input.clone(); + sim.reject_mode = false; + sim.reject_input.clear(); + sim.processing = true; + let state = self.state.clone(); + let sid = sim.session_id.clone(); + tokio::spawn(async move { + crate::commands::run_game_reject_outcome( + state, sid, gi, oi, correction, + ) + .await; + }); + } + } + } + Action::GameRejectCancel => { + if let Some(ref mut sim) = self.sim_state { + sim.reject_mode = false; + sim.reject_input.clear(); + } + } + Action::GameToggleUpdateLog => { + if let Some(ref mut sim) = self.sim_state { + sim.show_update_log = !sim.show_update_log; + } + } + Action::ToggleHelp => { self.show_help = !self.show_help; } @@ -2266,9 +2371,18 @@ impl App { } sim.decisions = self.state.get_sim_decisions(&session_id); - sim.interactions = - self.state.get_sim_interactions(&session_id); - sim.selected_interaction = 0; + if sim.game_mode { + sim.game_choice_groups = + self.state.get_sim_game_choice_groups(&session_id); + sim.selected_group = 0; + sim.selected_outcome = 0; + sim.game_spec_updates = + self.state.get_sim_game_spec_updates(&session_id); + } else { + sim.interactions = + self.state.get_sim_interactions(&session_id); + sim.selected_interaction = 0; + } sim.can_go_back = self.state.get_sim_nav_depth(&session_id) > 1; } @@ -2313,12 +2427,16 @@ impl App { scenario.clone(), ); self.state.set_sim_session(session); + self.state.update_sim_session(&session_id, |s| { + s.game_mode = self.sim_game_mode; + }); - let sim_state = crate::simulation::SimulationState::new( + 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(), diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 64e8d61..7b7d270 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -271,3 +271,37 @@ pub async fn run_sim_report_turn(state: Arc, session_id: String, input simulation::orchestrate::orchestrate_report_turn(state, session_id, input).await; } +/// Select a game mode outcome. +pub async fn run_game_select_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, +) { + simulation::orchestrate::orchestrate_game_select_outcome( + state, + session_id, + group_index, + outcome_index, + ) + .await; +} + +/// Reject a game mode outcome with a correction. +pub async fn run_game_reject_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, + correction: String, +) { + simulation::orchestrate::orchestrate_game_reject_outcome( + state, + session_id, + group_index, + outcome_index, + correction, + ) + .await; +} + diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index bea7e96..0666110 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -52,8 +52,18 @@ pub fn map_sim_scenario_key(key: KeyCode, modifiers: KeyModifiers) -> Action { } /// Maps keys for the simulation screen. Needs modifiers for Shift+Enter. -pub fn map_sim_key(key: KeyCode, modifiers: KeyModifiers, mode: SimInputMode) -> Action { +pub fn map_sim_key( + key: KeyCode, + modifiers: KeyModifiers, + mode: SimInputMode, + game_mode: bool, + reject_mode: bool, +) -> Action { + if reject_mode { + return map_game_reject_key(key, modifiers); + } match mode { + SimInputMode::Normal if game_mode => map_game_normal_key(key), SimInputMode::Normal => map_sim_normal_key(key), SimInputMode::Insert => map_sim_insert_key(key, modifiers), } @@ -79,6 +89,42 @@ fn map_sim_normal_key(key: KeyCode) -> Action { } } +fn map_game_normal_key(key: KeyCode) -> Action { + match key { + KeyCode::Char('i') => Action::SimEnterInsert, + KeyCode::Esc => Action::SimBackgroundSimulation, + KeyCode::Char('Q') => Action::SimEndSimulation, + KeyCode::Tab => Action::SimCycleChannel, + KeyCode::F(5) => Action::SimCycleLayout, + KeyCode::Char('r') => Action::SimEnterReport, + KeyCode::Char('S') => Action::SimEditScenario, + KeyCode::Char(c @ '0'..='9') => Action::SimRefDigit(c), + // Game mode: Up/Down navigate groups, Left/Right navigate outcomes + KeyCode::Up => Action::GameGroupUp, + KeyCode::Down => Action::GameGroupDown, + KeyCode::Left => Action::GameOutcomeLeft, + KeyCode::Right => Action::GameOutcomeRight, + KeyCode::Enter => Action::GameConfirmChoice, + KeyCode::Char('x') => Action::GameRejectOutcome, + KeyCode::Char('u') => Action::GameToggleUpdateLog, + KeyCode::Backspace => Action::SimNavigateBack, + _ => Action::Noop, + } +} + +fn map_game_reject_key(key: KeyCode, modifiers: KeyModifiers) -> Action { + match key { + KeyCode::Esc => Action::GameRejectCancel, + KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => Action::GameRejectSubmit, + KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => { + Action::GameRejectSubmit + } + KeyCode::Backspace => Action::GameRejectBackspace, + KeyCode::Char(c) => Action::GameRejectChar(c), + _ => Action::Noop, + } +} + fn map_sim_insert_key(key: KeyCode, modifiers: KeyModifiers) -> Action { use crate::simulation::CapturedKey; match key { @@ -111,6 +157,7 @@ fn map_sim_channel_picker_key(key: KeyCode) -> Action { KeyCode::Char(' ') => Action::SimChannelToggle, KeyCode::Tab => Action::SimChannelToggleWholeSpec, KeyCode::BackTab => Action::SimChannelToggleExploreCode, + KeyCode::Char('g') => Action::SimChannelToggleGameMode, KeyCode::Enter => Action::SimChannelConfirm, KeyCode::Esc => Action::SimChannelCancel, _ => Action::Noop, diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index c225848..cfbc741 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -1,4 +1,6 @@ -use spec_forest::simulation::{ChannelContent, PredictedInteraction, SimChannel}; +use spec_forest::simulation::{ + ChannelContent, GameChoiceGroup, GameSpecUpdate, PredictedInteraction, SimChannel, +}; use std::collections::HashMap; /// A captured keystroke in simulation insert mode. @@ -72,6 +74,23 @@ pub struct SimulationState { pub ref_digit_start_tick: Option, pub processing: bool, pub tick: u64, + // ── Game mode fields ───────────────────────────────────────────── + /// Whether this simulation is in game mode (spec-updating play-through). + pub game_mode: bool, + /// Grouped interaction choices with multiple outcomes each. + pub game_choice_groups: Vec, + /// Index of the currently selected interaction group. + pub selected_group: usize, + /// Index of the currently selected outcome within the group. + pub selected_outcome: usize, + /// Whether the user is typing a rejection correction. + pub reject_mode: bool, + /// Text input for rejection correction. + pub reject_input: String, + /// Log of spec updates triggered during this game session. + pub game_spec_updates: Vec, + /// Whether the spec update log overlay is visible. + pub show_update_log: bool, } impl SimulationState { @@ -100,6 +119,14 @@ impl SimulationState { ref_digit_start_tick: None, processing: false, tick: 0, + game_mode: false, + game_choice_groups: Vec::new(), + selected_group: 0, + selected_outcome: 0, + reject_mode: false, + reject_input: String::new(), + game_spec_updates: Vec::new(), + show_update_log: false, } } 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 d4f7e99..b0e51ed 100644 --- a/crates/spec-forest-tui/src/ui/sim_channel_picker.rs +++ b/crates/spec-forest-tui/src/ui/sim_channel_picker.rs @@ -17,6 +17,7 @@ pub fn render(app: &App, frame: &mut Frame) { Constraint::Min(3), // channel list Constraint::Length(3), // whole spec toggle Constraint::Length(3), // explore code toggle + Constraint::Length(3), // game mode toggle Constraint::Length(3), // footer ]) .split(frame.area()); @@ -106,6 +107,20 @@ pub fn render(app: &App, frame: &mut Frame) { .block(Block::default().borders(Borders::ALL)); frame.render_widget(explore_code, chunks[2]); + // Game mode toggle + let game_checkbox = if app.sim_game_mode { "[x]" } else { "[ ]" }; + let game_style = if app.sim_game_mode { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + let game_mode = Paragraph::new(Line::from(Span::styled( + format!(" {game_checkbox} Game Mode — choices update the spec as you play"), + game_style, + ))) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(game_mode, chunks[3]); + let selected_count = app.sim_channel_selection.len(); let mut footer_spans = vec![ Span::styled( @@ -114,12 +129,12 @@ pub fn render(app: &App, frame: &mut Frame) { ), ]; let badge_line = super::common::render_footer_line( - &[("Space", "Toggle"), ("Enter", "Start"), ("Esc", "Cancel"), ("?", "Help")], + &[("Space", "Toggle"), ("g", "Game"), ("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[3]); + frame.render_widget(footer, chunks[4]); } diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 2acfdcd..64109fa 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -29,12 +29,24 @@ pub fn render(app: &App, frame: &mut Frame) { 0 }; - let has_interactions = !sim.interactions.is_empty(); - let interactions_height = if has_interactions { - // 1 line per interaction + 2 for border, capped at 6 + 2 - (sim.interactions.len().min(6) + 2) as u16 + let has_interactions = if sim.game_mode { + !sim.game_choice_groups.is_empty() } else { + !sim.interactions.is_empty() + }; + let interactions_height = if !has_interactions { 0 + } else if sim.game_mode { + // Game mode: each group has 1 label line + outcomes, capped + let content_lines: usize = sim + .game_choice_groups + .iter() + .map(|g| 1 + g.outcomes.len()) + .sum(); + (content_lines.min(12) + 2) as u16 + } else { + // 1 line per interaction + 2 for border, capped at 6 + 2 + (sim.interactions.len().min(6) + 2) as u16 }; let mut constraints = vec![ @@ -67,7 +79,11 @@ pub fn render(app: &App, frame: &mut Frame) { idx += 1; } if has_interactions { - render_interactions_panel(sim, frame, chunks[idx]); + if sim.game_mode { + render_game_choices_panel(sim, frame, chunks[idx]); + } else { + render_interactions_panel(sim, frame, chunks[idx]); + } idx += 1; } render_input_area(app, frame, chunks[idx]); @@ -83,6 +99,14 @@ pub fn render(app: &App, frame: &mut Frame) { if let Some(ref report) = sim.report_overlay { render_report_overlay(report, frame, frame.area()); } + + // Game mode overlays + if sim.reject_mode { + render_reject_overlay(sim, frame, frame.area()); + } + if sim.show_update_log { + render_update_log_overlay(sim, frame, frame.area()); + } } fn render_tab_bar(app: &App, frame: &mut Frame, area: Rect) { @@ -354,7 +378,15 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { let hint_items: Vec<(&str, &str)> = match sim.mode { SimInputMode::Normal => { let mut items = Vec::new(); - if !sim.interactions.is_empty() { + if sim.game_mode && !sim.game_choice_groups.is_empty() { + items.extend_from_slice(&[ + ("↑↓", "Group"), + ("←→", "Outcome"), + ("Enter", "Confirm"), + ("x", "Reject"), + ("u", "Updates"), + ]); + } else if !sim.interactions.is_empty() { items.extend_from_slice(&[("↑↓", "Select"), ("Enter", "Confirm"), ("i", "Custom")]); } else { items.push(("i", "Insert")); @@ -363,7 +395,7 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { items.push(("Bksp", "Back")); } items.extend_from_slice(&[("Tab", "Channel"), ("Esc", "Background")]); - if sim.interactions.is_empty() { + if sim.interactions.is_empty() && sim.game_choice_groups.is_empty() { items.push(("?", "Help")); } items @@ -571,3 +603,171 @@ fn render_report_overlay( frame.render_widget(paragraph, overlay_area); } + +// ── Game Mode Rendering ────────────────────────────────────────────── + +fn render_game_choices_panel( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let mut lines = Vec::new(); + + for (gi, group) in sim.game_choice_groups.iter().enumerate() { + let is_selected_group = gi == sim.selected_group; + let group_prefix = if is_selected_group { "▸ " } else { " " }; + let group_style = if is_selected_group { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::from(Span::styled( + format!("{group_prefix}{}", group.interaction_label), + group_style, + ))); + + for (oi, outcome) in group.outcomes.iter().enumerate() { + let is_selected_outcome = is_selected_group && oi == sim.selected_outcome; + let marker = if is_selected_outcome { " ◄" } else { "" }; + let outcome_style = if is_selected_outcome { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + lines.push(Line::from(Span::styled( + format!(" [{}] {}{}", oi + 1, outcome.summary, marker), + outcome_style, + ))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Game Choices (↑↓ group, ←→ outcome, Enter confirm, x reject) "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_reject_overlay( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let overlay_width = (area.width as f32 * 0.6) as u16; + let overlay_height = 8; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let group_label = sim + .game_choice_groups + .get(sim.selected_group) + .map(|g| g.interaction_label.as_str()) + .unwrap_or("?"); + let outcome_label = sim + .game_choice_groups + .get(sim.selected_group) + .and_then(|g| g.outcomes.get(sim.selected_outcome)) + .map(|o| o.summary.as_str()) + .unwrap_or("?"); + + let lines = vec![ + Line::from(Span::styled( + format!("Rejecting: {} → {}", group_label, outcome_label), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from("What should happen instead?"), + Line::from(format!("> {}_", sim.reject_input)), + Line::from(""), + Line::from(Span::styled( + "Ctrl+S submit | Esc cancel", + Style::default().fg(Color::DarkGray), + )), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Reject Outcome "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, overlay_area); +} + +fn render_update_log_overlay( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let overlay_width = (area.width as f32 * 0.7) as u16; + let overlay_height = (area.height as f32 * 0.5) as u16; + let x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(x, y, overlay_width, overlay_height); + + frame.render_widget(Clear, overlay_area); + + let mut lines = Vec::new(); + + if sim.game_spec_updates.is_empty() { + lines.push(Line::from(Span::styled( + "No spec updates yet.", + Style::default().fg(Color::DarkGray), + ))); + } else { + for (i, update) in sim.game_spec_updates.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} → {}", update.interaction_label, update.outcome_summary), + Style::default().fg(Color::White), + ), + ])); + lines.push(Line::from(Span::styled( + format!(" {}", update.description), + Style::default().fg(Color::Green), + ))); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "[u] Close", + Style::default().fg(Color::DarkGray), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Spec Updates This Session "); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, overlay_area); +} diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 5c07a52..76cf224 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -6,12 +6,15 @@ pub mod tree; pub mod types; pub use prompt::{ - append_code_aware_section, build_initial_prompt, build_system_prompt, + append_code_aware_section, build_game_resume_prompt, build_game_spec_update_prompt, + build_game_system_prompt_whole_spec_with_tree, build_game_system_prompt_with_tree, + build_game_tree_output_format, build_initial_prompt, build_system_prompt, build_system_prompt_whole_spec, build_system_prompt_whole_spec_with_tree, build_system_prompt_with_tree, build_tree_output_format, build_tree_resume_prompt, }; pub use session::{SimChannel, SimSession, SimStatus}; pub use types::{ - ChannelContent, Decision, NodeRef, PredictedInteraction, SimInput, SimReport, - SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, + ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, + GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, + SimResponse, SimTreeNode, SimTreeResponse, }; diff --git a/crates/spec-forest/src/simulation/orchestrate.rs b/crates/spec-forest/src/simulation/orchestrate.rs index 0b3d92e..a6819ee 100644 --- a/crates/spec-forest/src/simulation/orchestrate.rs +++ b/crates/spec-forest/src/simulation/orchestrate.rs @@ -476,3 +476,416 @@ pub async fn orchestrate_scenario_update( ); orchestrate_ai_resume(state, session_id, input).await; } + +// ── Game Mode Orchestration ────────────────────────────────────────── + +/// Run the initial game-mode turn. +/// Similar to `orchestrate_initial_turn` but uses game prompts and parses `GameTreeResponse`. +pub async fn orchestrate_game_initial_turn( + state: Arc, + session_id: String, + consume_whole_spec: bool, + directory: Option, +) { + let (spec_id, model, channels, focus_node_id, scenario, tree_branching) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + ( + session.spec_id.clone(), + session.model.clone(), + session.channels.clone(), + session.root_node_id.clone().unwrap_or_default(), + session.scenario.clone(), + session.tree_branching, + ) + }; + + let focus_node = match crate::api::get_node(&state, &focus_node_id) { + Ok(node) => node, + Err(e) => { + tracing::error!("Failed to load focus node for game mode: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!("Failed to load focus node: {e}")); + }); + return; + } + }; + + let summary = match crate::api::get_spec(&state, &spec_id) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to load spec summary for game mode: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error(format!("Failed to load spec summary: {e}")); + }); + return; + } + }; + + let system_prompt = if consume_whole_spec { + let all_nodes = crate::api::get_spec_nodes(&state, &spec_id).unwrap_or_default(); + simulation::build_game_system_prompt_whole_spec_with_tree( + &channels, + &focus_node, + &all_nodes, + &summary, + tree_branching, + ) + } else { + let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); + let descendants = crate::api::get_descendants(&state, &focus_node_id).unwrap_or_default(); + + let context_ids: std::collections::HashSet<&str> = ancestors + .iter() + .chain(descendants.iter()) + .map(|n| n.id.as_str()) + .chain(std::iter::once(focus_node_id.as_str())) + .collect(); + let other_roots = crate::api::get_spec_roots(&state, &spec_id) + .unwrap_or_default() + .into_iter() + .filter(|n| !context_ids.contains(n.id.as_str())) + .collect::>(); + + simulation::build_game_system_prompt_with_tree( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + tree_branching, + ) + }; + + let system_prompt = if directory.is_some() { + simulation::append_code_aware_section(&system_prompt) + } else { + system_prompt + }; + let initial_prompt = simulation::build_initial_prompt(&channels, scenario.as_deref()); + + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = simulation::runner::SimConfig::new(model, system_prompt, mcp_url, directory); + + match simulation::runner::start_game_tree_turn(&config, &initial_prompt).await { + Ok((claude_session_id, mut response)) => { + tree::assign_game_node_ids(&mut response.root); + state.update_sim_session(&session_id, |s| { + s.claude_session_id = Some(claude_session_id); + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.game_tree = Some(response.root); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Game initial tree turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Handle a player selecting an (interaction, outcome) pair in game mode. +/// +/// Updates channel_contents from the selected outcome's result, then spawns +/// a background task to update the spec based on the choice. +pub async fn orchestrate_game_select_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, +) { + let (spec_id, choice_info) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let game_tree = match &session.game_tree { + Some(t) => t, + None => { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No game tree available".to_string()); + }); + return; + } + }; + + let group = match game_tree.choice_groups.get(group_index) { + Some(g) => g, + None => { + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!( + "Invalid group index: {group_index}" + )); + }); + return; + } + }; + + let outcome = match group.outcomes.get(outcome_index) { + Some(o) => o, + None => { + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(format!( + "Invalid outcome index: {outcome_index}" + )); + }); + return; + } + }; + + ( + session.spec_id.clone(), + ( + group.interaction_label.clone(), + outcome.summary.clone(), + outcome.related_spec_nodes.clone(), + outcome.result.channels.clone(), + outcome.result.decisions.clone(), + ), + ) + }; + + let (interaction_label, outcome_summary, related_spec_nodes, channels, decisions) = choice_info; + + // Update channel contents from the selected outcome + state.update_sim_session(&session_id, |s| { + s.channel_contents = channels; + s.decisions = decisions; + // Clear the game tree so the next turn generates a new one + s.game_tree = None; + s.status = simulation::SimStatus::Idle; + }); + + // Spawn background spec update (fire-and-forget) + let state_clone = state.clone(); + let sid_clone = session_id.clone(); + tokio::spawn(async move { + orchestrate_game_spec_update( + state_clone, + sid_clone, + spec_id, + interaction_label, + outcome_summary, + related_spec_nodes, + None, + ) + .await; + }); +} + +/// Handle a player rejecting an outcome with a correction in game mode. +/// +/// Spawns a background AI call with the correction to determine spec updates +/// and generate a corrected tree continuation. +pub async fn orchestrate_game_reject_outcome( + state: Arc, + session_id: String, + group_index: usize, + outcome_index: usize, + correction: String, +) { + let (spec_id, reject_info) = { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + + let game_tree = match &session.game_tree { + Some(t) => t, + None => { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No game tree available".to_string()); + }); + return; + } + }; + + let group = match game_tree.choice_groups.get(group_index) { + Some(g) => g, + None => return, + }; + + let outcome = match group.outcomes.get(outcome_index) { + Some(o) => o, + None => return, + }; + + ( + session.spec_id.clone(), + ( + group.interaction_label.clone(), + outcome.summary.clone(), + outcome.related_spec_nodes.clone(), + ), + ) + }; + + let (interaction_label, outcome_summary, related_spec_nodes) = reject_info; + + // Set processing while we generate a corrected tree + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Processing; + }); + + // Spawn background spec update with the correction + let state_bg = state.clone(); + let sid_bg = session_id.clone(); + let spec_id_bg = spec_id.clone(); + let interaction_bg = interaction_label.clone(); + let outcome_bg = outcome_summary.clone(); + let related_bg = related_spec_nodes.clone(); + let correction_bg = correction.clone(); + tokio::spawn(async move { + orchestrate_game_spec_update( + state_bg, + sid_bg, + spec_id_bg, + interaction_bg, + outcome_bg, + related_bg, + Some(correction_bg), + ) + .await; + }); + + // Resume the AI with the correction to get a new game tree + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + state.update_sim_session(&session_id, |s| { + s.status = + simulation::SimStatus::Error("No claude session ID for resume".to_string()); + }); + return; + } + + let prompt = format!( + "The player REJECTED the outcome \"{outcome_summary}\" for interaction \"{interaction_label}\".\n\ + Player's correction: \"{correction}\"\n\n\ + Generate a new game choice tree from the current state, incorporating the player's correction. \ + Respond ONLY with a valid JSON object matching the game choice tree format." + ); + + match simulation::runner::resume_game_tree_turn(&claude_sid, &prompt).await { + Ok(mut response) => { + tree::assign_game_node_ids(&mut response.root); + state.update_sim_session(&session_id, |s| { + s.channel_contents = response.root.channels.clone(); + s.decisions = response.root.decisions.clone(); + s.game_tree = Some(response.root); + s.status = simulation::SimStatus::Idle; + }); + } + Err(e) => { + tracing::error!("Game reject resume turn failed: {e}"); + state.update_sim_session(&session_id, |s| { + s.status = simulation::SimStatus::Error(e.to_string()); + }); + } + } +} + +/// Background task: use the AI to determine and apply spec updates based on +/// a player's game choice or rejection. +async fn orchestrate_game_spec_update( + state: Arc, + session_id: String, + spec_id: String, + interaction_label: String, + outcome_summary: String, + related_spec_nodes: Vec, + correction: Option, +) { + let claude_sid = state + .get_sim_claude_session_id(&session_id) + .unwrap_or_default(); + + if claude_sid.is_empty() { + tracing::warn!("No claude session for game spec update, skipping"); + return; + } + + let prompt = simulation::build_game_spec_update_prompt( + &spec_id, + &interaction_label, + &outcome_summary, + &related_spec_nodes, + correction.as_deref(), + ); + + match simulation::runner::resume_game_spec_update_turn(&claude_sid, &prompt).await { + Ok(response_text) => { + // Parse the response to extract what was done + #[derive(Debug, serde::Deserialize)] + struct SpecUpdateResult { + action: String, + #[serde(default)] + node_id: String, + #[serde(default)] + description: String, + } + + let update = serde_json::from_str::(&response_text) + .or_else(|_| { + // Try extracting JSON from the response + let trimmed = response_text.trim(); + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + serde_json::from_str::(&trimmed[start..=end]) + } else { + Err(serde_json::from_str::("").unwrap_err()) + } + }); + + match update { + Ok(result) if result.action != "none" => { + let spec_update = simulation::GameSpecUpdate { + interaction_label: interaction_label.clone(), + outcome_summary: outcome_summary.clone(), + description: result.description, + node_id: result.node_id, + }; + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(spec_update); + }); + tracing::info!( + session_id = %session_id, + action = %result.action, + "Game spec update applied" + ); + } + Ok(_) => { + tracing::info!( + session_id = %session_id, + "Game spec update: no changes needed" + ); + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + "Failed to parse game spec update response: {e}" + ); + } + } + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + "Game spec update turn failed: {e}" + ); + } + } +} diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 94138ed..57b4bfa 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -39,6 +39,37 @@ pub fn build_system_prompt_with_tree( build_system_prompt_inner(channels, focus_node, ancestors, descendants, summary, other_roots, Some((tree_depth, tree_branching))) } +/// Build the system prompt with game-mode tree output format. +/// +/// Game mode presents multiple alternative outcomes per interaction, +/// letting the user choose the correct behavior to refine the spec. +pub fn build_game_system_prompt_with_tree( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + tree_branching: u8, +) -> String { + build_system_prompt_inner_v2( + channels, focus_node, ancestors, descendants, summary, other_roots, true, Some((1, tree_branching)), + ) +} + +/// Build the whole-spec system prompt with game-mode tree output format. +pub fn build_game_system_prompt_whole_spec_with_tree( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + tree_branching: u8, +) -> String { + build_system_prompt_whole_spec_inner_v2( + channels, focus_node, all_nodes, summary, true, Some((1, tree_branching)), + ) +} + fn build_system_prompt_inner( channels: &[SimChannel], focus_node: &Node, @@ -47,6 +78,21 @@ fn build_system_prompt_inner( summary: &SpecSummary, other_roots: &[Node], tree_config: Option<(u8, u8)>, +) -> String { + build_system_prompt_inner_v2( + channels, focus_node, ancestors, descendants, summary, other_roots, false, tree_config, + ) +} + +fn build_system_prompt_inner_v2( + channels: &[SimChannel], + focus_node: &Node, + ancestors: &[Node], + descendants: &[Node], + summary: &SpecSummary, + other_roots: &[Node], + game_mode: bool, + tree_config: Option<(u8, u8)>, ) -> String { let channel_list = channels .iter() @@ -108,31 +154,20 @@ fn build_system_prompt_inner( } } - format!( - r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD -You are simulating the program that an AI developer would build from this specification. -Your job is to predict the implementation — not to audit the spec. - -Mental model: -1. An implementing AI will eventually build this software from the spec. -2. You predict what that implementer would create. -3. The user interacts with your simulation to preview what the spec leads to. -4. This helps the user refine the spec BEFORE actual implementation. + let cardinal_rule = if game_mode { + build_game_cardinal_rule() + } else { + build_sim_cardinal_rule() + }; -When simulating, you will inevitably make decisions the spec does not explicitly cover. -Apply an ENTROPY test to each such decision: + let output_format = match (game_mode, tree_config) { + (true, Some((_depth, branching))) => build_game_tree_output_format(branching, &channel_list), + (false, Some((depth, branching))) => build_tree_output_format(depth, branching, &channel_list), + (_, None) => build_flat_output_format(&channel_list), + }; -- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag - it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, - a close button on a modal, standard HTTP status codes for obvious cases. -- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different - choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. - Examples: what happens on auth failure (redirect vs inline error vs modal), pagination - strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution - strategy. - -The goal is NOT to flag every invented detail — it is to surface the decisions that actually -matter and that the spec should clarify. + format!( + r#"{cardinal_rule} Your responses MUST be valid JSON. @@ -268,10 +303,8 @@ Simulate how the application would respond to these inputs based on the spec."#, } else { other_roots_section }, - output_format = match tree_config { - Some((depth, branching)) => build_tree_output_format(depth, branching, &channel_list), - None => build_flat_output_format(&channel_list), - }, + output_format = output_format, + cardinal_rule = cardinal_rule, ) } @@ -306,6 +339,17 @@ fn build_system_prompt_whole_spec_inner( all_nodes: &[Node], summary: &SpecSummary, tree_config: Option<(u8, u8)>, +) -> String { + build_system_prompt_whole_spec_inner_v2(channels, focus_node, all_nodes, summary, false, tree_config) +} + +fn build_system_prompt_whole_spec_inner_v2( + channels: &[SimChannel], + focus_node: &Node, + all_nodes: &[Node], + summary: &SpecSummary, + game_mode: bool, + tree_config: Option<(u8, u8)>, ) -> String { let channel_list = channels .iter() @@ -339,31 +383,20 @@ fn build_system_prompt_whole_spec_inner( all_nodes_section.push('\n'); } - format!( - r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD -You are simulating the program that an AI developer would build from this specification. -Your job is to predict the implementation — not to audit the spec. - -Mental model: -1. An implementing AI will eventually build this software from the spec. -2. You predict what that implementer would create. -3. The user interacts with your simulation to preview what the spec leads to. -4. This helps the user refine the spec BEFORE actual implementation. + let cardinal_rule = if game_mode { + build_game_cardinal_rule() + } else { + build_sim_cardinal_rule() + }; -When simulating, you will inevitably make decisions the spec does not explicitly cover. -Apply an ENTROPY test to each such decision: + let output_format = match (game_mode, tree_config) { + (true, Some((_depth, branching))) => build_game_tree_output_format(branching, &channel_list), + (false, Some((depth, branching))) => build_tree_output_format(depth, branching, &channel_list), + (_, None) => build_flat_output_format(&channel_list), + }; -- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag - it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, - a close button on a modal, standard HTTP status codes for obvious cases. -- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different - choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. - Examples: what happens on auth failure (redirect vs inline error vs modal), pagination - strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution - strategy. - -The goal is NOT to flag every invented detail — it is to surface the decisions that actually -matter and that the spec should clarify. + format!( + r#"{cardinal_rule} Your responses MUST be valid JSON. @@ -479,10 +512,8 @@ Simulate how the application would respond to these inputs based on the spec."#, answered = summary.answered_count, unanswered = summary.unanswered_count, needs_review = summary.needs_review_count, - output_format = match tree_config { - Some((depth, branching)) => build_tree_output_format(depth, branching, &channel_list), - None => build_flat_output_format(&channel_list), - }, + output_format = output_format, + cardinal_rule = cardinal_rule, ) } @@ -699,3 +730,249 @@ pub fn build_tree_resume_prompt( prompt } + +// ── Cardinal rules ─────────────────────────────────────────────────── + +fn build_sim_cardinal_rule() -> String { + r#"## CARDINAL RULE: FAITHFULLY SIMULATE WHAT AN IMPLEMENTER WOULD BUILD +You are simulating the program that an AI developer would build from this specification. +Your job is to predict the implementation — not to audit the spec. + +Mental model: +1. An implementing AI will eventually build this software from the spec. +2. You predict what that implementer would create. +3. The user interacts with your simulation to preview what the spec leads to. +4. This helps the user refine the spec BEFORE actual implementation. + +When simulating, you will inevitably make decisions the spec does not explicitly cover. +Apply an ENTROPY test to each such decision: + +- LOW ENTROPY (any reasonable implementer would do the same thing): Just do it. Do not flag + it. Examples: a form has a submit button, a list is scrollable, pressing Enter submits, + a close button on a modal, standard HTTP status codes for obvious cases. +- HIGH ENTROPY (genuine ambiguity where different implementers would make materially different + choices): Flag these as a spec_gap. These are the decisions the spec SHOULD address. + Examples: what happens on auth failure (redirect vs inline error vs modal), pagination + strategy (infinite scroll vs numbered pages), data retention policy, conflict resolution + strategy. + +The goal is NOT to flag every invented detail — it is to surface the decisions that actually +matter and that the spec should clarify."# + .to_string() +} + +fn build_game_cardinal_rule() -> String { + r#"## CARDINAL RULE: PRESENT ALTERNATIVE OUTCOMES FOR THE USER TO DESIGN BEHAVIOR +You are helping the user design their application by presenting alternative outcomes for +each interaction. The user will choose which outcome is correct, and their choices will +update the specification. + +Mental model: +1. For each user interaction (e.g., pressing a key, clicking a button), generate multiple + plausible outcomes that a reasonable implementer might build. +2. Each outcome should be distinct and represent a meaningfully different design choice. +3. The user picks the outcome that matches their intended design. +4. Their choice becomes part of the specification. + +When generating outcomes: +- Each outcome for the same interaction should represent a genuinely different behavior, + not just cosmetic variations. Example: pressing X might "start audio playback" OR + "load a new sample" — these are meaningfully different. +- Annotate each outcome with the spec node IDs that justify it (related_spec_nodes). + If no spec node covers it, leave related_spec_nodes empty — this signals new information + the spec needs. +- For low-entropy interactions (only one reasonable outcome), you may provide a single + outcome. Reserve multiple outcomes for genuine design decision points. + +Apply the same ENTROPY test as simulation mode for spec_gaps."# + .to_string() +} + +// ── Game mode output format ────────────────────────────────────────── + +/// Build the game-mode tree output format section for system prompts. +/// +/// Instructs the AI to return a game tree with grouped choice outcomes +/// instead of the regular interaction tree. +pub fn build_game_tree_output_format(branching: u8, channel_list: &str) -> String { + format!( + r#"## Output Format — Game Choice Tree +Every response must be a JSON object containing a game choice tree. The tree presents +alternative outcomes for each predicted interaction, letting the user pick the correct behavior. + +Schema: +{{{{ + "root": {{{{ + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ], + "choice_groups": [ + {{{{ + "interaction_label": "Short description of user action (e.g., Press X)", + "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, + "outcomes": [ + {{{{ + "summary": "Short description of what happens (e.g., Starts audio playback)", + "related_spec_nodes": ["node-id-1", "node-id-2"], + "result": {{{{ + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + }}}}, + {{{{ + "summary": "Alternative outcome (e.g., Loads new sample)", + "related_spec_nodes": [], + "result": {{{{ + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + }}}} + ] + }}}} + ] + }}}} +}}}} + +Active channels: {channel_list} + +## Game Choice Tree Rules +1. Generate {branching} predicted interactions (choice_groups) — the most likely user actions. +2. Each choice_group should have 2-3 alternative outcomes representing meaningfully different + behaviors. If an interaction has only one reasonable outcome, 1 outcome is acceptable. +3. Each outcome's "result" contains the complete simulation state (all active channels + decisions) + that would result from that outcome. Results do NOT contain nested choice_groups — the tree + is one level deep. +4. Each outcome MUST include: + - **summary**: A concise description of what happens (shown alongside the interaction label). + - **related_spec_nodes**: Array of spec node IDs that justify this outcome. Empty array if + the outcome is based on assumptions not covered by the spec. + - **result**: Complete simulation state with all active channels and decisions. +5. Make outcomes genuinely distinct. Bad: "Button turns blue" vs "Button turns dark blue". + Good: "Opens settings panel" vs "Starts audio playback". +6. Every node (root and all results) must include entries for ALL active channels. +7. Every node must include a non-empty decisions array. +8. The [^N] marker namespace is PER NODE — each node's refs are self-contained."#, + channel_list = channel_list, + branching = branching, + ) +} + +// ── Game mode resume prompt ────────────────────────────────────────── + +/// Build a resume prompt for game mode that replays the player's choices +/// and generates the next game choice tree. +pub fn build_game_resume_prompt( + choice_history: &[(String, String)], // (interaction_label, chosen_outcome_summary) + custom_input: Option<&str>, +) -> String { + let mut prompt = String::new(); + + if !choice_history.is_empty() { + prompt.push_str( + "The player made these design choices since the last generation:\n\n", + ); + for (i, (interaction, outcome)) in choice_history.iter().enumerate() { + prompt.push_str(&format!( + "{}. Interaction: {} → Chosen outcome: {}\n", + i + 1, + interaction, + outcome, + )); + } + prompt.push('\n'); + } + + match custom_input { + Some(input) => { + prompt.push_str(&format!( + "Now the player provided a custom input that was NOT one of the predicted interactions:\n{}\n\n", + input + )); + } + None => { + prompt.push_str( + "The player has completed all available choices.\n\n", + ); + } + } + + prompt.push_str( + "Generate the next game choice tree from the current state. \ + Respond ONLY with a valid JSON object matching the game choice tree format. \ + Present alternative outcomes for each interaction so the player can design \ + the correct behavior. Annotate each outcome with related_spec_nodes.", + ); + + prompt +} + +// ── Game mode spec update prompt ───────────────────────────────────── + +/// Build a prompt for the background AI call that determines what spec changes +/// to make based on a player's game choice or rejection. +pub fn build_game_spec_update_prompt( + spec_id: &str, + interaction_label: &str, + outcome_summary: &str, + related_spec_nodes: &[String], + correction: Option<&str>, +) -> String { + let mut prompt = String::new(); + + prompt.push_str("You are updating a specification based on a player's design choice in game mode.\n\n"); + + match correction { + Some(correction_text) => { + prompt.push_str(&format!( + "The player REJECTED the outcome for interaction \"{}\".\n\ + Rejected outcome: \"{}\"\n\ + Player's correction: \"{}\"\n\n", + interaction_label, outcome_summary, correction_text + )); + } + None => { + prompt.push_str(&format!( + "The player CHOSE this outcome for interaction \"{}\":\n\ + Chosen outcome: \"{}\"\n\n", + interaction_label, outcome_summary + )); + } + } + + if !related_spec_nodes.is_empty() { + prompt.push_str("Related spec nodes (from the AI's annotation):\n"); + for node_id in related_spec_nodes { + prompt.push_str(&format!("- {}\n", node_id)); + } + prompt.push('\n'); + } + + prompt.push_str(&format!( + "Use the spec-forest tools to update the spec (spec_id: {spec_id}).\n\n\ + Instructions:\n\ + 1. First, use get_node to read the related spec nodes (if any) to understand context.\n\ + 2. If the choice confirms an existing spec answer, no update is needed — respond with \ + {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ + 3. If the choice contradicts or refines an existing answer, use answer_question to \ + update it. Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \ + \"description\": \"...\"}}.\n\ + 4. If the choice reveals new information not covered by any spec node, use \ + search_nodes to find the best parent, then add_children + answer_question \ + to add a new Q&A. Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \ + \"description\": \"...\"}}.\n\n\ + Respond with a JSON object describing what you did." + )); + + prompt +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 8b9eb6e..1b1ba0a 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,4 +1,6 @@ -use super::types::{SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse}; +use super::types::{ + GameTreeResponse, GameTreeRoot, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, +}; use std::error::Error; use std::time::Duration; @@ -491,6 +493,219 @@ fn parse_sim_report_response( .into()) } +// ── Game mode runner functions ──────────────────────────────────────── + +/// Start the first game-mode turn with game tree output. Returns (claude_session_id, game_tree_response). +pub async fn start_game_tree_turn( + config: &SimConfig, + prompt: &str, +) -> Result<(String, GameTreeResponse), Box> { + let mcp_config = serde_json::json!({ + "mcpServers": { + "spec-forest": { + "type": "http", + "url": config.mcp_url + } + } + }); + + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("json") + .arg("--model") + .arg(&config.model) + .arg("--system-prompt") + .arg(&config.system_prompt) + .arg("--mcp-config") + .arg(mcp_config.to_string()) + .arg("--allowedTools") + .arg(&config.allowed_tools) + .arg("-p") + .arg(prompt); + + if let Some(ref dir) = config.directory { + cmd.current_dir(dir); + } + + tracing::info!( + model = %config.model, + prompt_chars = prompt.len(), + "Starting game tree turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, session_id) = extract_cli_result(&raw_output)?; + tracing::info!( + response_chars = response_text.len(), + "Game initial tree turn complete" + ); + + let response = parse_game_tree_response(&response_text)?; + Ok((session_id, response)) +} + +/// Resume an existing game-mode session with game tree output. +pub async fn resume_game_tree_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("json") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming game tree turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, _) = extract_cli_result(&raw_output)?; + tracing::info!( + response_chars = response_text.len(), + "Game resume tree turn complete" + ); + + parse_game_tree_response(&response_text) +} + +/// Parse the agent's text response into a GameTreeResponse. +/// +/// Falls back to wrapping a flat SimResponse in a single-node game tree +/// with no choice groups if game tree parsing fails. +fn parse_game_tree_response(text: &str) -> Result> { + let trimmed = text.trim(); + + // Try direct game tree parse first + if let Ok(response) = serde_json::from_str::(trimmed) { + return Ok(response); + } + + // Try extracting from markdown code fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = &trimmed[start + 7..start + 7 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try extracting from plain code fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = &trimmed[start + 4..start + 4 + end].trim(); + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Try finding first { to last } + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + if start < end { + let json_str = &trimmed[start..=end]; + if let Ok(response) = serde_json::from_str::(json_str) { + return Ok(response); + } + } + } + + // Fallback: try parsing as flat SimResponse and wrap in single-node game tree + if let Ok(flat) = parse_sim_response(trimmed) { + tracing::warn!("Game tree parse failed, fell back to flat SimResponse"); + return Ok(GameTreeResponse { + root: GameTreeRoot { + node_id: String::new(), + channels: flat.channels, + decisions: flat.decisions, + choice_groups: vec![], + }, + }); + } + + Err(format!( + "Failed to parse game tree response as JSON. Raw response:\n{}", + &trimmed[..trimmed.floor_char_boundary(500)] + ) + .into()) +} + +/// Resume a game-mode session for a background spec update. +/// +/// Uses the spec update prompt and parses a simple JSON response describing +/// what spec changes were made. Returns the raw JSON string. +pub async fn resume_game_spec_update_turn( + claude_session_id: &str, + prompt: &str, +) -> Result> { + let mut cmd = tokio::process::Command::new("claude"); + cmd.arg("--print") + .arg("--output-format") + .arg("json") + .arg("--resume") + .arg(claude_session_id) + .arg("-p") + .arg(prompt); + + tracing::info!( + session_id = %claude_session_id, + prompt_chars = prompt.len(), + "Resuming game spec update turn" + ); + + let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { + Ok(result) => result?, + Err(_) => { + return Err("claude CLI timed out after 600 seconds".into()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + let raw_output = String::from_utf8(output.stdout)?; + let (response_text, _) = extract_cli_result(&raw_output)?; + tracing::info!( + response_chars = response_text.len(), + "Game spec update turn complete" + ); + + Ok(response_text) +} + #[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 6072373..f394dbf 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,4 +1,6 @@ -use super::types::{ChannelContent, Decision, SimReportResponse, SimTreeNode}; +use super::types::{ + ChannelContent, Decision, GameSpecUpdate, GameTreeRoot, SimReportResponse, SimTreeNode, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -104,6 +106,12 @@ pub struct SimSession { pub tree_generation: u64, /// Whether background pregeneration is in progress. pub pregenerating: bool, + /// Whether this session is in game mode (spec-updating play-through). + pub game_mode: bool, + /// The current game-mode interaction tree (set after each AI generation). + pub game_tree: Option, + /// Log of spec updates triggered by game choices during this session. + pub game_spec_updates: Vec, } impl SimSession { @@ -137,6 +145,9 @@ impl SimSession { pregen_target: None, tree_generation: 0, pregenerating: false, + game_mode: false, + game_tree: None, + game_spec_updates: Vec::new(), } } } diff --git a/crates/spec-forest/src/simulation/tree.rs b/crates/spec-forest/src/simulation/tree.rs index 38f7f15..b397015 100644 --- a/crates/spec-forest/src/simulation/tree.rs +++ b/crates/spec-forest/src/simulation/tree.rs @@ -1,4 +1,4 @@ -use super::types::{SimInput, SimTreeNode}; +use super::types::{GameTreeRoot, SimInput, SimTreeNode}; use uuid::Uuid; /// Recursively assign unique node IDs to every node in the tree. @@ -125,6 +125,18 @@ pub fn collect_path_history<'a>( history } +// ── Game tree helpers ───────────────────────────────────────────────── + +/// Recursively assign unique node IDs to every node in a game tree. +pub fn assign_game_node_ids(root: &mut GameTreeRoot) { + root.node_id = Uuid::new_v4().to_string(); + for group in &mut root.choice_groups { + for outcome in &mut group.outcomes { + assign_node_ids(&mut outcome.result); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/spec-forest/src/simulation/types.rs b/crates/spec-forest/src/simulation/types.rs index ee8e8e0..6b02bcd 100644 --- a/crates/spec-forest/src/simulation/types.rs +++ b/crates/spec-forest/src/simulation/types.rs @@ -100,6 +100,67 @@ pub struct SimTreeResponse { pub root: SimTreeNode, } +// ── Game Mode Types ────────────────────────────────────────────────── + +/// A group of outcomes for a single interaction in game mode. +/// +/// Unlike `PredictedInteraction` which has one result per interaction, +/// game mode presents multiple possible outcomes for the same user action, +/// letting the player pick the correct behavior. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameChoiceGroup { + /// Human-readable label for the interaction (e.g., "Press X"). + pub interaction_label: String, + /// The input this interaction represents. + pub input: SimInput, + /// Alternative outcomes the player chooses between. + pub outcomes: Vec, +} + +/// One possible outcome of an interaction in game mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameOutcome { + /// Short summary of what happens (e.g., "Starts audio playback"). + pub summary: String, + /// Full pre-computed simulation output for this outcome. + pub result: SimTreeNode, + /// Spec node IDs that this outcome relates to (for spec updates on selection). + #[serde(default)] + pub related_spec_nodes: Vec, +} + +/// The game-mode tree response envelope produced by the AI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameTreeResponse { + pub root: GameTreeRoot, +} + +/// Root node of a game-mode interaction tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameTreeRoot { + /// Unique identifier assigned server-side after parsing. + #[serde(default)] + pub node_id: String, + /// Channel outputs at this point in the game. + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, + /// Grouped interaction choices with multiple outcomes each. + #[serde(default)] + pub choice_groups: Vec, +} + +/// Record of a spec update triggered by a game choice or rejection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameSpecUpdate { + pub interaction_label: String, + pub outcome_summary: String, + /// Human-readable description of what was changed in the spec. + pub description: String, + /// Which spec node was affected. + pub node_id: String, +} + /// Structured input for behavior reporting. #[derive(Debug, Clone, Serialize)] pub struct SimReport { diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 16e0b54..7bb321e 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -540,6 +540,30 @@ impl AppState { .unwrap_or_default() } + /// Get the game choice groups from a game-mode session. + pub fn get_sim_game_choice_groups( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| s.game_tree.as_ref().map(|t| t.choice_groups.clone())) + .unwrap_or_default() + } + + /// Get the game spec updates log from a game-mode session. + pub fn get_sim_game_spec_updates( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .map(|s| s.game_spec_updates.clone()) + .unwrap_or_default() + } + /// Navigate back to the previous node in the simulation tree. /// Returns the node data (channels, decisions) if successful, None if already at root. pub fn sim_navigate_back( diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 9a9ca9e..f1201b8 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -257,6 +257,10 @@ pub struct SimCreateSessionParams { description = "Interaction tree branching factor: how many predicted interactions per node. Range 2-4. Default: 3" )] pub tree_branching: Option, + #[schemars( + description = "If true, enables game mode: the simulation presents alternative outcomes per interaction and the player's choices update the spec. Default: false" + )] + pub game_mode: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -321,3 +325,29 @@ pub struct SimGetChannelsParams { #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct SimListSessionsParams {} + +// -- Game mode tools -- + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GameSelectOutcomeParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the interaction group (0-based)")] + pub group_index: usize, + #[schemars(description = "Index of the outcome within the group (0-based)")] + pub outcome_index: usize, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GameRejectOutcomeParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars(description = "Index of the interaction group (0-based)")] + pub group_index: usize, + #[schemars(description = "Index of the outcome within the group (0-based)")] + pub outcome_index: usize, + #[schemars( + description = "What should happen instead. Describe the correct behavior that this interaction should produce." + )] + pub correction: String, +} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 3bfacb3..2aef2e3 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -944,6 +944,7 @@ impl SpecForestServer { session.whole_spec = whole_spec; session.tree_depth = tree_depth; session.tree_branching = tree_branching; + session.game_mode = params.game_mode.unwrap_or(false); self.state.set_sim_session(session); @@ -1013,15 +1014,26 @@ impl SpecForestServer { let state = self.state.clone(); let sid = params.session_id.clone(); let whole_spec = session.whole_spec; + let game_mode = session.game_mode; tokio::spawn(async move { - crate::simulation::orchestrate::orchestrate_initial_turn( - state, - sid, - whole_spec, - directory, - ) - .await; + if game_mode { + crate::simulation::orchestrate::orchestrate_game_initial_turn( + state, + sid, + whole_spec, + directory, + ) + .await; + } else { + crate::simulation::orchestrate::orchestrate_initial_turn( + state, + sid, + whole_spec, + directory, + ) + .await; + } }); Ok(CallToolResult::success(vec![Content::text( @@ -1287,6 +1299,9 @@ impl SpecForestServer { "has_pending_report": session.pending_report.is_some(), "at_leaf": at_leaf, "has_interactions": has_interactions, + "game_mode": session.game_mode, + "has_game_choices": session.game_tree.as_ref().map_or(false, |t| !t.choice_groups.is_empty()), + "game_spec_updates_count": session.game_spec_updates.len(), })) .unwrap(), )])) @@ -1493,6 +1508,225 @@ impl SpecForestServer { .unwrap(), )])) } + + // ── Game Mode Tools ────────────────────────────────────────────── + + #[tool(description = "Get available interaction choices with predicted outcomes for a game-mode session. Each choice group has an interaction label and multiple alternative outcomes. The player picks the correct (interaction, outcome) pair. Returns empty if not in game mode or no game tree available.")] + fn game_get_choices( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": false, + "choice_groups": [] + })) + .unwrap(), + )])); + } + + let choice_groups: Vec = session + .game_tree + .as_ref() + .map(|t| { + t.choice_groups + .iter() + .enumerate() + .map(|(gi, group)| { + let outcomes: Vec = group + .outcomes + .iter() + .enumerate() + .map(|(oi, outcome)| { + serde_json::json!({ + "outcome_index": oi, + "summary": outcome.summary, + "related_spec_nodes": outcome.related_spec_nodes, + }) + }) + .collect(); + serde_json::json!({ + "group_index": gi, + "interaction_label": group.interaction_label, + "outcomes": outcomes, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": true, + "choice_groups": choice_groups, + })) + .unwrap(), + )])) + } + + #[tool(description = "Select an (interaction, outcome) pair in game mode. The player has chosen which outcome is correct for this interaction. Updates channel contents from the selected outcome and spawns a background spec update. Session must be idle and in game mode.")] + fn game_select_outcome( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Err(ErrorData::invalid_params("Session is not in game mode", None)); + } + + match &session.status { + SimStatus::Idle => {} + _ => { + return Err(ErrorData::invalid_params( + "Session must be idle to select an outcome", + None, + )); + } + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let gi = params.group_index; + let oi = params.outcome_index; + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_game_select_outcome(state, sid, gi, oi) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "group_index": params.group_index, + "outcome_index": params.outcome_index, + })) + .unwrap(), + )])) + } + + #[tool(description = "Reject an outcome in game mode and provide a correction. The player says 'that's not what should happen' and describes the correct behavior. Triggers a background spec update with the correction and regenerates the game tree. Session must be idle and in game mode.")] + fn game_reject_outcome( + &self, + Parameters(params): Parameters, + ) -> Result { + use crate::simulation::SimStatus; + + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + if !session.game_mode { + return Err(ErrorData::invalid_params("Session is not in game mode", None)); + } + + match &session.status { + SimStatus::Idle => {} + _ => { + return Err(ErrorData::invalid_params( + "Session must be idle to reject an outcome", + None, + )); + } + } + + self.state.update_sim_session(¶ms.session_id, |s| { + s.status = SimStatus::Processing; + }); + + let state = self.state.clone(); + let sid = params.session_id.clone(); + let gi = params.group_index; + let oi = params.outcome_index; + let correction = params.correction.clone(); + tokio::spawn(async move { + crate::simulation::orchestrate::orchestrate_game_reject_outcome( + state, sid, gi, oi, correction, + ) + .await; + }); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "status": "processing", + "group_index": params.group_index, + "outcome_index": params.outcome_index, + })) + .unwrap(), + )])) + } + + #[tool(description = "Get the log of spec updates triggered by game choices during this session. Returns a list of changes made to the spec DAG as a result of the player's design decisions.")] + fn game_get_spec_updates( + &self, + Parameters(params): Parameters, + ) -> Result { + let session = self + .state + .get_sim_session(¶ms.session_id) + .ok_or_else(|| { + ErrorData::invalid_params( + format!("Session not found: {}", params.session_id), + None, + ) + })?; + + let updates: Vec = session + .game_spec_updates + .iter() + .map(|u| { + serde_json::json!({ + "interaction_label": u.interaction_label, + "outcome_summary": u.outcome_summary, + "description": u.description, + "node_id": u.node_id, + }) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "game_mode": session.game_mode, + "updates": updates, + })) + .unwrap(), + )])) + } } #[tool_handler] From ffb4e39a9b857ec581d12563f451a918695d9aeb Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:08:08 +1100 Subject: [PATCH 073/100] feat: prioritize high-entropy interactions in game mode prompts Update game mode cardinal rule and tree rules to steer the AI toward interactions that expose genuine spec ambiguity rather than obvious outcomes. At least half of interactions should target decision points where the player's choice resolves a meaningful specification question. --- crates/spec-forest/src/simulation/prompt.rs | 86 ++++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index 57b4bfa..c68b14a 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -762,29 +762,45 @@ matter and that the spec should clarify."# } fn build_game_cardinal_rule() -> String { - r#"## CARDINAL RULE: PRESENT ALTERNATIVE OUTCOMES FOR THE USER TO DESIGN BEHAVIOR -You are helping the user design their application by presenting alternative outcomes for -each interaction. The user will choose which outcome is correct, and their choices will -update the specification. + r#"## CARDINAL RULE: GUIDE THE PLAYER TOWARD HIGH-ENTROPY DESIGN DECISIONS +You are helping the user design their application through play. The user navigates the +simulated application, and at each step you present interactions that lead to meaningful +specification decisions. Their choices resolve ambiguities and update the spec. Mental model: -1. For each user interaction (e.g., pressing a key, clicking a button), generate multiple - plausible outcomes that a reasonable implementer might build. -2. Each outcome should be distinct and represent a meaningfully different design choice. -3. The user picks the outcome that matches their intended design. -4. Their choice becomes part of the specification. - -When generating outcomes: -- Each outcome for the same interaction should represent a genuinely different behavior, - not just cosmetic variations. Example: pressing X might "start audio playback" OR - "load a new sample" — these are meaningfully different. +1. The user is "playing" their future application to discover what it should do. +2. Your job is to steer them toward the INTERESTING decisions — the places where the spec + is silent or ambiguous and different implementers would make different choices. +3. When the user picks an outcome, that choice becomes part of the specification. +4. Low-entropy interactions (obvious, one-right-answer) are fine for navigation, but the + GOAL is to surface high-entropy decision points where the player's choice matters. + +## Interaction Selection Strategy +Choose interactions that LEAD TOWARD high-entropy situations: +- PRIORITIZE interactions that expose genuine design ambiguity — where the spec doesn't + specify what should happen and reasonable implementations would diverge. These are the + interactions where the player's choice teaches the spec something new. +- Include 1-2 "navigation" interactions (obvious outcomes) to keep the game moving, but + always include at least one interaction that reaches a genuine decision point. +- AVOID interactions where the outcome is completely obvious (e.g., "click expand arrow" → + "tree expands"). These waste the player's time without resolving any spec ambiguity. +- SEEK OUT interactions that touch unanswered spec nodes, spec gaps, or areas where the + spec is vague. These are the highest-value interactions. + +## Outcome Generation +For each interaction: +- HIGH-ENTROPY interactions (the spec is ambiguous): Generate 2-3 genuinely different + outcomes. Each should represent a plausible design direction an implementer might take. + Example: "Submit form" → "Shows inline validation errors" vs "Shows error modal" vs + "Navigates to error page". The player's choice RESOLVES the ambiguity. +- LOW-ENTROPY interactions (obvious result): Provide a single outcome. Don't fabricate + fake alternatives for interactions that have one clear answer. - Annotate each outcome with the spec node IDs that justify it (related_spec_nodes). If no spec node covers it, leave related_spec_nodes empty — this signals new information the spec needs. -- For low-entropy interactions (only one reasonable outcome), you may provide a single - outcome. Reserve multiple outcomes for genuine design decision points. -Apply the same ENTROPY test as simulation mode for spec_gaps."# +The goal is NOT to present a menu of random options. It is to DISCOVER the decisions the +spec needs to make, and let the player make them through natural interaction."# .to_string() } @@ -847,22 +863,29 @@ Schema: Active channels: {channel_list} ## Game Choice Tree Rules -1. Generate {branching} predicted interactions (choice_groups) — the most likely user actions. -2. Each choice_group should have 2-3 alternative outcomes representing meaningfully different - behaviors. If an interaction has only one reasonable outcome, 1 outcome is acceptable. -3. Each outcome's "result" contains the complete simulation state (all active channels + decisions) +1. Generate {branching} predicted interactions (choice_groups). +2. **Prioritize high-entropy interactions.** At least half of the choice_groups should target + genuine spec ambiguities — interactions where the spec is silent or vague and the player's + choice would resolve a meaningful design question. The remaining may be lower-entropy + interactions for navigation continuity. +3. HIGH-ENTROPY choice_groups should have 2-3 alternative outcomes representing meaningfully + different design directions. LOW-ENTROPY groups (one obvious answer) should have 1 outcome. +4. Each outcome's "result" contains the complete simulation state (all active channels + decisions) that would result from that outcome. Results do NOT contain nested choice_groups — the tree is one level deep. -4. Each outcome MUST include: +5. Each outcome MUST include: - **summary**: A concise description of what happens (shown alongside the interaction label). - **related_spec_nodes**: Array of spec node IDs that justify this outcome. Empty array if - the outcome is based on assumptions not covered by the spec. + the outcome is based on assumptions not covered by the spec. Outcomes with empty + related_spec_nodes are the highest value — they represent NEW information for the spec. - **result**: Complete simulation state with all active channels and decisions. -5. Make outcomes genuinely distinct. Bad: "Button turns blue" vs "Button turns dark blue". - Good: "Opens settings panel" vs "Starts audio playback". -6. Every node (root and all results) must include entries for ALL active channels. -7. Every node must include a non-empty decisions array. -8. The [^N] marker namespace is PER NODE — each node's refs are self-contained."#, +6. Make outcomes genuinely distinct. Bad: "Button turns blue" vs "Button turns dark blue". + Good: "Shows inline error" vs "Opens error modal" vs "Redirects to error page". +7. Every node (root and all results) must include entries for ALL active channels. +8. Every node must include a non-empty decisions array. +9. The [^N] marker namespace is PER NODE — each node's refs are self-contained. +10. When choosing interactions, consult the spec for unanswered nodes, nodes flagged for review, + and areas with known spec_gaps — these are the richest sources of high-entropy interactions."#, channel_list = channel_list, branching = branching, ) @@ -910,8 +933,11 @@ pub fn build_game_resume_prompt( prompt.push_str( "Generate the next game choice tree from the current state. \ Respond ONLY with a valid JSON object matching the game choice tree format. \ - Present alternative outcomes for each interaction so the player can design \ - the correct behavior. Annotate each outcome with related_spec_nodes.", + PRIORITIZE interactions that lead to high-entropy decision points — places where \ + the spec is ambiguous or silent and the player's choice would resolve a meaningful \ + design question. Use search_nodes and get_descendants to find unanswered nodes and \ + spec gaps that could inform your interaction choices. Include navigation interactions \ + to keep the game moving, but always surface at least one genuine design decision.", ); prompt From e79b1f2762c7299e5cb27edd4531c6b8529307dd Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:23:52 +1100 Subject: [PATCH 074/100] feat: add breadcrumb navigation trail for simulation interactions Show a breadcrumb bar (Start > Login > Email > Submit) in the TUI simulation view, allowing users to see their full interaction path and jump to any previous point. Press 'b' to focus the trail, use left/right to select, Enter to jump. Also exposes breadcrumbs via the sim_get_status MCP tool and adds a sim_navigate_to tool. --- crates/spec-forest-tui/src/action.rs | 5 ++ crates/spec-forest-tui/src/app.rs | 78 +++++++++++++++++- crates/spec-forest-tui/src/input.rs | 15 ++++ crates/spec-forest-tui/src/simulation.rs | 6 ++ crates/spec-forest-tui/src/ui/simulation.rs | 89 ++++++++++++++++++++- crates/spec-forest/src/simulation.rs | 1 + crates/spec-forest/src/simulation/tree.rs | 87 ++++++++++++++++++++ crates/spec-forest/src/state.rs | 41 ++++++++++ crates/spec-forest/src/tool_types.rs | 10 +++ crates/spec-forest/src/tools.rs | 33 ++++++++ 10 files changed, 360 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index d4cf992..a8f0141 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -137,6 +137,11 @@ pub enum Action { SimInteractionDown, SimConfirmInteraction, SimNavigateBack, + SimBreadcrumbFocus, + SimBreadcrumbLeft, + SimBreadcrumbRight, + SimBreadcrumbSelect, + SimBreadcrumbCancel, SimCloseOverlay, SimMouseClick { column: u16, row: u16 }, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index c7b3e1a..7e5bbbb 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -333,12 +333,12 @@ impl App { } // Simulation screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::Simulation { .. }) { - let (mode, game_mode, reject_mode) = self + let (mode, game_mode, reject_mode, breadcrumb_focused) = self .sim_state .as_ref() - .map(|s| (s.mode, s.game_mode, s.reject_mode)) - .unwrap_or((crate::simulation::SimInputMode::Normal, false, false)); - let action = input::map_sim_key(key, modifiers, mode, game_mode, reject_mode); + .map(|s| (s.mode, s.game_mode, s.reject_mode, s.breadcrumb_selected.is_some())) + .unwrap_or((crate::simulation::SimInputMode::Normal, false, false, false)); + let action = input::map_sim_key(key, modifiers, mode, game_mode, reject_mode, breadcrumb_focused); self.execute_action(action).await; return; } @@ -1006,10 +1006,75 @@ impl App { sim.selected_interaction = 0; sim.can_go_back = self.state.get_sim_nav_depth(&sim.session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&sim.session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); } } } } + Action::SimBreadcrumbFocus => { + if let Some(ref mut sim) = self.sim_state { + if sim.breadcrumbs.len() > 1 && !sim.processing { + sim.breadcrumb_selected = Some(sim.breadcrumbs.len() - 1); + } + } + } + Action::SimBreadcrumbLeft => { + if let Some(ref mut sim) = self.sim_state { + if let Some(ref mut idx) = sim.breadcrumb_selected { + if *idx > 0 { + *idx -= 1; + } + } + } + } + Action::SimBreadcrumbRight => { + if let Some(ref mut sim) = self.sim_state { + if let Some(ref mut idx) = sim.breadcrumb_selected { + if *idx + 1 < sim.breadcrumbs.len() { + *idx += 1; + } + } + } + } + Action::SimBreadcrumbSelect => { + if let Some(ref mut sim) = self.sim_state { + if let Some(idx) = sim.breadcrumb_selected { + if let Some((node_id, _)) = sim.breadcrumbs.get(idx).cloned() { + if let Some((_channels, _decisions)) = + self.state.sim_navigate_to(&sim.session_id, &node_id) + { + if let Some(contents) = + self.state.get_sim_channel_contents(&sim.session_id) + { + sim.channel_contents = contents; + } + sim.decisions = + self.state.get_sim_decisions(&sim.session_id); + sim.interactions = + self.state.get_sim_interactions(&sim.session_id); + sim.selected_interaction = 0; + sim.can_go_back = + self.state.get_sim_nav_depth(&sim.session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&sim.session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); + } + } + sim.breadcrumb_selected = None; + } + } + } + Action::SimBreadcrumbCancel => { + if let Some(ref mut sim) = self.sim_state { + sim.breadcrumb_selected = None; + } + } Action::SimRefDigit(c) => { if let Some(ref mut sim) = self.sim_state { sim.ref_digit_buffer.push(c); @@ -2385,6 +2450,11 @@ impl App { } sim.can_go_back = self.state.get_sim_nav_depth(&session_id) > 1; + let crumbs = self.state.get_sim_breadcrumbs(&session_id); + sim.breadcrumbs = crumbs + .into_iter() + .map(|b| (b.node_id, b.label)) + .collect(); } } } diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 0666110..9b5b3c2 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -58,10 +58,14 @@ pub fn map_sim_key( mode: SimInputMode, game_mode: bool, reject_mode: bool, + breadcrumb_focused: bool, ) -> Action { if reject_mode { return map_game_reject_key(key, modifiers); } + if breadcrumb_focused { + return map_breadcrumb_key(key); + } match mode { SimInputMode::Normal if game_mode => map_game_normal_key(key), SimInputMode::Normal => map_sim_normal_key(key), @@ -69,6 +73,16 @@ pub fn map_sim_key( } } +fn map_breadcrumb_key(key: KeyCode) -> Action { + match key { + KeyCode::Left | KeyCode::Char('h') => Action::SimBreadcrumbLeft, + KeyCode::Right | KeyCode::Char('l') => Action::SimBreadcrumbRight, + KeyCode::Enter => Action::SimBreadcrumbSelect, + KeyCode::Esc | KeyCode::Char('b') => Action::SimBreadcrumbCancel, + _ => Action::Noop, + } +} + fn map_sim_normal_key(key: KeyCode) -> Action { match key { KeyCode::Char('i') => Action::SimEnterInsert, @@ -85,6 +99,7 @@ fn map_sim_normal_key(key: KeyCode) -> Action { KeyCode::Down => Action::SimInteractionDown, KeyCode::Enter => Action::SimConfirmInteraction, KeyCode::Backspace => Action::SimNavigateBack, + KeyCode::Char('b') => Action::SimBreadcrumbFocus, _ => Action::Noop, } } diff --git a/crates/spec-forest-tui/src/simulation.rs b/crates/spec-forest-tui/src/simulation.rs index cfbc741..dbdf635 100644 --- a/crates/spec-forest-tui/src/simulation.rs +++ b/crates/spec-forest-tui/src/simulation.rs @@ -68,6 +68,10 @@ pub struct SimulationState { pub selected_interaction: usize, /// Whether the user can navigate back (not at root). pub can_go_back: bool, + /// Breadcrumb trail: (node_id, label) pairs from root to current position. + pub breadcrumbs: Vec<(String, String)>, + /// When Some, the breadcrumb bar is focused and this is the selected index. + pub breadcrumb_selected: Option, /// Whether background pregeneration is in progress. pub pregenerating: bool, pub ref_digit_buffer: String, @@ -114,6 +118,8 @@ impl SimulationState { interactions: Vec::new(), selected_interaction: 0, can_go_back: false, + breadcrumbs: Vec::new(), + breadcrumb_selected: None, pregenerating: false, ref_digit_buffer: String::new(), ref_digit_start_tick: None, diff --git a/crates/spec-forest-tui/src/ui/simulation.rs b/crates/spec-forest-tui/src/ui/simulation.rs index 64109fa..4b2f1a3 100644 --- a/crates/spec-forest-tui/src/ui/simulation.rs +++ b/crates/spec-forest-tui/src/ui/simulation.rs @@ -49,10 +49,15 @@ pub fn render(app: &App, frame: &mut Frame) { (sim.interactions.len().min(6) + 2) as u16 }; + let has_breadcrumbs = sim.breadcrumbs.len() > 1; + let mut constraints = vec![ Constraint::Length(1), // tab bar - Constraint::Min(3), // channel content ]; + if has_breadcrumbs { + constraints.push(Constraint::Length(1)); // breadcrumb bar + } + constraints.push(Constraint::Min(3)); // channel content if has_decisions { constraints.push(Constraint::Length(decisions_height)); // decisions panel } @@ -70,6 +75,10 @@ pub fn render(app: &App, frame: &mut Frame) { let mut idx = 0; render_tab_bar(app, frame, chunks[idx]); idx += 1; + if has_breadcrumbs { + render_breadcrumb_bar(sim, frame, chunks[idx]); + idx += 1; + } // Store channel content area for mouse click coordinate mapping app.sim_content_area.set(Some(chunks[idx])); render_channel_content(app, frame, chunks[idx]); @@ -144,6 +153,81 @@ fn render_tab_bar(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(Line::from(line_spans)), area); } +fn render_breadcrumb_bar( + sim: &crate::simulation::SimulationState, + frame: &mut Frame, + area: Rect, +) { + let focused = sim.breadcrumb_selected.is_some(); + let width = area.width as usize; + let sep = " > "; + + // Build the full breadcrumb string to check if it fits. + let full_len: usize = sim + .breadcrumbs + .iter() + .map(|(_, l)| l.len()) + .sum::() + + sep.len() * sim.breadcrumbs.len().saturating_sub(1); + + // Determine which breadcrumbs to show. If the full trail fits, show all. + // Otherwise, show "Start", ellipsis, and the last entries that fit. + let (entries, offset): (Vec<(usize, &str)>, usize) = if full_len <= width || sim.breadcrumbs.len() <= 3 { + ( + sim.breadcrumbs.iter().enumerate().map(|(i, (_, l))| (i, l.as_str())).collect(), + 0, + ) + } else { + // Always show first ("Start") + " > … > " + as many trailing as fit + let prefix_cost = sim.breadcrumbs[0].1.len() + " > … > ".len(); + let remaining = width.saturating_sub(prefix_cost); + let mut count = 0; + let mut used = 0; + for (_, label) in sim.breadcrumbs.iter().rev() { + let cost = label.len() + if count > 0 { sep.len() } else { 0 }; + if used + cost > remaining && count > 0 { + break; + } + used += cost; + count += 1; + } + let start = sim.breadcrumbs.len() - count; + let mut entries: Vec<(usize, &str)> = Vec::with_capacity(count + 2); + entries.push((0, sim.breadcrumbs[0].1.as_str())); + entries.push((usize::MAX, "…")); // sentinel for ellipsis + for i in start..sim.breadcrumbs.len() { + entries.push((i, sim.breadcrumbs[i].1.as_str())); + } + (entries, 0) + }; + let _ = offset; + + let dim = Style::default().fg(Color::DarkGray); + let normal = Style::default().fg(Color::Gray); + let selected_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let sep_style = if focused { normal } else { dim }; + + let mut spans = Vec::new(); + for (i, (real_idx, label)) in entries.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(sep, sep_style)); + } + let style = if *real_idx == usize::MAX { + // Ellipsis marker + dim + } else if focused && sim.breadcrumb_selected == Some(*real_idx) { + selected_style + } else if focused { + normal + } else { + dim + }; + spans.push(Span::styled(label.to_string(), style)); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + fn render_channel_content(app: &App, frame: &mut Frame, area: Rect) { let sim = app.sim_state.as_ref().unwrap(); @@ -394,6 +478,9 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { if sim.can_go_back { items.push(("Bksp", "Back")); } + if sim.breadcrumbs.len() > 1 { + items.push(("b", "Trail")); + } items.extend_from_slice(&[("Tab", "Channel"), ("Esc", "Background")]); if sim.interactions.is_empty() && sim.game_choice_groups.is_empty() { items.push(("?", "Help")); diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 76cf224..34f36ee 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -13,6 +13,7 @@ pub use prompt::{ build_system_prompt_with_tree, build_tree_output_format, build_tree_resume_prompt, }; pub use session::{SimChannel, SimSession, SimStatus}; +pub use tree::BreadcrumbEntry; pub use types::{ ChannelContent, Decision, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, GameTreeRoot, NodeRef, PredictedInteraction, SimInput, SimReport, SimReportResponse, diff --git a/crates/spec-forest/src/simulation/tree.rs b/crates/spec-forest/src/simulation/tree.rs index b397015..c28cc15 100644 --- a/crates/spec-forest/src/simulation/tree.rs +++ b/crates/spec-forest/src/simulation/tree.rs @@ -1,6 +1,14 @@ use super::types::{GameTreeRoot, SimInput, SimTreeNode}; +use serde::Serialize; use uuid::Uuid; +/// A single entry in the breadcrumb trail through the interaction tree. +#[derive(Debug, Clone, Serialize)] +pub struct BreadcrumbEntry { + pub node_id: String, + pub label: String, +} + /// Recursively assign unique node IDs to every node in the tree. /// Called after parsing the AI response (the AI does not produce IDs). pub fn assign_node_ids(node: &mut SimTreeNode) { @@ -125,6 +133,44 @@ pub fn collect_path_history<'a>( history } +/// Build a breadcrumb trail from the root to the current position. +/// Returns a vec of (node_id, label) pairs. The root is labelled "Start"; +/// each subsequent entry uses the label of the interaction that led to it. +pub fn collect_breadcrumbs(tree: &SimTreeNode, path: &[String]) -> Vec { + let mut crumbs = Vec::new(); + if path.is_empty() { + return crumbs; + } + + crumbs.push(BreadcrumbEntry { + node_id: path[0].clone(), + label: "Start".to_string(), + }); + + let mut current = tree; + for target_id in path.iter().skip(1) { + let mut found = false; + for interaction in ¤t.interactions { + if let Some(ref result) = interaction.result { + if result.node_id == *target_id { + crumbs.push(BreadcrumbEntry { + node_id: target_id.clone(), + label: interaction.label.clone(), + }); + current = result; + found = true; + break; + } + } + } + if !found { + break; + } + } + + crumbs +} + // ── Game tree helpers ───────────────────────────────────────────────── /// Recursively assign unique node IDs to every node in a game tree. @@ -280,4 +326,45 @@ mod tests { assert_eq!(history[0].0.raw_text, "\n"); assert_eq!(history[0].1.channels["ui"].text, "Login form"); } + + #[test] + fn test_collect_breadcrumbs() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + let child_id = tree.interactions[0] + .result + .as_ref() + .unwrap() + .node_id + .clone(); + + let path = vec![root_id.clone(), child_id.clone()]; + let crumbs = collect_breadcrumbs(&tree, &path); + assert_eq!(crumbs.len(), 2); + assert_eq!(crumbs[0].label, "Start"); + assert_eq!(crumbs[0].node_id, root_id); + assert_eq!(crumbs[1].label, "Click Login"); + assert_eq!(crumbs[1].node_id, child_id); + } + + #[test] + fn test_collect_breadcrumbs_root_only() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let root_id = tree.node_id.clone(); + + let path = vec![root_id.clone()]; + let crumbs = collect_breadcrumbs(&tree, &path); + assert_eq!(crumbs.len(), 1); + assert_eq!(crumbs[0].label, "Start"); + } + + #[test] + fn test_collect_breadcrumbs_empty_path() { + let mut tree = make_tree(); + assign_node_ids(&mut tree); + let crumbs = collect_breadcrumbs(&tree, &[]); + assert!(crumbs.is_empty()); + } } diff --git a/crates/spec-forest/src/state.rs b/crates/spec-forest/src/state.rs index 7bb321e..ac3d44e 100644 --- a/crates/spec-forest/src/state.rs +++ b/crates/spec-forest/src/state.rs @@ -588,6 +588,47 @@ impl AppState { }) } + /// Navigate to a specific node in the simulation breadcrumb trail. + /// The node_id must be present in the current navigation_path. + /// Truncates the path to end at that node and returns its data. + pub fn sim_navigate_to( + &self, + session_id: &str, + node_id: &str, + ) -> Option<( + std::collections::HashMap, + Vec, + )> { + self.sim_sessions.lock().get_mut(session_id).and_then(|s| { + let idx = s.navigation_path.iter().position(|id| id == node_id)?; + s.navigation_path.truncate(idx + 1); + s.current_node_id = Some(node_id.to_string()); + let tree = s.interaction_tree.as_ref()?; + let node = crate::simulation::tree::find_node(tree, node_id)?; + s.channel_contents = node.channels.clone(); + s.decisions = node.decisions.clone(); + Some((node.channels.clone(), node.decisions.clone())) + }) + } + + /// Get the breadcrumb trail for the current simulation position. + pub fn get_sim_breadcrumbs( + &self, + session_id: &str, + ) -> Vec { + self.sim_sessions + .lock() + .get(session_id) + .and_then(|s| { + let tree = s.interaction_tree.as_ref()?; + Some(crate::simulation::tree::collect_breadcrumbs( + tree, + &s.navigation_path, + )) + }) + .unwrap_or_default() + } + /// Check if background pregeneration is running for a simulation session. pub fn get_sim_pregenerating(&self, session_id: &str) -> bool { self.sim_sessions diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index f1201b8..9bcead7 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -326,6 +326,16 @@ pub struct SimGetChannelsParams { #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct SimListSessionsParams {} +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SimNavigateToParams { + #[schemars(description = "Simulation session ID")] + pub session_id: String, + #[schemars( + description = "Node ID to navigate to. Must be a node_id from the breadcrumbs array returned by sim_get_status." + )] + pub node_id: String, +} + // -- Game mode tools -- #[derive(Debug, Default, Deserialize, JsonSchema)] diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 2aef2e3..58bda9a 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1288,6 +1288,8 @@ impl SpecForestServer { }) .unwrap_or(false); + let breadcrumbs = self.state.get_sim_breadcrumbs(¶ms.session_id); + Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&serde_json::json!({ "session_id": params.session_id, @@ -1302,6 +1304,7 @@ impl SpecForestServer { "game_mode": session.game_mode, "has_game_choices": session.game_tree.as_ref().map_or(false, |t| !t.choice_groups.is_empty()), "game_spec_updates_count": session.game_spec_updates.len(), + "breadcrumbs": breadcrumbs, })) .unwrap(), )])) @@ -1425,6 +1428,36 @@ impl SpecForestServer { )])) } + #[tool(description = "Navigate to a specific point in the simulation breadcrumb trail. Use node_id values from the breadcrumbs array in sim_get_status. This allows jumping back to any previous position in the interaction history.")] + fn sim_navigate_to( + &self, + Parameters(params): Parameters, + ) -> Result { + match self + .state + .sim_navigate_to(¶ms.session_id, ¶ms.node_id) + { + Some(_) => { + let breadcrumbs = self.state.get_sim_breadcrumbs(¶ms.session_id); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({ + "session_id": params.session_id, + "navigated_to": params.node_id, + "breadcrumbs": breadcrumbs, + })) + .unwrap(), + )])) + } + None => Err(ErrorData::invalid_params( + format!( + "Cannot navigate to node '{}': not found in current breadcrumb trail", + params.node_id + ), + None, + )), + } + } + #[tool(description = "Get the pending report explanation from the most recent sim_ask_report call. Consumes the report (subsequent calls return null until a new report is requested). Returns the explanation with spec node references.")] fn sim_get_report( &self, From 627e1c4f55ed49daecc19b4636eaff1540941f85 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:27:43 +1100 Subject: [PATCH 075/100] fix: stricter JSON-only prompts and DRY up response parsing LLM responses sometimes include prose or markdown code fences around JSON, causing parse failures in simulation and game mode. Strengthen all prompts to explicitly forbid non-JSON output and extract a shared generic `extract_json()` helper to replace duplicated 4-step extraction logic across all four parse functions. --- crates/spec-forest/src/simulation/prompt.rs | 13 +- crates/spec-forest/src/simulation/runner.rs | 202 ++++++-------------- 2 files changed, 72 insertions(+), 143 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index c68b14a..f422954 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -169,7 +169,8 @@ fn build_system_prompt_inner_v2( format!( r#"{cardinal_rule} -Your responses MUST be valid JSON. +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap the JSON in code fences. Output raw JSON only — the very first character of your response must be `{{`. ## Focus Node (Primary Context) This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. @@ -398,7 +399,8 @@ fn build_system_prompt_whole_spec_inner_v2( format!( r#"{cardinal_rule} -Your responses MUST be valid JSON. +## CRITICAL: JSON-ONLY OUTPUT +Your ENTIRE response must be a single valid JSON object. Do NOT include any text, explanation, or markdown before or after the JSON. Do NOT wrap the JSON in code fences. Output raw JSON only — the very first character of your response must be `{{`. ## Focus Node (Primary Context) This simulation is focused on the following specification node. All behavior should be grounded in this node and its subtree. @@ -723,6 +725,8 @@ pub fn build_tree_resume_prompt( prompt.push_str( "Generate the next interaction tree from the current state. \ Respond ONLY with a valid JSON object matching the interaction tree format. \ + Do NOT include any text before or after the JSON. Do NOT use code fences. \ + The first character of your response must be `{`. \ Simulate the application as an implementer would build it from the spec. \ Cite spec nodes that drive specific behaviors. Only flag spec_gaps for high-entropy \ decisions where the spec is genuinely ambiguous — not for obvious implementation details.", @@ -933,6 +937,8 @@ pub fn build_game_resume_prompt( prompt.push_str( "Generate the next game choice tree from the current state. \ Respond ONLY with a valid JSON object matching the game choice tree format. \ + Do NOT include any text before or after the JSON. Do NOT use code fences. \ + The first character of your response must be `{`. \ PRIORITIZE interactions that lead to high-entropy decision points — places where \ the spec is ambiguous or silent and the player's choice would resolve a meaningful \ design question. Use search_nodes and get_descendants to find unanswered nodes and \ @@ -997,7 +1003,8 @@ pub fn build_game_spec_update_prompt( search_nodes to find the best parent, then add_children + answer_question \ to add a new Q&A. Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \ \"description\": \"...\"}}.\n\n\ - Respond with a JSON object describing what you did." + Respond with a raw JSON object describing what you did. \ + No markdown, no explanation, no code fences — raw JSON only." )); prompt diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 1b1ba0a..be1933a 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -28,6 +28,55 @@ fn extract_cli_result(raw: &str) -> Result<(String, String), Box(text: &str) -> Option { + let trimmed = text.trim(); + + // 1. Direct parse + if let Ok(v) = serde_json::from_str::(trimmed) { + return Some(v); + } + + // 2. ```json fence + if let Some(start) = trimmed.find("```json") { + if let Some(end) = trimmed[start + 7..].find("```") { + let json_str = trimmed[start + 7..start + 7 + end].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + return Some(v); + } + } + } + + // 3. Plain ``` fence + if let Some(start) = trimmed.find("```\n") { + if let Some(end) = trimmed[start + 4..].find("```") { + let json_str = trimmed[start + 4..start + 4 + end].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + return Some(v); + } + } + } + + // 4. First `{` to last `}` + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { + if start < end { + let json_str = &trimmed[start..=end]; + if let Ok(v) = serde_json::from_str::(json_str) { + return Some(v); + } + } + } + + None +} + /// Configuration for starting a simulation turn. pub struct SimConfig { pub model: String, @@ -185,8 +234,9 @@ pub async fn resume_sim_report_turn( let prompt = format!( "{}\n\n\ The user is reporting unexpected behavior in the simulation. \ - Do NOT update the simulation channels. Instead, respond ONLY with a JSON object:\n\ - {{\"explanation\": \"your explanation here with [^N] markers\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ + Do NOT update the simulation channels. Respond with ONLY a raw JSON object — no text, \ + no markdown, no code fences. The first character of your response must be {{.\n\ + Schema: {{\"explanation\": \"your explanation here with [^N] markers\", \"refs\": [{{\"marker\": \"[^1]\", \"node_id\": \"uuid\"}}]}}\n\n\ Explain WHY the simulation behaves this way, citing spec nodes with [^N] markers \ and corresponding entries in the refs array. If the user found a genuine spec gap, \ acknowledge it and explain what is missing from the spec.", @@ -339,45 +389,12 @@ pub async fn resume_sim_tree_turn( /// Falls back to wrapping a flat SimResponse in a single-node tree /// if tree parsing fails but flat parsing succeeds. fn parse_sim_tree_response(text: &str) -> Result> { - let trimmed = text.trim(); - - // Try direct tree parse first - if let Ok(response) = serde_json::from_str::(trimmed) { + if let Some(response) = extract_json::(text) { return Ok(response); } - // Try extracting from markdown code fence - if let Some(start) = trimmed.find("```json") { - if let Some(end) = trimmed[start + 7..].find("```") { - let json_str = &trimmed[start + 7..start + 7 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try extracting from plain code fence - if let Some(start) = trimmed.find("```\n") { - if let Some(end) = trimmed[start + 4..].find("```") { - let json_str = &trimmed[start + 4..start + 4 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try finding first { to last } - if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { - if start < end { - let json_str = &trimmed[start..=end]; - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - // Fallback: try parsing as flat SimResponse and wrap in single-node tree - if let Ok(flat) = parse_sim_response(trimmed) { + if let Ok(flat) = parse_sim_response(text) { tracing::warn!("Tree parse failed, fell back to flat SimResponse"); return Ok(SimTreeResponse { root: SimTreeNode { @@ -389,6 +406,7 @@ fn parse_sim_tree_response(text: &str) -> Result Result Result> { - let trimmed = text.trim(); - - // Try direct parse first - if let Ok(response) = serde_json::from_str::(trimmed) { + if let Some(response) = extract_json::(text) { return Ok(response); } - // Try extracting from markdown code fence - if let Some(start) = trimmed.find("```json") { - if let Some(end) = trimmed[start + 7..].find("```") { - let json_str = &trimmed[start + 7..start + 7 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try extracting from plain code fence - if let Some(start) = trimmed.find("```\n") { - if let Some(end) = trimmed[start + 4..].find("```") { - let json_str = &trimmed[start + 4..start + 4 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try finding first { to last } - if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { - if start < end { - let json_str = &trimmed[start..=end]; - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - + let trimmed = text.trim(); Err(format!( "Failed to parse simulation response as JSON. Raw response:\n{}", &trimmed[..trimmed.floor_char_boundary(500)] @@ -449,43 +435,11 @@ fn parse_sim_response(text: &str) -> Result Result> { - let trimmed = text.trim(); - - // Try direct parse first - if let Ok(response) = serde_json::from_str::(trimmed) { + if let Some(response) = extract_json::(text) { return Ok(response); } - // Try extracting from markdown code fence - if let Some(start) = trimmed.find("```json") { - if let Some(end) = trimmed[start + 7..].find("```") { - let json_str = &trimmed[start + 7..start + 7 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try extracting from plain code fence - if let Some(start) = trimmed.find("```\n") { - if let Some(end) = trimmed[start + 4..].find("```") { - let json_str = &trimmed[start + 4..start + 4 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try finding first { to last } - if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { - if start < end { - let json_str = &trimmed[start..=end]; - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - + let trimmed = text.trim(); Err(format!( "Failed to parse simulation report response as JSON. Raw response:\n{}", &trimmed[..trimmed.floor_char_boundary(500)] @@ -604,45 +558,12 @@ pub async fn resume_game_tree_turn( /// Falls back to wrapping a flat SimResponse in a single-node game tree /// with no choice groups if game tree parsing fails. fn parse_game_tree_response(text: &str) -> Result> { - let trimmed = text.trim(); - - // Try direct game tree parse first - if let Ok(response) = serde_json::from_str::(trimmed) { + if let Some(response) = extract_json::(text) { return Ok(response); } - // Try extracting from markdown code fence - if let Some(start) = trimmed.find("```json") { - if let Some(end) = trimmed[start + 7..].find("```") { - let json_str = &trimmed[start + 7..start + 7 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try extracting from plain code fence - if let Some(start) = trimmed.find("```\n") { - if let Some(end) = trimmed[start + 4..].find("```") { - let json_str = &trimmed[start + 4..start + 4 + end].trim(); - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - - // Try finding first { to last } - if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { - if start < end { - let json_str = &trimmed[start..=end]; - if let Ok(response) = serde_json::from_str::(json_str) { - return Ok(response); - } - } - } - // Fallback: try parsing as flat SimResponse and wrap in single-node game tree - if let Ok(flat) = parse_sim_response(trimmed) { + if let Ok(flat) = parse_sim_response(text) { tracing::warn!("Game tree parse failed, fell back to flat SimResponse"); return Ok(GameTreeResponse { root: GameTreeRoot { @@ -654,6 +575,7 @@ fn parse_game_tree_response(text: &str) -> Result Date: Wed, 1 Apr 2026 17:39:18 +1100 Subject: [PATCH 076/100] feat: switch simulation runner to stream-json for tool call visibility Simulation init was slow with no way to see what Claude was doing. Switch from --output-format json to stream-json, parse NDJSON to extract the same final result while logging tool calls at info level and all intermediate events at debug level. --- crates/spec-forest/src/simulation/runner.rs | 171 ++++++++++++++++---- 1 file changed, 142 insertions(+), 29 deletions(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index be1933a..ffab7de 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -6,26 +6,87 @@ use std::time::Duration; const CLAUDE_TIMEOUT: Duration = Duration::from_secs(600); -/// Wrapper for the Claude CLI `--output-format json` envelope. -/// -/// Using JSON output format ensures we get only the final assistant text -/// in the `result` field, excluding intermediate tool call/result content -/// that would otherwise be concatenated in `--output-format text`. +/// A single line from `--output-format stream-json` NDJSON output. +/// Only the fields we care about are deserialized; unknown fields are ignored. #[derive(serde::Deserialize)] -struct ClaudeCliOutput { - result: String, - session_id: String, +struct StreamEvent { + #[serde(rename = "type")] + event_type: String, + result: Option, + session_id: Option, + content_block: Option, } -/// Extract the assistant's final text and session ID from the CLI JSON envelope. -fn extract_cli_result(raw: &str) -> Result<(String, String), Box> { - let output: ClaudeCliOutput = serde_json::from_str(raw).map_err(|e| { +#[derive(serde::Deserialize)] +struct ContentBlock { + #[serde(rename = "type")] + block_type: String, + name: Option, +} + +/// Parse NDJSON from `--output-format stream-json` and extract the final result. +/// +/// Returns (result_text, session_id) — the same contract as the old JSON envelope. +/// Logs tool call metadata via tracing as a side-effect. +fn extract_stream_result(raw: &str) -> Result<(String, String), Box> { + let mut result_text: Option = None; + let mut session_id: Option = None; + let mut tool_names: Vec = Vec::new(); + + for line in raw.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + tracing::debug!( + event = &line[..line.len().min(200)], + "stream-json event" + ); + + let event: StreamEvent = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => continue, + }; + + match event.event_type.as_str() { + "result" => { + result_text = event.result; + if event.session_id.is_some() { + session_id = event.session_id; + } + } + "content_block_start" => { + if let Some(cb) = &event.content_block { + if cb.block_type == "tool_use" { + if let Some(name) = &cb.name { + tool_names.push(name.clone()); + } + } + } + } + _ => {} + } + } + + if !tool_names.is_empty() { + tracing::info!( + tool_count = tool_names.len(), + tools = ?tool_names, + "Tool calls during Claude session" + ); + } + + let result_text = result_text.ok_or_else(|| { format!( - "Failed to parse Claude CLI JSON output: {e}. Raw:\n{}", + "No 'result' event found in stream-json output. Raw (truncated):\n{}", &raw[..raw.len().min(500)] ) })?; - Ok((output.result, output.session_id)) + + let session_id = session_id.unwrap_or_default(); + + Ok((result_text, session_id)) } /// Try to extract and deserialize a JSON object from text that may contain @@ -127,7 +188,7 @@ pub async fn start_sim_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -162,7 +223,7 @@ pub async fn start_sim_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_cli_result(&raw_output)?; + let (response_text, session_id) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation initial turn complete" @@ -189,7 +250,7 @@ pub async fn resume_sim_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -214,7 +275,7 @@ pub async fn resume_sim_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_cli_result(&raw_output)?; + let (response_text, _) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation resume turn complete" @@ -246,7 +307,7 @@ pub async fn resume_sim_report_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -271,7 +332,7 @@ pub async fn resume_sim_report_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_cli_result(&raw_output)?; + let (response_text, _) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation report turn complete" @@ -297,7 +358,7 @@ pub async fn start_sim_tree_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -332,7 +393,7 @@ pub async fn start_sim_tree_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_cli_result(&raw_output)?; + let (response_text, session_id) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation initial tree turn complete" @@ -350,7 +411,7 @@ pub async fn resume_sim_tree_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -375,7 +436,7 @@ pub async fn resume_sim_tree_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_cli_result(&raw_output)?; + let (response_text, _) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Simulation resume tree turn complete" @@ -466,7 +527,7 @@ pub async fn start_game_tree_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -501,7 +562,7 @@ pub async fn start_game_tree_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_cli_result(&raw_output)?; + let (response_text, session_id) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Game initial tree turn complete" @@ -519,7 +580,7 @@ pub async fn resume_game_tree_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -544,7 +605,7 @@ pub async fn resume_game_tree_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_cli_result(&raw_output)?; + let (response_text, _) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Game resume tree turn complete" @@ -594,7 +655,7 @@ pub async fn resume_game_spec_update_turn( let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") - .arg("json") + .arg("stream-json") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -619,7 +680,7 @@ pub async fn resume_game_spec_update_turn( } let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_cli_result(&raw_output)?; + let (response_text, _) = extract_stream_result(&raw_output)?; tracing::info!( response_chars = response_text.len(), "Game spec update turn complete" @@ -690,4 +751,56 @@ Hope that helps!"#; let response = parse_sim_response(input).unwrap(); assert!(response.decisions.is_empty()); } + + #[test] + fn stream_result_happy_path() { + let ndjson = r#"{"type":"message_start","session_id":"ses-123"} +{"type":"content_block_start","content_block":{"type":"text"}} +{"type":"content_block_delta"} +{"type":"content_block_stop"} +{"type":"result","result":"final text here","session_id":"ses-123"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "final text here"); + assert_eq!(session_id, "ses-123"); + } + + #[test] + fn stream_result_with_tool_calls() { + let ndjson = r#"{"type":"message_start","session_id":"ses-456"} +{"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp__spec-forest__search_nodes","id":"tool_1"}} +{"type":"content_block_delta"} +{"type":"content_block_stop"} +{"type":"content_block_start","content_block":{"type":"tool_use","name":"Read","id":"tool_2"}} +{"type":"content_block_stop"} +{"type":"content_block_start","content_block":{"type":"text"}} +{"type":"result","result":"the answer","session_id":"ses-456"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "the answer"); + assert_eq!(session_id, "ses-456"); + } + + #[test] + fn stream_result_no_result_event() { + let ndjson = r#"{"type":"message_start","session_id":"ses-789"} +{"type":"content_block_start","content_block":{"type":"text"}}"#; + assert!(extract_stream_result(ndjson).is_err()); + } + + #[test] + fn stream_result_skips_malformed_lines() { + let ndjson = "not json at all\n\ +{broken\n\ +{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"ses-abc\"}"; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "ok"); + assert_eq!(session_id, "ses-abc"); + } + + #[test] + fn stream_result_empty_session_id_on_resume() { + let ndjson = r#"{"type":"result","result":"resumed response"}"#; + let (result, session_id) = extract_stream_result(ndjson).unwrap(); + assert_eq!(result, "resumed response"); + assert_eq!(session_id, ""); + } } From 108730b5ec669c4a9c813c9eb9c54f8b4660ba86 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:41:20 +1100 Subject: [PATCH 077/100] fix: add --verbose flag required by stream-json output format --- crates/spec-forest/src/simulation/runner.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index ffab7de..f8dca2f 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -189,6 +189,7 @@ pub async fn start_sim_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -251,6 +252,7 @@ pub async fn resume_sim_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -308,6 +310,7 @@ pub async fn resume_sim_report_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -359,6 +362,7 @@ pub async fn start_sim_tree_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -412,6 +416,7 @@ pub async fn resume_sim_tree_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -528,6 +533,7 @@ pub async fn start_game_tree_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--model") .arg(&config.model) .arg("--system-prompt") @@ -581,6 +587,7 @@ pub async fn resume_game_tree_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--resume") .arg(claude_session_id) .arg("-p") @@ -656,6 +663,7 @@ pub async fn resume_game_spec_update_turn( cmd.arg("--print") .arg("--output-format") .arg("stream-json") + .arg("--verbose") .arg("--resume") .arg(claude_session_id) .arg("-p") From 7fb3744fdb362f4aa729afad0d38bfe41a817a21 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:53:06 +1100 Subject: [PATCH 078/100] feat: stream Claude CLI stdout for real-time tool call logging Replace cmd.output() with spawn + BufReader line-by-line streaming so stream-json events are logged as they arrive, not after the full response completes. Tool use events now appear immediately in logs. --- crates/spec-forest/src/simulation/runner.rs | 273 ++++++++++---------- 1 file changed, 132 insertions(+), 141 deletions(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index f8dca2f..010e307 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -3,6 +3,7 @@ use super::types::{ }; use std::error::Error; use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; const CLAUDE_TIMEOUT: Duration = Duration::from_secs(600); @@ -24,10 +25,99 @@ struct ContentBlock { name: Option, } +/// Spawn a `claude` command and stream its NDJSON stdout line-by-line, +/// logging events in real time. Returns (result_text, session_id). +async fn run_claude_streaming( + mut cmd: tokio::process::Command, +) -> Result<(String, String), Box> { + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().ok_or("failed to capture stdout")?; + let mut reader = BufReader::new(stdout).lines(); + + let mut result_text: Option = None; + let mut session_id: Option = None; + let mut tool_names: Vec = Vec::new(); + + let stream_future = async { + while let Some(line) = reader.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + tracing::debug!( + event = &line[..line.len().min(200)], + "stream-json event" + ); + + let event: StreamEvent = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + match event.event_type.as_str() { + "result" => { + result_text = event.result; + if event.session_id.is_some() { + session_id = event.session_id; + } + } + "content_block_start" => { + if let Some(cb) = &event.content_block { + if cb.block_type == "tool_use" { + if let Some(name) = &cb.name { + tracing::info!(tool = %name, "Claude calling tool"); + tool_names.push(name.clone()); + } + } + } + } + _ => {} + } + } + Ok::<(), Box>(()) + }; + + match tokio::time::timeout(CLAUDE_TIMEOUT, stream_future).await { + Ok(result) => result?, + Err(_) => { + let _ = child.kill().await; + return Err("claude CLI timed out after 600 seconds".into()); + } + } + + let status = child.wait().await?; + if !status.success() { + let mut stderr_buf = Vec::new(); + if let Some(mut stderr) = child.stderr.take() { + tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut stderr_buf).await?; + } + let stderr = String::from_utf8_lossy(&stderr_buf); + return Err(format!("claude CLI failed: {}", stderr).into()); + } + + if !tool_names.is_empty() { + tracing::info!( + tool_count = tool_names.len(), + tools = ?tool_names, + "Tool calls during Claude session" + ); + } + + let result_text = result_text.ok_or("No 'result' event found in stream-json output")?; + let session_id = session_id.unwrap_or_default(); + + Ok((result_text, session_id)) +} + /// Parse NDJSON from `--output-format stream-json` and extract the final result. /// -/// Returns (result_text, session_id) — the same contract as the old JSON envelope. -/// Logs tool call metadata via tracing as a side-effect. +/// Returns (result_text, session_id). Logs tool call metadata via tracing. +/// Used by tests; production code uses `run_claude_streaming` for real-time logging. +#[cfg(test)] fn extract_stream_result(raw: &str) -> Result<(String, String), Box> { let mut result_text: Option = None; let mut session_id: Option = None; @@ -39,11 +129,6 @@ fn extract_stream_result(raw: &str) -> Result<(String, String), Box e, Err(_) => continue, @@ -69,21 +154,7 @@ fn extract_stream_result(raw: &str) -> Result<(String, String), Box(text: &str) -> Option { } } - // 4. First `{` to last `}` - if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) { - if start < end { - let json_str = &trimmed[start..=end]; - if let Ok(v) = serde_json::from_str::(json_str) { - return Some(v); - } + // 4. Try each `{` position (paired with the last `}`) until one parses. + // This handles prose before the JSON even if the prose contains braces. + let last_brace = trimmed.rfind('}')?; + let mut search_from = 0; + while let Some(pos) = trimmed[search_from..].find('{') { + let start = search_from + pos; + if start >= last_brace { + break; + } + let json_str = &trimmed[start..=last_brace]; + if let Ok(v) = serde_json::from_str::(json_str) { + return Some(v); } + search_from = start + 1; } None @@ -211,20 +288,7 @@ pub async fn start_sim_turn( "Starting simulation turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_stream_result(&raw_output)?; + let (response_text, session_id) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Simulation initial turn complete" @@ -264,20 +328,7 @@ pub async fn resume_sim_turn( "Resuming simulation turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_stream_result(&raw_output)?; + let (response_text, _) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Simulation resume turn complete" @@ -322,20 +373,7 @@ pub async fn resume_sim_report_turn( "Resuming simulation report turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_stream_result(&raw_output)?; + let (response_text, _) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Simulation report turn complete" @@ -384,20 +422,7 @@ pub async fn start_sim_tree_turn( "Starting simulation tree turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_stream_result(&raw_output)?; + let (response_text, session_id) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Simulation initial tree turn complete" @@ -428,20 +453,7 @@ pub async fn resume_sim_tree_turn( "Resuming simulation tree turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_stream_result(&raw_output)?; + let (response_text, _) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Simulation resume tree turn complete" @@ -555,20 +567,7 @@ pub async fn start_game_tree_turn( "Starting game tree turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, session_id) = extract_stream_result(&raw_output)?; + let (response_text, session_id) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Game initial tree turn complete" @@ -599,20 +598,7 @@ pub async fn resume_game_tree_turn( "Resuming game tree turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_stream_result(&raw_output)?; + let (response_text, _) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Game resume tree turn complete" @@ -675,20 +661,7 @@ pub async fn resume_game_spec_update_turn( "Resuming game spec update turn" ); - let output = match tokio::time::timeout(CLAUDE_TIMEOUT, cmd.output()).await { - Ok(result) => result?, - Err(_) => { - return Err("claude CLI timed out after 600 seconds".into()); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("claude CLI failed: {}", stderr).into()); - } - - let raw_output = String::from_utf8(output.stdout)?; - let (response_text, _) = extract_stream_result(&raw_output)?; + let (response_text, _) = run_claude_streaming(cmd).await?; tracing::info!( response_chars = response_text.len(), "Game spec update turn complete" @@ -728,6 +701,24 @@ Hope that helps!"#; assert_eq!(response.channels["ui"].refs[0].node_id, "abc"); } + #[test] + fn parse_json_after_prose_with_braces() { + // Simulates the real failure: LLM outputs thinking text (which may contain {}) + // before the actual JSON response. + let input = r#"Now I have a thorough understanding of the game mode implementation. Let me construct the simulation. +{"channels": {"ui": {"text": "Hello", "refs": []}}}"#; + let response = parse_sim_response(input).unwrap(); + assert_eq!(response.channels["ui"].text, "Hello"); + } + + #[test] + fn parse_tree_json_after_prose() { + let input = r#"Let me build the interaction tree for this spec. +{"root": {"channels": {"ui": {"text": "Welcome", "refs": []}}, "decisions": [{"description": "Rendered welcome", "refs": [], "spec_gaps": []}], "interactions": []}}"#; + let response = parse_sim_tree_response(input).unwrap(); + assert_eq!(response.root.channels["ui"].text, "Welcome"); + } + #[test] fn parse_with_spec_gaps() { let input = From 27ffd27dcbf9fc2e4517c37eda6350b566dab460 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 17:56:51 +1100 Subject: [PATCH 079/100] fix: use char-boundary-safe truncation for stream event logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-index slicing panicked on multi-byte UTF-8 characters (e.g. '…') when truncating log lines to 200 bytes. --- crates/spec-forest/src/simulation/runner.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 010e307..eb47248 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -49,7 +49,7 @@ async fn run_claude_streaming( } tracing::debug!( - event = &line[..line.len().min(200)], + event = truncate_to_char_boundary(&line, 200), "stream-json event" ); @@ -113,6 +113,18 @@ async fn run_claude_streaming( Ok((result_text, session_id)) } +fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { + if max_bytes >= s.len() { + return s; + } + // Find the largest char boundary <= max_bytes + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + /// Parse NDJSON from `--output-format stream-json` and extract the final result. /// /// Returns (result_text, session_id). Logs tool call metadata via tracing. From 2da051f0781c4f58312a7344cba9ea116a647883 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 1 Apr 2026 18:50:36 +1100 Subject: [PATCH 080/100] feat: flatten simulation tree JSON to nodes+edges adjacency list LLMs struggle to produce valid deeply-nested JSON at scale (~32KB), causing consistent parse failures like "key must be a string at column 18790". Replace the nested tree format with a flat {nodes, edges} adjacency list that eliminates nesting entirely. - Add FlatTree/FlatNode/FlatEdge wire types for Claude's output - Add flat_to_sim_tree and flat_to_game_tree conversion functions - Parse functions try flat format first, fall back to nested (legacy) - Update prompt schemas for both sim and game mode - Include serde error and full response in parse failure messages - Add tracing to extract_json for step-by-step diagnostics --- crates/spec-forest/src/simulation/prompt.rs | 192 +++++----- crates/spec-forest/src/simulation/runner.rs | 382 +++++++++++++++++--- crates/spec-forest/src/simulation/types.rs | 33 ++ 3 files changed, 476 insertions(+), 131 deletions(-) diff --git a/crates/spec-forest/src/simulation/prompt.rs b/crates/spec-forest/src/simulation/prompt.rs index f422954..7c85878 100644 --- a/crates/spec-forest/src/simulation/prompt.rs +++ b/crates/spec-forest/src/simulation/prompt.rs @@ -614,39 +614,44 @@ You MUST include an entry for each active channel in every response."#, /// Instructs the AI to return a tree of interaction nodes instead of a flat response. pub fn build_tree_output_format(depth: u8, branching: u8, channel_list: &str) -> String { format!( - r#"## Output Format — Interaction Tree -Every response must be a JSON object containing an interaction tree. The tree pre-computes -the most likely user interactions and their resulting simulation states. + r#"## Output Format — Flat Interaction Tree (nodes + edges) +Every response must be a JSON object with two arrays: "nodes" and "edges". +This flat adjacency-list format avoids deep nesting. The first node in the array is the root. Schema: {{{{ - "root": {{{{ - "channels": {{{{ - "": {{{{ - "text": "content with optional [^N] references", - "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], - "spec_gaps": ["one entry per high-entropy decision in this channel"] - }}}} - }}}}, - "decisions": [ - {{{{ - "description": "What you decided to do and why", - "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], - "spec_gaps": ["any high-entropy decision behind this choice"] - }}}} - ], - "interactions": [ - {{{{ - "label": "Short description of user action (e.g., Click Login)", - "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}, - "result": {{{{ - "channels": {{{{ ... }}}}, - "decisions": [...], - "interactions": [...] + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] }}}} - }}}} - ] - }}}} + ] + }}}}, + {{{{ + "id": "n1", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + ], + "edges": [ + {{{{ + "from": "root", + "to": "n1", + "label": "Short description of user action (e.g., Click Login)", + "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}} + }}}} + ] }}}} Active channels: {channel_list} @@ -654,16 +659,18 @@ Active channels: {channel_list} ## Interaction Tree Rules 1. Generate {depth} levels of interactions (the root is level 0, its children are level 1, etc.). 2. Every node (including nodes at maximum depth) should have {branching} predicted interactions — the most likely user actions. -3. Nodes at maximum depth should include interactions with "label" and "input" fields, but OMIT the "result" field. This gives the user action suggestions even at the tree boundary. -4. Each predicted interaction must include: +3. For interactions at maximum depth: include the edge (with label + input) but you may OMIT the target node from "nodes". This gives action suggestions at the tree boundary without requiring full state. +4. Each edge must include: + - **from**: The ID of the parent node. + - **to**: The ID of the child node. - **label**: A concise, human-readable description of the action (shown as a choice in the UI). - **input**: The exact keys and raw_text the user would send for this action. - - **result** (omit at maximum depth): The complete simulation state after that interaction — with all active channels. 5. Choose interactions that represent the MOST LIKELY user actions at each state. Prioritize actions that exercise different parts of the spec and cover distinct user paths. -6. Every node (root and all children) must include entries for ALL active channels. +6. Every node must include entries for ALL active channels. 7. Every node must include a non-empty decisions array. -8. The [^N] marker namespace is PER NODE — each node's refs are self-contained."#, +8. The [^N] marker namespace is PER NODE — each node's refs are self-contained. +9. Node IDs must be short unique strings (e.g., "root", "n1", "n2", etc.)."#, channel_list = channel_list, depth = depth, branching = branching, @@ -816,80 +823,89 @@ spec needs to make, and let the player make them through natural interaction."# /// instead of the regular interaction tree. pub fn build_game_tree_output_format(branching: u8, channel_list: &str) -> String { format!( - r#"## Output Format — Game Choice Tree -Every response must be a JSON object containing a game choice tree. The tree presents -alternative outcomes for each predicted interaction, letting the user pick the correct behavior. + r#"## Output Format — Flat Game Choice Tree (nodes + edges) +Every response must be a JSON object with two arrays: "nodes" and "edges". +This flat format avoids deep nesting. The first node is the root (current state). +Edges represent possible interactions. Multiple edges with the SAME "label" form a choice +group — each edge is an alternative outcome the player can pick. Schema: {{{{ - "root": {{{{ - "channels": {{{{ - "": {{{{ - "text": "content with optional [^N] references", - "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], - "spec_gaps": ["one entry per high-entropy decision in this channel"] - }}}} + "nodes": [ + {{{{ + "id": "root", + "channels": {{{{ + "": {{{{ + "text": "content with optional [^N] references", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["one entry per high-entropy decision in this channel"] + }}}} + }}}}, + "decisions": [ + {{{{ + "description": "What you decided to do and why", + "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], + "spec_gaps": ["any high-entropy decision behind this choice"] + }}}} + ] + }}}}, + {{{{ + "id": "n1", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}}, + {{{{ + "id": "n2", + "channels": {{{{ ... }}}}, + "decisions": [...] + }}}} + ], + "edges": [ + {{{{ + "from": "root", + "to": "n1", + "label": "Press X", + "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, + "outcome_summary": "Starts audio playback", + "related_spec_nodes": ["node-id-1", "node-id-2"] }}}}, - "decisions": [ - {{{{ - "description": "What you decided to do and why", - "refs": [{{{{"marker": "[^1]", "node_id": "uuid"}}}}], - "spec_gaps": ["any high-entropy decision behind this choice"] - }}}} - ], - "choice_groups": [ - {{{{ - "interaction_label": "Short description of user action (e.g., Press X)", - "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, - "outcomes": [ - {{{{ - "summary": "Short description of what happens (e.g., Starts audio playback)", - "related_spec_nodes": ["node-id-1", "node-id-2"], - "result": {{{{ - "channels": {{{{ ... }}}}, - "decisions": [...] - }}}} - }}}}, - {{{{ - "summary": "Alternative outcome (e.g., Loads new sample)", - "related_spec_nodes": [], - "result": {{{{ - "channels": {{{{ ... }}}}, - "decisions": [...] - }}}} - }}}} - ] - }}}} - ] - }}}} + {{{{ + "from": "root", + "to": "n2", + "label": "Press X", + "input": {{{{"keys": ["x"], "raw_text": "x"}}}}, + "outcome_summary": "Loads new sample", + "related_spec_nodes": [] + }}}} + ] }}}} Active channels: {channel_list} ## Game Choice Tree Rules -1. Generate {branching} predicted interactions (choice_groups). -2. **Prioritize high-entropy interactions.** At least half of the choice_groups should target +1. Generate {branching} predicted interactions (distinct labels). +2. **Prioritize high-entropy interactions.** At least half of the interactions should target genuine spec ambiguities — interactions where the spec is silent or vague and the player's choice would resolve a meaningful design question. The remaining may be lower-entropy interactions for navigation continuity. -3. HIGH-ENTROPY choice_groups should have 2-3 alternative outcomes representing meaningfully - different design directions. LOW-ENTROPY groups (one obvious answer) should have 1 outcome. -4. Each outcome's "result" contains the complete simulation state (all active channels + decisions) - that would result from that outcome. Results do NOT contain nested choice_groups — the tree - is one level deep. -5. Each outcome MUST include: - - **summary**: A concise description of what happens (shown alongside the interaction label). +3. HIGH-ENTROPY interactions should have 2-3 edges with the SAME label but different + outcome_summary values — these are alternative outcomes the player chooses between. + LOW-ENTROPY interactions (one obvious answer) should have 1 edge. +4. Each edge's target node contains the complete simulation state (all active channels + decisions) + that would result from that outcome. +5. Each edge MUST include: + - **outcome_summary**: A concise description of what happens (shown alongside the label). - **related_spec_nodes**: Array of spec node IDs that justify this outcome. Empty array if the outcome is based on assumptions not covered by the spec. Outcomes with empty related_spec_nodes are the highest value — they represent NEW information for the spec. - - **result**: Complete simulation state with all active channels and decisions. 6. Make outcomes genuinely distinct. Bad: "Button turns blue" vs "Button turns dark blue". Good: "Shows inline error" vs "Opens error modal" vs "Redirects to error page". -7. Every node (root and all results) must include entries for ALL active channels. +7. Every node must include entries for ALL active channels. 8. Every node must include a non-empty decisions array. 9. The [^N] marker namespace is PER NODE — each node's refs are self-contained. 10. When choosing interactions, consult the spec for unanswered nodes, nodes flagged for review, - and areas with known spec_gaps — these are the richest sources of high-entropy interactions."#, + and areas with known spec_gaps — these are the richest sources of high-entropy interactions. +11. Node IDs must be short unique strings (e.g., "root", "n1", "n2", etc.)."#, channel_list = channel_list, branching = branching, ) diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index eb47248..38b40fb 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,6 +1,8 @@ use super::types::{ - GameTreeResponse, GameTreeRoot, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, GameTreeRoot, + PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, }; +use std::collections::HashMap; use std::error::Error; use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -180,12 +182,15 @@ fn extract_stream_result(raw: &str) -> Result<(String, String), Box(text: &str) -> Option { +/// +/// Returns the best serde error message on failure (from the longest +/// `{…}` substring attempted in step 4). +fn extract_json(text: &str) -> Result { let trimmed = text.trim(); // 1. Direct parse if let Ok(v) = serde_json::from_str::(trimmed) { - return Some(v); + return Ok(v); } // 2. ```json fence @@ -193,7 +198,7 @@ fn extract_json(text: &str) -> Option { if let Some(end) = trimmed[start + 7..].find("```") { let json_str = trimmed[start + 7..start + 7 + end].trim(); if let Ok(v) = serde_json::from_str::(json_str) { - return Some(v); + return Ok(v); } } } @@ -203,28 +208,184 @@ fn extract_json(text: &str) -> Option { if let Some(end) = trimmed[start + 4..].find("```") { let json_str = trimmed[start + 4..start + 4 + end].trim(); if let Ok(v) = serde_json::from_str::(json_str) { - return Some(v); + return Ok(v); } } } // 4. Try each `{` position (paired with the last `}`) until one parses. // This handles prose before the JSON even if the prose contains braces. - let last_brace = trimmed.rfind('}')?; + // Keep the first (longest) serde error as the best diagnostic. + let last_brace = trimmed.rfind('}').ok_or_else(|| "no '}' found in response".to_string())?; let mut search_from = 0; + let mut best_err: Option = None; + let mut attempts = 0u32; while let Some(pos) = trimmed[search_from..].find('{') { let start = search_from + pos; if start >= last_brace { break; } let json_str = &trimmed[start..=last_brace]; - if let Ok(v) = serde_json::from_str::(json_str) { - return Some(v); + match serde_json::from_str::(json_str) { + Ok(v) => return Ok(v), + Err(e) => { + tracing::debug!( + attempt = attempts, + start_byte = start, + end_byte = last_brace, + substr_len = json_str.len(), + error = %e, + "extract_json step 4 attempt failed" + ); + if best_err.is_none() { + best_err = Some(e.to_string()); + } + } } + attempts += 1; search_from = start + 1; } - None + tracing::debug!(attempts, "extract_json: all steps failed"); + Err(best_err.unwrap_or_else(|| "no '{' found in response".to_string())) +} + +// ── Flat → Nested conversion ───────────────────────────────────────── + +/// Convert a flat adjacency-list tree into a nested `SimTreeResponse`. +fn flat_to_sim_tree(flat: FlatTree) -> Result { + if flat.nodes.is_empty() { + return Err("flat tree has no nodes".into()); + } + + // Index nodes by id + let mut node_map: HashMap = HashMap::new(); + let root_id = flat.nodes[0].id.clone(); + for node in flat.nodes { + node_map.insert(node.id.clone(), node); + } + + // Group edges by parent + let mut children: HashMap> = HashMap::new(); + for edge in &flat.edges { + children.entry(edge.from.clone()).or_default().push(edge); + } + + fn build_node( + id: &str, + node_map: &mut HashMap, + children: &HashMap>, + ) -> Result { + let flat_node = node_map + .remove(id) + .ok_or_else(|| format!("edge references unknown node '{id}'"))?; + + let interactions = match children.get(id) { + Some(edges) => edges + .iter() + .map(|edge| { + let result = if node_map.contains_key(&edge.to) { + Some(build_node(&edge.to, node_map, children)?) + } else { + // Target already consumed or is a leaf-only reference + None + }; + Ok(PredictedInteraction { + label: edge.label.clone(), + input: edge.input.clone(), + result, + }) + }) + .collect::, String>>()?, + None => vec![], + }; + + Ok(SimTreeNode { + node_id: String::new(), + channels: flat_node.channels, + decisions: flat_node.decisions, + interactions, + }) + } + + let root = build_node(&root_id, &mut node_map, &children)?; + Ok(SimTreeResponse { root }) +} + +/// Convert a flat adjacency-list tree into a nested `GameTreeResponse`. +fn flat_to_game_tree(flat: FlatTree) -> Result { + if flat.nodes.is_empty() { + return Err("flat tree has no nodes".into()); + } + + // Index nodes by id + let mut node_map: HashMap = HashMap::new(); + let root_id = flat.nodes[0].id.clone(); + for node in flat.nodes { + node_map.insert(node.id.clone(), node); + } + + let root_node = node_map + .remove(&root_id) + .ok_or("root node missing from map")?; + + // Group edges from root by (label, input) to form choice groups. + // Preserve insertion order with a Vec of keys + a map. + let root_edges: Vec<&FlatEdge> = flat + .edges + .iter() + .filter(|e| e.from == root_id) + .collect(); + + let mut group_keys: Vec<(String, super::types::SimInput)> = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + + for edge in &root_edges { + // Use label as the group key (edges with same label = same choice group) + let key = edge.label.clone(); + if !groups.contains_key(&key) { + group_keys.push((key.clone(), edge.input.clone())); + } + groups.entry(key).or_default().push(edge); + } + + let mut choice_groups = Vec::new(); + for (label, input) in group_keys { + let edges = groups.remove(&label).unwrap_or_default(); + let outcomes = edges + .into_iter() + .map(|edge| { + let target = node_map.remove(&edge.to).ok_or_else(|| { + format!("edge references unknown node '{}'", edge.to) + })?; + Ok(GameOutcome { + summary: edge.outcome_summary.clone().unwrap_or_default(), + related_spec_nodes: edge.related_spec_nodes.clone(), + result: SimTreeNode { + node_id: String::new(), + channels: target.channels, + decisions: target.decisions, + interactions: vec![], + }, + }) + }) + .collect::, String>>()?; + + choice_groups.push(GameChoiceGroup { + interaction_label: label, + input, + outcomes, + }); + } + + Ok(GameTreeResponse { + root: GameTreeRoot { + node_id: String::new(), + channels: root_node.channels, + decisions: root_node.decisions, + choice_groups, + }, + }) } /// Configuration for starting a simulation turn. @@ -476,14 +637,27 @@ pub async fn resume_sim_tree_turn( /// Parse the agent's text response into a SimTreeResponse. /// -/// Falls back to wrapping a flat SimResponse in a single-node tree -/// if tree parsing fails but flat parsing succeeds. +/// Tries flat adjacency-list format first, then nested format, +/// then falls back to wrapping a flat SimResponse in a single-node tree. fn parse_sim_tree_response(text: &str) -> Result> { - if let Some(response) = extract_json::(text) { - return Ok(response); + // 1. Try flat adjacency-list format (preferred) + if let Ok(flat) = extract_json::(text) { + match flat_to_sim_tree(flat) { + Ok(tree) => { + tracing::info!("Parsed simulation tree from flat adjacency-list format"); + return Ok(tree); + } + Err(e) => tracing::debug!(error = %e, "Flat tree conversion failed"), + } } - // Fallback: try parsing as flat SimResponse and wrap in single-node tree + // 2. Try nested format (legacy) + let tree_err = match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => e, + }; + + // 3. Fallback: try parsing as flat SimResponse and wrap in single-node tree if let Ok(flat) = parse_sim_response(text) { tracing::warn!("Tree parse failed, fell back to flat SimResponse"); return Ok(SimTreeResponse { @@ -496,10 +670,9 @@ fn parse_sim_tree_response(text: &str) -> Result Result Result> { - if let Some(response) = extract_json::(text) { - return Ok(response); + match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => Err(format!( + "Failed to parse simulation response as JSON.\nSerde error: {e}\nRaw response:\n{}", + text.trim() + ) + .into()), } - - let trimmed = text.trim(); - Err(format!( - "Failed to parse simulation response as JSON. Raw response:\n{}", - &trimmed[..trimmed.floor_char_boundary(500)] - ) - .into()) } /// Parse the agent's text response into a SimReportResponse JSON envelope. fn parse_sim_report_response( text: &str, ) -> Result> { - if let Some(response) = extract_json::(text) { - return Ok(response); + match extract_json::(text) { + Ok(response) => Ok(response), + Err(e) => Err(format!( + "Failed to parse simulation report response as JSON.\nSerde error: {e}\nRaw response:\n{}", + text.trim() + ) + .into()), } - - let trimmed = text.trim(); - Err(format!( - "Failed to parse simulation report response as JSON. Raw response:\n{}", - &trimmed[..trimmed.floor_char_boundary(500)] - ) - .into()) } // ── Game mode runner functions ──────────────────────────────────────── @@ -621,14 +790,27 @@ pub async fn resume_game_tree_turn( /// Parse the agent's text response into a GameTreeResponse. /// -/// Falls back to wrapping a flat SimResponse in a single-node game tree -/// with no choice groups if game tree parsing fails. +/// Tries flat adjacency-list format first, then nested format, +/// then falls back to wrapping a flat SimResponse in a single-node game tree. fn parse_game_tree_response(text: &str) -> Result> { - if let Some(response) = extract_json::(text) { - return Ok(response); + // 1. Try flat adjacency-list format (preferred) + if let Ok(flat) = extract_json::(text) { + match flat_to_game_tree(flat) { + Ok(tree) => { + tracing::info!("Parsed game tree from flat adjacency-list format"); + return Ok(tree); + } + Err(e) => tracing::debug!(error = %e, "Flat game tree conversion failed"), + } } - // Fallback: try parsing as flat SimResponse and wrap in single-node game tree + // 2. Try nested format (legacy) + let tree_err = match extract_json::(text) { + Ok(response) => return Ok(response), + Err(e) => e, + }; + + // 3. Fallback: try parsing as flat SimResponse and wrap in single-node game tree if let Ok(flat) = parse_sim_response(text) { tracing::warn!("Game tree parse failed, fell back to flat SimResponse"); return Ok(GameTreeResponse { @@ -641,10 +823,9 @@ fn parse_game_tree_response(text: &str) -> Result, } + +// ── Flat Wire Format ──────────────────────────────────────────────── + +/// Flat adjacency-list format for Claude's JSON output. +/// Converted to nested `SimTreeResponse` / `GameTreeResponse` after parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatTree { + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatNode { + pub id: String, + pub channels: HashMap, + #[serde(default)] + pub decisions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlatEdge { + pub from: String, + pub to: String, + pub label: String, + pub input: SimInput, + /// Game mode only: short description of what happens. + #[serde(default)] + pub outcome_summary: Option, + /// Game mode only: spec node IDs that justify this outcome. + #[serde(default)] + pub related_spec_nodes: Vec, +} From 2ea63812d5aefb3b07bae355e5583d5583f54c63 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 08:15:20 +1100 Subject: [PATCH 081/100] feat: add members screen to TUI for spec access management Mirrors the web UI's AccessPanel, allowing users to view spec members and creators to add/remove members via the sync server. --- crates/spec-forest-tui/src/action.rs | 11 ++ crates/spec-forest-tui/src/app.rs | 124 +++++++++++++++++- crates/spec-forest-tui/src/commands.rs | 31 +++++ crates/spec-forest-tui/src/input.rs | 24 ++++ crates/spec-forest-tui/src/ui.rs | 2 + crates/spec-forest-tui/src/ui/help_popup.rs | 27 ++++ crates/spec-forest-tui/src/ui/spec_members.rs | 113 ++++++++++++++++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 crates/spec-forest-tui/src/ui/spec_members.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index a8f0141..54ef21f 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -166,6 +166,17 @@ pub enum Action { SessionPickerDismiss, DismissNotification, + // Members screen + OpenMembers, + MembersActivateInput, + MembersDeactivateInput, + MembersRemoveMember, + MembersInputChar(char), + MembersInputBackspace, + MembersInputSubmit, + MembersUp, + MembersDown, + // Help ToggleHelp, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7e5bbbb..2580f83 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -99,6 +99,12 @@ pub struct App { pub sim_notifications: Vec, pub session_picker: Option, pub show_help: bool, + // Members screen + pub members: Vec, + pub members_creator: String, + pub members_selected: usize, + pub members_input: String, + pub members_input_active: bool, } #[derive(Clone)] @@ -110,6 +116,7 @@ pub enum Screen { SpecOptionsPicker, SpecView { spec_id: String }, SpecSettings { spec_id: String }, + SpecMembers { spec_id: String }, SyncConfig, SyncPasswordInput, ModelConfig, @@ -201,6 +208,11 @@ impl App { sim_notifications: Vec::new(), session_picker: None, show_help: false, + members: Vec::new(), + members_creator: String::new(), + members_selected: 0, + members_input: String::new(), + members_input_active: false, } } @@ -318,13 +330,22 @@ impl App { && self .sim_state .as_ref() - .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)); + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)) + || (matches!(self.screen, Screen::SpecMembers { .. }) && self.members_input_active); if !is_text_input { self.show_help = true; return; } } + // Members screen has dual-mode input (normal vs text) + if matches!(self.screen, Screen::SpecMembers { .. }) { + let is_creator = self.state.user_name() == self.members_creator; + let action = input::map_spec_members_key(key, self.members_input_active, is_creator); + self.execute_action(action).await; + return; + } + // Scenario input screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::SimScenario { .. }) { let action = input::map_sim_scenario_key(key, modifiers); @@ -669,6 +690,101 @@ impl App { } } + // Members + Action::OpenMembers => { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } | Screen::SpecSettings { spec_id } => spec_id.clone(), + _ => return, + }; + let spec_name = self.specs.iter() + .find(|s| s.id == spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::list_members(&self.state, &spec_name).await { + Ok((members, creator)) => { + self.members = members; + self.members_creator = creator; + self.members_selected = 0; + self.members_input.clear(); + self.members_input_active = false; + self.screen = Screen::SpecMembers { spec_id }; + } + Err(e) => { + self.message = Some(format!("Failed to load members: {e}")); + } + } + } + Action::MembersUp => { + self.members_selected = self.members_selected.saturating_sub(1); + } + Action::MembersDown => { + if !self.members.is_empty() { + self.members_selected = (self.members_selected + 1).min(self.members.len() - 1); + } + } + Action::MembersActivateInput => { + self.members_input_active = true; + self.members_input.clear(); + } + Action::MembersDeactivateInput => { + self.members_input_active = false; + self.members_input.clear(); + } + Action::MembersInputChar(c) => { + self.members_input.push(c); + } + Action::MembersInputBackspace => { + self.members_input.pop(); + } + Action::MembersInputSubmit => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + let username = self.members_input.trim().to_string(); + if !username.is_empty() { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::grant_access(&self.state, &spec_name, &username).await { + Ok(_) => { + self.members.push(username); + self.members_input.clear(); + self.members_input_active = false; + self.message = Some("Member added".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to add member: {e}")); + } + } + } + } + } + Action::MembersRemoveMember => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + if let Some(member) = self.members.get(self.members_selected).cloned() { + if member == self.members_creator { + self.message = Some("Cannot remove the creator".to_string()); + } else { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::revoke_access(&self.state, &spec_name, &member).await { + Ok(_) => { + self.members.remove(self.members_selected); + if self.members_selected >= self.members.len() && self.members_selected > 0 { + self.members_selected -= 1; + } + self.message = Some("Member removed".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to remove member: {e}")); + } + } + } + } + } + } + // Candidates Action::CandidateNext => { if !self.candidates.is_empty() { @@ -1470,6 +1586,12 @@ impl App { let spec_id = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; } + Screen::SpecMembers { spec_id } => { + let spec_id = spec_id.clone(); + self.members_input_active = false; + self.members_input.clear(); + self.screen = Screen::SpecView { spec_id }; + } _ => {} } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 7b7d270..5aab5a4 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -194,6 +194,37 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } +// ── Members ─────────────────────────────────────────────── + +pub async fn list_members( + state: &Arc, + spec_name: &str, +) -> Result<(Vec, String), TuiError> { + spec_forest::api::sync_list_members(state, spec_name) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn grant_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_grant_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn revoke_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_revoke_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + // ── Shadow answers ──────────────────────────────────────── pub fn start_shadow( diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 9b5b3c2..c316e22 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -31,6 +31,7 @@ pub fn map_key( Screen::UsernameInput => map_input_key(key), Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), Screen::ExploreDepthPicker { .. } => map_depth_picker_key(key), + Screen::SpecMembers { .. } => Action::Noop, // handled by map_spec_members_key Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key } @@ -261,6 +262,7 @@ fn map_spec_view_key(key: KeyCode, modifiers: KeyModifiers, tree_visible: bool, KeyCode::Char('t') => Action::ToggleTree, KeyCode::Char('l') => Action::ToggleLog, KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::Char('M') => Action::OpenMembers, KeyCode::Char('s') if modifiers.contains(KeyModifiers::ALT) => Action::RegenerateShadow, KeyCode::Up if log_focused && log_visible => Action::LogScrollLineUp, KeyCode::Down if log_focused && log_visible => Action::LogScrollLineDown, @@ -338,10 +340,32 @@ fn map_spec_settings_key(key: KeyCode) -> Action { KeyCode::Down => Action::NavigateDown, KeyCode::Enter => Action::SetSpecDirectory, KeyCode::Char('d') => Action::ClearSpecDirectory, + KeyCode::Char('m') => Action::OpenMembers, _ => Action::Noop, } } +pub fn map_spec_members_key(key: KeyCode, input_active: bool, is_creator: bool) -> Action { + if input_active { + match key { + KeyCode::Esc => Action::MembersDeactivateInput, + KeyCode::Enter => Action::MembersInputSubmit, + KeyCode::Backspace => Action::MembersInputBackspace, + KeyCode::Char(c) => Action::MembersInputChar(c), + _ => Action::Noop, + } + } else { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::MembersUp, + KeyCode::Down => Action::MembersDown, + KeyCode::Char('a') if is_creator => Action::MembersActivateInput, + KeyCode::Char('d') if is_creator => Action::MembersRemoveMember, + _ => Action::Noop, + } + } +} + fn map_model_config_key(key: KeyCode) -> Action { match key { KeyCode::Esc => Action::Cancel, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 8257a88..74b74d0 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -12,6 +12,7 @@ mod sim_channel_picker; mod sim_scenario; mod simulation; mod spec_list; +mod spec_members; mod spec_options_picker; mod spec_settings; mod spec_view; @@ -30,6 +31,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SpecOptionsPicker => spec_options_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), Screen::SpecSettings { .. } => spec_settings::render(app, frame), + Screen::SpecMembers { .. } => spec_members::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), Screen::ModelConfig => model_config::render(app, frame), diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index ae2ee44..1ecf603 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -78,6 +78,7 @@ fn help_sections(app: &App) -> Vec { ("t", "Toggle tree"), ("l", "Toggle log"), ("g", "Spec settings"), + ("M", "Members"), ], }); @@ -231,9 +232,35 @@ fn help_sections(app: &App) -> Vec { bindings: vec![ ("Enter", "Change directory"), ("d", "Clear directory"), + ("m", "Members"), ("Esc", "Back"), ], }], + Screen::SpecMembers { .. } => { + if app.members_input_active { + vec![HelpSection { + title: "Add Member", + bindings: vec![ + ("Enter", "Submit"), + ("Esc", "Cancel"), + ], + }] + } else { + let is_creator = app.state.user_name() == app.members_creator; + let mut bindings = vec![ + ("Up/Down", "Navigate members"), + ]; + if is_creator { + bindings.push(("a", "Add member")); + bindings.push(("d", "Remove member")); + } + bindings.push(("Esc", "Back")); + vec![HelpSection { + title: "Members", + bindings, + }] + } + } Screen::SyncConfig => { let has_url = app.state.sync_url().is_some(); if has_url { diff --git a/crates/spec-forest-tui/src/ui/spec_members.rs b/crates/spec-forest-tui/src/ui/spec_members.rs new file mode 100644 index 0000000..b4e2282 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_members.rs @@ -0,0 +1,113 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let spec_id = match &app.screen { + Screen::SpecMembers { spec_id } => spec_id, + _ => return, + }; + + let spec_name = app + .specs + .iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.as_str()) + .unwrap_or("?"); + + let is_creator = app.state.user_name() == app.members_creator; + + let constraints = if app.members_input_active { + vec![ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(3), + ] + } else { + vec![Constraint::Min(3), Constraint::Length(3)] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.area()); + + // Member list + let items: Vec = app + .members + .iter() + .map(|m| { + let mut spans = vec![Span::raw(format!(" {m}"))]; + if *m == app.members_creator { + spans.push(Span::styled(" (creator)", Style::default().fg(Color::Cyan))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let empty_msg = if items.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No members", + Style::default().fg(Color::DarkGray), + )))] + } else { + items + }; + + let list = List::new(empty_msg) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Members — {spec_name} ")), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if !app.members.is_empty() { + state.select(Some(app.members_selected)); + } + frame.render_stateful_widget(list, chunks[0], &mut state); + + // Input area (when active) + if app.members_input_active { + let input_display = format!(" Add member: {}█", app.members_input); + let input_widget = Paragraph::new(input_display) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)); + frame.render_widget(input_widget, chunks[1]); + } + + // Footer + let footer_idx = if app.members_input_active { 2 } else { 1 }; + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else if app.members_input_active { + super::common::render_footer_line( + &[("Enter", "Add"), ("Esc", "Cancel")], + None, + ) + } else if is_creator { + super::common::render_footer_line( + &[("a", "Add member"), ("d", "Remove"), ("Esc", "Back"), ("?", "Help")], + None, + ) + } else { + super::common::render_footer_line( + &[("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[footer_idx]); +} From 9fea7650cdbd598db1ad67f8bdd002cf56014ca1 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 08:52:15 +1100 Subject: [PATCH 082/100] feat: add lean game mode with DAG-based simulation navigation New experimental game mode focused on efficient spec refinement through simulated software play. Each output generates 2 new child nodes plus shortcut edges to existing nodes, forming a DAG. Batch pre-generation (depth 3) keeps navigation instant, and background spec updates refine the spec as the player navigates. Includes TUI panel, MCP tools, and entropy-guided interaction selection. --- crates/spec-forest-tui/src/action.rs | 18 + crates/spec-forest-tui/src/app.rs | 344 ++++++++++- crates/spec-forest-tui/src/input.rs | 45 ++ crates/spec-forest-tui/src/lean_state.rs | 68 +++ crates/spec-forest-tui/src/lib.rs | 1 + crates/spec-forest-tui/src/ui.rs | 2 + crates/spec-forest-tui/src/ui/help_popup.rs | 13 + crates/spec-forest-tui/src/ui/lean_game.rs | 359 ++++++++++++ .../src/ui/sim_channel_picker.rs | 19 +- crates/spec-forest/src/simulation.rs | 6 + .../spec-forest/src/simulation/lean_graph.rs | 422 ++++++++++++++ .../src/simulation/lean_orchestrate.rs | 544 ++++++++++++++++++ .../spec-forest/src/simulation/lean_prompt.rs | 349 +++++++++++ .../spec-forest/src/simulation/lean_types.rs | 102 ++++ crates/spec-forest/src/simulation/runner.rs | 154 ++++- crates/spec-forest/src/simulation/session.rs | 26 + crates/spec-forest/src/tool_types.rs | 26 + crates/spec-forest/src/tools.rs | 242 ++++++++ 18 files changed, 2711 insertions(+), 29 deletions(-) create mode 100644 crates/spec-forest-tui/src/lean_state.rs create mode 100644 crates/spec-forest-tui/src/ui/lean_game.rs create mode 100644 crates/spec-forest/src/simulation/lean_graph.rs create mode 100644 crates/spec-forest/src/simulation/lean_orchestrate.rs create mode 100644 crates/spec-forest/src/simulation/lean_prompt.rs create mode 100644 crates/spec-forest/src/simulation/lean_types.rs diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 54ef21f..6d1666e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -108,6 +108,7 @@ pub enum Action { SimChannelToggleWholeSpec, SimChannelToggleExploreCode, SimChannelToggleGameMode, + SimChannelToggleLeanMode, SimChannelConfirm, SimChannelCancel, @@ -158,6 +159,23 @@ pub enum Action { GameRejectCancel, GameToggleUpdateLog, + // Lean game mode + LeanSelectUp, + LeanSelectDown, + LeanConfirm, + LeanGoBack, + LeanEnterQuery, + LeanEnterModify, + LeanToggleUpdateLog, + LeanScrollUp, + LeanScrollDown, + LeanInputChar(char), + LeanInputBackspace, + LeanInputSubmit, + LeanInputCancel, + LeanInputNewline, + 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..65318b6 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 @@ -836,6 +853,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 { @@ -1383,6 +1409,155 @@ 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, + &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::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); + } + } + } + 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(); + } + } + } + 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'); + } + } + } + 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; + }); + } + } + } + Action::LeanInputCancel => { + if let Some(ref mut lean) = self.lean_state { + lean.query_mode = false; + lean.modify_mode = false; + lean.query_input.clear(); + lean.modify_input.clear(); + } + } + Action::LeanEnd => { + 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; + self.screen = Screen::SpecView { spec_id }; + } + } + Action::ToggleHelp => { self.show_help = !self.show_help; } @@ -2346,6 +2521,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 +2774,91 @@ 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; + // 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 + }; + crate::lean_state::LeanInteractionView { + label: edge.label.clone(), + edge_kind: edge.edge_kind, + entropy_hint: entropy, + } + }) + .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(); + } + } + spec_forest::simulation::SimStatus::Processing => { + lean.processing = true; + } + 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 +2885,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 +2938,36 @@ 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; + }); + } 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/input.rs b/crates/spec-forest-tui/src/input.rs index c316e22..5c1dd0d 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -34,6 +34,50 @@ 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('u') => Action::LeanToggleUpdateLog, + KeyCode::Char('Q') => Action::LeanEnd, + KeyCode::Esc => Action::LeanEnd, + 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 +218,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, 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..8e1314c --- /dev/null +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -0,0 +1,68 @@ +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, +} + +/// 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, +} + +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, + } + } + + /// Whether we're in any text input mode. + pub fn in_input_mode(&self) -> bool { + self.query_mode || self.modify_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..f8a47e4 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -315,6 +315,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..2396838 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -0,0 +1,359 @@ +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 { + 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(); + + // 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.processing { + " Output (generating...) " + } else { + " Output " + }; + + let border_color = if lean.processing { + Color::Yellow + } 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.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 => ("●", 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 items = vec![ + ("↑↓", "select"), + ("Enter", "go"), + ("Bksp", "back"), + ("i", "query"), + ("m", "modify"), + ("u", "updates"), + ("Q", "quit"), + ]; + + let spans: Vec = items + .iter() + .enumerate() + .flat_map(|(i, (key, desc))| { + let mut v = vec![ + Span::styled( + format!(" {key}"), + Style::default().fg(Color::Yellow), + ), + Span::styled( + format!(" {desc}"), + 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(); + + 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.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/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 34f36ee..a40af0f 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -1,3 +1,7 @@ +pub mod lean_graph; +pub mod lean_orchestrate; +pub mod lean_prompt; +pub mod lean_types; mod prompt; pub mod orchestrate; pub mod runner; @@ -14,6 +18,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, 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..5e25f93 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -0,0 +1,422 @@ +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; + } + } + } + } + + /// 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) + } + + /// 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 + } + + /// 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() > 80 { + format!("{}...", &text[..text.floor_char_boundary(80)]) + } 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..7968e05 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -0,0 +1,544 @@ +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 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; + } + }; + + 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::>(); + + // Collect high-entropy nodes. + let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); + + // Build system prompt. + let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); + let system_prompt = super::lean_prompt::build_lean_system_prompt( + &channels, + &focus_node, + &ancestors, + &descendants, + &summary, + &other_roots, + &high_entropy_nodes, + &spec_id, + ); + 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 and call AI. + let mcp_url = state + .mcp_url() + .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); + let config = SimConfig::new(model, full_system_prompt, mcp_url, None); + + 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(); + + 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"); + } + 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 should_pregen = { + 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(&target_node_id); + !s.lean_generating && depth < 2 + } else { + false + } + } else { + false + } + }; + + 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()); + // 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(); + } + } + }); + + // Spawn background pregen if needed. + if should_pregen { + let state2 = state.clone(); + let sid2 = session_id.clone(); + let target = target_node_id.clone(); + 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; + }); + } + + // Spawn background spec update. + let output_summary = { + let session = state.get_sim_session(&session_id); + session + .and_then(|s| s.lean_graph.as_ref().and_then(|g| g.get_node(&target_node_id).cloned())) + .and_then(|n| n.channels.get("ui").cloned()) + .map(|c| { + if c.text.len() > 200 { + format!("{}...", &c.text[..c.text.floor_char_boundary(200)]) + } else { + c.text + } + }) + .unwrap_or_default() + }; + + let state3 = state.clone(); + let sid3 = session_id.clone(); + tokio::spawn(async move { + orchestrate_lean_spec_update(state3, sid3, edge_label, output_summary).await; + }); + } + LeanEdgeKind::Leaf => { + // Need to generate first. + 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; + + // 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(); + drop(s); + state2.update_sim_session(&sid2, |s| { + s.lean_current_node_id = Some(target.clone()); + s.lean_navigation_path.push(target.clone()); + 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; + }); + 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: &AppState, session_id: &str) { + state.update_sim_session(session_id, |s| { + if s.lean_navigation_path.len() > 1 { + s.lean_navigation_path.pop(); + let prev_id = s.lean_navigation_path.last().cloned(); + s.lean_current_node_id = prev_id.clone(); + // 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(); + } + } + } + }); +} + +/// 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"); + } + Err(e) => { + error!(session_id, error = %e, "Lean batch pregen failed"); + set_lean_generating_false(&state, &session_id); + } + } +} + +/// Background spec update after a player navigates. +async fn orchestrate_lean_spec_update( + state: Arc, + session_id: String, + interaction_label: String, + output_summary: String, +) { + let session = match state.get_sim_session(&session_id) { + Some(s) => s, + None => return, + }; + let spec_id = session.spec_id.clone(); + let claude_session_id = match &session.claude_session_id { + Some(id) => id.clone(), + None => return, + }; + drop(session); + + let prompt = super::lean_prompt::build_lean_spec_update_prompt( + &spec_id, + &interaction_label, + &output_summary, + ); + + match super::runner::resume_lean_spec_update(&claude_session_id, &prompt).await { + Ok(update) => { + if let Some(update) = update { + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(update); + }); + } + } + Err(e) => { + error!(session_id, error = %e, "Lean spec update failed"); + } + } +} + +/// 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 { + graph.merge_batch(batch_response, cid); + } + } + s.status = SimStatus::Idle; + }); + } + Err(e) => { + set_error(&state, &session_id, &format!("Modify failed: {e}")); + } + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +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. +fn collect_high_entropy_nodes( + state: &AppState, + spec_id: &str, + limit: usize, +) -> Vec<(String, String)> { + let nodes = crate::api::get_spec_nodes(state, spec_id).unwrap_or_default(); + + let mut candidates: Vec<(String, String)> = Vec::new(); + + // Unanswered first. + for node in &nodes { + if node.answer.is_none() { + 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 { + 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..a33bad7 --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -0,0 +1,349 @@ +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, +) -> 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)); + } + + // High-entropy guidance. + let mut entropy_section = String::new(); + if !high_entropy_nodes.is_empty() { + entropy_section.push_str("## High-Uncertainty Spec Areas\n"); + entropy_section + .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + for (id, question) in high_entropy_nodes { + entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + } + + format!( + r#"## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY +You simulate the program that would be built from this spec. The player navigates +outputs and chooses interactions. Your job is to steer them toward the INTERESTING +decisions — places where the spec is silent or ambiguous. + +At each node, generate exactly 2 NEW child outputs via generative edges. +You may also add shortcut edges linking to existing nodes in the DAG. +One of the 2 generative edges should lead toward a high-entropy spec area. +The other should represent the expected/obvious path. + +## 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} + +{entropy_section} + +## Tools +You have 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 + +Use tools proactively when generating outputs that touch areas outside the loaded context. + +## Channel Semantics +Active channels: {channel_list} +- "ui": Unicode/box-drawing TUI rendering. Replace entirely each turn. 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. No refs, no spec_gaps — just the simulation output."#, + 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 + }, + entropy_section = entropy_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 (available for shortcut edges)\n\ + You may add shortcut edges (`\"shortcut\": true`) to any of these nodes:\n\n", + ); + for (id, summary) in existing_nodes { + let truncated = if summary.len() > 60 { + format!("{}...", &summary[..summary.floor_char_boundary(60)]) + } 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", "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}}}}}, + {{{{"from": "root", "to": "n2", "label": "Press Tab", "input": {{{{"keys": ["Tab"], "raw_text": "\\t"}}}}}}}}, + {{{{"from": "root", "to": "existing-uuid", "label": "Go Back", "input": {{{{"keys": ["Escape"], "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 MAY add any number of shortcut edges (`"shortcut": true`) linking to existing nodes. + Shortcuts should represent interactions that logically lead to an already-explored state. +4. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. + The other should represent the expected/obvious behavior. +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. + +{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. \ + 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 a spec update prompt for background spec refinement. +pub fn build_lean_spec_update_prompt( + spec_id: &str, + interaction_label: &str, + output_summary: &str, +) -> String { + format!( + "You are updating a specification based on a player's navigation in lean game mode.\n\n\ + The player chose interaction: \"{interaction_label}\"\n\ + The resulting output shows: \"{output_summary}\"\n\n\ + This confirms the simulated behavior is correct. Use spec-forest tools to update the \ + spec (spec_id: {spec_id}) if the player's path reveals decisions the spec should record.\n\n\ + Instructions:\n\ + 1. Use get_node to read related spec nodes.\n\ + 2. If the behavior is already covered by the spec, respond with \ + {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ + 3. If it reveals new info, use add_children + answer_question to add a Q&A. \ + Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\ + 4. If it refines an existing answer, use answer_question. \ + Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\n\ + JSON only, no markdown, no code fences." + ) +} 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..203449e --- /dev/null +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -0,0 +1,102 @@ +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, +} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 38b40fb..0a45fd9 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, GameSpecUpdate, GameTreeResponse, + GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, + SimTreeResponse, }; use std::collections::HashMap; use std::error::Error; @@ -863,6 +864,155 @@ 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 a lean game session for a background spec update. +/// +/// Parses the response as a GameSpecUpdate JSON. +pub async fn resume_lean_spec_update( + claude_session_id: &str, + prompt: &str, +) -> Result, Box> { + 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" + ); + + // Try to parse as a spec update action. + if let Ok(action) = extract_json::(&response_text) { + let action_type = action.get("action").and_then(|a| a.as_str()).unwrap_or("none"); + if action_type == "none" { + return Ok(None); + } + let node_id = action.get("node_id").and_then(|n| n.as_str()).unwrap_or("").to_string(); + let description = action.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(); + + return Ok(Some(GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: String::new(), + description, + node_id, + })); + } + + Ok(None) +} + +/// 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..8aa1821 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -1,3 +1,4 @@ +use super::lean_graph::LeanGraph; use super::types::{ ChannelContent, Decision, GameSpecUpdate, GameTreeRoot, SimReportResponse, SimTreeNode, }; @@ -112,6 +113,23 @@ 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, } impl SimSession { @@ -148,6 +166,14 @@ 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, } } } diff --git a/crates/spec-forest/src/tool_types.rs b/crates/spec-forest/src/tool_types.rs index 9bcead7..29352eb 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -361,3 +361,29 @@ 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, +} diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 58bda9a..4a38b2e 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1760,6 +1760,248 @@ 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)); + } + + 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)| { + serde_json::json!({ + "index": i, + "label": e.label, + "edge_kind": format!("{:?}", e.edge_kind), + "target_node_id": e.target_node_id, + }) + }) + .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, + ¶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 = "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] From 07edbf2ba4721a17e34ff9e1d09ed55296efb790 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:04:41 +1100 Subject: [PATCH 083/100] fix: restrict lean game AI to read-only spec tools Use spec_read_only config for lean batch generation so the AI can only call search_nodes, get_node, get_descendants, and get_spec_summary. Removes filesystem tools (Read, Glob, Grep) and explicitly tells the AI not to attempt any write or sim/game tools. --- .../src/simulation/lean_orchestrate.rs | 4 ++-- .../spec-forest/src/simulation/lean_prompt.rs | 8 +++++--- crates/spec-forest/src/simulation/runner.rs | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 7968e05..fb7b19c 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -90,11 +90,11 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str // Build initial prompt. let initial_prompt = super::lean_prompt::build_lean_initial_prompt(&channels, scenario.as_deref()); - // Build config and call AI. + // 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::new(model, full_system_prompt, mcp_url, None); + let config = SimConfig::spec_read_only(model, full_system_prompt, mcp_url); match super::runner::start_lean_batch_turn(&config, &initial_prompt).await { Ok((claude_session_id, batch_response)) => { diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index a33bad7..b940e57 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -112,14 +112,16 @@ Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_revi {entropy_section} -## Tools -You have access to spec-forest MCP tools. Use them to look up spec details: +## 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 -Use tools proactively when generating outputs that touch areas outside the loaded context. +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} diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 0a45fd9..1ab48c6 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -417,6 +417,24 @@ impl SimConfig { .join(","), } } + + /// Config with only spec read tools (no filesystem access). + /// Used for lean game batch generation. + pub fn spec_read_only(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__get_node", + "mcp__spec-forest__get_descendants", + "mcp__spec-forest__get_spec_summary", + ] + .join(","), + } + } } /// Start the first simulation turn. Returns (claude_session_id, response). From 2d148d19c7c0eb6068a6e59671f1f927bb32f7b9 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:05:56 +1100 Subject: [PATCH 084/100] feat: add delete spec from gallery with double-press confirmation --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 45 ++++++++++++- crates/spec-forest-tui/src/commands.rs | 9 +++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/ui/help_popup.rs | 1 + crates/spec-forest-tui/src/ui/spec_list.rs | 2 +- docs/tui-missing-features.md | 72 +++++++++++++++++++++ 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 docs/tui-missing-features.md diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 6d1666e..4bec96a 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), diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 65318b6..a17482c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -412,7 +412,7 @@ 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; } match action { @@ -448,6 +448,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(); } @@ -2395,6 +2397,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) { 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 5c1dd0d..62953e4 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -247,6 +247,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/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index f8a47e4..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"), 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/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) From 20cca4f07c9e1a550ad2cb89577b1b0037738029 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 09:26:26 +1100 Subject: [PATCH 085/100] fix: lean game back-nav during generation, auto-pregen on leaf nodes, and backgrounding Allow pressing back while a scene is generating by updating can_go_back during Processing status and cancelling in-flight leaf generation via the generation counter. Auto-trigger pregen when landing on nodes with shallow depth (after initial turn, navigation, and go-back). Change Esc to background lean games instead of destroying them, with full session restore via the existing session picker. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 138 +++++++++++++++--- crates/spec-forest-tui/src/input.rs | 2 +- .../src/simulation/lean_orchestrate.rs | 88 +++++++---- crates/spec-forest/src/tools.rs | 2 +- 5 files changed, 183 insertions(+), 48 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 4bec96a..47f549a 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -175,6 +175,7 @@ pub enum Action { LeanInputSubmit, LeanInputCancel, LeanInputNewline, + LeanBackground, LeanEnd, // Notification / session picker diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index a17482c..77b18df 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1449,7 +1449,7 @@ impl App { if let Some(ref lean) = self.lean_state { if lean.can_go_back { spec_forest::simulation::lean_orchestrate::orchestrate_lean_go_back( - &self.state, + self.state.clone(), &lean.session_id, ); } @@ -1550,6 +1550,44 @@ impl App { lean.modify_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; + self.screen = Screen::SpecView { spec_id }; + } + } Action::LeanEnd => { if let Screen::LeanGame { ref spec_id, ref session_id } = self.screen { let spec_id = spec_id.clone(); @@ -1602,6 +1640,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); @@ -1610,25 +1676,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); + } } } @@ -2889,6 +2976,19 @@ impl App { } spec_forest::simulation::SimStatus::Processing => { lean.processing = true; + // Update can_go_back during generation so user can navigate back. + if let Some(session) = self.state.get_sim_session(&session_id) { + lean.can_go_back = session.lean_navigation_path.len() > 1; + if let Some(ref graph) = session.lean_graph { + 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; diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 62953e4..e87887c 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -62,7 +62,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { KeyCode::Char('m') => Action::LeanEnterModify, KeyCode::Char('u') => Action::LeanToggleUpdateLog, KeyCode::Char('Q') => Action::LeanEnd, - KeyCode::Esc => Action::LeanEnd, + KeyCode::Esc => Action::LeanBackground, KeyCode::PageUp => Action::LeanScrollUp, KeyCode::PageDown => Action::LeanScrollDown, _ => Action::Noop, diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index fb7b19c..a22168b 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -100,6 +100,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str 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); @@ -114,6 +115,8 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str s.status = SimStatus::Idle; }); info!(session_id, "Lean game initial turn complete"); + // 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}")); @@ -158,20 +161,6 @@ pub async fn orchestrate_lean_navigate( match edge_kind { LeanEdgeKind::Generative | LeanEdgeKind::Shortcut => { // Instant navigation. - let should_pregen = { - 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(&target_node_id); - !s.lean_generating && depth < 2 - } else { - false - } - } else { - false - } - }; - 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()); @@ -184,18 +173,7 @@ pub async fn orchestrate_lean_navigate( }); // Spawn background pregen if needed. - if should_pregen { - let state2 = state.clone(); - let sid2 = session_id.clone(); - let target = target_node_id.clone(); - 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; - }); - } + maybe_trigger_pregen(&state, &session_id, &target_node_id); // Spawn background spec update. let output_summary = { @@ -221,6 +199,10 @@ pub async fn orchestrate_lean_navigate( } LeanEdgeKind::Leaf => { // 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; @@ -233,6 +215,14 @@ pub async fn orchestrate_lean_navigate( 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 { @@ -268,7 +258,7 @@ pub async fn orchestrate_lean_navigate( } /// Navigate back one step in the breadcrumb trail. -pub fn orchestrate_lean_go_back(state: &AppState, session_id: &str) { +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 { s.lean_navigation_path.pop(); @@ -280,8 +270,22 @@ pub fn orchestrate_lean_go_back(state: &AppState, session_id: &str) { 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. @@ -500,6 +504,36 @@ pub async fn orchestrate_lean_modify( // ── Helpers ───────────────────────────────────────────────────────────── +/// Check if the given node needs pregen and spawn it if so. +fn maybe_trigger_pregen(state: &Arc, session_id: &str, node_id: &str) { + let should_pregen = { + 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); + !s.lean_generating && depth < 2 + } else { + false + } + } else { + false + } + }; + + if should_pregen { + let state2 = state.clone(); + let sid2 = session_id.to_string(); + let target = node_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| { diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 4a38b2e..20a4046 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1879,7 +1879,7 @@ impl SpecForestServer { } crate::simulation::lean_orchestrate::orchestrate_lean_go_back( - &self.state, + self.state.clone(), ¶ms.session_id, ); From 3eaf5367dd28facf2d628d6a5b7f73baeb377597 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 10:07:14 +1100 Subject: [PATCH 086/100] feat: lean game spec updates use AI to find best placement across whole spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main lean AI now signals spec-relevant behavior via per-node spec_updates in the batch response. On navigation, a separate background AI session with write tools and full spec context determines the best placement — adding Q&A nodes, updating existing answers, or creating new features as appropriate. --- crates/spec-forest/src/simulation.rs | 2 +- .../spec-forest/src/simulation/lean_graph.rs | 11 +- .../src/simulation/lean_orchestrate.rs | 81 ++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 128 ++++++++++++++---- .../spec-forest/src/simulation/lean_types.rs | 12 ++ .../spec-forest/src/simulation/orchestrate.rs | 4 +- crates/spec-forest/src/simulation/runner.rs | 98 ++++++++++++++ crates/spec-forest/src/simulation/types.rs | 3 + 8 files changed, 276 insertions(+), 63 deletions(-) diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index a40af0f..98dd9db 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ 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, LeanNode}; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, LeanNode, LeanSpecSuggestion}; 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 index 5e25f93..35b3695 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -250,8 +250,8 @@ impl LeanGraph { .get("ui") .map(|c| { let text = &c.text; - if text.len() > 80 { - format!("{}...", &text[..text.floor_char_boundary(80)]) + if text.len() > 150 { + format!("{}...", &text[..text.floor_char_boundary(150)]) } else { text.clone() } @@ -272,6 +272,7 @@ pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse node_id: n.id, channels: n.channels, entropy_hint: n.entropy_hint, + spec_updates: n.spec_updates, }) .collect(); @@ -323,16 +324,19 @@ mod tests { node_id: "root".into(), channels: make_channel("Root screen"), entropy_hint: 0.5, + spec_updates: vec![], }, LeanNode { node_id: "n1".into(), channels: make_channel("Screen A"), entropy_hint: 0.8, + spec_updates: vec![], }, LeanNode { node_id: "n2".into(), channels: make_channel("Screen B"), entropy_hint: 0.3, + spec_updates: vec![], }, ], edges: vec![ @@ -367,11 +371,13 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, + spec_updates: vec![], }, LeanNode { node_id: "n1".into(), channels: make_channel("Child"), entropy_hint: 0.0, + spec_updates: vec![], }, ], edges: vec![LeanBatchEdge { @@ -405,6 +411,7 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, + spec_updates: vec![], }], edges: vec![LeanBatchEdge { from: "root".into(), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index a22168b..105ffc0 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -155,7 +155,6 @@ pub async fn orchestrate_lean_navigate( 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 { @@ -175,27 +174,26 @@ pub async fn orchestrate_lean_navigate( // Spawn background pregen if needed. maybe_trigger_pregen(&state, &session_id, &target_node_id); - // Spawn background spec update. - let output_summary = { + // Spawn background spec updates for any suggestions on this node. + let spec_suggestions = { let session = state.get_sim_session(&session_id); session - .and_then(|s| s.lean_graph.as_ref().and_then(|g| g.get_node(&target_node_id).cloned())) - .and_then(|n| n.channels.get("ui").cloned()) - .map(|c| { - if c.text.len() > 200 { - format!("{}...", &c.text[..c.text.floor_char_boundary(200)]) - } else { - c.text - } + .and_then(|s| { + s.lean_graph + .as_ref() + .and_then(|g| g.get_node(&target_node_id).cloned()) }) + .map(|n| n.spec_updates) .unwrap_or_default() }; - let state3 = state.clone(); - let sid3 = session_id.clone(); - tokio::spawn(async move { - orchestrate_lean_spec_update(state3, sid3, edge_label, output_summary).await; - }); + for suggestion in spec_suggestions { + let state3 = state.clone(); + let sid3 = session_id.clone(); + tokio::spawn(async move { + orchestrate_lean_spec_update(state3, sid3, suggestion.description).await; + }); + } } LeanEdgeKind::Leaf => { // Need to generate first. @@ -368,37 +366,54 @@ async fn orchestrate_lean_batch_pregen( } } -/// Background spec update after a player navigates. +/// Background spec update using a separate AI session with write tools. +/// +/// Loads the full spec tree, builds a compact outline, and spawns a fresh +/// Claude session that can search the entire spec and place updates wherever +/// they belong (not just under the current feature). async fn orchestrate_lean_spec_update( state: Arc, session_id: String, - interaction_label: String, - output_summary: String, + behavior_description: String, ) { let session = match state.get_sim_session(&session_id) { Some(s) => s, None => return, }; let spec_id = session.spec_id.clone(); - let claude_session_id = match &session.claude_session_id { - Some(id) => id.clone(), - None => return, - }; + let model = session.model.clone(); drop(session); - let prompt = super::lean_prompt::build_lean_spec_update_prompt( + // Load full spec tree for the outline. + 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); + let system_prompt = super::lean_prompt::build_spec_update_system_prompt(&spec_id); + let user_prompt = super::lean_prompt::build_spec_update_user_prompt( &spec_id, - &interaction_label, - &output_summary, + &behavior_description, + &spec_outline, ); - match super::runner::resume_lean_spec_update(&claude_session_id, &prompt).await { - Ok(update) => { - if let Some(update) = update { - state.update_sim_session(&session_id, |s| { - s.game_spec_updates.push(update); - }); - } + 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, system_prompt, mcp_url); + + match super::runner::start_spec_update_session(&config, &user_prompt).await { + Ok(Some(mut update)) => { + update.outcome_summary = behavior_description; + state.update_sim_session(&session_id, |s| { + s.game_spec_updates.push(update); + }); + info!(session_id, "Lean spec update applied"); + } + Ok(None) => { + info!(session_id, "Lean spec update: no changes needed"); } Err(e) => { error!(session_id, error = %e, "Lean spec update failed"); diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index b940e57..b767815 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -86,10 +86,14 @@ outputs and chooses interactions. Your job is to steer them toward the INTERESTI decisions — places where the spec is silent or ambiguous. At each node, generate exactly 2 NEW child outputs via generative edges. -You may also add shortcut edges linking to existing nodes in the DAG. One of the 2 generative edges should lead toward a high-entropy spec area. The other should represent 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. + ## 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. @@ -169,12 +173,14 @@ pub fn build_lean_batch_output_format( let mut existing_section = String::new(); if !existing_nodes.is_empty() { existing_section.push_str( - "## Existing DAG Nodes (available for shortcut edges)\n\ - You may add shortcut edges (`\"shortcut\": true`) to any of these nodes:\n\n", + "## 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() > 60 { - format!("{}...", &summary[..summary.floor_char_boundary(60)]) + let truncated = if summary.len() > 120 { + format!("{}...", &summary[..summary.floor_char_boundary(120)]) } else { summary.clone() }; @@ -195,7 +201,8 @@ Schema: "channels": {{{{ "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} }}}}, - "entropy_hint": 0.7 + "entropy_hint": 0.7, + "spec_updates": [{{{{"description": "User login defaults to OAuth flow when no password is set"}}}}] }}}}, {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} @@ -212,8 +219,11 @@ 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 MAY add any number of shortcut edges (`"shortcut": true`) linking to existing nodes. - Shortcuts should represent interactions that logically lead to an already-explored state. +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. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. The other should represent the expected/obvious behavior. 5. entropy_hint (0.0–1.0): how close this node's state is to unresolved spec decisions. @@ -222,6 +232,11 @@ Active channels: {channel_list} 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. spec_updates (optional array): Include when a node's behavior reveals something the spec \ + should record — a design decision, a default behavior, an edge case. Describe WHAT was \ + decided/observed, not WHERE it belongs in the spec. Omit if the behavior is already \ + clearly covered by the spec context above. A separate AI will determine the best \ + placement in the spec. {existing_section}"#, channel_list = channel_list, @@ -299,6 +314,9 @@ pub fn build_lean_resume_prompt( prompt.push_str( "Generate the next DAG batch from the current state. \ + 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 `{`.", ); @@ -326,26 +344,84 @@ pub fn build_lean_modify_prompt(modification: &str) -> String { ) } -/// Build a spec update prompt for background spec refinement. -pub fn build_lean_spec_update_prompt( +/// Build the system prompt for the background spec update AI. +pub fn build_spec_update_system_prompt(spec_id: &str) -> String { + format!( + "You are a spec placement AI. Your job is to find the best place in a specification \ + to record observed behavior from a simulation.\n\n\ + You have read-write access to the spec (spec_id: {spec_id}) via these tools:\n\ + - **search_nodes**: Semantic search across all spec nodes\n\ + - **search_features**: Find the closest matching feature root\n\ + - **get_node**: Read a specific node's details\n\ + - **get_descendants**: Read a node's subtree\n\ + - **get_spec_summary**: Get spec overview\n\ + - **add_children**: Create new Q&A nodes under a parent\n\ + - **answer_question**: Update or set a node's answer\n\ + - **add_feature**: Create a new root feature\n\n\ + ## Workflow\n\ + 1. Read the behavior description provided.\n\ + 2. Use search_nodes to find spec nodes related to the behavior.\n\ + 3. Decide the best action:\n\ + - **none**: The behavior is already clearly covered by an existing spec node.\n\ + - **update_answer**: An existing node covers this topic but the answer needs \ + updating. Call answer_question.\n\ + - **add_qa**: The behavior belongs under an existing node but no child covers it. \ + Use search_nodes/search_features to find the best parent, then call add_children \ + + answer_question.\n\ + - **add_feature**: The behavior represents an entirely new area not covered by \ + any existing feature. Call add_feature.\n\ + 4. Respond with a raw JSON object describing what you did.\n\n\ + ## Response Format\n\ + {{\"action\": \"none|add_qa|update_answer|add_feature\", \"node_id\": \"...\", \ + \"description\": \"...\"}}\n\n\ + JSON only, no markdown, no code fences." + ) +} + +/// Build the user prompt for a background spec update. +pub fn build_spec_update_user_prompt( spec_id: &str, - interaction_label: &str, - output_summary: &str, + behavior_description: &str, + spec_outline: &str, ) -> String { format!( - "You are updating a specification based on a player's navigation in lean game mode.\n\n\ - The player chose interaction: \"{interaction_label}\"\n\ - The resulting output shows: \"{output_summary}\"\n\n\ - This confirms the simulated behavior is correct. Use spec-forest tools to update the \ - spec (spec_id: {spec_id}) if the player's path reveals decisions the spec should record.\n\n\ - Instructions:\n\ - 1. Use get_node to read related spec nodes.\n\ - 2. If the behavior is already covered by the spec, respond with \ - {{\"action\": \"none\", \"reason\": \"...\"}}.\n\ - 3. If it reveals new info, use add_children + answer_question to add a Q&A. \ - Respond with {{\"action\": \"add_qa\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\ - 4. If it refines an existing answer, use answer_question. \ - Respond with {{\"action\": \"update_answer\", \"node_id\": \"...\", \"description\": \"...\"}}.\n\n\ - JSON only, no markdown, no code fences." + "The following behavior was observed during a lean game simulation and should be \ + recorded in the spec (spec_id: {spec_id}):\n\n\ + **Observed behavior:** {behavior_description}\n\n\ + ## Current Spec Outline\n\ + {spec_outline}\n\n\ + Find the best place in the spec for this behavior. Use the tools to search, read, \ + and modify the spec as needed. Then respond with your action JSON." ) } + +/// 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 index 203449e..7730bb5 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -20,6 +20,16 @@ pub struct LeanNode { /// How close this node is to high-entropy spec areas (0.0–1.0). #[serde(default)] pub entropy_hint: f64, + /// Spec-relevant behaviors observed at this node that should be recorded. + #[serde(default)] + pub spec_updates: Vec, +} + +/// A suggestion from the main lean AI about behavior worth recording in the spec. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeanSpecSuggestion { + /// Description of what behavior was observed or decided. + pub description: String, } // ── Edge ──────────────────────────────────────────────────────────────── @@ -88,6 +98,8 @@ pub struct LeanFlatNode { pub channels: HashMap, #[serde(default)] pub entropy_hint: f64, + #[serde(default)] + pub spec_updates: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] 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 1ab48c6..7e2616f 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -435,6 +435,27 @@ impl SimConfig { .join(","), } } + + /// Config with spec read + write tools for background spec updates. + 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). @@ -1007,6 +1028,83 @@ pub async fn resume_lean_spec_update( outcome_summary: String::new(), description, node_id, + action: action_type.to_string(), + })); + } + + Ok(None) +} + +/// Start a fresh Claude session for a background spec update. +/// +/// Unlike `resume_lean_spec_update`, this creates a new session with write tools +/// so the AI can search the whole spec and place updates wherever appropriate. +pub async fn start_spec_update_session( + config: &SimConfig, + prompt: &str, +) -> Result, 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); + + tracing::info!( + prompt_chars = prompt.len(), + "Starting spec update session" + ); + + let (response_text, _) = run_claude_streaming(cmd).await?; + tracing::info!( + response_chars = response_text.len(), + "Spec update session complete" + ); + + // Parse the response as a spec update action. + if let Ok(action) = extract_json::(&response_text) { + let action_type = action + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or("none"); + if action_type == "none" { + return Ok(None); + } + let node_id = action + .get("node_id") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let description = action + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + + return Ok(Some(GameSpecUpdate { + interaction_label: String::new(), + outcome_summary: String::new(), + description, + node_id, + action: action_type.to_string(), })); } 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. From 8aba3eab891e20ea5f9526867bb5a643c7fe1109 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 10:44:57 +1100 Subject: [PATCH 087/100] feat: replace background spec updates with manual send-actions flow Remove automatic background Claude sessions for spec updates during lean game navigation. Instead, navigation history accumulates as unsent actions that the user explicitly sends (press 's') with notes to the main AI session via --resume. The AI then uses write MCP tools to update the spec. Key changes: - Track unsent action count via lean_sent_path_len on SimSession - Queue leaf generation and send-actions when session is busy - Warn on quit if unsent actions remain (double-press Q to confirm) - Show send(N) in status bar, spec update progress in output title - Remove LeanSpecSuggestion, background spec update orchestration, and separate spec update Claude sessions --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 61 ++++++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/lean_state.rs | 17 +- crates/spec-forest-tui/src/ui/lean_game.rs | 75 +++++-- crates/spec-forest/src/simulation.rs | 2 +- .../spec-forest/src/simulation/lean_graph.rs | 12 +- .../src/simulation/lean_orchestrate.rs | 202 +++++++++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 100 ++++----- .../spec-forest/src/simulation/lean_types.rs | 12 -- crates/spec-forest/src/simulation/runner.rs | 128 +---------- crates/spec-forest/src/simulation/session.rs | 13 ++ 12 files changed, 356 insertions(+), 268 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index 47f549a..a524f24 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -167,6 +167,7 @@ pub enum Action { LeanGoBack, LeanEnterQuery, LeanEnterModify, + LeanEnterSendActions, LeanToggleUpdateLog, LeanScrollUp, LeanScrollDown, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 77b18df..7d24042 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -415,6 +415,12 @@ impl App { 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, @@ -1467,6 +1473,14 @@ impl App { lean.modify_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; @@ -1488,6 +1502,8 @@ impl App { 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); } } } @@ -1497,6 +1513,8 @@ impl App { lean.query_input.pop(); } else if lean.modify_mode { lean.modify_input.pop(); + } else if lean.send_actions_mode { + lean.send_actions_input.pop(); } } } @@ -1506,6 +1524,8 @@ impl App { 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'); } } } @@ -1539,6 +1559,28 @@ impl App { ) .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; + }); + } } } } @@ -1546,8 +1588,10 @@ impl App { if let Some(ref mut lean) = self.lean_state { lean.query_mode = false; lean.modify_mode = false; + lean.send_actions_mode = false; lean.query_input.clear(); lean.modify_input.clear(); + lean.send_actions_input.clear(); } } Action::LeanBackground => { @@ -1589,6 +1633,13 @@ impl App { } } 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(); @@ -2972,6 +3023,11 @@ impl App { lean.pregenerating = session.lean_generating; lean.game_spec_updates = session.game_spec_updates.clone(); + lean.unsent_action_count = session + .lean_navigation_path + .len() + .saturating_sub(session.lean_sent_path_len); + lean.spec_updating = session.lean_spec_updating; } } spec_forest::simulation::SimStatus::Processing => { @@ -2979,6 +3035,11 @@ impl App { // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; + lean.unsent_action_count = session + .lean_navigation_path + .len() + .saturating_sub(session.lean_sent_path_len); + lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { let crumbs = graph.collect_breadcrumbs(&session.lean_navigation_path); diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index e87887c..ca61287 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -60,6 +60,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { KeyCode::Backspace => Action::LeanGoBack, KeyCode::Char('i') => Action::LeanEnterQuery, KeyCode::Char('m') => Action::LeanEnterModify, + KeyCode::Char('s') => Action::LeanEnterSendActions, KeyCode::Char('u') => Action::LeanToggleUpdateLog, KeyCode::Char('Q') => Action::LeanEnd, KeyCode::Esc => Action::LeanBackground, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 8e1314c..585c318 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -1,6 +1,4 @@ -use spec_forest::simulation::{ - ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel, -}; +use spec_forest::simulation::{ChannelContent, GameSpecUpdate, LeanEdgeKind, SimChannel}; use std::collections::HashMap; use crate::simulation::ReportOverlay; @@ -27,6 +25,12 @@ pub struct LeanGameState { 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 quit_pending: bool, } /// View model for a single interaction in the lean game panel. @@ -58,11 +62,16 @@ impl LeanGameState { 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, + quit_pending: false, } } /// Whether we're in any text input mode. pub fn in_input_mode(&self) -> bool { - self.query_mode || self.modify_mode + self.query_mode || self.modify_mode || self.send_actions_mode } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 2396838..a7d498b 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -46,7 +46,7 @@ pub fn render(app: &App, frame: &mut Frame) { render_status_bar(app, frame, chunks[3]); // ── Overlays ──────────────────────────────────────────────────── - if lean.query_mode || lean.modify_mode { + if lean.query_mode || lean.modify_mode || lean.send_actions_mode { render_input_overlay(app, frame); } if lean.report_overlay.is_some() { @@ -125,12 +125,16 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { let title = if lean.processing { " Output (generating...) " + } else if lean.spec_updating { + " Output (updating spec...) " } else { " Output " }; let border_color = if lean.processing { Color::Yellow + } else if lean.spec_updating { + Color::Magenta } else { Color::Cyan }; @@ -217,31 +221,59 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect frame.render_widget(paragraph, area); } -fn render_status_bar(_app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { - let items = vec![ - ("↑↓", "select"), - ("Enter", "go"), - ("Bksp", "back"), - ("i", "query"), - ("m", "modify"), - ("u", "updates"), - ("Q", "quit"), +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)> = 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![ - Span::styled( + let mut v = Vec::new(); + if !key.is_empty() { + v.push(Span::styled( format!(" {key}"), Style::default().fg(Color::Yellow), - ), - Span::styled( - format!(" {desc}"), - Style::default().fg(Color::DarkGray), - ), - ]; + )); + } + 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))); } @@ -269,8 +301,13 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { let (title, input) = if lean.query_mode { (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) - } else { + } else if lean.modify_mode { (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + } else { + ( + " Send Actions — Add Notes (Ctrl+S to submit, Esc to cancel) ", + &lean.send_actions_input, + ) }; let paragraph = Paragraph::new(input.as_str()) diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index 98dd9db..a40af0f 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ 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, LeanNode, LeanSpecSuggestion}; +pub use lean_types::{LeanBatchEdge, LeanBatchResponse, LeanEdge, LeanEdgeKind, LeanFlatTree, 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 index 35b3695..e61f6d1 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -272,7 +272,6 @@ pub fn flat_to_batch(flat: super::lean_types::LeanFlatTree) -> LeanBatchResponse node_id: n.id, channels: n.channels, entropy_hint: n.entropy_hint, - spec_updates: n.spec_updates, }) .collect(); @@ -324,19 +323,19 @@ mod tests { node_id: "root".into(), channels: make_channel("Root screen"), entropy_hint: 0.5, - spec_updates: vec![], + }, LeanNode { node_id: "n1".into(), channels: make_channel("Screen A"), entropy_hint: 0.8, - spec_updates: vec![], + }, LeanNode { node_id: "n2".into(), channels: make_channel("Screen B"), entropy_hint: 0.3, - spec_updates: vec![], + }, ], edges: vec![ @@ -371,13 +370,13 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, - spec_updates: vec![], + }, LeanNode { node_id: "n1".into(), channels: make_channel("Child"), entropy_hint: 0.0, - spec_updates: vec![], + }, ], edges: vec![LeanBatchEdge { @@ -411,7 +410,6 @@ mod tests { node_id: "root".into(), channels: make_channel("Root"), entropy_hint: 0.0, - spec_updates: vec![], }], edges: vec![LeanBatchEdge { from: "root".into(), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 105ffc0..0e7e8a5 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -94,7 +94,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str let mcp_url = state .mcp_url() .unwrap_or_else(|| "http://127.0.0.1:8080/mcp".to_string()); - let config = SimConfig::spec_read_only(model, full_system_prompt, mcp_url); + 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)) => { @@ -173,29 +173,20 @@ pub async fn orchestrate_lean_navigate( // Spawn background pregen if needed. maybe_trigger_pregen(&state, &session_id, &target_node_id); - - // Spawn background spec updates for any suggestions on this node. - let spec_suggestions = { - let session = state.get_sim_session(&session_id); - session - .and_then(|s| { - s.lean_graph - .as_ref() - .and_then(|g| g.get_node(&target_node_id).cloned()) - }) - .map(|n| n.spec_updates) - .unwrap_or_default() - }; - - for suggestion in spec_suggestions { - let state3 = state.clone(); - let sid3 = session_id.clone(); - tokio::spawn(async move { - orchestrate_lean_spec_update(state3, sid3, suggestion.description).await; - }); - } } 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) @@ -358,67 +349,172 @@ async fn orchestrate_lean_batch_pregen( 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); } } } -/// Background spec update using a separate AI session with write tools. +/// Send accumulated navigation actions to the main Claude session for spec updates. /// -/// Loads the full spec tree, builds a compact outline, and spawns a fresh -/// Claude session that can search the entire spec and place updates wherever -/// they belong (not just under the current feature). -async fn orchestrate_lean_spec_update( +/// 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, - behavior_description: String, + user_notes: String, ) { - let session = match state.get_sim_session(&session_id) { - Some(s) => s, - None => return, + info!(session_id, "Starting lean send actions"); + + // 1. Snapshot unsent path 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 path = &session.lean_navigation_path; + let sent = session.lean_sent_path_len; + let new_sent_len = path.len(); + + // Build history text for unsent portion. + // Include the last sent node as context for the first transition. + let unsent_path: Vec = path[sent.saturating_sub(1)..].to_vec(); + let history_text = session + .lean_graph + .as_ref() + .map(|g| format_navigation_history(g, &unsent_path)) + .unwrap_or_default(); + + drop(session); + (claude_sid, spec_id, history_text, new_sent_len) }; - let spec_id = session.spec_id.clone(); - let model = session.model.clone(); - drop(session); - // Load full spec tree for the outline. + 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); - let system_prompt = super::lean_prompt::build_spec_update_system_prompt(&spec_id); - let user_prompt = super::lean_prompt::build_spec_update_user_prompt( + + // 3. Build prompt. + let prompt = super::lean_prompt::build_send_actions_prompt( + &unsent_history_text, + &user_notes, &spec_id, - &behavior_description, &spec_outline, ); - 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, system_prompt, mcp_url); - - match super::runner::start_spec_update_session(&config, &user_prompt).await { - Ok(Some(mut update)) => { - update.outcome_summary = behavior_description; + // 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_path_len) + .unwrap_or(0); state.update_sim_session(&session_id, |s| { - s.game_spec_updates.push(update); + s.lean_spec_updating = false; + s.lean_sent_path_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 spec update applied"); - } - Ok(None) => { - info!(session_id, "Lean spec update: no changes needed"); + 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 spec update failed"); + 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; + }); + } +} + +/// Format navigation history for the send actions prompt. +fn format_navigation_history( + graph: &super::lean_graph::LeanGraph, + path: &[String], +) -> String { + let history = graph.collect_path_history(path); + let mut text = String::new(); + for (i, (input, node)) in history.iter().enumerate() { + text.push_str(&format!("### Step {}\n", i + 1)); + text.push_str(&format!( + "**Action:** {}\n", + if input.raw_text.trim().is_empty() { + "(default/enter)" + } else { + input.raw_text.trim() + } + )); + if let Some(ui) = node.channels.get("ui") { + let output = if ui.text.len() > 500 { + format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) + } else { + ui.text.clone() + }; + text.push_str(&format!("**Output:**\n{}\n\n", output)); } } + text } /// Handle a player query about the current state. diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index b767815..3272fa1 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -201,8 +201,7 @@ Schema: "channels": {{{{ "": {{{{"text": "...", "refs": [], "spec_gaps": []}}}} }}}}, - "entropy_hint": 0.7, - "spec_updates": [{{{{"description": "User login defaults to OAuth flow when no password is set"}}}}] + "entropy_hint": 0.7 }}}}, {{{{"id": "n1", "channels": {{{{...}}}}, "entropy_hint": 0.9}}}}, {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} @@ -232,11 +231,6 @@ Active channels: {channel_list} 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. spec_updates (optional array): Include when a node's behavior reveals something the spec \ - should record — a design decision, a default behavior, an edge case. Describe WHAT was \ - decided/observed, not WHERE it belongs in the spec. Omit if the behavior is already \ - clearly covered by the spec context above. A separate AI will determine the best \ - placement in the spec. {existing_section}"#, channel_list = channel_list, @@ -344,55 +338,55 @@ pub fn build_lean_modify_prompt(modification: &str) -> String { ) } -/// Build the system prompt for the background spec update AI. -pub fn build_spec_update_system_prompt(spec_id: &str) -> String { - format!( - "You are a spec placement AI. Your job is to find the best place in a specification \ - to record observed behavior from a simulation.\n\n\ - You have read-write access to the spec (spec_id: {spec_id}) via these tools:\n\ - - **search_nodes**: Semantic search across all spec nodes\n\ - - **search_features**: Find the closest matching feature root\n\ - - **get_node**: Read a specific node's details\n\ - - **get_descendants**: Read a node's subtree\n\ - - **get_spec_summary**: Get spec overview\n\ - - **add_children**: Create new Q&A nodes under a parent\n\ - - **answer_question**: Update or set a node's answer\n\ - - **add_feature**: Create a new root feature\n\n\ - ## Workflow\n\ - 1. Read the behavior description provided.\n\ - 2. Use search_nodes to find spec nodes related to the behavior.\n\ - 3. Decide the best action:\n\ - - **none**: The behavior is already clearly covered by an existing spec node.\n\ - - **update_answer**: An existing node covers this topic but the answer needs \ - updating. Call answer_question.\n\ - - **add_qa**: The behavior belongs under an existing node but no child covers it. \ - Use search_nodes/search_features to find the best parent, then call add_children \ - + answer_question.\n\ - - **add_feature**: The behavior represents an entirely new area not covered by \ - any existing feature. Call add_feature.\n\ - 4. Respond with a raw JSON object describing what you did.\n\n\ - ## Response Format\n\ - {{\"action\": \"none|add_qa|update_answer|add_feature\", \"node_id\": \"...\", \ - \"description\": \"...\"}}\n\n\ - JSON only, no markdown, no code fences." - ) -} - -/// Build the user prompt for a background spec update. -pub fn build_spec_update_user_prompt( +/// 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, - behavior_description: &str, spec_outline: &str, ) -> String { - format!( - "The following behavior was observed during a lean game simulation and should be \ - recorded in the spec (spec_id: {spec_id}):\n\n\ - **Observed behavior:** {behavior_description}\n\n\ - ## Current Spec Outline\n\ - {spec_outline}\n\n\ - Find the best place in the spec for this behavior. Use the tools to search, read, \ - and modify the spec as needed. Then respond with your action JSON." - ) + let mut prompt = String::new(); + + prompt.push_str("## Spec Update Request\n\n"); + prompt.push_str( + "The player has been navigating through the simulation and wants to update the spec \ + based on their observations. Below is their navigation history showing each interaction \ + they chose and the resulting output.\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( + "Remember: the player may have also asked you questions or requested modifications \ + during the session — take those into account as well when deciding what to update.\n\n", + ); + + prompt.push_str(&format!( + "### Current Spec Outline (spec_id: {})\n{}\n\n", + spec_id, spec_outline + )); + + prompt.push_str( + "Based on the navigation history and player notes, 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\ + Skip any behavior already clearly covered by existing spec content.\n\n\ + After making all updates, respond with a brief summary of what you changed.", + ); + + prompt } /// Build a compact text outline of the entire spec tree. diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs index 7730bb5..203449e 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -20,16 +20,6 @@ pub struct LeanNode { /// How close this node is to high-entropy spec areas (0.0–1.0). #[serde(default)] pub entropy_hint: f64, - /// Spec-relevant behaviors observed at this node that should be recorded. - #[serde(default)] - pub spec_updates: Vec, -} - -/// A suggestion from the main lean AI about behavior worth recording in the spec. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LeanSpecSuggestion { - /// Description of what behavior was observed or decided. - pub description: String, } // ── Edge ──────────────────────────────────────────────────────────────── @@ -98,8 +88,6 @@ pub struct LeanFlatNode { pub channels: HashMap, #[serde(default)] pub entropy_hint: f64, - #[serde(default)] - pub spec_updates: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 7e2616f..9058216 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1,5 +1,5 @@ use super::types::{ - FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameSpecUpdate, GameTreeResponse, + FlatEdge, FlatTree, GameChoiceGroup, GameOutcome, GameTreeResponse, GameTreeRoot, PredictedInteraction, SimReportResponse, SimResponse, SimTreeNode, SimTreeResponse, }; @@ -418,25 +418,9 @@ impl SimConfig { } } - /// Config with only spec read tools (no filesystem access). - /// Used for lean game batch generation. - pub fn spec_read_only(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__get_node", - "mcp__spec-forest__get_descendants", - "mcp__spec-forest__get_spec_summary", - ] - .join(","), - } - } - - /// Config with spec read + write tools for background spec updates. + /// 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, @@ -985,13 +969,13 @@ pub async fn resume_lean_batch_turn( parse_lean_batch_response(&response_text) } -/// Resume a lean game session for a background spec update. +/// Resume the main lean session for a spec update turn. /// -/// Parses the response as a GameSpecUpdate JSON. -pub async fn resume_lean_spec_update( +/// 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, Box> { +) -> Result> { let mut cmd = tokio::process::Command::new("claude"); cmd.arg("--print") .arg("--output-format") @@ -1014,101 +998,7 @@ pub async fn resume_lean_spec_update( "Lean spec update turn complete" ); - // Try to parse as a spec update action. - if let Ok(action) = extract_json::(&response_text) { - let action_type = action.get("action").and_then(|a| a.as_str()).unwrap_or("none"); - if action_type == "none" { - return Ok(None); - } - let node_id = action.get("node_id").and_then(|n| n.as_str()).unwrap_or("").to_string(); - let description = action.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(); - - return Ok(Some(GameSpecUpdate { - interaction_label: String::new(), - outcome_summary: String::new(), - description, - node_id, - action: action_type.to_string(), - })); - } - - Ok(None) -} - -/// Start a fresh Claude session for a background spec update. -/// -/// Unlike `resume_lean_spec_update`, this creates a new session with write tools -/// so the AI can search the whole spec and place updates wherever appropriate. -pub async fn start_spec_update_session( - config: &SimConfig, - prompt: &str, -) -> Result, 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); - - tracing::info!( - prompt_chars = prompt.len(), - "Starting spec update session" - ); - - let (response_text, _) = run_claude_streaming(cmd).await?; - tracing::info!( - response_chars = response_text.len(), - "Spec update session complete" - ); - - // Parse the response as a spec update action. - if let Ok(action) = extract_json::(&response_text) { - let action_type = action - .get("action") - .and_then(|a| a.as_str()) - .unwrap_or("none"); - if action_type == "none" { - return Ok(None); - } - let node_id = action - .get("node_id") - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let description = action - .get("description") - .and_then(|d| d.as_str()) - .unwrap_or("") - .to_string(); - - return Ok(Some(GameSpecUpdate { - interaction_label: String::new(), - outcome_summary: String::new(), - description, - node_id, - action: action_type.to_string(), - })); - } - - Ok(None) + Ok(response_text) } /// Parse the AI's text response into a LeanBatchResponse. diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 8aa1821..917d371 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -130,6 +130,15 @@ pub struct SimSession { pub lean_generation_target: Option, /// Generation counter, incremented on modifications to invalidate stale pregens. pub lean_generation: u64, + /// How many entries in lean_navigation_path have been sent via "send actions." + /// New (unsent) actions are lean_navigation_path[lean_sent_path_len..]. + pub lean_sent_path_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, } impl SimSession { @@ -174,6 +183,10 @@ impl SimSession { lean_generating: false, lean_generation_target: None, lean_generation: 0, + lean_sent_path_len: 1, + lean_spec_updating: false, + lean_queued_leaf: None, + lean_queued_send: None, } } } From 062666c63b146ca75ad6c47a3c982d029dec4277 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:00:19 +1100 Subject: [PATCH 088/100] fix: lean game modify updates display, send actions shows edge labels - Add LeanGraph::replace_at() for modify: replaces current node's content and edges with the new batch so the modified output is immediately visible - Update format_navigation_history to use edge labels (e.g. "Click Submit") instead of raw_text for clearer action descriptions - Send actions overlay now lists all unsent actions by label before the notes input, so users can see what they're sending --- crates/spec-forest-tui/src/app.rs | 17 +++- crates/spec-forest-tui/src/lean_state.rs | 2 + crates/spec-forest-tui/src/ui/lean_game.rs | 98 ++++++++++++++----- .../spec-forest/src/simulation/lean_graph.rs | 72 ++++++++++++++ .../src/simulation/lean_orchestrate.rs | 20 ++-- 5 files changed, 170 insertions(+), 39 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7d24042..82eb235 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3023,10 +3023,22 @@ impl App { lean.pregenerating = session.lean_generating; lean.game_spec_updates = session.game_spec_updates.clone(); - lean.unsent_action_count = session + let unsent_count = session .lean_navigation_path .len() .saturating_sub(session.lean_sent_path_len); + lean.unsent_action_count = unsent_count; + // Collect edge labels for unsent actions. + if let Some(ref graph) = session.lean_graph { + let sent = session.lean_sent_path_len; + let path = &session.lean_navigation_path; + let unsent_path = &path[sent.saturating_sub(1)..]; + lean.unsent_action_labels = graph + .collect_labeled_path_history(unsent_path) + .into_iter() + .map(|(label, _)| label) + .collect(); + } lean.spec_updating = session.lean_spec_updating; } } @@ -3035,10 +3047,11 @@ impl App { // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; - lean.unsent_action_count = session + let unsent_count = session .lean_navigation_path .len() .saturating_sub(session.lean_sent_path_len); + lean.unsent_action_count = unsent_count; lean.spec_updating = session.lean_spec_updating; if let Some(ref graph) = session.lean_graph { let crumbs = diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 585c318..be98db9 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -30,6 +30,7 @@ pub struct LeanGameState { pub send_actions_input: String, pub spec_updating: bool, pub unsent_action_count: usize, + pub unsent_action_labels: Vec, pub quit_pending: bool, } @@ -66,6 +67,7 @@ impl LeanGameState { send_actions_input: String::new(), spec_updating: false, unsent_action_count: 0, + unsent_action_labels: Vec::new(), quit_pending: false, } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index a7d498b..97cb77f 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -289,36 +289,82 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { let lean = app.lean_state.as_ref().unwrap(); let area = frame.area(); - 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, - }; + 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); + frame.render_widget(Clear, overlay_area); - let (title, input) = if lean.query_mode { - (" Query (Ctrl+S to submit, Esc to cancel) ", &lean.query_input) - } else if lean.modify_mode { - (" Modify (Ctrl+S to submit, Esc to cancel) ", &lean.modify_input) + 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 { - ( - " Send Actions — Add Notes (Ctrl+S to submit, Esc to cancel) ", - &lean.send_actions_input, - ) - }; + // Query or modify 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, + }; - 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); + frame.render_widget(Clear, overlay_area); + + let (title, input) = 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) { diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index e61f6d1..06ecf4b 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -134,6 +134,55 @@ impl LeanGraph { } } + /// 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) @@ -235,6 +284,29 @@ impl LeanGraph { 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() diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 0e7e8a5..b8464e6 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -493,18 +493,11 @@ fn format_navigation_history( graph: &super::lean_graph::LeanGraph, path: &[String], ) -> String { - let history = graph.collect_path_history(path); + let history = graph.collect_labeled_path_history(path); let mut text = String::new(); - for (i, (input, node)) in history.iter().enumerate() { + for (i, (label, node)) in history.iter().enumerate() { text.push_str(&format!("### Step {}\n", i + 1)); - text.push_str(&format!( - "**Action:** {}\n", - if input.raw_text.trim().is_empty() { - "(default/enter)" - } else { - input.raw_text.trim() - } - )); + text.push_str(&format!("**Action:** {}\n", label)); if let Some(ui) = node.channels.get("ui") { let output = if ui.text.len() > 500 { format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) @@ -601,7 +594,12 @@ pub async fn orchestrate_lean_modify( state.update_sim_session(&session_id, |s| { if let Some(ref mut graph) = s.lean_graph { if let Some(ref cid) = current_id { - graph.merge_batch(batch_response, cid); + // 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; From bca11f0e46b753cbc207c1c67947345fe94623b8 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:20:28 +1100 Subject: [PATCH 089/100] fix: lean game frontier indicator and auto-pregen after leaf navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show "◐" yellow indicator on edges approaching ungenerated frontier nodes instead of the normal "●" green. Trigger background pregen immediately after navigating via a leaf edge so the next level generates without delay. --- crates/spec-forest-tui/src/app.rs | 4 ++++ crates/spec-forest-tui/src/lean_state.rs | 2 ++ crates/spec-forest-tui/src/ui/lean_game.rs | 1 + crates/spec-forest/src/simulation/lean_orchestrate.rs | 2 ++ crates/spec-forest/src/tools.rs | 4 ++++ 5 files changed, 13 insertions(+) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 82eb235..488530c 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -2997,10 +2997,14 @@ impl App { } 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(); diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index be98db9..72cc549 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -39,6 +39,8 @@ 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 { diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 97cb77f..9536f43 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -177,6 +177,7 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect // 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), diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index b8464e6..30fbcea 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -232,6 +232,8 @@ pub async fn orchestrate_lean_navigate( } s.status = SimStatus::Idle; }); + // Trigger pregen on the new node so next level starts generating. + maybe_trigger_pregen(&state2, &sid2, &target); return; } } diff --git a/crates/spec-forest/src/tools.rs b/crates/spec-forest/src/tools.rs index 20a4046..d315fd5 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1794,11 +1794,15 @@ impl SpecForestServer { .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(); From 763a4a502c7f60f0b8116de2e30d8b0dc11d3963 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 11:29:25 +1100 Subject: [PATCH 090/100] feat: lean send-actions prompt treats player journey as spec truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent navigation now signals acceptance — the AI is instructed to treat unmodified/unqueried outputs as correct and use them to fill unspecified gaps in the spec. --- .../spec-forest/src/simulation/lean_prompt.rs | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 3272fa1..debec0d 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -352,9 +352,25 @@ pub fn build_send_actions_prompt( prompt.push_str("## Spec Update Request\n\n"); prompt.push_str( - "The player has been navigating through the simulation and wants to update the spec \ - based on their observations. Below is their navigation history showing each interaction \ - they chose and the resulting output.\n\n", + "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\ + - **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\ + - **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"); @@ -364,26 +380,22 @@ pub fn build_send_actions_prompt( prompt.push_str(&format!("\n### Player Notes\n{}\n\n", user_notes)); } - prompt.push_str( - "Remember: the player may have also asked you questions or requested modifications \ - during the session — take those into account as well when deciding what to update.\n\n", - ); - prompt.push_str(&format!( "### Current Spec Outline (spec_id: {})\n{}\n\n", spec_id, spec_outline )); prompt.push_str( - "Based on the navigation history and player notes, use the spec tools to update \ - the specification. You can:\n\ + "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\ - Skip any behavior already clearly covered by existing spec content.\n\n\ - After making all updates, respond with a brief summary of what you changed.", + 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 From 79a034feb79a107431a562f8d094b19c758dbc79 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:04:12 +1100 Subject: [PATCH 091/100] fix: refresh spec nodes when returning from LeanGame/Simulation screens Op notifications were silently dropped while on LeanGame or Simulation screens, leaving self.nodes stale. Nodes added via MCP during those sessions would appear empty or missing when navigating back to SpecView. --- crates/spec-forest-tui/src/app.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 488530c..4783718 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -992,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 => { @@ -1018,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) => { @@ -1629,7 +1635,10 @@ impl App { 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 => { @@ -1645,7 +1654,10 @@ impl App { 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); } } From b8b3851d71a5b39a0eb44f975a0e4a2f59456cff Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:11:12 +1100 Subject: [PATCH 092/100] fix: prevent spec questions from leaking into lean game simulation outputs Add explicit instructions across system prompt, channel semantics, and DAG rules telling the AI to render concrete application output rather than surfacing spec-level questions or uncertainty markers in channel text. --- crates/spec-forest/src/simulation/lean_prompt.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index debec0d..813441b 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -94,6 +94,12 @@ shortcut edges (`"shortcut": true`) that link to them. Shortcuts create realisti navigation: back buttons, shared destinations, menu returns, and loop-backs. A DAG without shortcuts is an unrealistic tree — real applications have convergent paths. +## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS +Channel outputs must read like a REAL, FINISHED application. Never include spec questions, +uncertainty markers, or placeholder text like "What does this component do?" in any channel. +If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. +The entropy_hint field is where you signal uncertainty — not the channel text itself. + ## 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. @@ -135,7 +141,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps — just the simulation output."#, +Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ +markers — just concrete simulation output as a real application would display it. \ +If the spec is ambiguous, make a definitive choice and reflect it in the output."#, spec_id = spec_id, spec_name = summary.spec.name, answered = summary.answered_count, @@ -231,6 +239,9 @@ Active channels: {channel_list} 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. Use entropy_hint to signal + ambiguity — never leak it into the visible output. {existing_section}"#, channel_list = channel_list, From dcf0ad66ba9e11133baebad90ffb6e8b4280138f Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:21:29 +1100 Subject: [PATCH 093/100] feat: preserve full navigation history for lean game send-actions Back navigation no longer removes entries from the send-actions history. A separate append-only lean_action_history records every forward and back navigation chronologically, so send-actions reflects the complete player journey including backtracking. --- crates/spec-forest-tui/src/app.rs | 24 ++---- crates/spec-forest/src/simulation.rs | 2 +- .../src/simulation/lean_orchestrate.rs | 85 ++++++++++++------- .../spec-forest/src/simulation/lean_types.rs | 11 +++ crates/spec-forest/src/simulation/session.rs | 11 ++- 5 files changed, 84 insertions(+), 49 deletions(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 4783718..77f5268 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3040,21 +3040,15 @@ impl App { lean.game_spec_updates = session.game_spec_updates.clone(); let unsent_count = session - .lean_navigation_path + .lean_action_history .len() - .saturating_sub(session.lean_sent_path_len); + .saturating_sub(session.lean_sent_history_len); lean.unsent_action_count = unsent_count; - // Collect edge labels for unsent actions. - if let Some(ref graph) = session.lean_graph { - let sent = session.lean_sent_path_len; - let path = &session.lean_navigation_path; - let unsent_path = &path[sent.saturating_sub(1)..]; - lean.unsent_action_labels = graph - .collect_labeled_path_history(unsent_path) - .into_iter() - .map(|(label, _)| label) - .collect(); - } + 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; } } @@ -3064,9 +3058,9 @@ impl App { if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; let unsent_count = session - .lean_navigation_path + .lean_action_history .len() - .saturating_sub(session.lean_sent_path_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 { diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index a40af0f..ad4ac8b 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -19,7 +19,7 @@ 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, LeanNode}; +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_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 30fbcea..56cceac 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -155,14 +155,22 @@ pub async fn orchestrate_lean_navigate( 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) { @@ -221,10 +229,18 @@ pub async fn orchestrate_lean_navigate( 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(); @@ -252,9 +268,17 @@ pub async fn orchestrate_lean_navigate( 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) { @@ -377,7 +401,7 @@ pub async fn orchestrate_lean_send_actions( ) { info!(session_id, "Starting lean send actions"); - // 1. Snapshot unsent path range and set spec_updating flag. + // 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, @@ -391,18 +415,14 @@ pub async fn orchestrate_lean_send_actions( } }; let spec_id = session.spec_id.clone(); - let path = &session.lean_navigation_path; - let sent = session.lean_sent_path_len; - let new_sent_len = path.len(); - - // Build history text for unsent portion. - // Include the last sent node as context for the first transition. - let unsent_path: Vec = path[sent.saturating_sub(1)..].to_vec(); - let history_text = session - .lean_graph - .as_ref() - .map(|g| format_navigation_history(g, &unsent_path)) - .unwrap_or_default(); + 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) @@ -433,11 +453,11 @@ pub async fn orchestrate_lean_send_actions( Ok(response) => { let sent = state .get_sim_session(&session_id) - .map(|s| s.lean_sent_path_len) + .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_path_len = new_sent_len; + 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)), @@ -490,23 +510,30 @@ fn spawn_queued_work(state: Arc, session_id: String) { } } -/// Format navigation history for the send actions prompt. -fn format_navigation_history( - graph: &super::lean_graph::LeanGraph, - path: &[String], +/// 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 history = graph.collect_labeled_path_history(path); let mut text = String::new(); - for (i, (label, node)) in history.iter().enumerate() { + for (i, entry) in entries.iter().enumerate() { text.push_str(&format!("### Step {}\n", i + 1)); - text.push_str(&format!("**Action:** {}\n", label)); - if let Some(ui) = node.channels.get("ui") { - let output = if ui.text.len() > 500 { - format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) - } else { - ui.text.clone() - }; - text.push_str(&format!("**Output:**\n{}\n\n", output)); + 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) { + if let Some(ui) = node.channels.get("ui") { + let output = if ui.text.len() > 500 { + format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) + } else { + ui.text.clone() + }; + text.push_str(&format!("**Output:**\n{}\n\n", output)); + } + } } } text diff --git a/crates/spec-forest/src/simulation/lean_types.rs b/crates/spec-forest/src/simulation/lean_types.rs index 203449e..ffcfd3b 100644 --- a/crates/spec-forest/src/simulation/lean_types.rs +++ b/crates/spec-forest/src/simulation/lean_types.rs @@ -100,3 +100,14 @@ pub struct LeanFlatEdge { #[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/session.rs b/crates/spec-forest/src/simulation/session.rs index 917d371..050f079 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -130,9 +130,11 @@ pub struct SimSession { pub lean_generation_target: Option, /// Generation counter, incremented on modifications to invalidate stale pregens. pub lean_generation: u64, - /// How many entries in lean_navigation_path have been sent via "send actions." - /// New (unsent) actions are lean_navigation_path[lean_sent_path_len..]. - pub lean_sent_path_len: usize, + /// 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. @@ -183,7 +185,8 @@ impl SimSession { lean_generating: false, lean_generation_target: None, lean_generation: 0, - lean_sent_path_len: 1, + lean_action_history: Vec::new(), + lean_sent_history_len: 0, lean_spec_updating: false, lean_queued_leaf: None, lean_queued_send: None, From 57150e66997f84a8060f1150cced96db617aa66c Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:22:54 +1100 Subject: [PATCH 094/100] fix: eager pregen when navigating to frontier nodes in lean game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pregen was not triggering when navigating to a leaf node because: 1) A prior pregen (from an ancestor) held lean_generating=true, and after completing it never re-checked the current position. 2) The pregen anchor was the navigated-to node which might have only generative edges — merge_batch couldn't attach the new batch. Now find_pregen_target BFS-walks to the nearest node with leaf edges, and spawn_queued_work re-checks the current position after pregen ends. --- .../spec-forest/src/simulation/lean_graph.rs | 24 +++++++++++++++++ .../src/simulation/lean_orchestrate.rs | 27 ++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_graph.rs b/crates/spec-forest/src/simulation/lean_graph.rs index 06ecf4b..d43e01a 100644 --- a/crates/spec-forest/src/simulation/lean_graph.rs +++ b/crates/spec-forest/src/simulation/lean_graph.rs @@ -208,6 +208,30 @@ impl LeanGraph { .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; diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 56cceac..321c767 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -507,6 +507,15 @@ fn spawn_queued_work(state: Arc, session_id: String) { 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); } } @@ -643,25 +652,31 @@ pub async fn orchestrate_lean_modify( // ── 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 should_pregen = { + 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); - !s.lean_generating && depth < 2 + if !s.lean_generating && depth < 2 { + graph.find_pregen_target(node_id) + } else { + None + } } else { - false + None } } else { - false + None } }; - if should_pregen { + if let Some(target) = pregen_target { let state2 = state.clone(); let sid2 = session_id.to_string(); - let target = node_id.to_string(); state.update_sim_session(session_id, |s| { s.lean_generating = true; s.lean_generation_target = Some(target.clone()); From 3919b73e5f9212e9b2caea2b53ecf4f151fe6d84 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:40:34 +1100 Subject: [PATCH 095/100] fix: display interactions when navigating to existing nodes in lean game Generative/Shortcut navigation now explicitly sets status to Idle, and the Processing branch syncs interactions from the graph so existing nodes always show their edges regardless of status timing. --- crates/spec-forest-tui/src/app.rs | 38 ++++++++++++++++++- .../src/simulation/lean_orchestrate.rs | 2 + 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 77f5268..3a9779e 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3054,7 +3054,6 @@ impl App { } spec_forest::simulation::SimStatus::Processing => { lean.processing = true; - // Update can_go_back during generation so user can navigate back. if let Some(session) = self.state.get_sim_session(&session_id) { lean.can_go_back = session.lean_navigation_path.len() > 1; let unsent_count = session @@ -3064,6 +3063,43 @@ impl App { 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 diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 321c767..cfc3056 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -177,6 +177,8 @@ pub async fn orchestrate_lean_navigate( s.channel_contents = node.channels.clone(); } } + // Ensure status is Idle for instant navigation. + s.status = SimStatus::Idle; }); // Spawn background pregen if needed. From 9a58d9363bc241c95d5210c3656c8a82bb06921e Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 12:44:49 +1100 Subject: [PATCH 096/100] fix: lean game spec updates now cover all features, not just UI Include all simulation channels (network, audio, errors, logs) in the navigation history sent to the spec update prompt, and add guidance for the AI to reason about system-wide implications of UI interactions. --- .../src/simulation/lean_orchestrate.rs | 29 ++++++++++++++----- .../spec-forest/src/simulation/lean_prompt.rs | 4 +++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index cfc3056..d26776f 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -536,13 +536,28 @@ fn format_history_from_entries( } if let Some(graph) = graph { if let Some(node) = graph.get_node(&entry.to_node_id) { - if let Some(ui) = node.channels.get("ui") { - let output = if ui.text.len() > 500 { - format!("{}...", &ui.text[..ui.text.floor_char_boundary(500)]) - } else { - ui.text.clone() - }; - text.push_str(&format!("**Output:**\n{}\n\n", output)); + 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)); + has_output = true; + } + } + if !has_output { + text.push('\n'); } } } diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 813441b..42b8e72 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -373,6 +373,10 @@ pub fn build_send_actions_prompt( - **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\ From d5c1c477ddefc6f5b5447470982d93387061ce95 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 13:36:11 +1100 Subject: [PATCH 097/100] fix: add spec fidelity instructions to lean game system prompt The lean game AI was not grounding its outputs in the spec, making different choices even when the spec clearly specified behavior. Add SPEC FIDELITY and WHEN THE SPEC IS SILENT sections to both system prompt builders to prioritize faithful spec rendering over entropy exploration. --- .../spec-forest/src/simulation/lean_prompt.rs | 183 +++++++++++++++++- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_prompt.rs b/crates/spec-forest/src/simulation/lean_prompt.rs index 42b8e72..59a8e7d 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -80,7 +80,26 @@ pub fn build_lean_system_prompt( } format!( - r#"## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY + 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 entropy_hint +field is where you signal that you made an unspecified choice, not the channel +text itself. + +## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY You simulate the program that would be built from this spec. The player navigates outputs and chooses interactions. Your job is to steer them toward the INTERESTING decisions — places where the spec is silent or ambiguous. @@ -135,7 +154,9 @@ Use these read-only tools when generating outputs that touch areas outside the l ## Channel Semantics Active channels: {channel_list} -- "ui": Unicode/box-drawing TUI rendering. Replace entirely each turn. Keep concise. +- "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 @@ -170,6 +191,158 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. ) } +/// 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, +) -> 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'); + } + + // High-entropy guidance. + let mut entropy_section = String::new(); + if !high_entropy_nodes.is_empty() { + entropy_section.push_str("## High-Uncertainty Spec Areas\n"); + entropy_section + .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + for (id, question) in high_entropy_nodes { + entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + } + } + + 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 entropy_hint +field is where you signal that you made an unspecified choice, not the channel +text itself. + +## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY +You simulate the program that would be built from this spec. The player navigates +outputs and chooses interactions. Your job is to steer them toward the INTERESTING +decisions — places where the spec is silent or ambiguous. + +At each node, generate exactly 2 NEW child outputs via generative edges. +One of the 2 generative edges should lead toward a high-entropy spec area. +The other should represent 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. + +## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS +Channel outputs must read like a REAL, FINISHED application. Never include spec questions, +uncertainty markers, or placeholder text like "What does this component do?" in any channel. +If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. +The entropy_hint field is where you signal uncertainty — not the channel text itself. + +## 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} + +{entropy_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. No refs, no spec_gaps, no spec questions, no uncertainty \ +markers — just concrete simulation output as a real application would display it. \ +If the spec is ambiguous, make a definitive choice and reflect it in the output."#, + 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 + }, + entropy_section = entropy_section, + channel_list = channel_list, + ) +} + /// Build the lean batch output format section. /// /// Describes the DAG wire format: nodes + edges with generative/shortcut distinction. @@ -215,9 +388,9 @@ Schema: {{{{"id": "n2", "channels": {{{{...}}}}, "entropy_hint": 0.2}}}} ], "edges": [ - {{{{"from": "root", "to": "n1", "label": "Click Submit", "input": {{{{"keys": ["Enter"], "raw_text": "\\n"}}}}}}}}, - {{{{"from": "root", "to": "n2", "label": "Press Tab", "input": {{{{"keys": ["Tab"], "raw_text": "\\t"}}}}}}}}, - {{{{"from": "root", "to": "existing-uuid", "label": "Go Back", "input": {{{{"keys": ["Escape"], "raw_text": ""}}}}, "shortcut": true}}}} + {{{{"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}}}} ] }}}} From 9bf504b00a595d8be55f708803c89ae362855362 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:03:49 +1100 Subject: [PATCH 098/100] feat: add warmup interactions while lean game loads Spawn fast Haiku-based text scenarios in parallel with the slow initial lean game turn so the player has something to do while waiting. Warmup picks high-entropy spec nodes, generates short situational prompts, and captures player responses for later spec updates via send-actions. --- crates/spec-forest-tui/src/app.rs | 9 + crates/spec-forest/src/simulation.rs | 2 + .../src/simulation/lean_orchestrate.rs | 101 ++++++++--- crates/spec-forest/src/simulation/runner.rs | 32 ++++ crates/spec-forest/src/simulation/session.rs | 23 +++ .../src/simulation/warmup_orchestrate.rs | 169 ++++++++++++++++++ .../src/simulation/warmup_types.rs | 27 +++ crates/spec-forest/src/tool_types.rs | 8 + crates/spec-forest/src/tools.rs | 65 +++++++ 9 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 crates/spec-forest/src/simulation/warmup_orchestrate.rs create mode 100644 crates/spec-forest/src/simulation/warmup_types.rs diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 3a9779e..3d2f3a6 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -3215,6 +3215,15 @@ impl App { ) .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( diff --git a/crates/spec-forest/src/simulation.rs b/crates/spec-forest/src/simulation.rs index ad4ac8b..5884c40 100644 --- a/crates/spec-forest/src/simulation.rs +++ b/crates/spec-forest/src/simulation.rs @@ -8,6 +8,8 @@ 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, diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index d26776f..564ed4d 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -24,6 +24,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str 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 => { @@ -50,36 +51,48 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str } }; - 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::>(); - // Collect high-entropy nodes. let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); - // Build system prompt. + // Build system prompt — whole-spec or focused. let channel_list = channels.iter().map(|c| c.key()).collect::>().join(", "); - let system_prompt = super::lean_prompt::build_lean_system_prompt( - &channels, - &focus_node, - &ancestors, - &descendants, - &summary, - &other_roots, - &high_entropy_nodes, - &spec_id, - ); + 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, + ) + } 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, + ) + }; let output_format = super::lean_prompt::build_lean_batch_output_format( batch_depth, &channel_list, @@ -115,6 +128,8 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str 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); } @@ -442,10 +457,40 @@ pub async fn orchestrate_lean_send_actions( .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, - &user_notes, + &full_notes, &spec_id, &spec_outline, ); @@ -720,7 +765,7 @@ fn set_lean_generating_false(state: &AppState, session_id: &str) { } /// Collect high-entropy nodes from the spec for prompt guidance. -fn collect_high_entropy_nodes( +pub(crate) fn collect_high_entropy_nodes( state: &AppState, spec_id: &str, limit: usize, diff --git a/crates/spec-forest/src/simulation/runner.rs b/crates/spec-forest/src/simulation/runner.rs index 9058216..c3b0de9 100644 --- a/crates/spec-forest/src/simulation/runner.rs +++ b/crates/spec-forest/src/simulation/runner.rs @@ -1001,6 +1001,38 @@ pub async fn resume_lean_spec_update_turn( 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, diff --git a/crates/spec-forest/src/simulation/session.rs b/crates/spec-forest/src/simulation/session.rs index 050f079..33756a3 100644 --- a/crates/spec-forest/src/simulation/session.rs +++ b/crates/spec-forest/src/simulation/session.rs @@ -2,6 +2,7 @@ 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; @@ -141,6 +142,21 @@ pub struct SimSession { 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 { @@ -190,6 +206,13 @@ impl SimSession { 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/warmup_orchestrate.rs b/crates/spec-forest/src/simulation/warmup_orchestrate.rs new file mode 100644 index 0000000..32538a4 --- /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); + 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 29352eb..1c946e4 100644 --- a/crates/spec-forest/src/tool_types.rs +++ b/crates/spec-forest/src/tool_types.rs @@ -387,3 +387,11 @@ pub struct LeanModifyParams { #[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 d315fd5..e4fb976 100644 --- a/crates/spec-forest/src/tools.rs +++ b/crates/spec-forest/src/tools.rs @@ -1782,6 +1782,28 @@ impl SpecForestServer { 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 @@ -1971,6 +1993,49 @@ impl SpecForestServer { )])) } + #[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, From c54c6a7d8b03aa59217b9d0e21428e631e8df331 Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:10:29 +1100 Subject: [PATCH 099/100] feat: scenario-driven spec gap exploration in lean game Restructure lean game prompts so the AI acts as a scenario designer rather than a generic simulator. The AI now identifies high-entropy decisions it had to make and designs DAG paths as mini-scenarios that force the player to confront those assumptions. Key changes: - Activate spec_gaps field: AI logs implementer assumptions per channel - Feature-scoped entropy: high-entropy nodes filtered to focus feature - Scenario design framing: CARDINAL RULE rewritten for gap exploration - spec_gaps flow into send-actions prompt as validation evidence - Resume prompt guides continued scenario exploration --- .../src/simulation/lean_orchestrate.rs | 71 ++++++- .../spec-forest/src/simulation/lean_prompt.rs | 186 ++++++++++++------ .../src/simulation/warmup_orchestrate.rs | 2 +- 3 files changed, 198 insertions(+), 61 deletions(-) diff --git a/crates/spec-forest/src/simulation/lean_orchestrate.rs b/crates/spec-forest/src/simulation/lean_orchestrate.rs index 564ed4d..1df6267 100644 --- a/crates/spec-forest/src/simulation/lean_orchestrate.rs +++ b/crates/spec-forest/src/simulation/lean_orchestrate.rs @@ -51,8 +51,18 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str } }; - // Collect high-entropy nodes. - let high_entropy_nodes = collect_high_entropy_nodes(&state, &spec_id, 10); + // 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(", "); @@ -65,6 +75,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str &summary, &high_entropy_nodes, &spec_id, + is_root_focus, ) } else { let ancestors = crate::api::get_ancestors(&state, &focus_node_id).unwrap_or_default(); @@ -91,6 +102,7 @@ pub async fn orchestrate_lean_initial_turn(state: Arc, session_id: Str &other_roots, &high_entropy_nodes, &spec_id, + is_root_focus, ) }; let output_format = super::lean_prompt::build_lean_batch_output_format( @@ -598,6 +610,13 @@ fn format_history_from_entries( 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; } } @@ -765,29 +784,73 @@ fn set_lean_generating_false(state: &AppState, session_id: &str) { } /// 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() { + 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 { + 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 index 59a8e7d..c853b25 100644 --- a/crates/spec-forest/src/simulation/lean_prompt.rs +++ b/crates/spec-forest/src/simulation/lean_prompt.rs @@ -19,6 +19,7 @@ pub fn build_lean_system_prompt( other_roots: &[Node], high_entropy_nodes: &[(String, String)], // (node_id, question) spec_id: &str, + is_root_focus: bool, ) -> String { let channel_list = channels .iter() @@ -68,15 +69,35 @@ pub fn build_lean_system_prompt( other_roots_section.push_str(&format!("- {} (ID: {})\n", node.question, node.id)); } - // High-entropy guidance. - let mut entropy_section = String::new(); + // 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() { - entropy_section.push_str("## High-Uncertainty Spec Areas\n"); - entropy_section - .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + 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 { - entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); } + scenario_section.push('\n'); } format!( @@ -95,29 +116,39 @@ pub fn build_lean_system_prompt( ## 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 entropy_hint -field is where you signal that you made an unspecified choice, not the channel -text itself. +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. -## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY -You simulate the program that would be built from this spec. The player navigates -outputs and chooses interactions. Your job is to steer them toward the INTERESTING -decisions — places where the spec is silent or ambiguous. +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. -One of the 2 generative edges should lead toward a high-entropy spec area. -The other should represent the expected/obvious path. +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. -## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS -Channel outputs must read like a REAL, FINISHED application. Never include spec questions, -uncertainty markers, or placeholder text like "What does this component do?" in any channel. -If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. -The entropy_hint field is where you signal uncertainty — not the channel text itself. +## 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, @@ -139,7 +170,7 @@ Spec "{spec_name}" — {answered} answered, {unanswered} unanswered, {needs_revi ### Other Areas {other_roots_section} -{entropy_section} +{scenario_section} ## Tools (READ-ONLY) You have read-only access to spec-forest MCP tools. Use them to look up spec details: @@ -162,9 +193,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ -markers — just concrete simulation output as a real application would display it. \ -If the spec is ambiguous, make a definitive choice and reflect it in the 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, @@ -186,7 +217,7 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. } else { other_roots_section }, - entropy_section = entropy_section, + scenario_section = scenario_section, channel_list = channel_list, ) } @@ -202,6 +233,7 @@ pub fn build_lean_system_prompt_whole_spec( summary: &SpecSummary, high_entropy_nodes: &[(String, String)], // (node_id, question) spec_id: &str, + is_root_focus: bool, ) -> String { let channel_list = channels .iter() @@ -234,15 +266,35 @@ pub fn build_lean_system_prompt_whole_spec( all_nodes_section.push('\n'); } - // High-entropy guidance. - let mut entropy_section = String::new(); + // 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() { - entropy_section.push_str("## High-Uncertainty Spec Areas\n"); - entropy_section - .push_str("Steer interactions toward these areas — they need player decisions:\n\n"); + 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 { - entropy_section.push_str(&format!("- **{}**: {}\n", id, question)); + scenario_section.push_str(&format!("- **{}**: {}\n", id, question)); } + scenario_section.push('\n'); } format!( @@ -261,29 +313,39 @@ pub fn build_lean_system_prompt_whole_spec( ## 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 entropy_hint -field is where you signal that you made an unspecified choice, not the channel -text itself. +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. -## CARDINAL RULE: GUIDE THE PLAYER THROUGH HIGH-ENTROPY DECISIONS EFFICIENTLY -You simulate the program that would be built from this spec. The player navigates -outputs and chooses interactions. Your job is to steer them toward the INTERESTING -decisions — places where the spec is silent or ambiguous. +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. -One of the 2 generative edges should lead toward a high-entropy spec area. -The other should represent the expected/obvious path. +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. -## CRITICAL: NO SPEC QUESTIONS IN OUTPUTS -Channel outputs must read like a REAL, FINISHED application. Never include spec questions, -uncertainty markers, or placeholder text like "What does this component do?" in any channel. -If the spec is silent on something, MAKE A CONCRETE CHOICE and render it confidently. -The entropy_hint field is where you signal uncertainty — not the channel text itself. +## 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, @@ -301,7 +363,7 @@ The entire spec has been loaded. All nodes are listed below: {all_nodes_section} -{entropy_section} +{scenario_section} ## Tools (READ-ONLY) You have read-only access to spec-forest MCP tools. Use them to look up spec details: @@ -324,9 +386,9 @@ Active channels: {channel_list} - "errors": Error messages from the simulated application - "logs": Application log output -Keep channel text concise. No refs, no spec_gaps, no spec questions, no uncertainty \ -markers — just concrete simulation output as a real application would display it. \ -If the spec is ambiguous, make a definitive choice and reflect it in the 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, @@ -338,7 +400,7 @@ If the spec is ambiguous, make a definitive choice and reflect it in the output. } else { all_nodes_section }, - entropy_section = entropy_section, + scenario_section = scenario_section, channel_list = channel_list, ) } @@ -404,8 +466,10 @@ Active channels: {channel_list} 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. One of the 2 generative edges should lead toward a HIGH-ENTROPY spec area. - The other should represent the expected/obvious behavior. +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. @@ -413,8 +477,10 @@ Active channels: {channel_list} 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. Use entropy_hint to signal - ambiguity — never leak it into the visible output. + 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, @@ -492,6 +558,9 @@ pub fn build_lean_resume_prompt( 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\ @@ -556,6 +625,11 @@ pub fn build_send_actions_prompt( - **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", diff --git a/crates/spec-forest/src/simulation/warmup_orchestrate.rs b/crates/spec-forest/src/simulation/warmup_orchestrate.rs index 32538a4..b7265b5 100644 --- a/crates/spec-forest/src/simulation/warmup_orchestrate.rs +++ b/crates/spec-forest/src/simulation/warmup_orchestrate.rs @@ -19,7 +19,7 @@ pub async fn start_warmup(state: Arc, session_id: String) { // Collect candidate nodes (already prioritised: unanswered first, then needs-review). let mut candidates = - super::lean_orchestrate::collect_high_entropy_nodes(&state, &spec_id, 20); + 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; From a6e9f82c3b85837691240480f701957a4ba408cf Mon Sep 17 00:00:00 2001 From: freesig Date: Thu, 2 Apr 2026 14:21:14 +1100 Subject: [PATCH 100/100] feat: add TUI rendering and input handling for lean game warmup Show warmup scenarios in the output panel while the main game loads. Players press 'r' to enter response mode and Ctrl+S to submit. The status bar shows warmup-specific hints and a "game ready!" indicator when the real game has loaded. Warmup state is synced from the session and cleared on transition to the real game. --- crates/spec-forest-tui/src/action.rs | 1 + crates/spec-forest-tui/src/app.rs | 52 ++++++++ crates/spec-forest-tui/src/input.rs | 1 + crates/spec-forest-tui/src/lean_state.rs | 17 ++- crates/spec-forest-tui/src/ui/lean_game.rs | 142 ++++++++++++++------- 5 files changed, 169 insertions(+), 44 deletions(-) diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index a524f24..a869d2e 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -168,6 +168,7 @@ pub enum Action { LeanEnterQuery, LeanEnterModify, LeanEnterSendActions, + LeanEnterWarmupRespond, LeanToggleUpdateLog, LeanScrollUp, LeanScrollDown, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 3d2f3a6..0f8716f 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -1479,6 +1479,14 @@ impl App { 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 { @@ -1510,6 +1518,8 @@ impl App { 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); } } } @@ -1521,6 +1531,8 @@ impl App { lean.modify_input.pop(); } else if lean.send_actions_mode { lean.send_actions_input.pop(); + } else if lean.warmup_mode { + lean.warmup_input.pop(); } } } @@ -1532,6 +1544,8 @@ impl App { 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'); } } } @@ -1587,6 +1601,22 @@ impl App { .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; + }); + } } } } @@ -1595,9 +1625,11 @@ impl App { 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 => { @@ -2975,6 +3007,14 @@ impl App { 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) @@ -3055,6 +3095,18 @@ impl App { 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 diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index ca61287..ee08cf2 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -60,6 +60,7 @@ fn map_lean_normal_key(key: KeyCode) -> Action { 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, diff --git a/crates/spec-forest-tui/src/lean_state.rs b/crates/spec-forest-tui/src/lean_state.rs index 72cc549..c169aa2 100644 --- a/crates/spec-forest-tui/src/lean_state.rs +++ b/crates/spec-forest-tui/src/lean_state.rs @@ -32,6 +32,14 @@ pub struct LeanGameState { 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. @@ -71,11 +79,18 @@ impl LeanGameState { 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.query_mode || self.modify_mode || self.send_actions_mode || self.warmup_mode } } diff --git a/crates/spec-forest-tui/src/ui/lean_game.rs b/crates/spec-forest-tui/src/ui/lean_game.rs index 9536f43..98ffe4b 100644 --- a/crates/spec-forest-tui/src/ui/lean_game.rs +++ b/crates/spec-forest-tui/src/ui/lean_game.rs @@ -46,7 +46,7 @@ pub fn render(app: &App, frame: &mut Frame) { render_status_bar(app, frame, chunks[3]); // ── Overlays ──────────────────────────────────────────────────── - if lean.query_mode || lean.modify_mode || lean.send_actions_mode { + 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() { @@ -88,42 +88,79 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { // Build combined output from all channels. let mut lines: Vec = Vec::new(); - // 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(), - }; + // 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( - format!("{prefix}{line}"), - style, + " 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.processing { + let title = if lean.warmup_active { + " Warmup (game loading...) " + } else if lean.processing { " Output (generating...) " } else if lean.spec_updating { " Output (updating spec...) " @@ -131,7 +168,9 @@ fn render_output(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) { " Output " }; - let border_color = if lean.processing { + let border_color = if lean.warmup_active { + Color::Green + } else if lean.processing { Color::Yellow } else if lean.spec_updating { Color::Magenta @@ -158,7 +197,12 @@ fn render_interactions(app: &App, frame: &mut Frame, area: ratatui::layout::Rect let mut lines: Vec = Vec::new(); if lean.interactions.is_empty() { - if lean.processing { + 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), @@ -237,13 +281,23 @@ fn render_status_bar(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) return; } - let mut items: Vec<(&str, String)> = vec![ - ("↑↓", "select".into()), - ("Enter", "go".into()), - ("Bksp", "back".into()), - ("i", "query".into()), - ("m", "modify".into()), - ]; + 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))); @@ -339,7 +393,7 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { .wrap(Wrap { trim: false }); frame.render_widget(paragraph, overlay_area); } else { - // Query or modify overlay. + // Query, modify, or warmup respond overlay. let overlay_height = 5; let overlay_area = ratatui::layout::Rect { x: area.x + 1, @@ -350,7 +404,9 @@ fn render_input_overlay(app: &App, frame: &mut Frame) { frame.render_widget(Clear, overlay_area); - let (title, input) = if lean.query_mode { + 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)