use crate::input;
use crate::scroll_buffer::ScrollBuffer;
use crate::sink::UiEvent;
use crate::tui_context::TuiContext;
use crate::tui_types::{MenuContent, PromptMode, TuiState};
use crate::tui_viewport::draw_viewport;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use futures_util::StreamExt;
use koda_core::engine::{ApprovalDecision, EngineCommand, EngineEvent};
use koda_core::persistence::Persistence;
use koda_core::trust::{self, TrustMode};
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use tokio::sync::mpsc;
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.clone();
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 turn = self
.session
.run_turn(&self.config, pending_images, &cli_sink, cmd_rx);
tokio::pin!(turn);
loop {
let (term_w, term_h) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
self.scroll_buffer.clamp_offset(term_w, term_h);
let mode = trust::read_trust(&self.shared_mode);
let ctx = self.context_pct;
let _ = self.terminal.draw(|f| {
draw_viewport(
f,
&self.textarea,
&self.config.model,
mode,
ctx,
self.tui_state,
&self.prompt_mode,
self.input_queue.len(),
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(),
);
});
tokio::select! {
biased;
Some(Ok(ev)) = self.crossterm_events.next() => {
handle_crossterm_event_inline(
ev,
&cancel_token,
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.input_queue,
&mut self.paste_blocks,
&db_handle,
).await;
}
Some(ui_event) = ui_rx.recv() => {
if let UiEvent::Engine(EngineEvent::ContextUsage { used, max }) = &ui_event {
self.context_pct = if *max > 0 { (used * 100 / max) as u32 } else { 0 };
}
handle_inference_ui_inline(
ui_event,
&mut self.scroll_buffer,
&mut self.menu,
&mut self.prompt_mode,
&mut self.renderer,
);
while let Ok(extra) = ui_rx.try_recv() {
if let UiEvent::Engine(EngineEvent::ContextUsage { used, max }) = &extra {
self.context_pct = if *max > 0 { (used * 100 / max) as u32 } else { 0 };
}
handle_inference_ui_inline(
extra,
&mut self.scroll_buffer,
&mut self.menu,
&mut self.prompt_mode,
&mut self.renderer,
);
}
}
result = &mut turn => {
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).await;
Ok(())
}
async fn post_turn_cleanup(&mut self, ui_rx: &mut mpsc::UnboundedReceiver<UiEvent>) {
if self.session.cancel.is_cancelled() && !self.input_queue.is_empty() {
let n = self.input_queue.len();
self.input_queue.clear();
self.scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\u{1f6ab} Cleared {n} queued message(s)"),
Style::default().fg(Color::DarkGray),
),
]));
}
self.tui_state = TuiState::Idle;
self.inference_start = None;
self.session.cancel = tokio_util::sync::CancellationToken::new();
if let Ok(mut undo) = self.agent.tools.undo.lock() {
undo.commit_turn();
}
while let Ok(UiEvent::Engine(e)) = ui_rx.try_recv() {
if let EngineEvent::ContextUsage { used, max } = &e {
self.context_pct = if *max > 0 {
(used * 100 / max) as u32
} else {
0
};
}
self.renderer.render_to_buffer(e, &mut self.scroll_buffer);
}
self.maybe_auto_compact().await;
}
async fn maybe_auto_compact(&mut self) {
let ctx_pct = self.context_pct as usize;
if ctx_pct < koda_core::inference_helpers::AUTO_COMPACT_THRESHOLD {
return;
}
let pending = self
.session
.db
.has_pending_tool_calls(&self.session.id)
.await
.unwrap_or(false);
if pending {
if !self.silent_compact_deferred {
self.scroll_buffer.push(
Line::from(vec![
Span::raw(" "),
Span::styled(
format!(
"\u{1f43b} Context at {ctx_pct}% \u{2014} deferring compact (tool calls pending)"
),
Style::default().fg(Color::Yellow),
),
]),
);
self.silent_compact_deferred = true;
}
return;
}
self.silent_compact_deferred = false;
self.scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\u{1f43b} Context at {ctx_pct}% \u{2014} auto-compacting..."),
Style::default().fg(Color::Cyan),
),
]));
match koda_core::compact::compact_session(
&self.session.db,
&self.session.id,
self.config.max_context_tokens,
&self.config.model_settings,
&self.provider,
)
.await
{
Ok(Ok(result)) => {
self.scroll_buffer.push(Line::styled(
format!(
" \u{2713} Compacted {} messages \u{2192} ~{} tokens",
result.deleted, result.summary_tokens
),
Style::default().fg(Color::Green),
));
}
Ok(Err(_skip)) => {} Err(e) => {
self.scroll_buffer.push(Line::styled(
format!(" \u{2717} Auto-compact failed: {e:#}"),
Style::default().fg(Color::Red),
));
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_crossterm_event_inline(
ev: Event,
cancel_token: &tokio_util::sync::CancellationToken,
cmd_tx: &mpsc::Sender<EngineCommand>,
scroll_buffer: &mut ScrollBuffer,
hist_h: usize,
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
pending_approval_id: &mut Option<String>,
textarea: &mut ratatui_textarea::TextArea<'static>,
shared_mode: &koda_core::trust::SharedTrustMode,
completer: &mut crate::completer::InputCompleter,
history: &mut Vec<String>,
history_idx: &mut Option<usize>,
input_queue: &mut std::collections::VecDeque<String>,
paste_blocks: &mut Vec<input::PasteBlock>,
db: &koda_core::db::Database,
) {
use crossterm::event::MouseEventKind;
match ev {
Event::Resize(_, _) => {
let (w, h) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
scroll_buffer.clamp_offset(w, h);
}
Event::Mouse(mouse) => {
let (w, _) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
match mouse.kind {
MouseEventKind::ScrollUp => scroll_buffer.scroll_up(3, w, hist_h),
MouseEventKind::ScrollDown => scroll_buffer.scroll_down(3),
_ => {}
}
}
Event::Paste(text) => {
let char_count = text.chars().count();
if char_count < input::PASTE_BLOCK_THRESHOLD {
textarea.insert_str(&text);
} else {
paste_blocks.push(input::PasteBlock {
content: text,
char_count,
});
}
}
Event::Key(key) => {
handle_inference_key_inline(
key,
cancel_token,
cmd_tx,
scroll_buffer,
menu,
prompt_mode,
pending_approval_id,
textarea,
shared_mode,
completer,
history,
history_idx,
input_queue,
db,
)
.await;
}
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_inference_key_inline(
key: crossterm::event::KeyEvent,
cancel_token: &tokio_util::sync::CancellationToken,
cmd_tx: &mpsc::Sender<EngineCommand>,
scroll_buffer: &mut ScrollBuffer,
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
pending_approval_id: &mut Option<String>,
textarea: &mut ratatui_textarea::TextArea<'static>,
shared_mode: &koda_core::trust::SharedTrustMode,
completer: &mut crate::completer::InputCompleter,
history: &mut Vec<String>,
history_idx: &mut Option<usize>,
input_queue: &mut std::collections::VecDeque<String>,
db: &koda_core::db::Database,
) {
if let MenuContent::Approval { id, .. } = menu {
let approval_id = id.clone();
let decision = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => Some(ApprovalDecision::Approve),
KeyCode::Char('n') | KeyCode::Char('N') => Some(ApprovalDecision::Reject),
KeyCode::Char('a') | KeyCode::Char('A') => {
trust::set_trust(shared_mode, TrustMode::Auto);
Some(ApprovalDecision::Approve)
}
KeyCode::Char('f') | KeyCode::Char('F') => {
*prompt_mode = PromptMode::WizardInput {
label: "Feedback".into(),
};
*menu = MenuContent::WizardTrail(vec![(
"Action".into(),
"Rejected with feedback".into(),
)]);
*pending_approval_id = Some(approval_id.clone());
textarea.select_all();
textarea.cut();
None
}
KeyCode::Esc => Some(ApprovalDecision::Reject),
_ => None,
};
if let Some(d) = decision {
*menu = MenuContent::None;
let _ = cmd_tx
.send(EngineCommand::ApprovalResponse {
id: approval_id,
decision: d,
})
.await;
}
return;
}
if matches!(menu, MenuContent::LoopCap) {
let action = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
Some(koda_core::loop_guard::LoopContinuation::Continue200)
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
Some(koda_core::loop_guard::LoopContinuation::Stop)
}
_ => None,
};
if let Some(a) = action {
*menu = MenuContent::None;
let _ = cmd_tx.send(EngineCommand::LoopDecision { action: a }).await;
}
return;
}
if matches!(menu, MenuContent::AskUser { .. })
&& matches!(prompt_mode, PromptMode::WizardInput { .. })
{
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let answer = textarea.lines().join("\n");
textarea.select_all();
textarea.cut();
*prompt_mode = PromptMode::Chat;
if let MenuContent::AskUser { id, .. } = std::mem::replace(menu, MenuContent::None)
{
let _ = cmd_tx
.send(EngineCommand::AskUserResponse { id, answer })
.await;
}
}
(KeyCode::Esc, _) => {
textarea.select_all();
textarea.cut();
*prompt_mode = PromptMode::Chat;
if let MenuContent::AskUser { id, .. } = std::mem::replace(menu, MenuContent::None)
{
let _ = cmd_tx
.send(EngineCommand::AskUserResponse {
id,
answer: String::new(),
})
.await;
}
}
_ => {
textarea.input(Event::Key(key));
}
}
return;
}
if matches!(prompt_mode, PromptMode::WizardInput { .. }) && pending_approval_id.is_some() {
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let feedback = textarea.lines().join("\n");
textarea.select_all();
textarea.cut();
*prompt_mode = PromptMode::Chat;
*menu = MenuContent::None;
if let Some(aid) = pending_approval_id.take() {
let decision = if feedback.trim().is_empty() {
ApprovalDecision::Reject
} else {
ApprovalDecision::RejectWithFeedback { feedback }
};
let _ = cmd_tx
.send(EngineCommand::ApprovalResponse { id: aid, decision })
.await;
}
}
(KeyCode::Esc, _) => {
textarea.select_all();
textarea.cut();
*prompt_mode = PromptMode::Chat;
*menu = MenuContent::None;
if let Some(aid) = pending_approval_id.take() {
let _ = cmd_tx
.send(EngineCommand::ApprovalResponse {
id: aid,
decision: ApprovalDecision::Reject,
})
.await;
}
}
_ => {
textarea.input(Event::Key(key));
}
}
return;
}
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let text = textarea.lines().join("\n");
if !text.trim().is_empty() {
textarea.select_all();
textarea.cut();
history.push(text.clone());
let _ = db.history_push(&text).await;
*history_idx = None;
let preview = truncate_preview(&text, 80);
scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled("📋 Queued: ", Style::default().fg(Color::Yellow)),
Span::styled(preview, Style::default().fg(Color::DarkGray)),
]));
input_queue.push_back(text);
}
}
(KeyCode::Esc, _) => {
cancel_token.cancel();
}
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
cancel_token.cancel();
}
(KeyCode::Char('u'), m) if m.contains(KeyModifiers::CONTROL) => {
if !input_queue.is_empty() {
let n = input_queue.len();
input_queue.clear();
scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("🚫 Cleared {n} queued message(s)"),
Style::default().fg(Color::DarkGray),
),
]));
}
}
(KeyCode::BackTab, _) => {
trust::cycle_trust(shared_mode);
}
(KeyCode::Tab, KeyModifiers::NONE) => {
let current = textarea.lines().join("\n");
if let Some(completed) = completer.complete(¤t) {
textarea.select_all();
textarea.cut();
textarea.insert_str(&completed);
}
}
_ => {
completer.reset();
textarea.input(Event::Key(key));
}
}
}
fn truncate_preview(s: &str, max_chars: usize) -> String {
let flat: String = s.chars().map(|c| if c == '\n' { '↵' } else { c }).collect();
if flat.len() <= max_chars {
flat
} else {
let mut out: String = flat.chars().take(max_chars.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn handle_inference_ui_inline(
ui_event: UiEvent,
buffer: &mut ScrollBuffer,
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
renderer: &mut crate::tui_render::TuiRenderer,
) {
match ui_event {
UiEvent::Engine(EngineEvent::AskUserRequest {
id,
question,
options,
}) => {
*prompt_mode = PromptMode::WizardInput {
label: "Answer".into(),
};
*menu = MenuContent::AskUser {
id,
question,
options,
};
}
UiEvent::Engine(EngineEvent::ApprovalRequest {
id,
tool_name,
detail,
preview,
..
}) => {
if preview.is_some() {
renderer.preview_shown = true;
}
if let Some(ref prev) = preview {
let diff_lines = crate::diff_render::render_lines(prev);
let gutter = crate::diff_render::GUTTER_WIDTH;
for line in diff_lines {
buffer.push_with_gutter(line, gutter);
}
}
*menu = MenuContent::Approval {
id,
tool_name,
detail,
};
}
UiEvent::Engine(EngineEvent::LoopCapReached { cap, recent_tools }) => {
buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\u{26a0} Hard cap reached ({cap} iterations)"),
Style::default().fg(Color::Yellow),
),
]));
for name in &recent_tools {
buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\u{25cf} {name}"),
Style::default().fg(Color::DarkGray),
),
]));
}
*menu = MenuContent::LoopCap;
}
UiEvent::Engine(event) => {
renderer.render_to_buffer(event, buffer);
}
}
}