use crossterm::event::{KeyCode, KeyModifiers, KeyEvent};
use super::{SettingsState, Focus, RuntimeSnapshot, ActiveEditor};
use super::schema::{CATEGORIES, EditorKind, visible_categories};
use super::draw::current_value_for;
pub(crate) enum InputOutcome {
None,
Close,
Apply { key: &'static str, value: String },
PluginApply { plugin_id: String, key: String, value: String },
PluginCustomOpen { plugin_id: String, category: String, key: String },
SetProviderKey { provider_id: String, value: String },
TogglePlugin { name: String, enabled: bool },
PreviewTheme { name: String },
RevertTheme,
OpenPluginsMarketplace,
PingModels,
}
pub(crate) fn handle_event(
state: &mut SettingsState,
key: KeyEvent,
snap: &RuntimeSnapshot,
) -> InputOutcome {
if state.edit_mode.is_some() {
if key.code == KeyCode::Esc {
let revert = matches!(
&state.edit_mode,
Some(ActiveEditor::Picker { setting_key: "theme", .. })
) && state.original_theme_name.is_some();
state.edit_mode = None;
if revert {
state.original_theme_name = None;
return InputOutcome::RevertTheme;
}
return InputOutcome::None;
}
return handle_editor_key(state, key);
}
let visible = visible_categories(&snap.lifecycle_claims);
if state.focus == Focus::Right && state.category_idx < visible.len() {
let cat = visible[state.category_idx];
if cat == super::schema::Category::Providers {
if matches!(key.code, KeyCode::Char('p')) && state.edit_mode.is_none() {
return InputOutcome::PingModels;
}
if state.setting_idx == 0 {
match key.code {
KeyCode::Enter => {
state.row_error = None;
let current_url = snap.provider_keys.get("local.url")
.cloned()
.unwrap_or_default();
state.edit_mode = Some(ActiveEditor::ApiKey {
provider_id: "local.url".to_string(),
buffer: current_url,
});
return InputOutcome::None;
}
KeyCode::Delete | KeyCode::Char('d') => {
if snap.provider_keys.contains_key("local.url") {
state.row_error = None;
return InputOutcome::SetProviderKey {
provider_id: "local.url".to_string(),
value: String::new(),
};
}
return InputOutcome::None;
}
_ => {}
}
} else {
let providers = synaps_cli::runtime::openai::registry::providers();
if let Some(p) = providers.get(state.setting_idx - 1) {
match key.code {
KeyCode::Enter => {
state.row_error = None;
state.edit_mode = Some(ActiveEditor::ApiKey {
provider_id: p.key.to_string(),
buffer: String::new(),
});
return InputOutcome::None;
}
KeyCode::Delete | KeyCode::Char('d') => {
let has_key = snap.provider_keys.get(p.key).map(|v| !v.is_empty()).unwrap_or(false);
if has_key {
state.row_error = None;
return InputOutcome::SetProviderKey {
provider_id: p.key.to_string(),
value: String::new(),
};
}
return InputOutcome::None;
}
_ => {}
}
}
}
}
if cat == super::schema::Category::Plugins {
let toggle_at = |idx: usize| -> InputOutcome {
if let Some(row) = snap.plugins.get(idx) {
let was_disabled = snap.disabled_plugins.iter().any(|d| d == &row.name);
InputOutcome::TogglePlugin {
name: row.name.clone(),
enabled: was_disabled,
}
} else {
InputOutcome::None
}
};
match key.code {
KeyCode::Enter if state.setting_idx == 0 => {
return InputOutcome::OpenPluginsMarketplace;
}
KeyCode::Char(' ') if state.setting_idx == 0 => {
return InputOutcome::None;
}
KeyCode::Char(' ') => {
return toggle_at(state.setting_idx - 1);
}
KeyCode::Enter => {
return InputOutcome::None;
}
_ => {}
}
}
}
if state.focus == Focus::Right && state.is_plugin_category(snap) {
if let Some(field) = state.current_plugin_field(snap).cloned() {
let plugin_id = state
.current_plugin_category(snap)
.map(|c| c.plugin.clone())
.unwrap_or_default();
use synaps_cli::skills::registry::PluginSettingsEditor as PE;
match (key.code, &field.editor) {
(KeyCode::Left | KeyCode::Right, PE::Cycler { options }) if !options.is_empty() => {
let current = plugin_field_current_value(&plugin_id, &field);
let idx = options.iter().position(|o| *o == current).unwrap_or(0);
let new_idx = match key.code {
KeyCode::Left => if idx > 0 { idx - 1 } else { idx },
KeyCode::Right => if idx + 1 < options.len() { idx + 1 } else { idx },
_ => idx,
};
if new_idx != idx {
state.row_error = None;
return InputOutcome::PluginApply {
plugin_id,
key: field.key.clone(),
value: options[new_idx].clone(),
};
}
return InputOutcome::None;
}
(KeyCode::Enter, PE::Text { numeric }) => {
state.row_error = None;
let buffer = plugin_field_current_value(&plugin_id, &field);
state.edit_mode = Some(ActiveEditor::PluginText {
plugin_id,
key: field.key.clone(),
buffer,
numeric: *numeric,
error: None,
});
return InputOutcome::None;
}
(KeyCode::Enter, PE::Picker) => {
state.row_error = Some((
field.key.clone(),
"picker editor not yet wired".to_string(),
));
return InputOutcome::None;
}
(KeyCode::Enter, PE::Cycler { options }) if !options.is_empty() => {
let current = plugin_field_current_value(&plugin_id, &field);
let idx = options.iter().position(|o| *o == current).unwrap_or(0);
let new_idx = if idx + 1 < options.len() { idx + 1 } else { 0 };
state.row_error = None;
return InputOutcome::PluginApply {
plugin_id,
key: field.key.clone(),
value: options[new_idx].clone(),
};
}
(KeyCode::Enter, PE::Custom) => {
let category = state
.current_plugin_category(snap)
.map(|c| c.id.clone())
.unwrap_or_default();
return InputOutcome::PluginCustomOpen {
plugin_id,
category,
key: field.key.clone(),
};
}
_ => {}
}
}
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => InputOutcome::Close,
(KeyCode::Tab, _) | (KeyCode::Char('h'), KeyModifiers::CONTROL) => {
state.focus = match state.focus {
Focus::Left => Focus::Right,
Focus::Right => Focus::Left,
};
state.row_error = None;
InputOutcome::None
}
(KeyCode::Up, _) => {
match state.focus {
Focus::Left => {
if state.category_idx > 0 {
state.category_idx -= 1;
state.setting_idx = 0;
}
}
Focus::Right => {
if state.setting_idx > 0 { state.setting_idx -= 1; }
}
}
state.row_error = None;
InputOutcome::None
}
(KeyCode::Down, _) => {
match state.focus {
Focus::Left => {
let total_categories = visible_categories(&snap.lifecycle_claims).len() + snap.plugin_categories.len();
if state.category_idx + 1 < total_categories {
state.category_idx += 1;
state.setting_idx = 0;
}
}
Focus::Right => {
let n = row_count(state, snap);
if state.setting_idx + 1 < n { state.setting_idx += 1; }
}
}
state.row_error = None;
InputOutcome::None
}
(KeyCode::Left, _) | (KeyCode::Right, _) if state.focus == Focus::Right => {
if let Some(def) = state.current_setting() {
if let EditorKind::Cycler(options) = def.editor {
let current = cycler_current_value(def.key, snap);
let idx = options.iter().position(|o| *o == current).unwrap_or(0);
let new_idx = match key.code {
KeyCode::Left => if idx > 0 { idx - 1 } else { idx },
KeyCode::Right => if idx + 1 < options.len() { idx + 1 } else { idx },
_ => idx,
};
if new_idx != idx {
state.row_error = None;
return InputOutcome::Apply {
key: def.key,
value: options[new_idx].to_string(),
};
}
}
}
InputOutcome::None
}
(KeyCode::Enter, _) if state.focus == Focus::Right => {
if let Some(def) = state.current_setting() {
match def.editor {
EditorKind::Text { numeric } => {
state.row_error = None;
state.edit_mode = Some(ActiveEditor::Text {
buffer: current_value_for(def, snap),
setting_key: def.key,
numeric,
error: None,
});
}
EditorKind::ModelPicker => {
state.row_error = None;
let mut opts: Vec<String> = vec!["── Anthropic ──".to_string()];
opts.extend(synaps_cli::models::KNOWN_MODELS
.iter().map(|(id, desc)| format!(" {} — {}", id, desc)));
let registry = synaps_cli::runtime::openai::registry::providers();
for spec in registry {
let has_config_key = snap.provider_keys.contains_key(spec.key);
let has_env_key = spec.env_vars.iter()
.any(|v| std::env::var(v).is_ok_and(|s| !s.is_empty()));
if !has_config_key && !has_env_key { continue; }
opts.push(format!("── {} ──", spec.name));
for (id, label, tier) in spec.models {
let full = format!("{}/{}", spec.key, id);
let health = snap.model_health.get(&full)
.map(|(s, ms)| format!("{} {:>6} ", s.icon(), fmt_latency(*s, *ms)))
.unwrap_or_default();
opts.push(format!(" {}{} — {} [{}]", health, full, label, tier));
}
}
opts.push("Custom…".to_string());
let current = current_value_for(def, snap);
let cursor = opts.iter()
.position(|o| o.trim_start().starts_with(¤t))
.unwrap_or(0);
state.edit_mode = Some(ActiveEditor::Picker {
setting_key: def.key,
options: opts,
cursor,
});
}
EditorKind::ThemePicker => {
state.row_error = None;
let opts = super::theme_options();
let cursor = opts.iter().position(|o| o == &snap.theme_name).unwrap_or(0);
state.original_theme_name = Some(snap.theme_name.clone());
state.edit_mode = Some(ActiveEditor::Picker {
setting_key: "theme",
options: opts,
cursor,
});
}
_ => {}
}
}
InputOutcome::None
}
_ => InputOutcome::None,
}
}
fn handle_editor_key(state: &mut SettingsState, key: KeyEvent) -> InputOutcome {
let editor = state.edit_mode.as_mut().expect("caller checks");
match editor {
ActiveEditor::Text { buffer, setting_key, numeric, error } => {
match key.code {
KeyCode::Enter => {
if *numeric && buffer.parse::<u64>().is_err() {
*error = Some("must be a number".to_string());
return InputOutcome::None;
}
InputOutcome::Apply { key: *setting_key, value: buffer.clone() }
}
KeyCode::Backspace => { buffer.pop(); *error = None; InputOutcome::None }
KeyCode::Char(c) => {
if *numeric && !c.is_ascii_digit() {
*error = Some("digits only".to_string());
return InputOutcome::None;
}
buffer.push(c);
*error = None;
InputOutcome::None
}
_ => InputOutcome::None,
}
}
ActiveEditor::Picker { setting_key, options, cursor } => {
match key.code {
KeyCode::Up => {
if *cursor > 0 {
*cursor -= 1;
while *cursor > 0 && options[*cursor].starts_with("──") {
*cursor -= 1;
}
}
if *setting_key == "theme" {
return InputOutcome::PreviewTheme { name: options[*cursor].clone() };
}
InputOutcome::None
}
KeyCode::Down => {
if *cursor + 1 < options.len() {
*cursor += 1;
while *cursor + 1 < options.len() && options[*cursor].starts_with("──") {
*cursor += 1;
}
}
if *setting_key == "theme" {
return InputOutcome::PreviewTheme { name: options[*cursor].clone() };
}
InputOutcome::None
}
KeyCode::Enter => {
let selection = options[*cursor].clone();
if selection.starts_with("──") {
return InputOutcome::None;
}
if (*setting_key == "model" || *setting_key == "compaction_model") && selection == "Custom…" {
state.edit_mode = Some(ActiveEditor::CustomModel { buffer: String::new(), setting_key });
return InputOutcome::None;
}
let raw = selection.split(" —").next().unwrap_or(&selection).trim();
let value = if let Some(pos) = raw.find("claude-") {
raw[pos..].to_string()
} else if let Some(pos) = raw.find('/') {
let before = &raw[..pos];
let key_start = before.rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
.map(|i| i + before[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1))
.unwrap_or(0);
raw[key_start..].to_string()
} else {
raw.to_string()
};
let key = *setting_key;
if key == "theme" {
state.original_theme_name = None;
}
InputOutcome::Apply { key, value }
}
_ => InputOutcome::None,
}
}
ActiveEditor::CustomModel { buffer, setting_key } => {
match key.code {
KeyCode::Enter => {
if buffer.trim().is_empty() {
return InputOutcome::None;
}
InputOutcome::Apply { key: setting_key, value: buffer.trim().to_string() }
}
KeyCode::Backspace => { buffer.pop(); InputOutcome::None }
KeyCode::Char(c) => { buffer.push(c); InputOutcome::None }
_ => InputOutcome::None,
}
}
ActiveEditor::ApiKey { provider_id, buffer } => {
match key.code {
KeyCode::Enter => {
InputOutcome::SetProviderKey {
provider_id: provider_id.clone(),
value: buffer.trim().to_string(),
}
}
KeyCode::Backspace => { buffer.pop(); InputOutcome::None }
KeyCode::Char(c) => { buffer.push(c); InputOutcome::None }
_ => InputOutcome::None,
}
}
ActiveEditor::PluginText { plugin_id, key: field_key, buffer, numeric, error } => {
match key.code {
KeyCode::Enter => {
if *numeric && buffer.parse::<i64>().is_err() {
*error = Some("must be a number".to_string());
return InputOutcome::None;
}
InputOutcome::PluginApply {
plugin_id: plugin_id.clone(),
key: field_key.clone(),
value: buffer.clone(),
}
}
KeyCode::Backspace => { buffer.pop(); *error = None; InputOutcome::None }
KeyCode::Char(c) => {
if *numeric && !(c.is_ascii_digit() || c == '-') {
*error = Some("digits only".to_string());
return InputOutcome::None;
}
buffer.push(c);
*error = None;
InputOutcome::None
}
_ => InputOutcome::None,
}
}
ActiveEditor::PluginCustom { .. } => InputOutcome::None,
}
}
fn cycler_current_value(key: &str, snap: &RuntimeSnapshot) -> String {
match key {
"thinking" => snap.thinking.clone(),
"context_window" => snap.context_window.clone(),
"sidecar_toggle_key" => synaps_cli::config::read_config_value("sidecar_toggle_key")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "F8".to_string()),
_ => String::new(),
}
}
pub(crate) fn plugin_field_current_value(
plugin_id: &str,
field: &synaps_cli::skills::registry::PluginSettingsField,
) -> String {
if let Some(v) = synaps_cli::extensions::config_store::read_plugin_config(plugin_id, &field.key) {
return v;
}
match &field.default {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Bool(b)) => b.to_string(),
Some(serde_json::Value::Number(n)) => n.to_string(),
Some(other) => other.to_string(),
None => String::new(),
}
}
fn row_count(state: &SettingsState, snap: &RuntimeSnapshot) -> usize {
if state.is_plugin_category(snap) {
return state
.current_plugin_category(snap)
.map(|c| c.fields.len())
.unwrap_or(0);
}
let visible = visible_categories(&snap.lifecycle_claims);
let cat = visible[state.category_idx];
if cat == super::schema::Category::Plugins {
snap.plugins.len() + 1
} else if cat == super::schema::Category::Providers {
synaps_cli::runtime::openai::registry::providers().len() + 1 } else {
state.current_settings().len()
}
}
fn fmt_latency(status: synaps_cli::runtime::openai::ping::PingStatus, ms: u64) -> String {
use synaps_cli::runtime::openai::ping::PingStatus;
match status {
PingStatus::Online => {
if ms >= 1000 { format!("{:.1}s", ms as f64 / 1000.0) }
else { format!("{}ms", ms) }
}
other => other.label().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn snap() -> RuntimeSnapshot {
RuntimeSnapshot {
model: "m".into(),
thinking: "medium".into(),
context_window: "auto".into(),
compaction_model: "m".into(),
max_tool_output: 0,
bash_timeout: 0,
bash_max_timeout: 0,
subagent_timeout: 0,
api_retries: 0,
theme_name: "t".into(),
plugins: vec![
super::super::PluginRow { name: "p1".into(), skill_count: 1 },
super::super::PluginRow { name: "p2".into(), skill_count: 2 },
],
disabled_plugins: vec!["p2".into()],
provider_keys: std::collections::BTreeMap::new(),
model_health: std::collections::HashMap::new(),
plugin_categories: Vec::new(),
lifecycle_claims: Vec::new(),
}
}
fn plugins_state_at(idx: usize) -> SettingsState {
let mut state = SettingsState::new();
state.category_idx = super::super::schema::CATEGORIES
.iter().position(|c| *c == super::super::schema::Category::Plugins).unwrap();
state.focus = Focus::Right;
state.setting_idx = idx;
state
}
#[test]
fn enter_on_marketplace_row_opens_plugins_marketplace() {
let mut state = plugins_state_at(0);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &snap());
assert!(matches!(out, InputOutcome::OpenPluginsMarketplace));
}
#[test]
fn enter_on_plugin_row_is_noop() {
let mut state = plugins_state_at(1);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &snap());
assert!(matches!(out, InputOutcome::None));
}
#[test]
fn space_on_plugin_row_toggles_off() {
let mut state = plugins_state_at(1);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), &snap());
match out {
InputOutcome::TogglePlugin { name, enabled } => {
assert_eq!(name, "p1");
assert!(!enabled);
}
_ => panic!("expected TogglePlugin"),
}
}
#[test]
fn enter_on_disabled_plugin_is_noop() {
let mut state = plugins_state_at(2);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &snap());
assert!(matches!(out, InputOutcome::None));
}
#[test]
fn space_on_disabled_plugin_toggles_on() {
let mut state = plugins_state_at(2);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), &snap());
match out {
InputOutcome::TogglePlugin { name, enabled } => {
assert_eq!(name, "p2");
assert!(enabled);
}
_ => panic!("expected TogglePlugin"),
}
}
use synaps_cli::skills::registry::{
PluginSettingsCategory, PluginSettingsEditor, PluginSettingsField,
};
fn plugin_field(key: &str, label: &str, editor: PluginSettingsEditor) -> PluginSettingsField {
PluginSettingsField {
key: key.to_string(),
label: label.to_string(),
editor,
help: None,
default: None,
}
}
fn snap_with_plugin_cats(cats: Vec<PluginSettingsCategory>) -> RuntimeSnapshot {
let mut s = snap();
s.plugin_categories = cats;
s
}
fn at_first_plugin_cat(s: &RuntimeSnapshot) -> SettingsState {
let mut state = SettingsState::new();
state.category_idx = super::super::schema::CATEGORIES.len();
state.focus = Focus::Right;
state.setting_idx = 0;
assert!(state.is_plugin_category(s));
state
}
#[test]
fn plugin_categories_extend_left_pane_navigation() {
let s = snap_with_plugin_cats(vec![PluginSettingsCategory {
plugin: "demo".into(),
id: "demo.main".into(),
label: "Demo".into(),
fields: vec![plugin_field(
"speed",
"Speed",
PluginSettingsEditor::Cycler { options: vec!["slow".into(), "fast".into()] },
)],
}]);
let mut state = SettingsState::new();
for _ in 0..super::super::schema::CATEGORIES.len() {
handle_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &s);
}
assert_eq!(state.category_idx, super::super::schema::CATEGORIES.len());
handle_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &s);
assert_eq!(state.category_idx, super::super::schema::CATEGORIES.len());
}
#[test]
fn cycler_right_emits_plugin_apply_with_next_option() {
let s = snap_with_plugin_cats(vec![PluginSettingsCategory {
plugin: "demo".into(),
id: "demo.main".into(),
label: "Demo".into(),
fields: vec![plugin_field(
"speed",
"Speed",
PluginSettingsEditor::Cycler {
options: vec!["slow".into(), "fast".into()],
},
)],
}]);
let mut state = at_first_plugin_cat(&s);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &s);
match out {
InputOutcome::PluginApply { plugin_id, key, value } => {
assert_eq!(plugin_id, "demo");
assert_eq!(key, "speed");
assert_eq!(value, "fast");
}
_ => panic!("expected PluginApply, got something else"),
}
}
#[test]
fn enter_on_plugin_text_opens_editor_and_applies() {
let s = snap_with_plugin_cats(vec![PluginSettingsCategory {
plugin: "demo".into(),
id: "demo.main".into(),
label: "Demo".into(),
fields: vec![plugin_field(
"label",
"Label",
PluginSettingsEditor::Text { numeric: false },
)],
}]);
let mut state = at_first_plugin_cat(&s);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &s);
assert!(matches!(out, InputOutcome::None));
assert!(matches!(state.edit_mode, Some(ActiveEditor::PluginText { .. })));
handle_event(&mut state, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), &s);
handle_event(&mut state, KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &s);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &s);
match out {
InputOutcome::PluginApply { plugin_id, key, value } => {
assert_eq!(plugin_id, "demo");
assert_eq!(key, "label");
assert_eq!(value, "hi");
}
_ => panic!("expected PluginApply"),
}
}
#[test]
fn enter_on_plugin_custom_field_requests_plugin_editor_open() {
let s = snap_with_plugin_cats(vec![PluginSettingsCategory {
plugin: "demo".into(),
id: "capture".into(),
label: "Demo".into(),
fields: vec![plugin_field("body", "Body", PluginSettingsEditor::Custom)],
}]);
let mut state = at_first_plugin_cat(&s);
let out = handle_event(&mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &s);
match out {
InputOutcome::PluginCustomOpen { plugin_id, category, key } => {
assert_eq!(plugin_id, "demo");
assert_eq!(category, "capture");
assert_eq!(key, "body");
}
other => panic!("expected PluginCustomOpen, got {:?}",
std::mem::discriminant(&other)),
}
assert!(state.edit_mode.is_none(), "async upper layer opens the editor after RPC returns");
}
#[test]
fn cycler_current_value_uses_plugin_default_when_unset() {
let field = PluginSettingsField {
key: "speed".into(),
label: "Speed".into(),
editor: PluginSettingsEditor::Cycler {
options: vec!["slow".into(), "fast".into()],
},
help: None,
default: Some(serde_json::Value::String("fast".into())),
};
let v = super::plugin_field_current_value("__nonexistent_plugin_xyz__", &field);
assert_eq!(v, "fast");
}
}