use crate::sink::UiEvent;
use crate::tui_context::TuiContext;
use crate::tui_types::TuiState;
use crate::tui_viewport::draw_viewport;
use futures_util::StreamExt;
use koda_core::engine::{EngineCommand, EngineEvent};
use koda_core::trust;
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use tokio::sync::mpsc;
enum SelectArm {
Crossterm(crossterm::event::Event),
Ui(UiEvent),
Draw,
TurnDone(anyhow::Result<()>),
}
fn drain_bounded<T, F: FnMut(T)>(
rx: &mut mpsc::UnboundedReceiver<T>,
cap: usize,
mut on_event: F,
) -> usize {
let mut n = 0;
for _ in 0..cap {
match rx.try_recv() {
Ok(item) => {
on_event(item);
n += 1;
}
Err(_) => break,
}
}
n
}
impl TuiContext {
pub(crate) async fn run_inference_turn(
&mut self,
pending_images: Option<Vec<koda_core::providers::ImageData>>,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
ui_rx: &mut mpsc::UnboundedReceiver<UiEvent>,
cmd_tx: &mpsc::Sender<EngineCommand>,
cmd_rx: &mut mpsc::Receiver<EngineCommand>,
) -> anyhow::Result<()> {
let cli_sink = crate::sink::CliSink::channel(ui_tx.clone());
let cancel_token = self.session.cancel_token().child_token();
let session_cancel = self.session.cancel_handle();
let db_handle = self.session.db.clone();
self.tui_state = TuiState::Inferring;
self.inference_start = Some(std::time::Instant::now());
self.renderer.last_turn_stats = None;
let bg_agents_for_status = self.session.bg_agents.clone();
let agent_for_status = self.agent.clone();
{
let turn = self.session.run_turn(
&self.config,
pending_images,
&cli_sink,
cmd_rx,
Some(cancel_token.clone()),
);
tokio::pin!(turn);
let mut prefer_input = true;
self.frame_requester.schedule_frame();
loop {
prefer_input = !prefer_input;
const MAX_DRAIN: usize = 64;
let select_result = if prefer_input {
tokio::select! {
biased;
Some(()) = self.draw_rx.recv() => SelectArm::Draw,
Some(Ok(ev)) = self.crossterm_events.next() => {
SelectArm::Crossterm(ev)
}
Some(ui_event) = ui_rx.recv() => {
SelectArm::Ui(ui_event)
}
result = &mut turn => SelectArm::TurnDone(result),
}
} else {
tokio::select! {
biased;
Some(()) = self.draw_rx.recv() => SelectArm::Draw,
Some(ui_event) = ui_rx.recv() => {
SelectArm::Ui(ui_event)
}
Some(Ok(ev)) = self.crossterm_events.next() => {
SelectArm::Crossterm(ev)
}
result = &mut turn => SelectArm::TurnDone(result),
}
};
match select_result {
SelectArm::Draw => {
let (term_w, _) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
let hist_viewport = (self.history_area_height as usize).max(1);
self.scroll_buffer.clamp_offset(term_w, hist_viewport);
let mode = trust::read_trust(&self.shared_mode);
let ctx = self.context_pct;
let mcp_info = self.agent.mcp_status_bar_info();
let agent_snaps = bg_agents_for_status.snapshot();
let process_snaps = agent_for_status.tools.bg_registry.snapshot();
let (child_activity_rows, child_activity_total) =
self.child_activity.build_rows(&agent_snaps, &process_snaps);
let queue_total = self.later_queue.len();
let queue_preview: Vec<String> = self
.later_queue
.iter()
.take(crate::widgets::queue_preview::MAX_VISIBLE)
.cloned()
.collect();
let _ = self.terminal.draw(|f| {
draw_viewport(
f,
&self.textarea,
&self.config.model,
mode,
ctx,
self.tui_state,
&self.prompt_mode,
&queue_preview,
queue_total,
self.inference_start
.map(|s| s.elapsed().as_secs())
.unwrap_or(0),
self.renderer.last_turn_stats.as_ref(),
&self.menu,
&self.scroll_buffer,
self.mouse_selection.as_ref(),
mcp_info,
&self.project_root,
&child_activity_rows,
child_activity_total,
);
});
}
SelectArm::Crossterm(ev) => {
super::crossterm_handler::handle_crossterm_event_inline(
ev,
&session_cancel,
cmd_tx,
&mut self.scroll_buffer,
self.history_area_height as usize,
&mut self.menu,
&mut self.prompt_mode,
&mut self.pending_approval_id,
&mut self.textarea,
&self.shared_mode,
&mut self.completer,
&mut self.history,
&mut self.history_idx,
&mut self.later_queue,
&mut self.paste_blocks,
&db_handle,
&bg_agents_for_status,
&agent_for_status.tools.bg_registry,
&mut self.child_activity,
)
.await;
self.frame_requester.schedule_frame();
}
SelectArm::Ui(ui_event) => {
if let UiEvent::Engine(EngineEvent::ContextUsage { used, max }) = &ui_event
{
self.context_pct = if *max > 0 {
(used * 100 / max) as u32
} else {
0
};
}
super::ui_handler::handle_inference_ui_inline(
ui_event,
&mut self.scroll_buffer,
&mut self.menu,
&mut self.prompt_mode,
&mut self.renderer,
&mut self.child_activity,
);
let _ = drain_bounded(ui_rx, MAX_DRAIN, |extra| {
if let UiEvent::Engine(EngineEvent::ContextUsage { used, max }) = &extra
{
self.context_pct = if *max > 0 {
(used * 100 / max) as u32
} else {
0
};
}
super::ui_handler::handle_inference_ui_inline(
extra,
&mut self.scroll_buffer,
&mut self.menu,
&mut self.prompt_mode,
&mut self.renderer,
&mut self.child_activity,
);
});
self.frame_requester.schedule_frame();
}
SelectArm::TurnDone(result) => {
if let Err(e) = result {
self.scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\u{2717} Turn failed: {e:#}"),
Style::default().fg(Color::Red),
),
]));
}
break;
}
}
}
}
self.post_turn_cleanup(ui_rx, &cancel_token).await;
Ok(())
}
}
#[cfg(test)]
mod drain_tests {
use super::drain_bounded;
use tokio::sync::mpsc;
#[test]
fn drain_returns_zero_on_empty_channel() {
let (_tx, mut rx) = mpsc::unbounded_channel::<u32>();
let mut seen = Vec::new();
let n = drain_bounded(&mut rx, 64, |item| seen.push(item));
assert_eq!(n, 0);
assert!(seen.is_empty());
}
#[test]
fn drain_processes_all_when_below_cap() {
let (tx, mut rx) = mpsc::unbounded_channel::<u32>();
for i in 0..10 {
tx.send(i).unwrap();
}
let mut seen = Vec::new();
let n = drain_bounded(&mut rx, 64, |item| seen.push(item));
assert_eq!(n, 10);
assert_eq!(seen, (0..10).collect::<Vec<_>>());
}
#[test]
fn drain_stops_at_cap_and_leaves_remainder() {
let (tx, mut rx) = mpsc::unbounded_channel::<u32>();
for i in 0..1_000 {
tx.send(i).unwrap();
}
let mut seen = Vec::new();
let n = drain_bounded(&mut rx, 64, |item| seen.push(item));
assert_eq!(n, 64, "drain must stop at cap, even with 1000 items queued");
assert_eq!(seen.len(), 64);
assert_eq!(seen.first(), Some(&0));
assert_eq!(seen.last(), Some(&63));
let mut more = Vec::new();
let m = drain_bounded(&mut rx, 10_000, |item| more.push(item));
assert_eq!(m, 1_000 - 64);
assert_eq!(more.first(), Some(&64));
assert_eq!(more.last(), Some(&999));
}
#[test]
fn drain_with_cap_zero_processes_nothing() {
let (tx, mut rx) = mpsc::unbounded_channel::<u32>();
for i in 0..10 {
tx.send(i).unwrap();
}
let mut seen = Vec::new();
let n = drain_bounded(&mut rx, 0, |item| seen.push(item));
assert_eq!(n, 0);
assert!(seen.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn sustained_flood_is_drained_across_multiple_iterations() {
let (tx, mut rx) = mpsc::unbounded_channel::<u32>();
let total: u32 = 500;
for i in 0..total {
tx.send(i).unwrap();
}
let _producer_keepalive = tx;
let mut total_seen = 0;
let mut iterations = 0;
loop {
let n = drain_bounded(&mut rx, 64, |_| {});
total_seen += n;
iterations += 1;
if n == 0 {
break;
}
assert!(iterations < 100, "drain should converge within 100 iters");
}
assert_eq!(total_seen as u32, total);
assert!(
iterations >= 8,
"expected >=8 iters at cap 64 for 500 items"
);
}
}