use anyhow::{Context, Result};
use futures::StreamExt;
use agent_client_protocol::schema::v1::SessionUpdate;
use bitrouter_sdk::acp::client::AcpClient;
use crate::chat::effects::Wire;
static SESSION_LOG: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub fn remember_session_log(path: std::path::PathBuf) {
let _ = SESSION_LOG.set(path);
}
fn write_session_log_tail(out: &mut impl std::io::Write) -> Result<()> {
let Some(path) = SESSION_LOG.get() else {
return Ok(());
};
let log = match std::fs::read_to_string(path) {
Ok(log) => log,
Err(e) => format!("(could not read the session log: {e})"),
};
bitrouter_tui::plain::write(
out,
&bitrouter_tui::log_tail::render(path, &log, bitrouter_tui::log_tail::TAIL_LINES),
)
.context("writing the session log tail")
}
pub(crate) fn report_failed_launch() {
let _ = write_session_log_tail(&mut std::io::stderr());
}
pub(crate) async fn run(
session: &mut crate::acp_cli::ControlledSession,
session_id: &str,
agent_id: &str,
recorder: Option<std::sync::Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
via: Option<String>,
surface: crate::actions::session::SessionSurface<'_>,
) -> Result<()> {
let (mut view, mut stdin) = match open_terminal(via) {
Ok(terminal) => terminal,
Err(error) => {
session.shutdown().await;
return Err(error);
}
};
let outcome = drive(
&mut view,
&mut stdin,
&session.client,
session_id,
agent_id,
recorder,
surface,
)
.await;
let clean = session.shutdown().await;
let finished = view.finish().context("closing the view");
drop(stdin);
let tailed = if !clean || matches!(outcome, Ok(true) | Err(_)) {
write_session_log_tail(&mut std::io::stdout())
} else {
Ok(())
};
outcome?;
finished?;
tailed?;
if clean {
Ok(())
} else {
Err(anyhow::anyhow!(
"shutting down chat session: teardown did not confirm; see the session log"
))
}
}
fn open_terminal(
via: Option<String>,
) -> Result<(bitrouter_tui::view::View, crate::chat::input::Stdin)> {
let view = bitrouter_tui::view::View::open(via).context("opening the view")?;
let stdin = crate::chat::input::Stdin::open().context("taking the terminal for input")?;
Ok((view, stdin))
}
async fn in_flight<F: std::future::Future + Unpin>(slot: &mut Option<F>) -> F::Output {
match slot {
Some(turn) => turn.await,
None => std::future::pending().await,
}
}
async fn drive(
view: &mut bitrouter_tui::view::View,
stdin: &mut crate::chat::input::Stdin,
client: &AcpClient,
session_id: &str,
agent_id: &str,
recorder: Option<std::sync::Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
surface: crate::actions::session::SessionSurface<'_>,
) -> Result<bool> {
use agent_client_protocol::schema::v1::{
ContentBlock, PromptRequest, PromptResponse, SessionId, TextContent,
};
use bitrouter_tui::machine::{Action, Effect, Notice, State, step};
use bitrouter_tui::writer::{Schedule, Trigger};
bitrouter_tui::lifecycle::install_panic_restore();
let mut shutdown = crate::chat::signals::Shutdown::install();
let mut permissions = client.subscribe_permissions();
let mut updates = client.subscribe_raw_updates();
let shared = std::sync::Arc::new(std::sync::Mutex::new(
bitrouter_tui::journal::Journal::default(),
));
let (dirty_tx, mut dirty_rx) = tokio::sync::mpsc::unbounded_channel();
let pump_journal = std::sync::Arc::clone(&shared);
let pump = tokio::spawn(async move {
while let Some(update) = updates.next().await {
bitrouter_tui::view::lock(&pump_journal).apply(update);
if dirty_tx.send(()).is_err() {
return;
}
}
});
let crate::actions::session::SessionSurface {
commands,
prompt_commands,
ports,
} = surface;
let mut state = State::new(commands);
state.prompt_commands = prompt_commands;
let mut schedule = Schedule::default();
let mut ticker = tokio::time::interval(Schedule::INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut wire = Wire::new(client, session_id);
let mut turn: Option<
std::pin::Pin<Box<dyn std::future::Future<Output = Result<PromptResponse>> + '_>>,
> = None;
let mut started = std::time::Instant::now();
let mut abnormal = false;
let mut exit = false;
view.set_input("");
view.paint(&shared).context("painting a frame")?;
while !exit {
let action = match wire.reply() {
Some(action) => action,
None => tokio::select! {
() = shutdown.recv() => Action::Signal,
event = stdin.next_event() => match event {
Some(event) => Action::Key(event),
None => Action::InputClosed,
},
Some(()) = dirty_rx.recv() => Action::Dirty,
Some(request) = permissions.next() => Action::Permission(wire.admit(request)),
result = in_flight(&mut turn) => match result {
Ok(response) => {
crate::acp_cli::report_turn(
client,
agent_id,
recorder.as_ref(),
&response,
started.elapsed(),
);
Action::TurnEnded(Ok(response.stop_reason))
}
Err(error) => {
abnormal = true;
Action::TurnEnded(Err(format!("{error}")))
}
},
_ = ticker.tick(), if state.streaming() => Action::Tick,
},
};
if matches!(action, Action::TurnEnded(_)) {
turn = None;
}
for effect in step(&mut state, action) {
if matches!(effect, Effect::Cancel) {
turn = None;
}
let Some(effect) = wire.apply(effect).await else {
continue;
};
match effect {
Effect::Paint(trigger) => {
if schedule.wake(trigger, std::time::Instant::now()) {
view.paint(&shared).context("painting a frame")?;
}
}
Effect::Redraw => {
view.redraw(&shared).context("repainting")?;
schedule.wake(Trigger::Key, std::time::Instant::now());
}
Effect::Echo => view.set_input(state.editor.line()),
Effect::Notice(Notice::Say(text)) => view.notice(text),
Effect::Notice(Notice::Commands) => {
let report = {
let journal = bitrouter_tui::view::lock(&shared);
crate::actions::commands::commands_report(
&state.commands,
&state.prompt_commands,
journal.commands(),
journal.commands_received(),
)
};
let rendered = crate::output::Output::new(crate::output::Format::Human)
.render_to_vec(&report);
view.notice_lines(bitrouter_tui::render::session::plain_lines(&rendered));
}
Effect::Action { action, args } => {
let rendered = match ports.run(action, &args).await {
Ok(report) => crate::output::Output::new(crate::output::Format::Human)
.render_to_vec(report.as_ref()),
Err(error) => format!("{error}").into_bytes(),
};
view.notice_lines(bitrouter_tui::render::session::plain_lines(&rendered));
}
Effect::ClearNotice => view.clear_notice(),
Effect::Modal(Some(row)) => view.open_modal(row),
Effect::Modal(None) => view.close_modal(),
Effect::ShowPermission(prompt) => view.set_permission(prompt),
Effect::Prompt { line, nth } => {
bitrouter_tui::view::lock(&shared)
.apply(SessionUpdate::UserMessageChunk(prompt_chunk(&line, nth)));
started = std::time::Instant::now();
turn = Some(Box::pin(client.prompt_typed(PromptRequest::new(
SessionId::new(session_id),
vec![ContentBlock::Text(TextContent::new(line))],
))));
}
Effect::RouteInForce(route) => view.set_route(route),
Effect::Exit => exit = true,
Effect::Resolve { .. }
| Effect::Cancel
| Effect::ListRoutes
| Effect::SetRoute(_)
| Effect::ResetRoute => {}
}
}
}
pump.abort();
Ok(abnormal)
}
fn prompt_chunk(line: &str, nth: usize) -> agent_client_protocol::schema::v1::ContentChunk {
use agent_client_protocol::schema::v1::{ContentBlock, ContentChunk, MessageId, TextContent};
let mut chunk = ContentChunk::new(ContentBlock::Text(TextContent::new(line.to_string())));
chunk.message_id = Some(MessageId::from(format!("chat:prompt:{nth}")));
chunk
}
pub(crate) async fn chat_plain(
client: &AcpClient,
session_id: &str,
agent_id: &str,
recorder: Option<std::sync::Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
surface: crate::actions::session::SessionSurface<'_>,
) -> Result<()> {
use std::io::Write as _;
use futures::FutureExt as _;
use tokio::io::AsyncBufReadExt as _;
let crate::actions::session::SessionSurface {
commands,
prompt_commands,
ports,
} = surface;
let mut out = std::io::stdout();
let mut transcript = bitrouter_tui::plain::Transcript::default();
let policy = bitrouter_tui::permission::Policy::default();
let mut wire = Wire::new(client, session_id);
let mut prompts = 0_usize;
let mut updates = client.subscribe_raw_updates();
let mut permissions = client.subscribe_permissions();
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
loop {
let Some(line) = lines.next_line().await.context("reading stdin")? else {
break Ok(());
};
if line.trim().is_empty() {
continue;
}
let line = match bitrouter_tui::machine::resolve(&commands, &prompt_commands, &line) {
bitrouter_tui::machine::Resolution::Owned { action, .. } => {
let name = commands
.iter()
.find(|command| command.action == action)
.map_or(action, |command| command.name);
writeln!(out, "/{name} needs a terminal").context("writing to stdout")?;
continue;
}
bitrouter_tui::machine::Resolution::Action { action, args } => {
match ports.run(action, &args).await {
Ok(report) => out
.write_all(
&crate::output::Output::new(crate::output::Format::Human)
.render_to_vec(report.as_ref()),
)
.context("writing to stdout")?,
Err(error) => writeln!(out, "{error}").context("writing to stdout")?,
}
continue;
}
bitrouter_tui::machine::Resolution::Unavailable(reason) => {
writeln!(out, "{reason}").context("writing to stdout")?;
continue;
}
bitrouter_tui::machine::Resolution::Expand(prompt)
| bitrouter_tui::machine::Resolution::Prompt(prompt) => prompt,
};
prompts = prompts.saturating_add(1);
transcript.apply(SessionUpdate::UserMessageChunk(prompt_chunk(
&line, prompts,
)));
let started = std::time::Instant::now();
let turn = client.prompt(session_id, &line);
tokio::pin!(turn);
let outcome = loop {
tokio::select! {
update = updates.next() => if let Some(update) = update {
transcript.apply(update);
},
request = permissions.next() => if let Some(request) = request {
let (decision, prompt) = wire.answer(request, &policy).await;
writeln!(out, " permission {decision}: {}", prompt.title())
.context("writing the permission decision")?;
},
result = &mut turn => break result,
}
};
while let Some(Some(update)) = updates.next().now_or_never() {
transcript.apply(update);
}
bitrouter_tui::plain::write(&mut out, &transcript.unwritten())
.context("writing the session to stdout")?;
match outcome {
Ok(response) => {
crate::acp_cli::report_turn(
client,
agent_id,
recorder.as_ref(),
&response,
started.elapsed(),
);
writeln!(out, "[{:?}]", response.stop_reason).context("writing the stop reason")?
}
Err(e) => {
writeln!(out, "turn failed: {e}").context("writing the failure")?;
write_session_log_tail(&mut out)?;
}
}
}
}