use std::sync::Arc;
use super::app::{App, ChatMessage};
use crossterm::event::{Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind};
use synaps_cli::skills::registry::CommandRegistry;
pub(super) enum InputAction {
None,
Submit(String),
SlashCommand(String, String),
StreamingInput(String),
SubagentSteer { handle_id: String, text: String },
Quit,
Abort,
SettingsApply(&'static str, String),
ModelsApply(String),
GrantWorkerModel(String),
EffortApply(super::effort::EffortApply),
SessionsOp(super::sessions::SessionsOp),
SessionsPast(super::sessions::past::PastCmd),
CopyText(String),
ModelsExpandProvider(String),
PluginsOutcome(super::plugins::InputOutcome),
HelpFindOutcome,
OpenPluginsMarketplace,
PingModels,
PluginEditorOpen {
plugin_id: String,
category: String,
field: String,
},
PluginEditorKey {
plugin_id: String,
category: String,
field: String,
key: crossterm::event::KeyEvent,
},
}
pub(super) fn handle_event(
event: Event,
app: &mut App,
runtime: &impl agent_engine::session::RuntimeRead,
streaming: bool,
registry: &Arc<CommandRegistry>,
keybinds: &synaps_cli::skills::keybinds::KeybindRegistry,
scroll_lines: u16,
) -> InputAction {
if let Event::Key(k) = &event {
if k.kind == crossterm::event::KeyEventKind::Release {
return InputAction::None;
}
}
let action = match app.modal_stack.top() {
super::focus::PaneId::Chat => {
handle_event_inner(event, app, streaming, registry, keybinds, scroll_lines)
}
super::focus::PaneId::HelpFind => route_help_find(event, app),
super::focus::PaneId::Effort => route_effort(event, app),
super::focus::PaneId::Sessions => route_sessions(event, app),
super::focus::PaneId::Models => route_models(event, app, runtime),
super::focus::PaneId::Plugins => route_plugins(event, app),
super::focus::PaneId::Settings => route_settings(event, app, runtime, registry),
super::focus::PaneId::PluginEditor => route_settings(event, app, runtime, registry),
super::focus::PaneId::SecretPrompt => route_secret_prompt(event, app),
};
#[cfg(debug_assertions)]
super::focus::debug_assert_stack_sync(app);
action
}
fn handle_event_inner(
event: Event,
app: &mut App,
streaming: bool,
registry: &Arc<CommandRegistry>,
keybinds: &synaps_cli::skills::keybinds::KeybindRegistry,
scroll_lines: u16,
) -> InputAction {
if matches!(event, Event::Key(_) | Event::Paste(_)) {
let now = app.clock.now();
app.prompt_clock.touch(now);
}
match event {
Event::Key(key) => handle_key(key, app, streaming, registry, keybinds),
Event::Mouse(mouse) => handle_mouse(mouse, app, scroll_lines),
Event::Paste(text) => {
if let Some(deadline) = app.suppress_paste_until {
if app.clock.now() < deadline {
app.suppress_paste_until = None;
return InputAction::None;
}
app.suppress_paste_until = None;
}
const MAX_PASTE_CHARS: usize = 100_000;
let text = if text.chars().count() > MAX_PASTE_CHARS {
let truncated: String = text.chars().take(MAX_PASTE_CHARS).collect();
app.push_msg(ChatMessage::System(format!(
"Paste truncated to {} chars (was {})",
MAX_PASTE_CHARS,
text.chars().count()
)));
truncated
} else {
text
};
if app.input_before_paste.is_none() {
app.input_before_paste = Some(app.input_text());
}
let text = text.replace("\r\n", "\n").replace('\r', "\n");
app.insert_at_cursor(&text);
app.pasted_char_count += text.chars().count();
InputAction::None
}
_ => InputAction::None,
}
}
fn handle_mouse(
mouse: crossterm::event::MouseEvent,
app: &mut App,
scroll_lines: u16,
) -> InputAction {
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
if let Some(i) = super::subagent_tray::row_at(
app.tray_area,
app.subagents.len(),
mouse.column,
mouse.row,
) {
let sa = &app.subagents[i];
let c = super::watch::Candidate {
handle_id: format!("sa_{}", sa.id),
subagent_id: sa.id,
agent_name: sa.name.clone(),
};
super::watch::focus_worker(app, &c, std::time::Instant::now());
return InputAction::None;
}
}
let banner = super::watch::banner(app);
let ctx = super::transcript::RenderCtx {
spinner_frame: app.spinner_frame,
streaming: match &banner {
Some(b) => matches!(b.status.as_str(), "running" | "connecting"),
None => app.streaming,
},
agent_name: banner.as_ref().map_or(app.agent_name.as_str(), |b| b.agent_name.as_str()),
};
let view = super::threads::active_mut(&mut app.transcript, &mut app.threads);
match mouse.kind {
MouseEventKind::ScrollUp => {
view.scroll_up(scroll_lines);
}
MouseEventKind::ScrollDown => {
view.scroll_down(scroll_lines);
}
MouseEventKind::Down(MouseButton::Left) => {
if view.hit_test(mouse.column, mouse.row) {
view.selection_begin(mouse.column, mouse.row, &ctx);
} else {
view.clear_selection();
}
}
MouseEventKind::Drag(MouseButton::Left) => {
view.selection_drag(mouse.column, mouse.row, &ctx);
}
MouseEventKind::Up(MouseButton::Left) => {
view.selection_release(mouse.column, mouse.row, &ctx);
}
MouseEventKind::Down(MouseButton::Right) => {
if view.has_selection() {
let copied = view.selected_text();
view.clear_selection();
if let Some(text) = copied {
copy_to_clipboard(&text);
super::watch::notice(app, format!("Copied {} chars", text.chars().count()));
}
app.suppress_paste_until =
Some(app.clock.now() + std::time::Duration::from_millis(150));
} else {
if let Some(text) = paste_from_clipboard() {
if !text.is_empty() {
if app.input_before_paste.is_none() {
app.input_before_paste = Some(app.input_text());
}
app.insert_at_cursor(&text);
app.pasted_char_count += text.chars().count();
}
}
app.suppress_paste_until =
Some(app.clock.now() + std::time::Duration::from_millis(150));
}
}
_ => {}
}
InputAction::None
}
pub(crate) fn copy_to_clipboard(text: &str) {
use std::sync::{mpsc, OnceLock};
static TX: OnceLock<mpsc::Sender<String>> = OnceLock::new();
let sender = TX.get_or_init(|| {
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
let Ok(mut clipboard) = arboard::Clipboard::new() else {
return;
};
while let Ok(text) = rx.recv() {
let _ = clipboard.set_text(&text);
}
});
tx
});
let _ = sender.send(text.to_string());
}
fn paste_from_clipboard() -> Option<String> {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
if !text.is_empty() {
return Some(text);
}
}
}
None
}
fn handle_key(
key: crossterm::event::KeyEvent,
app: &mut App,
streaming: bool,
registry: &Arc<CommandRegistry>,
keybinds: &synaps_cli::skills::keybinds::KeybindRegistry,
) -> InputAction {
let (code, modifiers) = (key.code, key.modifiers);
super::threads::active_mut(&mut app.transcript, &mut app.threads).clear_selection();
if !matches!(code, KeyCode::Tab) {
app.tab_cycle = None;
}
if !streaming {
let traceable = matches!(code, KeyCode::F(_))
|| modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT);
if traceable {
tracing::info!(?code, ?modifiers, "key event received in chatui input");
}
if let Some(bind) = keybinds.match_key(code, modifiers) {
use synaps_cli::skills::keybinds::KeybindAction;
return match &bind.action {
KeybindAction::SlashCommand(cmd) => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
let resolved = super::commands::resolve_prefix(
parts[0],
&super::commands::all_commands_with_skills(registry),
);
InputAction::SlashCommand(resolved, parts.get(1).unwrap_or(&"").to_string())
}
KeybindAction::LoadSkill(skill) => {
InputAction::SlashCommand("load".to_string(), skill.clone())
}
KeybindAction::InjectPrompt(text) if super::watch::focused(app).is_some() => {
match super::watch::steer_target(app) {
Some(Ok(handle_id)) => InputAction::SubagentSteer {
handle_id,
text: text.clone(),
},
Some(Err(reason)) => {
super::watch::steer_unavailable(app, reason);
InputAction::None
}
None => InputAction::None,
}
}
KeybindAction::InjectPrompt(text) => InputAction::Submit(text.clone()),
KeybindAction::Disabled => InputAction::None,
KeybindAction::RunScript { .. } => {
InputAction::None
}
};
}
}
let watching = super::watch::focused(app).is_some();
match (code, modifiers) {
(KeyCode::Up | KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::ALT) => {
let step = if code == KeyCode::Up {
super::watch::Step::Up
} else {
super::watch::Step::Down
};
super::watch::switch(app, step, std::time::Instant::now());
return InputAction::None;
}
(KeyCode::Esc, _) if watching => {
super::watch::leave_on_esc(app, std::time::Instant::now());
return InputAction::None;
}
(KeyCode::Esc, _)
if streaming && super::watch::esc_guarded(app, std::time::Instant::now()) =>
{
return InputAction::None;
}
(KeyCode::Enter, m) if watching && m.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => {
app.editor.insert_newline();
return InputAction::None;
}
(KeyCode::Enter, _)
if watching && !app.input_is_empty() && !app.input_first_line().starts_with('/') =>
{
match super::watch::steer_target(app) {
Some(Ok(handle_id)) => return process_subagent_steer(app, handle_id),
Some(Err(reason)) => super::watch::steer_unavailable(app, reason),
None => super::watch::steer_unavailable(app, "this view has no subagent to steer".into()),
}
return InputAction::None;
}
(KeyCode::Enter, _)
if watching
&& !slash_is_known(app, registry)
&& (streaming || slash_word(app).is_empty()) =>
{
let word = app.input_first_line().split_whitespace().next().unwrap_or("/").to_string();
super::watch::notice(app, format!("unknown command: {word}"));
return InputAction::None;
}
(KeyCode::Enter, _) if watching && app.input_is_empty() => {
if !app.pending_attachments.is_empty() {
super::watch::notice(
app,
"attachments go to the main thread \u{2014} Esc, then Enter sends them".into(),
);
}
return InputAction::None;
}
_ => {}
}
match (code, modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
if app.driver_ui.is_armed() {
return InputAction::Abort;
}
return InputAction::Quit;
}
(KeyCode::Esc, _) if streaming || app.driver_ui.is_armed() => {
return InputAction::Abort;
}
(KeyCode::Enter, KeyModifiers::SHIFT) if !streaming => {
app.editor.insert_newline();
}
(KeyCode::Enter, KeyModifiers::ALT) if !streaming => {
app.editor.insert_newline();
}
(KeyCode::Enter, _) if !streaming && !app.input_is_empty() => {
return process_submit(app, registry);
}
(KeyCode::Enter, _) if !streaming && app.input_is_empty() && !app.pending_attachments.is_empty() => {
return process_submit_attachment_only(app);
}
(KeyCode::Enter, _) if streaming && !app.input_is_empty() => {
return process_streaming_submit(app);
}
(KeyCode::Enter, _) => {}
(KeyCode::Tab, _)
if app.input_first_line().starts_with('/') && app.input_first_line().len() > 1 =>
{
if open_help_find_for_ambiguous_slash(app, registry) {
return InputAction::HelpFindOutcome;
}
handle_tab_complete(app, registry);
return InputAction::None;
}
(KeyCode::Tab, _) => {}
(KeyCode::Esc, _) => {}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
app.clear_input();
}
(KeyCode::Char('z'), KeyModifiers::CONTROL) => {
app.editor.undo();
}
(KeyCode::Char('y'), KeyModifiers::CONTROL) => {
app.editor.redo();
}
(KeyCode::Char('o'), KeyModifiers::CONTROL) => {
let view = super::threads::active_mut(&mut app.transcript, &mut app.threads);
view.set_show_full_output(!view.show_full_output());
app.request_redraw();
}
(KeyCode::Left, KeyModifiers::ALT) => {
app.editor.move_cursor(tui_textarea::CursorMove::WordBack);
}
(KeyCode::Right, KeyModifiers::ALT) => {
app.editor
.move_cursor(tui_textarea::CursorMove::WordForward);
}
(KeyCode::Up, KeyModifiers::SHIFT) => {
super::threads::active_mut(&mut app.transcript, &mut app.threads).scroll_up(1);
}
(KeyCode::Down, KeyModifiers::SHIFT) => {
super::threads::active_mut(&mut app.transcript, &mut app.threads).scroll_down(1);
}
(KeyCode::Up, _) if app.editor.cursor().0 == 0 => {
app.history_up();
}
(KeyCode::Up, _) => {
app.editor.input(key);
}
(KeyCode::Down, _) if app.editor.cursor().0 + 1 >= app.editor.lines().len() => {
app.history_down();
}
(KeyCode::Down, _) => {
app.editor.input(key);
}
_ => {
app.editor.input(key);
}
}
InputAction::None
}
fn slash_is_known(app: &App, registry: &Arc<CommandRegistry>) -> bool {
if !app.input_first_line().starts_with('/') {
return true;
}
let raw = slash_word(app);
if raw.is_empty() {
return false;
}
let streaming = super::commands::to_owned_commands(super::commands::STREAMING_COMMANDS);
if super::commands::is_streaming_command(&super::commands::resolve_prefix(raw, &streaming)) {
return true;
}
let all = super::commands::all_commands_with_skills(registry);
let resolved = super::commands::resolve_prefix(raw, &all);
all.contains(&resolved)
}
fn slash_word(app: &App) -> &str {
let rest = app.input_first_line().strip_prefix('/').unwrap_or("");
if rest.starts_with(char::is_whitespace) {
return "";
}
rest.split_whitespace().next().unwrap_or("")
}
fn process_submit(app: &mut App, registry: &Arc<CommandRegistry>) -> InputAction {
let now = app.clock.now();
app.prompt_clock.sent(now);
if app.transcript.is_empty() {
app.logo_dismiss_t = Some(0.001);
}
let input = app.input_text();
app.input_history.push(input.clone());
app.history_index = None;
app.input_stash.clear();
app.clear_input();
if super::watch::focused(app).is_none() {
app.transcript.scroll_to_bottom();
}
if input.starts_with('/') && input.len() > 1 {
let parts: Vec<&str> = input[1..].splitn(2, char::is_whitespace).collect();
let raw_cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string();
let commands = super::commands::all_commands_with_skills(registry);
let cmd = super::commands::resolve_prefix(raw_cmd, &commands);
InputAction::SlashCommand(cmd, arg)
} else {
InputAction::Submit(input)
}
}
fn process_submit_attachment_only(app: &mut App) -> InputAction {
let now = app.clock.now();
app.prompt_clock.sent(now);
if app.transcript.is_empty() {
app.logo_dismiss_t = Some(0.001);
}
app.transcript.scroll_to_bottom();
InputAction::Submit(String::new())
}
fn process_streaming_submit(app: &mut App) -> InputAction {
let now = app.clock.now();
app.prompt_clock.sent(now);
let input = app.input_text();
app.input_history.push(input.clone());
app.history_index = None;
app.input_stash.clear();
app.clear_input();
app.input_before_paste = None;
app.pasted_char_count = 0;
InputAction::StreamingInput(input)
}
fn process_subagent_steer(app: &mut App, handle_id: String) -> InputAction {
let text = app.input_text();
app.input_history.push(text.clone());
app.history_index = None;
app.input_stash.clear();
app.clear_input();
app.input_before_paste = None;
app.pasted_char_count = 0;
InputAction::SubagentSteer { handle_id, text }
}
fn route_help_find(event: Event, app: &mut App) -> InputAction {
let Some(state) = &mut app.help_find else {
return InputAction::None;
};
if let Event::Key(key) = event {
return match super::help_find::handle_event(state, key) {
super::help_find::HelpFindAction::Close => {
app.help_find = None;
app.modal_stack.pop();
InputAction::None
}
super::help_find::HelpFindAction::None => InputAction::HelpFindOutcome,
};
}
InputAction::None
}
fn route_effort(event: Event, app: &mut App) -> InputAction {
let Some(state) = &mut app.effort else {
return InputAction::None;
};
if let Event::Key(key) = event {
return match super::effort::handle_event(state, key) {
super::effort::InputOutcome::Close => {
app.effort = None;
app.modal_stack.pop();
InputAction::None
}
super::effort::InputOutcome::Apply(level) => {
app.effort = None;
app.modal_stack.pop();
InputAction::EffortApply(level)
}
super::effort::InputOutcome::None => InputAction::None,
};
}
InputAction::None
}
fn route_sessions(event: Event, app: &mut App) -> InputAction {
let Some(state) = &mut app.sessions else {
return InputAction::None;
};
if let Event::Key(key) = event {
return match super::sessions::handle_event(state, key) {
super::sessions::InputOutcome::Close => {
super::sessions::close(app);
InputAction::None
}
super::sessions::InputOutcome::Op(op) => InputAction::SessionsOp(op),
super::sessions::InputOutcome::Past(cmd) => InputAction::SessionsPast(cmd),
super::sessions::InputOutcome::Copy(text) => InputAction::CopyText(text),
super::sessions::InputOutcome::None => InputAction::None,
};
}
InputAction::None
}
fn route_models(
event: Event,
app: &mut App,
runtime: &impl agent_engine::session::RuntimeRead,
) -> InputAction {
let Some(state) = &mut app.models else {
return InputAction::None;
};
if let Event::Key(key) = event {
return match super::models::handle_event(state, key, runtime.model()) {
super::models::InputOutcome::Close => {
app.models = None;
app.modal_stack.pop();
InputAction::None
}
super::models::InputOutcome::Apply(model) => {
app.models = None;
app.modal_stack.pop();
InputAction::ModelsApply(model)
}
super::models::InputOutcome::None => InputAction::None,
super::models::InputOutcome::Trusted(model) => {
InputAction::GrantWorkerModel(model)
}
super::models::InputOutcome::ExpandProvider(provider) => {
InputAction::ModelsExpandProvider(provider)
}
};
}
InputAction::None
}
fn route_plugins(event: Event, app: &mut App) -> InputAction {
let Some(state) = &mut app.plugins else {
return InputAction::None;
};
if let Event::Key(key) = event {
return match super::plugins::handle_event(state, key) {
super::plugins::InputOutcome::Close => {
app.plugins = None;
app.modal_stack.pop();
InputAction::None
}
super::plugins::InputOutcome::None => InputAction::None,
other => InputAction::PluginsOutcome(other),
};
}
InputAction::None
}
fn route_settings(
event: Event,
app: &mut App,
runtime: &impl agent_engine::session::RuntimeRead,
registry: &Arc<CommandRegistry>,
) -> InputAction {
if let Some(state) = &mut app.settings {
if let Some(super::settings::ActiveEditor::PluginCustom {
plugin_id,
category,
field,
..
}) = &state.edit_mode
{
if let Event::Key(key) = event {
if key.code == KeyCode::Esc {
state.edit_mode = None;
app.modal_stack.pop();
return InputAction::None;
}
return InputAction::PluginEditorKey {
plugin_id: plugin_id.clone(),
category: category.clone(),
field: field.clone(),
key,
};
}
return InputAction::None;
}
if let Event::Paste(text) = event {
match &mut state.edit_mode {
Some(super::settings::ActiveEditor::ApiKey { buffer, .. }) => {
buffer.push_str(&text);
}
Some(super::settings::ActiveEditor::Text { buffer, .. }) => {
buffer.push_str(&text);
}
Some(super::settings::ActiveEditor::CustomModel { buffer, .. }) => {
buffer.push_str(&text);
}
_ => {}
}
return InputAction::None;
}
if let Event::Key(key) = event {
let mut snap = super::settings::RuntimeSnapshot::from_runtime_with_health(
runtime,
registry,
app.model_health.clone(),
);
snap.catalog_overrides = app.catalog_overrides.clone();
let outcome = super::settings::handle_event(state, key, &snap);
if app.streaming {
if let Some(refusal) = super::settings::mid_turn_refusal(&outcome) {
state.row_error = Some(refusal);
return InputAction::None;
}
}
match outcome {
super::settings::InputOutcome::Close => {
app.settings = None;
app.modal_stack.pop();
}
super::settings::InputOutcome::None => {}
super::settings::InputOutcome::Apply { key, value } => {
return InputAction::SettingsApply(key, value);
}
super::settings::InputOutcome::PluginApply {
plugin_id,
key,
value,
} => {
let row_key = format!("plugin.{}.{}", plugin_id, key);
match synaps_cli::extensions::config_store::write_plugin_config(
&plugin_id, &key, &value,
) {
Ok(()) => {
state.edit_mode = None;
state.row_error = Some((row_key, "saved".to_string()));
}
Err(e) => {
state.row_error = Some((row_key, e.to_string()));
}
}
}
super::settings::InputOutcome::PluginCustomOpen {
plugin_id,
category,
key,
} => {
return InputAction::PluginEditorOpen {
plugin_id,
category,
field: key,
};
}
super::settings::InputOutcome::SetProviderKey { provider_id, value } => {
let result = if provider_id == "local.url" {
synaps_cli::config::write_config_value(
&format!("provider.{}", provider_id),
&value,
)
.map_err(|e| e.to_string())
} else {
synaps_cli::auth::save_static_key(&provider_id, &value)
};
let row_key = format!("provider.{}", provider_id);
match result {
Ok(()) => {
state.edit_mode = None;
state.row_error = Some((row_key, "saved".to_string()));
}
Err(e) => {
state.row_error = Some((row_key, e));
}
}
}
super::settings::InputOutcome::TogglePlugin { name, enabled } => {
let mut config = synaps_cli::config::load_config();
match super::plugins::actions::toggle_plugin_config(
&name,
enabled,
&mut config,
registry,
) {
Ok(()) => {
state.row_error = None;
}
Err(e) => {
state.row_error = Some(("disabled_plugins".to_string(), e));
}
}
}
super::settings::InputOutcome::PreviewTheme { name } => {
if let Some(theme) = app.resolve_apply_theme(&name) {
super::theme::transition::apply_animated(
&mut app.theme_transition,
theme,
None,
std::time::Instant::now(),
);
}
}
super::settings::InputOutcome::RevertTheme => {
let theme = app
.myx_last_live
.clone()
.filter(|_| app.myx_task.is_some())
.unwrap_or_else(super::theme::load_theme_from_config);
super::theme::transition::apply_animated(
&mut app.theme_transition,
theme,
None,
std::time::Instant::now(),
);
}
super::settings::InputOutcome::OpenPluginsMarketplace => {
return InputAction::OpenPluginsMarketplace;
}
super::settings::InputOutcome::PingModels => {
return InputAction::PingModels;
}
}
}
return InputAction::None;
}
InputAction::None
}
pub(super) fn reconcile_secret_prompt(app: &mut App) {
let active = app.secret_prompts.is_active();
let on_stack = app.modal_stack.contains(super::focus::PaneId::SecretPrompt);
match (active, on_stack) {
(true, false) => app.modal_stack.push(super::focus::PaneId::SecretPrompt),
(false, true) => app.modal_stack.remove(super::focus::PaneId::SecretPrompt),
_ => {}
}
}
fn route_secret_prompt(event: Event, app: &mut App) -> InputAction {
use synaps_cli::tools::PromptKind;
let is_confirm = app
.secret_prompts
.active()
.is_some_and(|p| p.kind == PromptKind::Confirm);
if is_confirm {
use synaps_cli::tools::ConfirmChoice;
let repeat = matches!(&event, Event::Key(k) if k.kind == crossterm::event::KeyEventKind::Repeat);
let typed = matches!(&event, Event::Key(_) | Event::Paste(_));
let enter_or_esc = matches!(
&event,
Event::Key(k) if matches!(k.code, KeyCode::Enter | KeyCode::Esc)
);
if typed && !enter_or_esc {
app.secret_prompts.cancel_allow_all_pending();
}
if repeat || !app.secret_prompts.input_armed() {
if typed {
app.secret_prompts.restart_input_guard();
}
return InputAction::None;
}
if let Event::Key(key) = event {
let answered = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
app.secret_prompts.confirm_answer(ConfirmChoice::Allow)
}
KeyCode::Esc if app.secret_prompts.cancel_allow_all_pending() => None,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.secret_prompts.confirm_answer(ConfirmChoice::Deny)
}
KeyCode::Enter => app.secret_prompts.confirm_activate_focused(),
KeyCode::Right | KeyCode::Tab => {
app.secret_prompts.move_confirm_focus(true);
None
}
KeyCode::Left | KeyCode::BackTab => {
app.secret_prompts.move_confirm_focus(false);
None
}
_ => None,
};
if answered.is_some() {
reconcile_secret_prompt(app);
}
}
return InputAction::None;
}
match event {
Event::Key(key) => match key.code {
KeyCode::Enter => {
app.secret_prompts.submit();
reconcile_secret_prompt(app);
}
KeyCode::Esc => {
app.secret_prompts.cancel();
reconcile_secret_prompt(app);
}
KeyCode::Backspace => app.secret_prompts.backspace(),
KeyCode::Char(c) => app.secret_prompts.push_char(c),
_ => {}
},
Event::Paste(text) => {
for ch in text.chars() {
app.secret_prompts.push_char(ch);
}
}
_ => {}
}
InputAction::None
}
fn open_help_find_for_ambiguous_slash(app: &mut App, registry: &Arc<CommandRegistry>) -> bool {
let Some(query) = synaps_cli::help::prefilter_query_for_slash_command(app.input_first_line())
else {
return false;
};
let help_registry = synaps_cli::help::HelpRegistry::new(
synaps_cli::help::builtin_entries(),
registry.plugin_help_entries(),
);
if help_registry.command_prefix_match_count(&query) < 2 {
return false;
}
app.help_find = Some(synaps_cli::help::HelpFindState::new(
help_registry.entries().to_vec(),
&query,
));
app.modal_stack.push(super::focus::PaneId::HelpFind);
true
}
fn handle_tab_complete(app: &mut App, registry: &Arc<CommandRegistry>) {
let commands = super::commands::all_commands_with_skills(registry);
if let Some((ref prefix, idx, ref matching_cmds)) = app.tab_cycle.clone() {
if matching_cmds.is_empty() {
app.tab_cycle = None;
return;
}
let next = (idx + 1) % matching_cmds.len();
app.set_input_text(&format!("/{}", matching_cmds[next]));
app.tab_cycle = Some((prefix.clone(), next, matching_cmds.clone()));
return;
}
let partial = app.input_first_line()[1..].to_string();
let matches: Vec<String> = commands
.iter()
.filter(|c| c.starts_with(partial.as_str()))
.cloned()
.collect();
if matches.len() == 1 {
app.set_input_text(&format!("/{}", matches[0]));
return;
}
if !matches.is_empty() {
let first = &matches[0];
let common_len = (0..first.len())
.take_while(|&i| {
matches
.iter()
.all(|m| m.as_bytes().get(i) == first.as_bytes().get(i))
})
.count();
if common_len > partial.len() {
app.set_input_text(&format!("/{}", &first[..common_len]));
} else {
app.set_input_text(&format!("/{}", matches[0]));
app.tab_cycle = Some((partial, 0, matches));
}
return;
}
if let Some(fuzzy) = super::commands::fuzzy_match(&partial, &commands) {
app.set_input_text(&format!("/{}", fuzzy));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use synaps_cli::Session;
fn models_key(code: KeyCode) -> Event {
Event::Key(crossterm::event::KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn routed_models_search_navigation_applies_visible_provider_qualified_id() {
use crate::tui::focus::PaneId;
use crate::tui::models::{
ExpandedLoadState, ExpandedModelEntry, ExpandedModelsState, ModelsModalState,
};
let runtime = synaps_cli::Runtime::new_headless();
let mut app = make_app();
let mut models = ModelsModalState::new();
models.expanded = Some(ExpandedModelsState {
provider_key: "github-copilot".into(),
provider_name: "GitHub Copilot".into(),
cursor: 0,
search: String::new(),
load_state: ExpandedLoadState::Ready(vec![
ExpandedModelEntry::new(
"github-copilot/claude-opus-4.7".into(),
"Opus 4.7".into(),
false,
),
ExpandedModelEntry::new(
"github-copilot/claude-opus-4.8".into(),
"Opus 4.8".into(),
false,
),
]),
});
app.models = Some(models);
app.modal_stack.push(PaneId::Models);
for ch in "opus 4".chars() {
assert!(matches!(
route_models(models_key(KeyCode::Char(ch)), &mut app, &runtime),
InputAction::None
));
}
assert!(matches!(
route_models(models_key(KeyCode::Down), &mut app, &runtime),
InputAction::None
));
match route_models(models_key(KeyCode::Enter), &mut app, &runtime) {
InputAction::ModelsApply(id) => assert_eq!(id, "github-copilot/claude-opus-4.8"),
_ => panic!("Enter did not apply selected visible model"),
}
assert!(app.models.is_none());
assert_eq!(app.modal_stack.top(), PaneId::Chat);
}
#[test]
fn key_release_events_are_dropped_in_models_pane() {
use crate::tui::focus::PaneId;
use crate::tui::models::ModelsModalState;
let runtime = synaps_cli::Runtime::new_headless();
let mut app = make_app();
app.models = Some(ModelsModalState::new());
app.modal_stack.push(PaneId::Models);
let keybinds = synaps_cli::skills::keybinds::KeybindRegistry::default();
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let release = Event::Key(crossterm::event::KeyEvent {
code: KeyCode::Down,
modifiers: KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Release,
state: crossterm::event::KeyEventState::empty(),
});
assert!(matches!(
handle_event(release, &mut app, &runtime, false, ®istry, &keybinds, 3),
InputAction::None
));
}
fn make_app() -> App {
App::new(Session::new("test-model", "low", None))
}
fn scroll_event(kind: MouseEventKind) -> crossterm::event::MouseEvent {
MouseEvent {
kind,
column: 5,
row: 5,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn scroll_up_uses_configured_step() {
let mut app = make_app();
app.transcript.test_set_scroll_back(0);
handle_mouse(scroll_event(MouseEventKind::ScrollUp), &mut app, 5);
assert_eq!(
app.transcript.scroll_back_pos(),
5,
"scroll_back should be 5 with scroll_lines=5"
);
}
#[test]
fn scroll_down_uses_configured_step() {
let mut app = make_app();
app.transcript.test_set_scroll_back(10);
handle_mouse(scroll_event(MouseEventKind::ScrollDown), &mut app, 5);
assert_eq!(
app.transcript.scroll_back_pos(),
5,
"scroll_back should decrease by 5"
);
}
#[test]
fn scroll_default_step_is_3() {
let cfg = synaps_cli::config::SynapsConfig::default();
let step = cfg.scroll_lines.unwrap_or(3);
assert_eq!(step, 3, "default scroll step must be 3 when unconfigured");
}
#[test]
fn scroll_configured_step_is_used_via_config() {
let cfg = synaps_cli::config::SynapsConfig {
scroll_lines: Some(7),
..Default::default()
};
let step = cfg.scroll_lines.unwrap_or(3);
assert_eq!(step, 7);
}
fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) -> InputAction {
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
handle_key(
crossterm::event::KeyEvent::new(code, mods),
app,
false,
®istry,
&keybinds,
)
}
fn press_inject(app: &mut App, prompt: &str) -> InputAction {
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
keybinds.register_plugin(
"p",
&[synaps_cli::skills::keybinds::ManifestKeybind {
key: "C-S-p".into(),
action: "inject_prompt".into(),
command: None,
skill: None,
prompt: Some(prompt.into()),
script: None,
description: None,
}],
std::path::Path::new("/tmp"),
);
let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
handle_key(crossterm::event::KeyEvent::new(KeyCode::Char('p'), mods), app, false, ®istry, &keybinds)
}
#[test]
fn an_inject_prompt_keybind_in_a_worker_view_never_submits_to_main() {
let mut app = make_app();
assert!(
matches!(press_inject(&mut app, "run the tests"), InputAction::Submit(t) if t == "run the tests"),
"on main it submits, as before"
);
let clock = app.clock.clone();
let w = app.threads.open_worker("sa_1", 1, "scout", clock, (0, 0));
w.status = "running".into();
w.steerable = true;
app.threads.focus_worker("sa_1");
assert!(matches!(
press_inject(&mut app, "run the tests"),
InputAction::SubagentSteer { handle_id, text } if handle_id == "sa_1" && text == "run the tests"
));
let w = app.threads.worker_mut("sa_1").unwrap();
w.status = "completed".into();
w.steerable = false;
assert!(matches!(press_inject(&mut app, "run the tests"), InputAction::None));
let note = app.threads.worker("sa_1").unwrap().store.messages().last().map(|m| match &m.msg {
ChatMessage::System(t) => t.clone(),
_ => String::new(),
});
assert!(note.unwrap_or_default().contains("read-only"), "refused in the view");
}
#[test]
fn enter_with_only_attachments_in_a_worker_view_sends_nothing_to_main() {
let mut app = make_app();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.txt");
std::fs::write(&path, "hi").unwrap();
let file = agent_engine::attachments::load_attachment_sync(&path).unwrap();
app.pending_attachments.add(file).unwrap();
let clock = app.clock.clone();
app.threads.open_worker("sa_1", 1, "scout", clock, (0, 0));
app.threads.focus_worker("sa_1");
assert!(matches!(
press(&mut app, KeyCode::Enter, KeyModifiers::NONE),
InputAction::None
));
assert_eq!(app.pending_attachments.len(), 1, "still staged for main");
}
#[test]
fn shift_enter_inserts_newline() {
let mut app = make_app();
press(&mut app, KeyCode::Char('a'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(matches!(
press(&mut app, KeyCode::Enter, KeyModifiers::SHIFT),
InputAction::None
));
press(&mut app, KeyCode::Char('c'), KeyModifiers::NONE);
assert_eq!(app.input_text(), "ab\nc");
assert_eq!(app.cursor_char_pos(), 4);
}
#[test]
fn up_on_multiline_buffer_moves_cursor_not_history() {
let mut app = make_app();
app.input_history.push("older entry".to_string());
app.set_input_text("line1\nline2"); press(&mut app, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(app.input_text(), "line1\nline2", "buffer must be untouched");
assert_eq!(app.history_index, None, "history must not engage");
assert_eq!(app.editor.cursor().0, 0, "cursor should move to row 0");
press(&mut app, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(app.input_text(), "older entry");
assert_eq!(app.history_index, Some(0));
}
#[test]
fn up_on_single_line_triggers_history() {
let mut app = make_app();
app.input_history.push("older entry".to_string());
app.set_input_text("draft");
press(&mut app, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(app.input_text(), "older entry");
assert_eq!(app.history_index, Some(0));
press(&mut app, KeyCode::Down, KeyModifiers::NONE);
assert_eq!(app.input_text(), "draft", "Down at edge restores stash");
assert_eq!(app.history_index, None);
}
#[test]
fn ctrl_u_clears_whole_buffer() {
let mut app = make_app();
app.set_input_text("hello\nworld");
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
assert!(app.input_is_empty());
assert_eq!(app.cursor_char_pos(), 0);
}
#[test]
fn ctrl_c_while_driver_armed_aborts_not_quits() {
let mut app = make_app();
assert!(matches!(
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL),
InputAction::Quit
));
app.driver_ui.armed = true;
assert!(matches!(
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL),
InputAction::Abort
));
}
#[test]
fn esc_while_driver_armed_aborts_even_when_idle() {
let mut app = make_app();
assert!(matches!(
press(&mut app, KeyCode::Esc, KeyModifiers::NONE),
InputAction::None
));
app.driver_ui.armed = true;
assert!(matches!(
press(&mut app, KeyCode::Esc, KeyModifiers::NONE),
InputAction::Abort
));
}
#[test]
fn editing_keys_forward_to_editor() {
let mut app = make_app();
for c in "hi there".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(app.input_text(), "hi ther");
press(&mut app, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(app.input_text(), "hi ");
assert_eq!(app.cursor_char_pos(), 3);
}
}