use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crate::app::App;
use crate::app_state::{
current_sandbox_id, sandbox_backend_config, sandbox_display_name, ApiKeyDialogState, InputMode,
SandboxDialog,
};
use crate::cron_dialog::CronDialogMode;
use crate::gateway::SessionStore;
use crate::input::EventHandler;
use crate::interaction_prompt::{PromptFocus, PromptResolution};
use crate::mcp_service::render_mcp_overview;
use crate::provider_dialog::{DialogFocus, ProviderDialog};
use crate::provider_service::{
copy_to_clipboard, persist_active_provider_selection, persisted_selection_settings,
validate_and_connect_api_key,
};
use crate::remote_sessions_service::{
list_remote_sessions, record_remote_session, RemoteSessionDialog, RemoteSessionDialogEntry,
RemoteSessionDialogMode, RemoteSessionRecord,
};
use crate::services::input_history::save_input_history;
use crate::services::turn_delete::DeleteDialog;
use crate::session_snapshot_service::{
apply_snapshot, list_session_snapshots, load_snapshot, load_snapshot_by_key,
manual_snapshot_name_from_command, save_manual_snapshot, snapshot_name_from_command,
SessionSnapshotDialog, SessionSnapshotListEntry,
};
use crate::skills_service::render_skills_overview;
use crate::workspace_service::{first_token_is_dir_command, resolve_dir_command};
impl App {
pub(crate) async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return Ok(());
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(event::KeyModifiers::CONTROL)
|| key.code == KeyCode::Char('\x03')
{
if let Some(text) = self
.state
.chat_state
.input
.selected_text()
.map(str::to_owned)
{
self.state.chat_state.input.clear_selection();
if let Err(e) = copy_to_clipboard(&text) {
tracing::warn!("copy_to_clipboard failed: {}", e);
}
return Ok(());
}
if let Some(text) = self.state.transcript_selected_text() {
self.state.transcript_selection = None;
if let Err(e) = copy_to_clipboard(&text) {
tracing::warn!("copy_to_clipboard failed: {}", e);
}
return Ok(());
}
self.state.should_quit = true;
self.state.quit_via_interrupt = true;
return Ok(());
}
if key.code == KeyCode::Char('x') && key.modifiers.contains(event::KeyModifiers::CONTROL) {
if let Some(text) = self.state.chat_state.input.delete_selected() {
if let Err(e) = copy_to_clipboard(&text) {
tracing::warn!("copy_to_clipboard failed: {}", e);
}
self.state.chat_state.reset_input_history_navigation();
self.state.note_input_changed();
}
return Ok(());
}
if is_leave_subagent_view_key(&key)
&& self.state.is_subagent_view_active()
&& self.state.api_key_dialog.is_none()
&& self.state.provider_dialog.is_none()
&& self.state.sandbox_dialog.is_none()
&& self.state.remote_session_dialog.is_none()
&& self.state.session_snapshot_dialog.is_none()
&& self.state.delete_dialog.is_none()
&& self.state.interaction_prompt.is_none()
{
self.state.leave_subagent_view();
return Ok(());
}
if key.code == KeyCode::Esc && self.state.chat_state.is_loading {
self.gateway.cancel_streaming(&mut self.state);
return Ok(());
}
if self.state.api_key_dialog.is_some() {
return self.handle_api_key_dialog_key(key);
}
if self.state.input_mode == InputMode::InteractionPrompt {
return self.handle_interaction_prompt_key(key);
}
if self.state.input_mode == InputMode::SessionSnapshotSelection {
return self.handle_session_snapshot_selection_key(key).await;
}
if self.state.input_mode == InputMode::SandboxSelection {
return self.handle_sandbox_selection_key(key);
}
if self.state.input_mode == InputMode::RemoteSessionSelection {
return self.handle_remote_session_selection_key(key).await;
}
if self.state.input_mode == InputMode::TurnDelete {
return self.handle_turn_delete_key(key).await;
}
match self.state.input_mode {
InputMode::Editing => self.handle_editing_mode_key(key).await,
InputMode::ProviderSelection => self.handle_provider_selection_key(key),
InputMode::SandboxSelection => Ok(()),
InputMode::RemoteSessionSelection => Ok(()),
InputMode::SessionSnapshotSelection => Ok(()),
InputMode::InteractionPrompt => Ok(()),
InputMode::TurnDelete => Ok(()),
InputMode::CronManagement => self.handle_cron_management_key(key).await,
}
}
fn handle_api_key_dialog_key(&mut self, key: KeyEvent) -> Result<()> {
let Some(mut dialog) = self.state.api_key_dialog.take() else {
tracing::warn!("TUI: api key dialog state missing while handling key event");
self.state.input_mode = InputMode::Editing;
return Ok(());
};
match key.code {
KeyCode::Esc => {
self.state.input_mode = InputMode::Editing;
}
KeyCode::Enter => {
let api_key = dialog.input.value().trim().to_string();
if api_key.is_empty() {
dialog.error = Some("API key cannot be empty.".to_string());
self.state.api_key_dialog = Some(dialog);
} else {
let provider = dialog.provider.clone();
let model = dialog.model.clone();
match validate_and_connect_api_key(&mut self.state, provider, model, &api_key) {
Ok(()) => {}
Err(error) => {
dialog.error = Some(error);
self.state.api_key_dialog = Some(dialog);
}
}
}
}
_ => {
dialog.input.handle_event(&Event::Key(key));
self.state.api_key_dialog = Some(dialog);
}
}
Ok(())
}
fn handle_interaction_prompt_key(&mut self, key: KeyEvent) -> Result<()> {
let mut resolution = None;
if let Some(prompt) = self.state.interaction_prompt.as_mut() {
match key.code {
KeyCode::Esc => {
resolution = Some(PromptResolution::Cancelled);
}
KeyCode::Tab => {
prompt.toggle_focus();
}
KeyCode::Enter => {
if prompt.request.multi_select {
let choice_ids: Vec<String> = prompt
.multi_checked
.iter()
.enumerate()
.filter(|(_, checked)| **checked)
.map(|(index, _)| prompt.request.choices[index].id.clone())
.collect();
resolution = Some(PromptResolution::Multi { choice_ids });
} else {
let choice_id = prompt
.request
.choices
.get(prompt.selected)
.map(|choice| choice.id.clone())
.unwrap_or_default();
let supplement = if prompt.request.allow_custom_input {
let value = prompt.supplement.value().trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
} else {
None
};
resolution = Some(PromptResolution::Single {
choice_id,
supplement,
});
}
}
KeyCode::Char(' ') => {
if prompt.focus == PromptFocus::List {
prompt.toggle_multi_at_cursor();
} else {
prompt.supplement.handle_event(&Event::Key(key));
}
}
KeyCode::Up => {
if prompt.focus == PromptFocus::List {
prompt.move_up();
} else {
prompt.supplement.handle_event(&Event::Key(key));
}
}
KeyCode::Down => {
if prompt.focus == PromptFocus::List {
prompt.move_down();
} else {
prompt.supplement.handle_event(&Event::Key(key));
}
}
KeyCode::PageUp => {
if prompt.focus == PromptFocus::List {
prompt.page_up();
} else {
prompt.supplement.handle_event(&Event::Key(key));
}
}
KeyCode::PageDown => {
if prompt.focus == PromptFocus::List {
prompt.page_down();
} else {
prompt.supplement.handle_event(&Event::Key(key));
}
}
_ => {
if prompt.focus == PromptFocus::Supplement {
prompt.supplement.handle_event(&Event::Key(key));
} else if prompt.request.allow_custom_input {
match key.code {
KeyCode::Char(_) => {
let modifiers = key.modifiers;
if modifiers.is_empty() || modifiers == event::KeyModifiers::SHIFT {
prompt.focus = PromptFocus::Supplement;
prompt.supplement.handle_event(&Event::Key(key));
}
}
KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Left
| KeyCode::Right
| KeyCode::Home
| KeyCode::End => {
prompt.focus = PromptFocus::Supplement;
prompt.supplement.handle_event(&Event::Key(key));
}
_ => {}
}
}
}
}
}
if let Some(resolution) = resolution {
self.gateway
.resolve_interaction_prompt(&mut self.state, resolution);
}
Ok(())
}
async fn handle_editing_mode_key(&mut self, key: KeyEvent) -> Result<()> {
if self.state.is_subagent_view_active() {
match key.code {
KeyCode::Esc => {
self.state.transcript_selection = None;
}
KeyCode::Up if key.modifiers.is_empty() => {
self.state.active_transcript_scroll_up();
}
KeyCode::Down if key.modifiers.is_empty() => {
self.state.active_transcript_scroll_down();
}
KeyCode::PageUp => {
for _ in 0..10 {
self.state.active_transcript_scroll_up();
}
}
KeyCode::PageDown => {
for _ in 0..10 {
self.state.active_transcript_scroll_down();
}
}
_ => {}
}
return Ok(());
}
if key.code == KeyCode::Tab {
let has_slash_prefix = crate::slash_complete::slash_typed_prefix(
self.state.chat_state.input.value(),
self.state.chat_state.input.cursor(),
)
.is_some();
if has_slash_prefix {
if self.state.slash_menu_visible() {
self.state.apply_slash_selection();
} else {
crate::slash_complete::apply_slash_tab(
&mut self.state.chat_state.input,
&self.state.external_commands,
);
}
self.state.chat_state.reset_input_history_navigation();
self.state.note_input_changed();
} else {
self.state.cycle_agent_role(false);
}
return Ok(());
}
if key.code == KeyCode::Char('t') && key.modifiers.contains(event::KeyModifiers::CONTROL) {
self.state.cycle_reasoning_effort();
return Ok(());
}
if self.state.slash_menu_visible() {
match key.code {
KeyCode::Up => {
self.state.slash.selected = self.state.slash.selected.saturating_sub(1);
return Ok(());
}
KeyCode::Down => {
let candidate_count = self.state.slash_candidate_count();
if candidate_count > 0 {
self.state.slash.selected =
(self.state.slash.selected + 1).min(candidate_count - 1);
}
return Ok(());
}
KeyCode::Enter => {
self.state.apply_slash_selection();
self.state.dismiss_current_slash_menu();
return Ok(());
}
KeyCode::Esc => {
self.state.dismiss_current_slash_menu();
return Ok(());
}
KeyCode::PageUp | KeyCode::PageDown => {
return Ok(());
}
_ => {}
}
}
match key.code {
KeyCode::Esc => {
self.state.transcript_selection = None;
}
KeyCode::Enter => {
if editing_key_inserts_newline(key.code, key.modifiers) {
self.insert_newline_into_input();
} else {
self.submit_editing_input().await?
}
}
KeyCode::Char('j') | KeyCode::Char('J')
if editing_key_inserts_newline(key.code, key.modifiers) =>
{
self.insert_newline_into_input();
}
KeyCode::Up if key.modifiers.is_empty() => {
if self.state.chat_state.input_history_cursor.is_some()
|| self.state.chat_state.input.value().is_empty()
{
self.state.chat_state.previous_input_history();
self.state.note_input_changed();
} else {
self.state.chat_state.input.handle_event(&Event::Key(key));
self.state.note_input_changed();
}
}
KeyCode::Down if key.modifiers.is_empty() => {
if self.state.chat_state.input_history_cursor.is_some()
|| self.state.chat_state.input.value().is_empty()
{
self.state.chat_state.next_input_history();
self.state.note_input_changed();
} else {
self.state.chat_state.input.handle_event(&Event::Key(key));
self.state.note_input_changed();
}
}
KeyCode::PageUp => {
self.state.chat_state.scroll_page_up();
}
KeyCode::PageDown => {
self.state.chat_state.scroll_page_down();
}
_ => {
let before = self.state.chat_state.input.value().to_string();
self.state.chat_state.input.handle_event(&Event::Key(key));
if self.state.chat_state.input.value() != before {
self.state.chat_state.reset_input_history_navigation();
}
self.state.note_input_changed();
}
}
Ok(())
}
fn insert_newline_into_input(&mut self) {
self.state
.chat_state
.input
.handle(crate::input::InputRequest::InsertChar('\n'));
self.state.chat_state.reset_input_history_navigation();
self.state.note_input_changed();
}
async fn submit_editing_input(&mut self) -> Result<()> {
let user_input = self.state.chat_state.input.value().to_string();
if user_input.trim().is_empty() {
return Ok(());
}
let trimmed = user_input.trim();
self.record_submitted_input_history(&user_input).await;
if trimmed.eq_ignore_ascii_case("/delete") {
self.state.chat_state.input.reset();
if self.state.chat_state.is_loading {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"当前任务仍在运行。请等待它结束,或先按 Esc 取消。".to_string(),
));
} else if let Some(dialog) = DeleteDialog::new(&self.state.chat_state.messages) {
self.state.input_mode = InputMode::TurnDelete;
self.state.delete_dialog = Some(dialog);
} else {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"当前会话没有可删除的对话。".to_string(),
));
}
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/new") {
let old_session_id = self.state.session_id.clone();
let was_remote_mode = self.gateway.is_remote_mode();
self.gateway.reset_for_new_session(&mut self.state);
self.state.reset_for_new_session();
if was_remote_mode {
tracing::info!(old_session_id = %old_session_id, "Detaching old remote session (preserving on daemon)");
self.gateway
.detach_remote_session_bounded(&old_session_id, "new_session")
.await;
} else {
tracing::info!(old_session_id = %old_session_id, "Releasing old local session backend");
match tokio::time::timeout(
std::time::Duration::from_secs(5),
self.gateway.release_session_backend(&old_session_id),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(error = %e, "Failed to release local session backend");
}
Err(_) => {
tracing::warn!("Local session backend release timed out after 5 seconds");
}
}
}
if let Some(base_url) = self.gateway.remote_base_url() {
self.state
.status_panel
.set_backend(format!("Remote: {}", base_url));
self.state.status_panel.set_remote_workspace(base_url);
}
self.state
.chat_state
.messages
.push(crate::chat::Message::system("New session started"));
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if self.state.chat_state.is_loading {
if let Some((body, command_context)) = self.external_command_body(trimmed) {
self.state
.chat_state
.enqueue_pending_turn(body, Some(command_context), 0);
return Ok(());
}
if trimmed.starts_with('/') || first_token_is_dir_command(trimmed) {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"当前任务仍在运行。普通消息会进入待发送队列;控制命令请等待当前任务结束,或先按 Esc 取消。"
.to_string(),
));
} else {
self.state
.chat_state
.enqueue_pending_turn(user_input, None, 0);
}
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if is_named_slash_command(trimmed, "/save") {
self.state.chat_state.input.reset();
match manual_snapshot_name_from_command(trimmed, "/save") {
Ok(requested_name) => {
let record = self.gateway.session_snapshot(&self.state.session_id).await;
match save_manual_snapshot(&self.state, record, requested_name.as_deref()) {
Ok((path, context)) => {
let name = context.name.clone();
self.state.current_snapshot_context = Some(context);
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Session snapshot saved: {} ({})",
name,
path.display()
)))
}
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Save snapshot failed: {error:#}"
)))
}
}
}
Err(error) => self
.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Save snapshot failed: {error:#}"
))),
}
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if is_named_slash_command(trimmed, "/load") {
self.state.chat_state.input.reset();
if slash_command_argument(trimmed, "/load").is_none() {
self.open_load_snapshot_dialog();
} else {
match snapshot_name_from_command(trimmed, "/load").and_then(|name| {
let matches = load_snapshot(&name)?;
Ok((name, matches))
}) {
Ok((name, matches)) => {
if matches.len() == 1 {
let (snapshot_key, snapshot, parent_chain) =
matches.into_iter().next().unwrap();
self.load_snapshot_into_state(
&snapshot_key,
&name,
snapshot,
parent_chain,
)
.await;
} else {
let entries: Vec<SessionSnapshotListEntry> = matches
.into_iter()
.map(|(snapshot_key, snapshot, parent_chain)| {
SessionSnapshotListEntry {
kind: snapshot.kind,
name: snapshot.name,
snapshot_key,
saved_at_ms: snapshot.saved_at_ms,
parent_name: parent_chain.last().cloned(),
parent_chain,
depth: 0,
base_manual_name: None,
}
})
.collect();
self.state.session_snapshot_dialog =
Some(SessionSnapshotDialog::manual_only(entries));
self.state.input_mode = InputMode::SessionSnapshotSelection;
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Multiple snapshots named '{}' found. Please select one.",
name
)));
}
}
Err(error) => self
.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Load snapshot failed: {error:#}"
))),
}
}
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/connect") {
self.state.chat_state.input.reset();
self.open_provider_selection_dialog();
return Ok(());
}
if is_named_slash_command(trimmed, "/remote") {
self.handle_remote_command(trimmed).await;
return Ok(());
}
if is_named_slash_command(trimmed, "/sessions") {
self.handle_sessions_command(trimmed).await;
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/sandbox") {
self.state.chat_state.input.reset();
self.open_sandbox_selection_dialog();
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/skills") {
self.state.chat_state.input.reset();
self.state
.chat_state
.messages
.push(crate::chat::Message::system(render_skills_overview(
&self.state.agent_config,
)));
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/mcp") {
self.state.chat_state.input.reset();
self.state
.chat_state
.messages
.push(crate::chat::Message::system(render_mcp_overview(
&self.state.agent_config,
)));
self.state.chat_state.stick_to_bottom = true;
return Ok(());
}
if trimmed.eq_ignore_ascii_case("/cron") {
self.state.chat_state.input.reset();
self.open_cron_dialog();
return Ok(());
}
if first_token_is_dir_command(trimmed) {
match resolve_dir_command(trimmed, &self.state.workspace) {
Ok(path) => {
self.state.workspace = path;
self.state.status_panel.set_workspace(&self.state.workspace);
self.state.sync_diff_tracker_workspace();
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Workspace: {}",
self.state.workspace.display()
)));
self.state.chat_state.stick_to_bottom = true;
self.state.chat_state.input.reset();
}
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(error));
self.state.chat_state.stick_to_bottom = true;
self.state.chat_state.input.reset();
}
}
return Ok(());
}
if let Some((body, command_context)) = self.external_command_body(trimmed) {
self.state.chat_state.input.reset();
if let Err(error) = self
.gateway
.start_turn_for_command(&mut self.state, body, command_context)
.await
{
let display_error = crate::error_log::record_tui_error("start_turn", &error);
self.state
.chat_state
.messages
.push(crate::chat::Message::error(display_error));
self.state.chat_state.stick_to_bottom = true;
}
return Ok(());
}
if let Err(error) = self.gateway.start_turn(&mut self.state, user_input).await {
let display_error = crate::error_log::record_tui_error("start_turn", &error);
self.state
.chat_state
.messages
.push(crate::chat::Message::error(display_error));
self.state.chat_state.stick_to_bottom = true;
}
Ok(())
}
async fn record_submitted_input_history(&mut self, input: &str) {
self.state.chat_state.record_input_history(input);
let history = self.state.chat_state.input_history.clone();
match tokio::task::spawn_blocking(move || save_input_history(&history)).await {
Ok(Ok(())) => {}
Ok(Err(error)) => {
tracing::warn!("failed to persist input history: {error:#}");
}
Err(error) => {
tracing::warn!("input history persistence task failed: {error}");
}
}
}
fn external_command_body(
&self,
trimmed: &str,
) -> Option<(String, agent_types::chat::CommandContext)> {
expand_external_command(trimmed, &self.state.external_commands)
}
async fn handle_remote_command(&mut self, trimmed: &str) {
self.state.chat_state.input.reset();
let arg = slash_command_argument(trimmed, "/remote")
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(arg) = arg else {
self.open_remote_session_dialog();
return;
};
let message = match arg {
"status" => crate::chat::Message::system(self.gateway.remote_status(&self.state).await),
"off" => match self.gateway.disconnect_remote(&mut self.state).await {
Ok(()) => crate::chat::Message::system(format!(
"Remote disconnected. Remote sessions are kept on the daemon. Backend: {}.",
sandbox_display_name(&self.state.agent_config.operation_backend)
)),
Err(error) => {
crate::chat::Message::error(format!("Remote disconnect failed: {error}"))
}
},
"close" => {
let session_id = self.state.session_id.clone();
match self.gateway.close_remote_session(&session_id).await {
Ok(()) => crate::chat::Message::system(format!(
"Remote session closed on daemon: {session_id}"
)),
Err(error) => {
crate::chat::Message::error(format!("Remote session close failed: {error}"))
}
}
}
base_url => {
let base_url = normalize_remote_url_input(base_url);
let token_env = self
.state
.agent_config
.tui
.remote
.as_ref()
.and_then(|remote| remote.bearer_token_env.clone());
match self
.gateway
.connect_remote(&mut self.state, base_url.clone(), token_env)
.await
{
Ok(message) => {
let _ = record_remote_session(
&self.state.session_id,
&base_url,
self.state
.agent_config
.tui
.remote
.as_ref()
.and_then(|remote| remote.bearer_token_env.clone()),
None,
);
crate::chat::Message::system(message)
}
Err(error) => {
crate::chat::Message::error(format!("Remote connect failed: {error}"))
}
}
}
};
self.state.chat_state.messages.push(message);
self.state.chat_state.stick_to_bottom = true;
}
fn open_remote_session_dialog(&mut self) {
match list_remote_sessions() {
Ok(records) => {
self.state.input_mode = InputMode::RemoteSessionSelection;
self.state.remote_session_dialog = Some(RemoteSessionDialog::new(records));
}
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Remote session history failed: {error:#}"
)));
self.state.chat_state.stick_to_bottom = true;
}
}
}
async fn handle_sessions_command(&mut self, trimmed: &str) {
self.state.chat_state.input.reset();
let Some(base_url) = self.gateway.remote_base_url().map(str::to_string) else {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"No remote daemon connected. Use /remote <url> first.".to_string(),
));
self.state.chat_state.stick_to_bottom = true;
return;
};
let normalized = crate::remote_sessions_service::normalize_base_url(&base_url);
if let Some(arg) = slash_command_argument(trimmed, "/sessions")
.map(str::trim)
.filter(|value| !value.is_empty())
{
if arg == self.state.session_id {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Already on session: {arg}"
)));
} else {
let known = match list_remote_sessions() {
Ok(records) => records.iter().any(|record| {
record.session_id == arg
&& crate::remote_sessions_service::normalize_base_url(&record.base_url)
== normalized
}),
Err(_) => false,
};
if !known {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Session {arg} is not recorded for {normalized}. \
Run /sessions without an argument to pick a known session."
)));
} else {
self.switch_to_remote_session(arg.to_string()).await;
}
}
self.state.chat_state.stick_to_bottom = true;
return;
}
let records = match list_remote_sessions() {
Ok(records) => records,
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Remote session history failed: {error:#}"
)));
self.state.chat_state.stick_to_bottom = true;
return;
}
};
let filtered: Vec<RemoteSessionRecord> = records
.into_iter()
.filter(|record| {
crate::remote_sessions_service::normalize_base_url(&record.base_url) == normalized
})
.collect();
if filtered.is_empty() {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"No other sessions recorded for {normalized}. Use /remote to create one."
)));
self.state.chat_state.stick_to_bottom = true;
return;
}
if filtered.len() == 1
&& filtered
.iter()
.any(|record| record.session_id == self.state.session_id)
{
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Only the current session is recorded for {normalized}."
)));
self.state.chat_state.stick_to_bottom = true;
return;
}
self.state.input_mode = InputMode::RemoteSessionSelection;
self.state.remote_session_dialog = Some(RemoteSessionDialog::new_for_switch(
filtered,
Some(self.state.session_id.clone()),
));
}
async fn handle_remote_session_selection_key(&mut self, key: KeyEvent) -> Result<()> {
let Some(mode) = self
.state
.remote_session_dialog
.as_ref()
.map(|dialog| dialog.mode)
else {
self.state.input_mode = InputMode::Editing;
return Ok(());
};
match mode {
RemoteSessionDialogMode::List => self.handle_remote_session_list_key(key).await,
RemoteSessionDialogMode::NewUrl => self.handle_remote_session_new_url_key(key).await,
}
}
async fn handle_remote_session_list_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.state.input_mode = InputMode::Editing;
self.state.remote_session_dialog = None;
}
KeyCode::Up => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.move_up();
}
}
KeyCode::Down => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.move_down();
}
}
KeyCode::Enter => {
let selected = self
.state
.remote_session_dialog
.as_ref()
.and_then(|dialog| dialog.selected_entry().cloned());
match selected {
Some(RemoteSessionDialogEntry::Existing(record)) => {
self.state.input_mode = InputMode::Editing;
self.state.remote_session_dialog = None;
self.activate_remote_session(record).await;
}
Some(RemoteSessionDialogEntry::New) => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.enter_new_url_mode();
}
}
None => {}
}
}
_ => {}
}
Ok(())
}
async fn handle_remote_session_new_url_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.mode = RemoteSessionDialogMode::List;
dialog.error = None;
}
}
KeyCode::Enter => {
let url = self
.state
.remote_session_dialog
.as_ref()
.map(|dialog| dialog.url_input.value().trim().to_string())
.unwrap_or_default();
if url.is_empty() {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.error =
Some("请输入 daemon URL,例如 http://127.0.0.1:8070".to_string());
}
return Ok(());
}
self.create_new_remote_session(normalize_remote_url_input(&url))
.await;
}
_ => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.url_input.handle_event(&Event::Key(key));
dialog.error = None;
}
}
}
Ok(())
}
async fn activate_remote_session(&mut self, record: RemoteSessionRecord) {
if record.session_id == self.state.session_id {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Already on session: {}",
record.session_id
)));
self.state.chat_state.stick_to_bottom = true;
return;
}
let system_message = format!(
"Remote session selected: {} ({})",
record.session_id, record.base_url
);
self.apply_remote_session_switch(
record.session_id.clone(),
record.base_url.clone(),
record.bearer_token_env.clone(),
system_message,
)
.await;
}
async fn create_new_remote_session(&mut self, base_url: String) {
let token_env = self
.state
.agent_config
.tui
.remote
.as_ref()
.and_then(|remote| remote.bearer_token_env.clone());
match self
.gateway
.connect_remote(&mut self.state, base_url.clone(), token_env.clone())
.await
{
Ok(message) => {
self.gateway.reset_for_new_session(&mut self.state);
self.state.reset_for_new_session();
self.gateway
.configure_remote(&mut self.state, base_url.clone(), token_env.clone());
let session_id = self.state.session_id.clone();
let _ = record_remote_session(&session_id, &base_url, token_env, None);
self.state.input_mode = InputMode::Editing;
self.state.remote_session_dialog = None;
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"{message}\nNew remote session: {session_id}"
)));
self.state.chat_state.stick_to_bottom = true;
}
Err(error) => {
if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
dialog.error = Some(format!("Remote connect failed: {error}"));
}
}
}
}
fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<()> {
let mut selection_to_apply = None;
let mut need_api_key_dialog = None;
let mut close_dialog = false;
let mut check_local_fetch = false;
if let Some(dialog) = self.state.provider_dialog.as_mut() {
match key.code {
KeyCode::Esc => {
close_dialog = true;
}
KeyCode::Enter => {
if let Some((provider_name, model_id)) = dialog.selected() {
let (api_key_env, api_base) =
persisted_selection_settings(&self.state.agent_config, &provider_name);
if api_key_env.is_some()
&& api_key_env
.as_deref()
.and_then(|name| std::env::var(name).ok())
.filter(|value| !value.trim().is_empty())
.is_none()
{
need_api_key_dialog = Some(ApiKeyDialogState {
provider: provider_name,
model: model_id,
input: crate::input::Input::default(),
error: None,
show_plaintext: false,
});
} else {
selection_to_apply =
Some((provider_name, model_id, api_key_env, api_base));
}
}
close_dialog = true;
}
KeyCode::Up => {
dialog.move_up();
check_local_fetch = true;
}
KeyCode::Down => {
dialog.move_down();
check_local_fetch = true;
}
KeyCode::Left => dialog.switch_to_providers(),
KeyCode::Right => dialog.switch_to_models(),
KeyCode::Tab => {
if dialog.focus == DialogFocus::Providers {
dialog.switch_to_models();
} else {
dialog.switch_to_providers();
}
}
_ => {}
}
}
if check_local_fetch {
self.attempt_local_model_fetch();
}
if let Some(dialog) = need_api_key_dialog {
self.state.api_key_dialog = Some(dialog);
}
if let Some((provider_name, model_id, api_key_env, api_base)) = selection_to_apply {
persist_active_provider_selection(
&mut self.state,
provider_name,
model_id,
api_key_env,
api_base,
);
}
if close_dialog {
self.state.input_mode = InputMode::Editing;
self.state.provider_dialog = None;
}
Ok(())
}
fn open_load_snapshot_dialog(&mut self) {
match list_session_snapshots() {
Ok(catalog) if catalog.is_empty() => {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"No session snapshots found in ~/.xiaoo/session/.".to_string(),
));
}
Ok(catalog) => {
self.state.input_mode = InputMode::SessionSnapshotSelection;
self.state.session_snapshot_dialog = Some(SessionSnapshotDialog::new(catalog));
}
Err(error) => self
.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Load snapshot failed: {error:#}"
))),
}
self.state.chat_state.stick_to_bottom = true;
}
fn open_sandbox_selection_dialog(&mut self) {
if self.gateway.remote_base_url().is_some() {
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"Remote backend is active. Use /remote off before switching local sandbox."
.to_string(),
));
self.state.chat_state.stick_to_bottom = true;
return;
}
let current = current_sandbox_id(&self.state.agent_config);
self.state.input_mode = InputMode::SandboxSelection;
self.state.sandbox_dialog = Some(SandboxDialog::new(current));
}
fn handle_sandbox_selection_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.state.input_mode = InputMode::Editing;
self.state.sandbox_dialog = None;
}
KeyCode::Up => {
if let Some(dialog) = self.state.sandbox_dialog.as_mut() {
dialog.move_up();
}
}
KeyCode::Down => {
if let Some(dialog) = self.state.sandbox_dialog.as_mut() {
dialog.move_down();
}
}
KeyCode::Enter => {
let selected = self
.state
.sandbox_dialog
.as_ref()
.and_then(|dialog| dialog.selected())
.map(|option| (option.id, option.name));
self.state.input_mode = InputMode::Editing;
self.state.sandbox_dialog = None;
if let Some((id, name)) = selected {
self.state.agent_config.operation_backend =
sandbox_backend_config(id, &self.state.agent_config.operation_backend);
self.state.status_panel.set_backend(sandbox_display_name(
&self.state.agent_config.operation_backend,
));
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Sandbox backend: {name}. Applies to new local sessions. Use /new to start one."
)));
self.state.chat_state.stick_to_bottom = true;
}
}
_ => {}
}
Ok(())
}
async fn handle_session_snapshot_selection_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.state.input_mode = InputMode::Editing;
self.state.session_snapshot_dialog = None;
}
KeyCode::Up => {
if let Some(dialog) = self.state.session_snapshot_dialog.as_mut() {
dialog.move_up();
}
}
KeyCode::Down => {
if let Some(dialog) = self.state.session_snapshot_dialog.as_mut() {
dialog.move_down();
}
}
KeyCode::Tab | KeyCode::BackTab => {
if let Some(dialog) = self.state.session_snapshot_dialog.as_mut() {
dialog.toggle_pane();
}
}
KeyCode::Enter => {
let selected = self
.state
.session_snapshot_dialog
.as_ref()
.and_then(|dialog| dialog.selected_entry())
.map(|entry| entry.snapshot_key.clone());
self.state.input_mode = InputMode::Editing;
self.state.session_snapshot_dialog = None;
if let Some(snapshot_key) = selected {
match load_snapshot_by_key(&snapshot_key) {
Ok((snapshot, parent_chain)) => {
let name = snapshot.name.clone();
self.load_snapshot_into_state(
&snapshot_key,
&name,
snapshot,
parent_chain,
)
.await
}
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Load snapshot failed: {error:#}"
)))
}
}
self.state.chat_state.stick_to_bottom = true;
}
}
_ => {}
}
Ok(())
}
async fn load_snapshot_into_state(
&mut self,
snapshot_key: &str,
name: &str,
snapshot: crate::session_snapshot_service::TuiSessionSnapshot,
parent_chain: Vec<String>,
) {
self.gateway.reset_for_new_session(&mut self.state);
let snapshot_context = crate::session_snapshot_service::SnapshotContext::from_snapshot(
snapshot_key.to_string(),
&snapshot,
);
let record = apply_snapshot(&mut self.state, snapshot);
let chain_display = if parent_chain.is_empty() {
name.to_string()
} else {
format!("{} → {}", parent_chain.join(" → "), name)
};
self.state.current_snapshot_context = Some(snapshot_context);
if let Some(record) = record {
self.gateway.import_session_snapshot(record).await;
}
self.state
.chat_state
.messages
.push(crate::chat::Message::system(format!(
"Session snapshot loaded: {chain_display}"
)));
}
fn open_provider_selection_dialog(&mut self) {
self.state.input_mode = InputMode::ProviderSelection;
self.state.provider_dialog = Some(ProviderDialog::new_with_selection(
self.state.chat_state.available_providers.clone(),
Some(&self.state.agent_config.llm.provider),
Some(&self.state.agent_config.llm.model),
));
self.attempt_local_model_fetch();
}
fn attempt_local_model_fetch(&mut self) {
let should_fetch = self.state.provider_dialog.as_ref().map_or(false, |d| {
!d.local_models_loading
&& d.providers.get(d.selected_provider).map_or(false, |p| {
p.name == "local" && p.models.len() == 1 && p.models[0].name.contains("(Local)")
})
});
if !should_fetch {
return;
}
if let Some(dialog) = self.state.provider_dialog.as_mut() {
dialog.set_local_models_loading();
}
let api_base = crate::provider_service::default_api_base_for_provider("local");
self.start_local_model_fetch(api_base);
}
async fn handle_turn_delete_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
if let Some(dialog) = self.state.delete_dialog.as_mut() {
if dialog.is_selecting() {
self.state.input_mode = InputMode::Editing;
self.state.delete_dialog = None;
} else {
self.state.delete_dialog =
DeleteDialog::new(&self.state.chat_state.messages);
}
} else {
self.state.input_mode = InputMode::Editing;
}
}
KeyCode::Up => {
if let Some(dialog) = self.state.delete_dialog.as_mut() {
dialog.move_up();
}
}
KeyCode::Down => {
if let Some(dialog) = self.state.delete_dialog.as_mut() {
dialog.move_down();
}
}
KeyCode::Enter => {
let action = match self.state.delete_dialog.as_mut() {
Some(dialog) => {
if dialog.is_selecting() {
dialog.advance_to_confirm();
None
} else {
dialog.selected_turn().map(|t| (t.msg_range, t.turn_index))
}
}
None => None,
};
if let Some(((start, end), turn_index)) = action {
self.state.input_mode = InputMode::Editing;
self.state.delete_dialog = None;
{
let session_id = self.state.session_id.clone();
let store = self.gateway.session_store_handle();
if let Some(mut record) = store.load(&session_id).await {
if let Some(ref mut snapshot) = record.loop_state {
crate::services::turn_delete::remove_turn_from_session_messages(
&mut snapshot.messages,
turn_index,
);
}
store.save(record).await;
}
}
crate::services::turn_delete::remove_turn_from_session_messages(
&mut self.state.session_messages,
turn_index,
);
self.state.chat_state.messages.drain(start..end);
self.state.render_state = crate::app_state::RenderState::default();
self.state.chat_state.stick_to_bottom = true;
}
}
_ => {}
}
Ok(())
}
fn open_cron_dialog(&mut self) {
match crate::services::cron_service::load_cron_snapshot(&self.state.config_path) {
Ok(snapshot) => {
let dialog = crate::cron_dialog::CronDialog::new(
snapshot.jobs,
snapshot.jobs_file,
snapshot.default_timeout_secs,
snapshot.cron_section_present,
);
self.state.input_mode = InputMode::CronManagement;
self.state.cron_dialog = Some(dialog);
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"Cron job management opened. Use arrow keys to navigate, 'a' to add, 'e' to edit, 'd' to delete, Space to toggle.".to_string(),
));
self.state.chat_state.stick_to_bottom = true;
}
Err(error) => {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Failed to load cron config: {error:#}"
)));
self.state.chat_state.stick_to_bottom = true;
}
}
}
async fn handle_cron_management_key(&mut self, key: KeyEvent) -> Result<()> {
let Some(dialog) = self.state.cron_dialog.as_mut() else {
self.state.input_mode = InputMode::Editing;
return Ok(());
};
let mode = dialog.mode.clone();
match &mode {
CronDialogMode::List => match key.code {
KeyCode::Esc => {
self.state.input_mode = InputMode::Editing;
self.state.cron_dialog = None;
self.state
.chat_state
.messages
.push(crate::chat::Message::system(
"Cron job management closed. Restart daemon to apply changes."
.to_string(),
));
self.state.chat_state.stick_to_bottom = true;
}
KeyCode::Up => {
dialog.move_up();
}
KeyCode::Down => {
dialog.move_down();
}
KeyCode::Char('a') => {
dialog.start_add();
}
KeyCode::Char('e') => {
dialog.start_edit();
}
KeyCode::Char('d') => {
dialog.start_delete_confirm();
}
KeyCode::Char(' ') => {
if let Err(e) = dialog.toggle_enabled() {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Failed to toggle job: {e}"
)));
self.state.chat_state.stick_to_bottom = true;
}
}
_ => {}
},
CronDialogMode::ConfirmDelete { .. } => match key.code {
KeyCode::Esc => {
dialog.back_to_list();
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Err(e) = dialog.confirm_delete() {
self.state
.chat_state
.messages
.push(crate::chat::Message::error(format!(
"Failed to delete job: {e}"
)));
self.state.chat_state.stick_to_bottom = true;
}
}
KeyCode::Char('n') | KeyCode::Char('N') => {
dialog.back_to_list();
}
_ => {}
},
CronDialogMode::EditForm { .. } => match key.code {
KeyCode::Esc => {
dialog.back_to_list();
}
KeyCode::Tab if key.modifiers.contains(event::KeyModifiers::SHIFT) => {
dialog.edit_prev_field();
}
KeyCode::Tab => {
dialog.edit_next_field();
}
KeyCode::Enter => match dialog.save_edit_form() {
Ok(()) => {}
Err(error) => {
dialog.edit_set_error(error);
}
},
KeyCode::Backspace => {
dialog.edit_backspace();
dialog.edit_clear_error();
}
KeyCode::Char(c) => {
dialog.edit_push_char(c);
dialog.edit_clear_error();
}
_ => {}
},
}
Ok(())
}
}
fn editing_key_inserts_newline(code: KeyCode, modifiers: event::KeyModifiers) -> bool {
match code {
KeyCode::Enter => {
modifiers.intersects(event::KeyModifiers::ALT | event::KeyModifiers::CONTROL)
}
KeyCode::Char('j') | KeyCode::Char('J') => {
modifiers.contains(event::KeyModifiers::CONTROL)
&& !modifiers.contains(event::KeyModifiers::SHIFT)
}
_ => false,
}
}
fn is_leave_subagent_view_key(key: &KeyEvent) -> bool {
key.code == KeyCode::Left && key.modifiers.is_empty()
}
fn is_named_slash_command(trimmed: &str, command: &str) -> bool {
let Some(first) = trimmed.split_whitespace().next() else {
return false;
};
first.eq_ignore_ascii_case(command)
}
fn slash_command_argument<'a>(trimmed: &'a str, command: &str) -> Option<&'a str> {
let first = trimmed.split_whitespace().next()?;
if !first.eq_ignore_ascii_case(command) {
return None;
}
let rest = trimmed[first.len()..].trim();
if rest.is_empty() {
None
} else {
Some(rest)
}
}
fn normalize_remote_url_input(value: &str) -> String {
let trimmed = value.trim().trim_end_matches('/');
if trimmed.contains("://") || trimmed.is_empty() {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
fn expand_external_command(
trimmed: &str,
external: &[crate::services::command_loader::ExternalCommand],
) -> Option<(String, agent_types::chat::CommandContext)> {
let (cmd_name, user_args) = external_command_parts(trimmed)?;
external
.iter()
.find(|cmd| cmd.name.eq_ignore_ascii_case(cmd_name))
.map(|cmd| {
let body = append_external_command_args(&cmd.body, user_args);
(
body,
agent_types::chat::CommandContext {
command: cmd.name.clone(),
arguments: user_args.to_string(),
},
)
})
}
fn external_command_parts(trimmed: &str) -> Option<(&str, &str)> {
let first = trimmed.split_whitespace().next()?;
let cmd_name = first.strip_prefix('/')?;
if cmd_name.is_empty() {
return None;
}
let user_args = trimmed[first.len()..].trim();
Some((cmd_name, user_args))
}
fn append_external_command_args(body: &str, user_args: &str) -> String {
if user_args.is_empty() {
return body.to_string();
}
if body.is_empty() {
return user_args.to_string();
}
format!("{body}\n\n{user_args}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::command_loader::ExternalCommand;
fn external_commands() -> Vec<ExternalCommand> {
vec![ExternalCommand {
name: "review".to_string(),
description: "Review code".to_string(),
body: "Review this carefully.".to_string(),
}]
}
#[test]
fn editing_newline_key_plain_enter_is_not_newline() {
assert!(!editing_key_inserts_newline(
KeyCode::Enter,
event::KeyModifiers::empty()
));
}
#[test]
fn editing_newline_key_alt_enter_inserts_newline() {
assert!(editing_key_inserts_newline(
KeyCode::Enter,
event::KeyModifiers::ALT
));
}
#[test]
fn editing_newline_key_ctrl_enter_inserts_newline() {
assert!(editing_key_inserts_newline(
KeyCode::Enter,
event::KeyModifiers::CONTROL
));
}
#[test]
fn editing_newline_key_ctrl_j_inserts_newline() {
assert!(editing_key_inserts_newline(
KeyCode::Char('j'),
event::KeyModifiers::CONTROL
));
}
#[test]
fn editing_newline_key_ctrl_uppercase_j_inserts_newline() {
assert!(editing_key_inserts_newline(
KeyCode::Char('J'),
event::KeyModifiers::CONTROL
));
}
#[test]
fn editing_newline_key_ctrl_shift_j_does_not_insert_newline() {
assert!(!editing_key_inserts_newline(
KeyCode::Char('j'),
event::KeyModifiers::CONTROL | event::KeyModifiers::SHIFT
));
}
#[test]
fn editing_newline_key_plain_j_is_not_newline() {
assert!(!editing_key_inserts_newline(
KeyCode::Char('j'),
event::KeyModifiers::empty()
));
}
#[test]
fn editing_newline_key_alt_j_is_not_newline() {
assert!(!editing_key_inserts_newline(
KeyCode::Char('j'),
event::KeyModifiers::ALT
));
}
#[test]
fn plain_left_leaves_subagent_view() {
assert!(is_leave_subagent_view_key(&KeyEvent::new(
KeyCode::Left,
event::KeyModifiers::empty()
)));
}
#[test]
fn modified_left_does_not_leave_subagent_view() {
assert!(!is_leave_subagent_view_key(&KeyEvent::new(
KeyCode::Left,
event::KeyModifiers::SHIFT
)));
}
#[test]
fn former_shift_up_shortcut_does_not_leave_subagent_view() {
assert!(!is_leave_subagent_view_key(&KeyEvent::new(
KeyCode::Up,
event::KeyModifiers::SHIFT
)));
}
#[test]
fn external_command_exact_match_expands_to_body() {
assert_eq!(
expand_external_command("/review", &external_commands()),
Some((
"Review this carefully.".to_string(),
agent_types::chat::CommandContext {
command: "review".to_string(),
arguments: "".to_string()
}
))
);
}
#[test]
fn external_command_appends_user_input_after_command_token() {
assert_eq!(
expand_external_command("/review src/main.rs 看一下边界条件", &external_commands()),
Some((
"Review this carefully.\n\nsrc/main.rs 看一下边界条件".to_string(),
agent_types::chat::CommandContext {
command: "review".to_string(),
arguments: "src/main.rs 看一下边界条件".to_string()
}
))
);
}
#[test]
fn external_command_match_only_uses_first_token() {
assert_eq!(
expand_external_command("/review-extra input", &external_commands()),
None
);
assert_eq!(
expand_external_command("/reviewer input", &external_commands()),
None
);
}
#[test]
fn external_command_with_empty_body_uses_user_input() {
let commands = vec![ExternalCommand {
name: "ask".to_string(),
description: String::new(),
body: String::new(),
}];
assert_eq!(
expand_external_command("/ask hello", &commands),
Some((
"hello".to_string(),
agent_types::chat::CommandContext {
command: "ask".to_string(),
arguments: "hello".to_string()
}
))
);
}
}