use std::{
future::Future,
io::{self, Stdout},
time::Duration,
};
use futures_util::StreamExt;
use microagents_core::types::{AgentError, RunStream};
use microagents_events::{AgentEventAny, DeltaType, types::ToolResult};
use ratatui::{
Frame, Terminal,
crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
},
layout::{Constraint, Direction, Layout, Rect},
prelude::CrosstermBackend,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
};
use tokio::sync::mpsc;
use unicode_width::UnicodeWidthStr;
mod theme {
use ratatui::style::Color;
pub const ACCENT: Color = Color::Rgb(137, 180, 250); pub const ACCENT_SOFT: Color = Color::Rgb(116, 199, 236);
pub const USER: Color = Color::Rgb(166, 227, 161); pub const ASSISTANT: Color = Color::Rgb(205, 214, 244); pub const THINKING: Color = Color::Rgb(147, 153, 178); pub const TOOL: Color = Color::Rgb(249, 226, 175); pub const TOOL_OK: Color = Color::Rgb(166, 227, 161);
pub const TOOL_ERR: Color = Color::Rgb(243, 139, 168);
pub const SKILL: Color = Color::Rgb(203, 166, 247); pub const DIM: Color = Color::Rgb(108, 112, 134);
pub const ERROR: Color = Color::Rgb(243, 139, 168);
}
#[derive(Debug, Clone)]
enum Msg {
User(String),
Assistant(String),
Thinking(String),
ToolCall { name: String, input: String },
ToolResult(ToolResult),
Skill(String),
Session(String),
Error(String),
}
#[derive(Debug)]
enum UiEvent {
Agent(AgentEventAny),
AgentError(AgentError),
RunFinished,
}
const INPUT_MIN_ROWS: u16 = 1;
const INPUT_MAX_ROWS: u16 = 8;
struct App {
input: String,
cursor: usize,
transcript: Vec<Msg>,
session_id: Option<String>,
busy: bool,
scroll: u16,
auto_scroll: bool,
quit: bool,
last_content_height: u16,
last_viewport_height: u16,
}
impl App {
fn new(session_id: Option<String>) -> Self {
Self {
input: String::new(),
cursor: 0,
transcript: Vec::new(),
session_id,
busy: false,
scroll: 0,
auto_scroll: true,
quit: false,
last_content_height: 0,
last_viewport_height: 0,
}
}
fn push(&mut self, block: Msg) {
self.transcript.push(block);
if self.auto_scroll {
self.scroll = u16::MAX; }
}
fn append_assistant_delta(&mut self, delta: &str, thinking: bool) {
if delta.is_empty() {
return;
}
let target_is_match = match self.transcript.last() {
Some(Msg::Assistant(_)) if !thinking => true,
Some(Msg::Thinking(_)) if thinking => true,
_ => false,
};
if !target_is_match {
self.transcript.push(if thinking {
Msg::Thinking(String::new())
} else {
Msg::Assistant(String::new())
});
}
match self.transcript.last_mut().unwrap() {
Msg::Assistant(s) | Msg::Thinking(s) => s.push_str(delta),
_ => unreachable!(),
}
if self.auto_scroll {
self.scroll = u16::MAX;
}
}
fn apply(&mut self, ev: UiEvent) {
match ev {
UiEvent::Agent(e) => self.apply_agent_event(e, false),
UiEvent::AgentError(e) => self.push(Msg::Error(e.to_string())),
UiEvent::RunFinished => self.busy = false,
}
}
pub fn apply_agent_event(&mut self, ev: AgentEventAny, is_replay: bool) {
match ev {
AgentEventAny::SessionInit(s) => {
self.session_id = Some(s.session_id.clone());
let kind = match s.init_type {
microagents_events::SessionInitType::Start => "started",
microagents_events::SessionInitType::Resume => "resumed",
_ => unreachable!("SessionInitType should not reach this branch"),
};
self.push(Msg::Session(format!(
"session {} • {} • {}/{}",
kind, s.session_id, s.provider, s.model
)));
}
AgentEventAny::SessionStop(s) => {
if let Some(err) = s.error {
self.push(Msg::Error(err));
}
self.push(Msg::Session(format!(
"session stopped • {} • {:?}ms • {:?} est. input tokens • {:?} est. output tokens",
if s.success { "ok" } else { "failed" },
s.usage.latency,
s.usage.estimated_input_tokens,
s.usage.estimated_output_tokens,
)));
}
AgentEventAny::UserPromptSubmit(m) => {
if is_replay {
self.push(Msg::User(m.prompt));
}
}
AgentEventAny::StreamDelta(d) => {
let thinking = matches!(d.delta_type, DeltaType::Thinking);
self.append_assistant_delta(&d.delta, thinking);
}
AgentEventAny::ToolCall(t) => {
let input = serde_json::to_string(&t.input).unwrap_or_else(|_| "{}".into());
self.push(Msg::ToolCall {
name: t.name,
input,
});
}
AgentEventAny::ToolResult(r) => self.push(Msg::ToolResult(r.result)),
AgentEventAny::SkillLoad(s) => self.push(Msg::Skill(s.skill_name)),
AgentEventAny::AssistantResponse(r) => {
if let Some(calls) = r.tool_calls {
for c in calls {
let input =
serde_json::from_str::<serde_json::Value>(&c.function.arguments)
.ok()
.and_then(|v| serde_json::to_string(&v).ok())
.unwrap_or(c.function.arguments);
self.push(Msg::ToolCall {
name: c.function.name,
input,
});
}
}
}
_ => unreachable!("AgentEventAny should not reach this branch"),
}
}
}
pub async fn run_with_session<F, Fut, H, Hfut>(
session_id: Option<String>,
mut start_run: F,
load_history: H,
) -> io::Result<()>
where
F: FnMut(String, Option<String>) -> Fut + Send + 'static,
Fut: Future<Output = Result<RunStream, AgentError>> + Send + 'static,
H: FnOnce(String) -> Hfut + Send + 'static,
Hfut: Future<Output = Result<Vec<AgentEventAny>, AgentError>> + Send + 'static,
{
let mut terminal = setup_terminal()?;
let history = if let Some(ref sid) = session_id {
match load_history(sid.clone()).await {
Ok(events) => events,
Err(e) => {
restore_terminal(&mut terminal)?;
return Err(io::Error::other(e.to_string()));
}
}
} else {
vec![]
};
let res = event_loop(&mut terminal, &mut start_run, session_id, history).await;
restore_terminal(&mut terminal)?;
res
}
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
Terminal::new(CrosstermBackend::new(stdout))
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
async fn event_loop<F, Fut>(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
start_run: &mut F,
session_id: Option<String>,
history: Vec<AgentEventAny>,
) -> io::Result<()>
where
F: FnMut(String, Option<String>) -> Fut,
Fut: Future<Output = Result<RunStream, AgentError>> + Send + 'static,
{
let mut app = App::new(session_id);
for ev in history {
app.apply_agent_event(ev, true);
}
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<UiEvent>();
let mut tick = tokio::time::interval(Duration::from_millis(80));
while !app.quit {
terminal.draw(|f| draw(f, &mut app))?;
tokio::select! {
biased;
Some(ev) = ui_rx.recv() => {
app.apply(ev);
while let Ok(more) = ui_rx.try_recv() {
app.apply(more);
}
}
_ = tick.tick() => {
while event::poll(Duration::from_millis(0))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press { continue; }
handle_key(key, &mut app, start_run, &ui_tx).await;
}
}
}
}
}
Ok(())
}
async fn handle_key<F, Fut>(
key: event::KeyEvent,
app: &mut App,
start_run: &mut F,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
) where
F: FnMut(String, Option<String>) -> Fut,
Fut: Future<Output = Result<RunStream, AgentError>> + Send + 'static,
{
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => app.quit = true,
KeyCode::Char('d') if ctrl && app.input.is_empty() => app.quit = true,
KeyCode::Esc => app.quit = true,
KeyCode::Enter if !app.busy => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
app.input.insert(app.cursor, '\n');
app.cursor += 1;
return;
}
let prompt = app.input.trim().to_string();
if prompt.is_empty() {
return;
}
if matches!(prompt.as_str(), "/exit" | "/quit") {
app.quit = true;
return;
}
app.input.clear();
app.cursor = 0;
app.push(Msg::User(prompt.clone()));
app.busy = true;
app.auto_scroll = true;
let fut = start_run(prompt, app.session_id.clone());
let tx = ui_tx.clone();
tokio::spawn(async move {
match fut.await {
Ok(mut stream) => {
while let Some(item) = stream.next().await {
match item {
Ok(e) => {
if tx.send(UiEvent::Agent(e)).is_err() {
return;
}
}
Err(e) => {
let _ = tx.send(UiEvent::AgentError(e));
}
}
}
}
Err(e) => {
let _ = tx.send(UiEvent::AgentError(e));
}
}
let _ = tx.send(UiEvent::RunFinished);
});
}
KeyCode::Char(c) if !ctrl => {
app.input.insert(app.cursor, c);
app.cursor += c.len_utf8();
}
KeyCode::Backspace => {
if app.cursor > 0 {
let mut new_cursor = app.cursor - 1;
while !app.input.is_char_boundary(new_cursor) && new_cursor > 0 {
new_cursor -= 1;
}
app.input.replace_range(new_cursor..app.cursor, "");
app.cursor = new_cursor;
}
}
KeyCode::Left => {
if app.cursor > 0 {
let mut nc = app.cursor - 1;
while !app.input.is_char_boundary(nc) && nc > 0 {
nc -= 1;
}
app.cursor = nc;
}
}
KeyCode::Right => {
if app.cursor < app.input.len() {
let mut nc = app.cursor + 1;
while nc < app.input.len() && !app.input.is_char_boundary(nc) {
nc += 1;
}
app.cursor = nc;
}
}
KeyCode::Home => app.cursor = 0,
KeyCode::End => app.cursor = app.input.len(),
KeyCode::PageUp => {
app.auto_scroll = false;
app.scroll = app.scroll.saturating_sub(app.last_viewport_height.max(1));
}
KeyCode::PageDown => {
let max = app
.last_content_height
.saturating_sub(app.last_viewport_height);
app.scroll = app.scroll.saturating_add(app.last_viewport_height).min(max);
app.auto_scroll = app.scroll >= max;
}
_ => {}
}
}
fn draw(f: &mut Frame, app: &mut App) {
let input_height = input_visual_height(&app.input, f.area().width);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(input_height), Constraint::Length(1), ])
.split(f.area());
draw_header(f, chunks[0], app);
draw_transcript(f, chunks[1], app);
draw_input(f, chunks[2], app);
draw_hint(f, chunks[3], app);
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let session = app.session_id.as_deref().unwrap_or("—");
let status = if app.busy { "● thinking" } else { "○ idle" };
let status_color = if app.busy {
theme::ACCENT_SOFT
} else {
theme::DIM
};
let title = Line::from(vec![
Span::styled("✦ ", Style::default().fg(theme::ACCENT)),
Span::styled(
"microagents",
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("session {}", session),
Style::default().fg(theme::DIM),
),
Span::raw(" "),
Span::styled(status, Style::default().fg(status_color)),
]);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(theme::DIM));
let p = Paragraph::new(title).block(block).left_aligned();
f.render_widget(p, area);
}
fn block_lines(block: &Msg) -> Vec<Line<'static>> {
match block {
Msg::User(text) => prefixed(" you", theme::USER, text, theme::ASSISTANT),
Msg::Assistant(text) => prefixed(" agent", theme::ACCENT, text, theme::ASSISTANT),
Msg::Thinking(text) => {
let lines = wrap_to_lines(text);
let mut out = Vec::with_capacity(lines.len() + 1);
out.push(Line::from(Span::styled(
" · thinking",
Style::default()
.fg(theme::THINKING)
.add_modifier(Modifier::ITALIC),
)));
for l in lines {
out.push(Line::from(Span::styled(
format!(" {}", l),
Style::default()
.fg(theme::THINKING)
.add_modifier(Modifier::ITALIC),
)));
}
out
}
Msg::ToolCall { name, input } => {
let preview = if input.len() > 400 {
format!("{}…", &input[..400])
} else {
input.clone()
};
vec![
Line::from(Span::styled(
" tool",
Style::default()
.fg(theme::TOOL)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" ↪ ", Style::default().fg(theme::TOOL)),
Span::styled(
name.clone(),
Style::default()
.fg(theme::TOOL)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(preview, Style::default().fg(theme::DIM)),
]),
]
}
Msg::ToolResult(r) => match r {
ToolResult::Ok(s) => {
let actual_res = if s.len() > 200 {
s[..s.len().min(200)].to_string() + "..."
} else {
s.to_string()
};
prefixed_inline(" ✓ ", theme::TOOL_OK, &actual_res, theme::DIM)
}
ToolResult::Err(s) => {
let actual_res = if s.len() > 200 {
s[..s.len().min(200)].to_string() + "..."
} else {
s.to_string()
};
prefixed_inline(" ✗ ", theme::TOOL_ERR, &actual_res, theme::TOOL_ERR)
}
_ => unreachable!("ToolResult should not reach this branch"),
},
Msg::Skill(name) => vec![Line::from(vec![
Span::styled(" ✧ ", Style::default().fg(theme::SKILL)),
Span::styled(
format!("skill loaded: {}", name),
Style::default().fg(theme::SKILL),
),
])],
Msg::Session(msg) => vec![Line::from(Span::styled(
format!(" ── {}", msg),
Style::default()
.fg(theme::DIM)
.add_modifier(Modifier::ITALIC),
))],
Msg::Error(msg) => vec![Line::from(vec![
Span::styled(" ! ", Style::default().fg(theme::ERROR)),
Span::styled(
msg.clone(),
Style::default()
.fg(theme::ERROR)
.add_modifier(Modifier::BOLD),
),
])],
}
}
fn prefixed(label: &str, label_color: Color, text: &str, body_color: Color) -> Vec<Line<'static>> {
let mut out = vec![Line::from(Span::styled(
label.to_string(),
Style::default()
.fg(label_color)
.add_modifier(Modifier::BOLD),
))];
for l in wrap_to_lines(text) {
out.push(Line::from(Span::styled(
format!(" {}", l),
Style::default().fg(body_color),
)));
}
out
}
fn prefixed_inline(
prefix: &str,
prefix_color: Color,
text: &str,
body_color: Color,
) -> Vec<Line<'static>> {
let lines = wrap_to_lines(text);
if lines.is_empty() {
return vec![Line::from(Span::styled(
prefix.to_string(),
Style::default().fg(prefix_color),
))];
}
let mut out = Vec::with_capacity(lines.len());
for (i, l) in lines.into_iter().enumerate() {
if i == 0 {
out.push(Line::from(vec![
Span::styled(
prefix.to_string(),
Style::default()
.fg(prefix_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(l, Style::default().fg(body_color)),
]));
} else {
out.push(Line::from(Span::styled(
format!(" {}", l),
Style::default().fg(body_color),
)));
}
}
out
}
fn wrap_to_lines(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
text.split('\n').map(|s| s.to_string()).collect()
}
fn draw_transcript(f: &mut Frame, area: Rect, app: &mut App) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.padding(Padding::new(1, 1, 0, 0));
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line<'static>> = Vec::new();
for (i, b) in app.transcript.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
lines.extend(block_lines(b));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" Ask anything to get started.",
Style::default().fg(theme::DIM).add_modifier(Modifier::DIM),
)));
}
let wrap_width = inner.width.max(1);
let total = wrapped_line_count(&lines, wrap_width);
let viewport = inner.height;
app.last_content_height = total;
app.last_viewport_height = viewport;
let max_scroll = total.saturating_sub(viewport);
if app.auto_scroll || app.scroll > max_scroll {
app.scroll = max_scroll;
}
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0));
f.render_widget(paragraph, inner);
if total > viewport {
let mut sb_state = ScrollbarState::new(total as usize).position(app.scroll as usize);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(Style::default().fg(theme::DIM));
f.render_stateful_widget(scrollbar, area, &mut sb_state);
}
}
fn wrapped_line_count(lines: &[Line<'_>], width: u16) -> u16 {
let w = width.max(1);
let mut count: u32 = 0;
for line in lines {
let line_width: u32 = line.spans.iter().map(|s| s.content.width() as u32).sum();
let rows = line_width.div_ceil(w as u32).max(1);
count = count.saturating_add(rows);
}
count.min(u16::MAX as u32) as u16
}
fn input_visual_height(input: &str, area_width: u16) -> u16 {
let usable = area_width.saturating_sub(4); let prefix_cols = 2; let wrap_width = usable.saturating_sub(prefix_cols).max(1);
let mut rows: u16 = 1;
for line in input.split('\n') {
let line_len = line.chars().count() as u16;
rows += line_len.saturating_sub(1) / wrap_width + 1;
}
rows.clamp(INPUT_MIN_ROWS, INPUT_MAX_ROWS) + 2 }
fn draw_input(f: &mut Frame, area: Rect, app: &App) {
let border_color = if app.busy { theme::DIM } else { theme::ACCENT };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.padding(Padding::horizontal(1));
let prompt_marker = Span::styled(
"▎ ",
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
);
let lines: Vec<Line> = if app.input.is_empty() && !app.busy {
vec![Line::from(vec![
prompt_marker,
Span::styled(
"Type a message and press Enter…",
Style::default().fg(theme::DIM).add_modifier(Modifier::DIM),
),
])]
} else if app.busy && app.input.is_empty() {
vec![Line::from(vec![
prompt_marker,
Span::styled(
"Waiting for the agent…",
Style::default()
.fg(theme::DIM)
.add_modifier(Modifier::ITALIC),
),
])]
} else {
let mut out = Vec::new();
for (i, line_text) in app.input.split('\n').enumerate() {
if i == 0 {
out.push(Line::from(vec![
prompt_marker.clone(),
Span::styled(line_text.to_string(), Style::default().fg(theme::ASSISTANT)),
]));
} else {
out.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(line_text.to_string(), Style::default().fg(theme::ASSISTANT)),
]));
}
}
out
};
let p = Paragraph::new(lines)
.block(block.clone())
.wrap(Wrap { trim: false });
f.render_widget(p, area);
if !app.busy {
let inner = block.inner(area);
let prefix_cols = 2u16; let wrap_width = inner.width.saturating_sub(prefix_cols).max(1);
let text_before_cursor = &app.input[..app.cursor];
let line_index = text_before_cursor.matches('\n').count() as u16;
let current_line_start = text_before_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0);
let current_line_text = &app.input[current_line_start..app.cursor];
let col_in_line = current_line_text.chars().count() as u16;
let cx = if line_index == 0 {
inner.x + prefix_cols + col_in_line.min(wrap_width)
} else {
inner.x + col_in_line % wrap_width
};
let cy = inner.y + line_index + col_in_line / wrap_width;
if cy < inner.y + inner.height {
f.set_cursor_position((cx, cy));
}
}
}
fn draw_hint(f: &mut Frame, area: Rect, app: &App) {
let hint = if app.busy {
" streaming… • Ctrl+C quit"
} else {
" Enter send • PgUp/PgDn scroll • /exit or Esc to quit"
};
let p = Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(theme::DIM),
)));
f.render_widget(p, area);
}