use std::{
io::IsTerminal,
path::PathBuf,
time::{Duration, Instant},
};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
use ratatui::{
layout::{Constraint, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Paragraph,
DefaultTerminal, Frame,
};
use rho_sdk::model::{ContextUsage, ModelUsage};
use crate::{
herdr::{HerdrReporter, HerdrState},
run_artifacts::{AttachmentEvent, AttachmentReader},
subagent::{self, RunState, RunStatus},
};
use super::super::{
mouse_capture,
provider_attempt::ProviderAttempt,
render::{entry_lines, truncate_one_line},
scrollbar::{HistoryScrollChrome, HistoryScrollbar, ScrollbarMouseInput},
terminal_events::TerminalEvents,
theme::Theme,
usage_cost::{
format_token_count, format_usage_token_summary, format_usd, resolved_usage_cost_usd_micros,
AttemptAwareRunUsage,
},
Entry, HistoryScroll, ReasoningEntry, ToolEntry, ToolEntryState, HISTORY_MOUSE_SCROLL_LINES,
HISTORY_SCROLLBAR_REVEAL_DURATION,
};
const REFRESH_INTERVAL: Duration = Duration::from_millis(100);
const MAX_TOOL_OUTPUT_LINES: usize = 20;
pub(crate) async fn run(id: &str, herdr: HerdrReporter) -> anyhow::Result<()> {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
anyhow::bail!("rho attach requires an interactive terminal");
}
let id = subagent::normalize_id(id)?;
let lookup_id = id.clone();
let directory =
tokio::task::spawn_blocking(move || subagent::resolve_run_directory(&lookup_id)).await??;
let mut terminal = ratatui::init();
let _restore_terminal = RestoreTerminal {
mouse_capture: mouse_capture::Guard::acquire(),
};
Theme::initialize_from_terminal();
let message = format!("attached to agent run {id}");
herdr
.report_state(HerdrState::Working, Some(&message), Some(&id))
.await;
let result = AttachmentApp::new(&id, directory, herdr.clone())
.run(&mut terminal)
.await;
herdr.release().await;
result
}
struct RestoreTerminal {
mouse_capture: mouse_capture::Guard,
}
impl Drop for RestoreTerminal {
fn drop(&mut self) {
self.mouse_capture.release();
ratatui::restore();
}
}
struct AttachmentApp {
id: String,
directory: PathBuf,
reader: AttachmentReader,
transcript: Vec<Entry>,
pending_tool: Option<ToolEntry>,
context_usage: Option<ContextUsage>,
run_usage: AttemptAwareRunUsage,
provider_attempt: ProviderAttempt,
status: Option<RunStatus>,
reported_state: Option<RunState>,
herdr: HerdrReporter,
scroll: HistoryScrollChrome,
last_mouse_position: Option<(u16, u16)>,
viewport_height: usize,
history_area: Rect,
content_len: usize,
should_quit: bool,
}
impl AttachmentApp {
fn new(id: &str, directory: PathBuf, herdr: HerdrReporter) -> Self {
let reader = AttachmentReader::new(directory.join(subagent::ATTACHMENT_FILE_NAME));
Self {
id: id.to_string(),
directory,
reader,
transcript: Vec::new(),
pending_tool: None,
context_usage: None,
run_usage: AttemptAwareRunUsage::default(),
provider_attempt: ProviderAttempt::default(),
status: None,
reported_state: None,
herdr,
scroll: HistoryScrollChrome::default(),
last_mouse_position: None,
viewport_height: 0,
history_area: Rect::default(),
content_len: 0,
should_quit: false,
}
}
async fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let mut terminal_events = TerminalEvents::new();
let mut refresh = tokio::time::interval(REFRESH_INTERVAL);
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
self.refresh().await?;
terminal.draw(|frame| self.draw(frame))?;
while !self.should_quit {
let redraw = tokio::select! {
event = terminal_events.next() => self.handle_event(event?),
_ = refresh.tick() => {
let changed = self.refresh().await?;
changed || self.scroll.should_render(Instant::now())
},
};
if redraw {
terminal.draw(|frame| self.draw(frame))?;
}
}
Ok(())
}
async fn refresh(&mut self) -> anyhow::Result<bool> {
let events = self.reader.read_new()?;
let mut changed = !events.is_empty();
for event in events {
self.apply_event(event);
}
let status_path = self.directory.join(subagent::RESULT_FILE_NAME);
if let Some(status) = subagent::read_status(&status_path) {
changed |= self.status.as_ref() != Some(&status);
let state_changed = self.reported_state != Some(status.state);
self.status = Some(status.clone());
if state_changed {
let (state, message) = herdr_status(&self.id, &status);
self.herdr
.report_state(state, Some(&message), Some(&self.id))
.await;
self.reported_state = Some(status.state);
}
}
Ok(changed)
}
fn apply_event(&mut self, event: AttachmentEvent) {
match event {
AttachmentEvent::Prompt(prompt) => self.transcript.push(Entry::User(prompt)),
AttachmentEvent::AssistantTextDelta(text) => {
let can_append = self
.provider_attempt
.can_append_to_last(self.transcript.len());
append_stream(
&mut self.transcript,
StreamTarget::Assistant,
text,
can_append,
);
}
AttachmentEvent::ReasoningDelta(text) => {
let can_append = self
.provider_attempt
.can_append_to_last(self.transcript.len());
append_stream(
&mut self.transcript,
StreamTarget::Reasoning,
text,
can_append,
);
}
AttachmentEvent::ToolStarted { display_lines }
| AttachmentEvent::ToolUpdated { display_lines } => {
self.pending_tool = Some(ToolEntry {
state: ToolEntryState::Running,
display_lines,
expanded: false,
image: None,
});
}
AttachmentEvent::ToolFinished {
ok,
display_style,
display_lines,
} => {
self.pending_tool = None;
self.transcript.push(Entry::Tool(ToolEntry {
state: ToolEntryState::Finished { ok, display_style },
display_lines,
expanded: false,
image: None,
}));
}
AttachmentEvent::Notice(notice) => self.transcript.push(Entry::Notice(notice)),
AttachmentEvent::ContextUsage(usage) => self.context_usage = Some(usage),
AttachmentEvent::Usage(usage) => {
self.run_usage.apply_snapshot(usage, |snapshot| snapshot);
}
AttachmentEvent::StepStarted => {
self.provider_attempt.begin(self.transcript.len());
self.run_usage.step_started();
}
AttachmentEvent::ProviderStreamReset => {
self.provider_attempt.reset_output(&mut self.transcript);
self.pending_tool = None;
self.run_usage.attempt_reset();
}
AttachmentEvent::Completed => {
self.pending_tool = None;
}
AttachmentEvent::Cancelled => {
self.pending_tool = None;
self.transcript.push(Entry::Notice("agent stopped".into()));
}
AttachmentEvent::Failed(message) => {
self.pending_tool = None;
self.transcript.push(Entry::Error(message));
}
}
}
fn handle_event(&mut self, event: Event) -> bool {
let now = Instant::now();
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
true
}
KeyCode::Char('q') | KeyCode::Esc => {
self.should_quit = true;
true
}
KeyCode::Up => {
self.scroll_lines(now, -1);
true
}
KeyCode::Down => {
self.scroll_lines(now, 1);
true
}
KeyCode::PageUp => {
self.scroll_lines(now, -(self.viewport_height.max(1) as isize));
true
}
KeyCode::PageDown => {
self.scroll_lines(now, self.viewport_height.max(1) as isize);
true
}
KeyCode::Home => {
self.scroll
.set_top_line(self.content_len, self.viewport_height, 0);
if !matches!(self.scroll.scroll(), HistoryScroll::Bottom) {
self.scroll.reveal(now, HISTORY_SCROLLBAR_REVEAL_DURATION);
}
true
}
KeyCode::End => {
self.scroll.scroll_to_bottom();
true
}
_ => false,
},
Event::Mouse(mouse) => {
if matches!(mouse.kind, MouseEventKind::Moved)
&& self.last_mouse_position == Some((mouse.column, mouse.row))
{
return false;
}
if matches!(mouse.kind, MouseEventKind::Moved) {
self.last_mouse_position = Some((mouse.column, mouse.row));
}
self.scroll.handle_scrollbar_mouse(
mouse.kind,
mouse.column,
mouse.row,
ScrollbarMouseInput {
now,
reveal_duration: HISTORY_SCROLLBAR_REVEAL_DURATION,
scrollbar: self.history_scrollbar(),
content_len: self.content_len,
viewport_len: self.viewport_height,
wheel_lines: HISTORY_MOUSE_SCROLL_LINES,
},
);
true
}
Event::FocusGained => {
mouse_capture::reassert();
false
}
Event::Resize(_, _) => true,
_ => false,
}
}
fn scroll_lines(&mut self, now: Instant, delta: isize) {
self.scroll
.scroll_by(self.content_len, self.viewport_height, delta);
if !matches!(self.scroll.scroll(), HistoryScroll::Bottom) {
self.scroll.reveal(now, HISTORY_SCROLLBAR_REVEAL_DURATION);
}
}
fn history_scrollbar(&self) -> Option<HistoryScrollbar> {
HistoryScrollbar::new(
self.history_area,
self.content_len,
self.scroll
.visible_start(self.content_len, self.viewport_height),
)
}
fn sync_history_geometry(&mut self, area: Rect, content_len: usize) {
self.history_area = area;
self.viewport_height = area.height as usize;
self.content_len = content_len;
self.scroll.clamp(self.content_len, self.viewport_height);
}
fn draw(&mut self, frame: &mut Frame<'_>) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(4),
Constraint::Min(1),
Constraint::Length(2),
])
.split(area);
let width = area.width as usize;
let status = self.status.as_ref();
let state = status.map_or("starting", |status| status.state.as_str());
let agent_id = status
.and_then(|status| status.agent_id.as_deref())
.unwrap_or("agent");
let activity = status
.and_then(|status| status.last_activity.as_deref())
.unwrap_or("waiting for activity");
let metrics = status_metrics_line(status, agent_id, activity, self.run_usage.current());
let live_metrics = live_metrics_line(
self.context_usage.as_ref(),
self.run_usage.current(),
status,
);
let header = vec![
Line::from(vec![
Span::styled("rho", Theme::brand()),
Span::raw(format!(" attached to {}", self.id)),
Span::styled(format!(" {state}"), state_style(status)),
]),
Line::styled(truncate_one_line(&metrics, width), Theme::dim()),
Line::styled(truncate_one_line(&live_metrics, width), Theme::dim()),
Line::styled("─".repeat(width.max(1)), Theme::dim()),
];
frame.render_widget(Paragraph::new(header), chunks[0]);
let lines = self.history_lines(width, status);
self.sync_history_geometry(chunks[1], lines.len());
let start = self
.scroll
.visible_start(self.content_len, self.viewport_height);
let end = start.saturating_add(self.viewport_height).min(lines.len());
frame.render_widget(Paragraph::new(lines[start..end].to_vec()), chunks[1]);
let now = Instant::now();
if let Some(scrollbar) = self
.history_scrollbar()
.filter(|_| self.scroll.should_render(now))
{
scrollbar.render(frame, self.scroll.drag().is_some());
}
let footer = vec![
Line::styled("─".repeat(width.max(1)), Theme::dim()),
Line::styled(
truncate_one_line("read-only | scroll | home/end | q detach", width),
Theme::dim(),
),
];
frame.render_widget(Paragraph::new(footer).style(Style::default()), chunks[2]);
}
fn history_lines(&self, width: usize, status: Option<&RunStatus>) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for entry in &self.transcript {
lines.extend(entry_lines(entry, width, MAX_TOOL_OUTPUT_LINES));
}
if let Some(tool) = &self.pending_tool {
lines.extend(entry_lines(
&Entry::Tool(tool.clone()),
width,
MAX_TOOL_OUTPUT_LINES,
));
}
let has_assistant = self
.transcript
.iter()
.any(|entry| matches!(entry, Entry::Assistant(_)));
if !has_assistant {
let fallback = status.and_then(|status| {
status
.result
.as_deref()
.or(status.last_text.as_deref())
.filter(|text| !text.is_empty())
});
if let Some(text) = fallback {
lines.extend(entry_lines(
&Entry::Assistant(text.to_string()),
width,
MAX_TOOL_OUTPUT_LINES,
));
}
}
if let Some(error) = status.and_then(|status| status.error.as_deref()) {
lines.extend(entry_lines(
&Entry::Error(error.to_string()),
width,
MAX_TOOL_OUTPUT_LINES,
));
}
if let Some(error) = status.and_then(|status| status.attachment_error.as_deref()) {
lines.extend(entry_lines(
&Entry::Error(error.to_string()),
width,
MAX_TOOL_OUTPUT_LINES,
));
}
if lines.is_empty() {
lines.push(Line::styled("waiting for agent output...", Theme::dim()));
}
lines
}
}
fn status_metrics_line(
status: Option<&RunStatus>,
agent_id: &str,
activity: &str,
run_usage: Option<&ModelUsage>,
) -> String {
let Some(status) = status else {
return format!("{agent_id} | {activity}");
};
let mut parts = vec![
agent_id.to_string(),
activity.to_string(),
format!("turn {}", status.turns),
];
if let Some(session_id) = status.claude_session_id.as_deref() {
parts.push(format!("claude {session_id}"));
}
if let Some(cost) = format_run_cost(status, run_usage) {
parts.push(cost);
}
parts.join(" | ")
}
fn live_metrics_line(
context: Option<&ContextUsage>,
run_usage: Option<&ModelUsage>,
status: Option<&RunStatus>,
) -> String {
let mut parts = Vec::new();
if let Some(context_summary) = format_context_summary(context) {
parts.push(context_summary);
}
if let Some(usage_summary) = run_usage
.and_then(format_usage_token_summary)
.or_else(|| status.and_then(|status| format_usage_token_summary(&run_status_usage(status))))
{
parts.push(usage_summary);
}
parts.join(" | ")
}
fn run_status_usage(status: &RunStatus) -> ModelUsage {
ModelUsage {
input_tokens: status.input_tokens,
output_tokens: status.output_tokens,
..ModelUsage::default()
}
}
fn format_context_summary(context: Option<&ContextUsage>) -> Option<String> {
let context = context?;
let tokens = context.tokens?;
match context.context_window.filter(|window| *window > 0) {
Some(window) => {
let percent = tokens as f64 * 100.0 / window as f64;
Some(format!(
"context {}/{} ({percent:.1}%)",
format_token_count(tokens),
format_token_count(window)
))
}
None => Some(format!("context {}", format_token_count(tokens))),
}
}
fn format_run_cost(status: &RunStatus, run_usage: Option<&ModelUsage>) -> Option<String> {
if let Some(cost) = status.total_cost_usd {
return Some(format_usd(subagent::usd_to_micros(cost)));
}
run_usage
.and_then(|usage| resolved_usage_cost_usd_micros(usage, None))
.map(format_usd)
}
#[derive(Clone, Copy)]
enum StreamTarget {
Assistant,
Reasoning,
}
fn append_stream(
transcript: &mut Vec<Entry>,
target: StreamTarget,
text: String,
can_append: bool,
) {
match (target, transcript.last_mut().filter(|_| can_append)) {
(StreamTarget::Assistant, Some(Entry::Assistant(existing))) => existing.push_str(&text),
(StreamTarget::Reasoning, Some(Entry::Reasoning(existing)))
if existing.thought_for.is_none() =>
{
existing.text.push_str(&text)
}
(StreamTarget::Assistant, _) => transcript.push(Entry::Assistant(text)),
(StreamTarget::Reasoning, _) => {
transcript.push(Entry::Reasoning(ReasoningEntry::new(text)))
}
}
}
fn herdr_status(id: &str, status: &RunStatus) -> (HerdrState, String) {
let state = match status.state {
RunState::Starting | RunState::Running => HerdrState::Working,
RunState::Error => HerdrState::Blocked,
RunState::Ok | RunState::Stopped => HerdrState::Idle,
};
let detail = status
.last_activity
.as_deref()
.unwrap_or_else(|| status.state.as_str());
(state, format!("agent run {id}: {detail}"))
}
fn state_style(status: Option<&RunStatus>) -> ratatui::style::Style {
match status.map(|status| status.state) {
Some(RunState::Ok) => Theme::success(),
Some(RunState::Error | RunState::Stopped) => Theme::error(),
Some(RunState::Starting | RunState::Running) | None => Theme::warning(),
}
}
#[cfg(test)]
#[path = "app_tests.rs"]
mod tests;