use std::path::PathBuf;
use anyhow::Result;
use crossterm::event::EventStream;
use futures::StreamExt;
use tokio::time::{Duration, interval};
use crate::app::Config;
use crate::app::event_source::event_to_msg;
use crate::app::lifecycle::RuntimeLifecycle;
use crate::app::recorder::{Recorder, record_msg_body};
use crate::app::terminal::TerminalGuard;
use crate::domain::{Cmd, Msg, RuntimeSignal, State, update};
use crate::effect::EffectRunner;
use crate::providers::ToolRegistry;
use crate::render::{RenderCache, render};
use crate::session::ConversationHistory;
#[derive(Default)]
pub struct InteractiveOptions {
pub recorder: Option<Recorder>,
pub seed_conversation: Option<ConversationHistory>,
}
pub async fn run_interactive(
config: Config,
cwd: PathBuf,
model_id: String,
recorder: Option<Recorder>,
) -> Result<()> {
run_interactive_with(
config,
cwd,
model_id,
InteractiveOptions {
recorder,
seed_conversation: None,
},
)
.await
}
pub async fn run_interactive_with(
config: Config,
cwd: PathBuf,
model_id: String,
mut opts: InteractiveOptions,
) -> Result<()> {
let mut state = State::new(config.clone(), cwd.clone(), model_id);
if let Some(history) = opts.seed_conversation.take() {
let title = history.title.clone();
state.session.conversation = history;
state.ui.last_title_dispatched = Some(title);
}
let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
let tools = ToolRegistry::build(
&config,
crate::providers::TuiMode::Interactive,
providers.clone(),
);
let (mut runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
let mut terminal = Some(TerminalGuard::setup()?);
let mut rstate = RenderCache::new();
let mut events = EventStream::new();
let mut lifecycle = RuntimeLifecycle::new();
let mut tick = interval(Duration::from_millis(16));
let mut recorder = opts.recorder;
for cmd in bootstrap_cmds(&config) {
runner.dispatch(cmd);
}
loop {
terminal
.as_mut()
.expect("terminal guard is alive while the render loop runs")
.inner_mut()
.draw(|f| render(&state, &mut rstate, f))?;
let msg = tokio::select! {
biased;
m = msg_rx.recv() => m,
e = events.next() => match e {
Some(Ok(evt)) => {
if let crossterm::event::Event::Mouse(m) = &evt
&& matches!(m.kind, crossterm::event::MouseEventKind::Down(_))
&& m.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& let Some(target) = rstate.chat.find_image_at_screen_pos(m.row)
{
Some(Msg::OpenImageAt {
message_index: target.message_index,
image_index: target.image_index,
})
} else {
event_to_msg(evt)
}
},
Some(Err(error)) => {
tracing::warn!(error = %error, "terminal event stream failed");
None
},
None => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
},
s = lifecycle.next_msg() => s,
_ = tick.tick() => Some(Msg::Tick),
};
let Some(msg) = msg else { continue };
if let Some(r) = recorder.as_mut() {
let body = record_msg_body(&msg);
let _ = r.record_kind(msg.kind(), msg.turn_id(), body);
}
let (new_state, cmds) = update(state, msg);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
if state.should_exit {
break;
}
}
drop(events);
if let Some(mut terminal) = terminal.take() {
terminal.restore_now();
}
runner.shutdown().await;
Ok(())
}
fn bootstrap_cmds(config: &Config) -> Vec<Cmd> {
let mut cmds = vec![Cmd::RefreshInstructions];
if !config.mcp_servers.is_empty() {
cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
}
cmds
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bootstrap_includes_refresh_instructions() {
let cmds = bootstrap_cmds(&Config::default());
assert!(cmds.iter().any(|c| matches!(c, Cmd::RefreshInstructions)));
}
#[test]
fn bootstrap_skips_mcp_init_when_no_servers_configured() {
let cmds = bootstrap_cmds(&Config::default());
assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
}
#[test]
fn bootstrap_includes_mcp_init_when_servers_configured() {
let mut cfg = Config::default();
cfg.mcp_servers.insert(
"example".to_string(),
crate::app::McpServerConfig {
command: "echo".to_string(),
args: vec![],
env: std::collections::HashMap::new(),
},
);
let cmds = bootstrap_cmds(&cfg);
assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
}
}