use anyhow::Result;
use crossterm::event::{
Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use futures_util::StreamExt;
use ratatui::DefaultTerminal;
use ratatui::layout::{Position, Rect};
use tui_textarea::CursorMove;
use crate::app::{App, AppEvent, ModelPanel, MouseTarget, Popup};
use crate::ui;
fn edit_file_target(app: &App, key: &KeyEvent) -> Option<std::path::PathBuf> {
if !(app.popup == Popup::Space
&& app.space_mode == crate::app::SpaceMode::Browse
&& key.modifiers.contains(KeyModifiers::CONTROL))
{
return None;
}
match key.code {
KeyCode::Char('e') => app.instructions_path_for_selected(),
KeyCode::Char('k') => app.memory_path_for_selected(),
_ => None,
}
}
fn skill_edit_target(app: &App, key: &KeyEvent) -> Option<std::path::PathBuf> {
if !(app.popup == Popup::Skills
&& app.skills_mode == crate::app::SkillsMode::Browse
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('e'))
{
return None;
}
app.skill_edit_path_for_selected()
}
fn system_prompt_edit_target(app: &App, key: &KeyEvent) -> Option<std::path::PathBuf> {
if !(app.popup == Popup::Settings
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('e'))
{
return None;
}
crate::config::system_prompt_path().ok()
}
pub async fn run(mut app: App, terminal: &mut DefaultTerminal) -> Result<()> {
let result = run_loop(&mut app, terminal).await;
app.cancel_chat_tasks();
result
}
#[allow(clippy::too_many_lines)]
async fn run_loop(app: &mut App, terminal: &mut DefaultTerminal) -> Result<()> {
let mut reader = EventStream::new();
let mut theme_poll = tokio::time::interval(std::time::Duration::from_secs(2));
loop {
terminal.draw(|f| ui::render(f, app))?;
if app.should_quit {
break;
}
let streaming = app.is_streaming();
let long_deadline = app.sel.deadline();
let welcome = app.is_welcome();
tokio::select! {
maybe = reader.next() => match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
if let Some(path) = edit_file_target(app, &k) {
edit_in_external_editor(terminal, &path)?;
} else if let Some(path) = skill_edit_target(app, &k) {
edit_in_external_editor(terminal, &path)?;
app.reload_skills();
} else if let Some(path) = system_prompt_edit_target(app, &k) {
edit_in_external_editor(terminal, &path)?;
app.reload_base_system_prompt();
} else if app.popup == Popup::Context && k.code == KeyCode::Char('v') {
match app.compact_summary_path() {
Some(path) => {
edit_in_external_editor(terminal, &path)?;
app.reload_compact_summary(&path)?;
}
None => app.status = "session hasn't been compacted yet".to_string(),
}
} else {
handle_key(app, k)?;
if let Some(edit) = app.pending_editor.take() {
match edit {
crate::app::PendingEditor::AppFile(path) => {
if let Err(e) = edit_in_external_editor(terminal, &path) {
app.status = format!("editor failed: {e}");
}
}
crate::app::PendingEditor::Persona(path) => {
match edit_in_external_editor(terminal, &path) {
Ok(()) => app.apply_swarm_persona_editor(&path)?,
Err(e) => app.status = format!("editor failed: {e}"),
}
}
crate::app::PendingEditor::ScriptFile(path) => {
if let Err(e) = edit_in_external_editor(terminal, &path) {
app.status = format!("editor failed: {e}");
}
app.refresh_scripts();
}
}
}
}
}
Some(Ok(Event::Mouse(m))) => {
let size = terminal.size()?;
handle_mouse(app, m, Rect::new(0, 0, size.width, size.height))?;
}
Some(Ok(Event::Paste(text))) => {
app.paste(&text);
}
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => break,
},
event = app.next_event() => match event {
AppEvent::Stream(Some((task_id, e))) => app.on_chat_event(task_id, e)?,
AppEvent::Stream(None) => {}
AppEvent::Models(r) => app.on_models_result(r),
AppEvent::Title(t) => app.on_title_result(t),
AppEvent::Memory(m) => app.on_memory_result(m),
AppEvent::Compact(c) => app.on_compact_result(c),
AppEvent::SkillInstall(r) => app.on_skill_install_result(r),
AppEvent::Ocr(r) => app.on_ocr_done(r),
AppEvent::Embed(r) => app.on_embed_done(r),
AppEvent::OcrPull(r) => app.on_ocr_pull(r),
AppEvent::Research(r) => app.on_research_done(r),
AppEvent::ResearchTopic(r) => app.on_research_topic_derived(r),
AppEvent::Login(r) => app.on_login_result(r),
AppEvent::Swarm(r) => app.on_swarm_update(r),
},
() = async {
if streaming {
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
} else {
std::future::pending::<()>().await;
}
} => app.tick_spinner(),
() = async {
match long_deadline {
Some(d) => tokio::time::sleep(d.saturating_duration_since(std::time::Instant::now())).await,
None => std::future::pending::<()>().await,
}
} => {
match app.sel.check_long_press() {
Some(crate::selection::LongPress::Code(text)) => app.copy_text(&text),
Some(crate::selection::LongPress::Message(idx)) => app.copy_message(idx),
Some(crate::selection::LongPress::Url(url)) => app.copy_text(&url),
None => {}
}
}
() = async {
if welcome {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} else {
std::future::pending::<()>().await;
}
} => {}
_ = theme_poll.tick() => {
let target = crate::theme::current_link_target();
if target != app.theme_link {
app.theme = crate::theme::load();
app.theme_link = target;
app.theme_gen = app.theme_gen.wrapping_add(1);
}
}
}
}
Ok(())
}
fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SHIFT)
&& key.code == KeyCode::Char('c')
{
app.should_quit = true;
return Ok(());
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('v') {
app.paste_from_clipboard();
return Ok(());
}
match app.popup {
Popup::Model => ui::popups::model::handle_key(app, key)?,
Popup::Session => crate::ui::popups::session::handle_key(app, key)?,
Popup::Key => crate::ui::popups::key::handle_key(app, key),
Popup::Settings => ui::popups::settings::handle_key(app, key)?,
Popup::Copy => crate::ui::popups::copy::handle_key(app, key),
Popup::Space => crate::ui::popups::space::handle_key(app, key)?,
Popup::Context => crate::ui::popups::context::handle_key(app, key),
Popup::Skills => crate::ui::popups::skills::handle_key(app, key),
Popup::Files => ui::popups::files::handle_key(app, key)?,
Popup::Apps => ui::popups::apps::handle_key(app, key)?,
Popup::Watch => ui::popups::watches::handle_key(app, key)?,
Popup::ResearchLive => {
ui::popups::research_live::handle_key(app, key);
}
Popup::Swarm => ui::popups::swarm::handle_key(app, key)?,
Popup::Usage => {
ui::popups::usage::handle_key(app, key);
}
Popup::Login => {
ui::popups::login::handle_key(app, key);
}
Popup::None => handle_normal(app, key)?,
}
Ok(())
}
fn edit_in_external_editor(terminal: &mut DefaultTerminal, path: &std::path::Path) -> Result<()> {
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste
);
ratatui::restore();
let editor_raw = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let mut parts = editor_raw.split_whitespace();
let editor = parts.next().unwrap_or("vi");
let status = std::process::Command::new(editor)
.args(parts)
.arg(path)
.status();
*terminal = ratatui::init();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::EnableMouseCapture,
crossterm::event::EnableBracketedPaste
);
terminal.clear()?;
match status {
Ok(code) if code.success() => Ok(()),
Ok(code) => {
Err(anyhow::anyhow!(
"editor exited with code {}",
code.code().unwrap_or(-1)
))
}
Err(e) => Err(anyhow::anyhow!("could not launch editor: {e}")),
}
}
#[allow(clippy::too_many_lines)]
fn handle_normal(app: &mut App, key: KeyEvent) -> Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if !app.command_matches().is_empty() {
match key.code {
KeyCode::Up => {
app.move_command_selection(-1);
return Ok(());
}
KeyCode::Down => {
app.move_command_selection(1);
return Ok(());
}
KeyCode::Tab => {
app.accept_command(false)?;
return Ok(());
}
KeyCode::Enter => {
app.accept_command(true)?;
return Ok(());
}
KeyCode::Esc => {
app.set_input("");
return Ok(());
}
_ => {}
}
}
if app.at_state.is_some() {
match key.code {
KeyCode::Up => {
app.move_at_selection(-1);
return Ok(());
}
KeyCode::Down => {
app.move_at_selection(1);
return Ok(());
}
KeyCode::Tab | KeyCode::Enter => {
app.accept_at_match();
return Ok(());
}
KeyCode::Esc => {
app.at_state = None;
return Ok(());
}
_ => {}
}
}
match key.code {
KeyCode::Enter if shift || ctrl => app.input.insert_newline(),
KeyCode::Enter if app.survey_gate_targets_current_session() => {
let text = app.input_text();
app.set_input("");
app.reply_to_survey_gate(&text);
}
KeyCode::Enter => app.submit()?,
KeyCode::Char('a') if ctrl => app.input.select_all(),
KeyCode::Char('c' | 'C') if ctrl && shift => app.copy_selection(),
KeyCode::Char('x') if ctrl => app.cut_selection(),
KeyCode::Char('r') if ctrl => app.toggle_reasoning_view()?,
KeyCode::Char('t') if ctrl => {
app.show_tool_detail = !app.show_tool_detail;
app.pin_viewport_top = true;
}
KeyCode::Char('n') if ctrl => app.toggle_incognito()?,
KeyCode::Char('o') if ctrl && app.sel.selected_text().is_some() => {
app.open_session_link();
}
KeyCode::Char('p') if !ctrl && !shift && app.sel.selected_text().is_some() => {
app.flag_source_under_selection(Some("pinned"));
}
KeyCode::Char('x') if !ctrl && !shift && app.sel.selected_text().is_some() => {
app.flag_source_under_selection(Some("discarded"));
}
KeyCode::Up if ctrl && app.research_rx.is_some() => app.open_research_live(),
KeyCode::Char('g') if ctrl => app.popup = Popup::Context,
KeyCode::Backspace if ctrl => {
app.input.delete_word();
app.refresh_at_matches();
return Ok(());
}
KeyCode::Up if !shift => {
let before = app.input.cursor();
app.input.move_cursor(CursorMove::Up);
if app.input.cursor() == before {
app.scroll = app.scroll.saturating_add(1).min(app.max_scroll);
}
app.refresh_at_matches();
return Ok(());
}
KeyCode::Down if !shift => {
let before = app.input.cursor();
app.input.move_cursor(CursorMove::Down);
if app.input.cursor() == before {
app.scroll = app.scroll.saturating_sub(1);
}
app.refresh_at_matches();
return Ok(());
}
KeyCode::PageUp => app.scroll = app.scroll.saturating_add(10).min(app.max_scroll),
KeyCode::PageDown => app.scroll = app.scroll.saturating_sub(10),
KeyCode::Esc if app.viewing_stream() => app.stop_stream()?,
KeyCode::Esc => {
app.set_input("");
}
_ => {
app.input.input(key);
if app.input.is_selecting() {
app.copy_selection_live();
}
}
}
app.refresh_at_matches();
Ok(())
}
fn handle_input_mouse(app: &mut App, m: MouseEvent) {
let over_input = app.input_inner.contains(Position::new(m.column, m.row));
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
if over_input {
app.mouse_target = MouseTarget::Input;
app.sel.clear();
let count = app.composer_click_down((m.column, m.row));
composer_jump(app, m);
match count {
2 => app.select_composer_word(),
n if n >= 3 => app.select_composer_line(),
_ => {
app.input.cancel_selection();
app.composer_word_anchor = None;
}
}
} else if let Some(p) = app.sel.pos_at(m.column, m.row) {
app.mouse_target = MouseTarget::History;
app.sel.on_down(p);
} else {
app.mouse_target = MouseTarget::None;
}
}
MouseEventKind::Drag(MouseButton::Left) => match app.mouse_target {
MouseTarget::Input => match app.composer_click_count {
2 => {
composer_jump(app, m);
app.extend_composer_word_selection();
}
n if n >= 3 => {
composer_jump(app, m);
app.extend_composer_line_selection();
}
_ => {
if !app.input.is_selecting() {
app.input.start_selection();
}
composer_jump(app, m);
}
},
MouseTarget::History => {
if let Some(p) = app.sel.pos_at(m.column, m.row) {
app.sel.on_drag(p);
}
}
MouseTarget::None => {}
},
MouseEventKind::Up(MouseButton::Left) => {
match app.mouse_target {
MouseTarget::History => {
let p = app.sel.pos_at(m.column, m.row);
let was_image = p.is_some_and(|p| app.open_image_at_line(p.0));
match app.sel.on_up(p) {
Some(crate::selection::Action::Copy(text)) => app.copy_text(&text),
Some(crate::selection::Action::OpenUrl(url)) => {
let _ = open::that_detached(&url);
app.status = format!("opened {url}");
}
None if !was_image && p.is_some() => {
}
None => {}
}
}
MouseTarget::Input if app.input.is_selecting() => app.copy_selection(),
MouseTarget::Input | MouseTarget::None => {}
}
app.mouse_target = MouseTarget::None;
}
MouseEventKind::ScrollUp => {
app.scroll = app.scroll.saturating_add(3).min(app.max_scroll);
}
MouseEventKind::ScrollDown => app.scroll = app.scroll.saturating_sub(3),
_ => {}
}
}
fn composer_jump(app: &mut App, m: MouseEvent) {
let row = m.row.saturating_sub(app.input_inner.y);
let col = m.column.saturating_sub(app.input_inner.x);
app.input.move_cursor(CursorMove::Jump(row, col));
}
fn handle_mouse(app: &mut App, m: MouseEvent, screen: Rect) -> Result<()> {
if app.popup == Popup::None {
if m.kind == MouseEventKind::Down(MouseButton::Left) {
let pos = Position::new(m.column, m.row);
if let Some((_, index)) = app
.notification_areas
.iter()
.find(|(area, _)| area.contains(pos))
.copied()
{
app.activate_notification(index)?;
return Ok(());
}
}
handle_input_mouse(app, m);
return Ok(());
}
if app.popup != Popup::Model {
return Ok(());
}
let (fav_outer, avail_outer) = ui::popups::model::model_popup_areas(screen);
let fav_inner = ui::popups::model::list_inner(fav_outer);
let avail_inner = ui::popups::model::list_inner(avail_outer);
let pos = Position::new(m.column, m.row);
let panel = if fav_inner.contains(pos) {
Some((ModelPanel::Favorites, fav_inner, app.fav_state.offset()))
} else if avail_inner.contains(pos) {
Some((ModelPanel::Available, avail_inner, app.avail_state.offset()))
} else {
None
};
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some((p, inner, offset)) = panel {
let index = offset + (m.row - inner.y) as usize;
app.pick_model_at(p, index)?; }
}
MouseEventKind::ScrollDown => {
if let Some((p, ..)) = panel {
app.model_focus = p;
app.move_model_selection(1);
}
}
MouseEventKind::ScrollUp => {
if let Some((p, ..)) = panel {
app.model_focus = p;
app.move_model_selection(-1);
}
}
_ => {}
}
Ok(())
}