Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/spec-forest-tui/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ pub enum Action {
SessionPickerDismiss,
DismissNotification,

// Members screen
OpenMembers,
MembersActivateInput,
MembersDeactivateInput,
MembersRemoveMember,
MembersInputChar(char),
MembersInputBackspace,
MembersInputSubmit,
MembersUp,
MembersDown,

// Help
ToggleHelp,

Expand Down
124 changes: 123 additions & 1 deletion crates/spec-forest-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ pub struct App {
pub sim_notifications: Vec<crate::notification::SimNotification>,
pub session_picker: Option<crate::notification::SessionPickerState>,
pub show_help: bool,
// Members screen
pub members: Vec<String>,
pub members_creator: String,
pub members_selected: usize,
pub members_input: String,
pub members_input_active: bool,
}

#[derive(Clone)]
Expand All @@ -110,6 +116,7 @@ pub enum Screen {
SpecOptionsPicker,
SpecView { spec_id: String },
SpecSettings { spec_id: String },
SpecMembers { spec_id: String },
SyncConfig,
SyncPasswordInput,
ModelConfig,
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 };
}
_ => {}
}
}
Expand Down
31 changes: 31 additions & 0 deletions crates/spec-forest-tui/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
spec_name: &str,
) -> Result<(Vec<String>, 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<AppState>,
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<AppState>,
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(
Expand Down
24 changes: 24 additions & 0 deletions crates/spec-forest-tui/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/spec-forest-tui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions crates/spec-forest-tui/src/ui/help_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fn help_sections(app: &App) -> Vec<HelpSection> {
("t", "Toggle tree"),
("l", "Toggle log"),
("g", "Spec settings"),
("M", "Members"),
],
});

Expand Down Expand Up @@ -231,9 +232,35 @@ fn help_sections(app: &App) -> Vec<HelpSection> {
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 {
Expand Down
Loading