use std::time::{Duration, Instant};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use crate::localization::MessageId;
use crate::models::Message;
use crate::tools::subagent::SubAgentStatus;
use crate::tui::app::App;
use crate::tui::history::{HistoryCell, history_cells_from_message};
const REFRESH_INTERVAL: Duration = Duration::from_millis(400);
#[derive(Debug, Clone)]
pub struct AgentFocus {
pub agent_id: String,
pub label: String,
pub cells: Vec<HistoryCell>,
pub source_message_count: usize,
pub omitted_messages: usize,
pub local_cells: Vec<HistoryCell>,
pub scroll_top: Option<usize>,
pub last_visible: usize,
pub last_total: usize,
receipt_count: usize,
last_refresh: Instant,
}
impl AgentFocus {
fn new(agent_id: String, label: String) -> Self {
Self {
agent_id,
label,
cells: Vec::new(),
source_message_count: 0,
omitted_messages: 0,
local_cells: Vec::new(),
scroll_top: None,
last_visible: 0,
last_total: 0,
receipt_count: 0,
last_refresh: Instant::now() - REFRESH_INTERVAL,
}
}
pub fn is(&self, agent_id: &str) -> bool {
self.agent_id == agent_id
}
}
pub(crate) fn resolve_agent_transcript_messages(
app: &App,
agent_id: &str,
) -> (Vec<Message>, usize) {
if let Ok(messages) =
crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
&& !messages.is_empty()
{
return (messages, 0);
}
use crate::tools::handle::{HandleValue, VarHandle};
let lookup = VarHandle {
kind: "var_handle".to_string(),
session_id: format!("agent:{agent_id}"),
name: "full_transcript".to_string(),
type_name: String::new(),
length: 0,
repr_preview: String::new(),
sha256: String::new(),
};
let Ok(store) = app.runtime_services.handle_store.try_lock() else {
return (Vec::new(), 0);
};
let Some(record) = store.get(&lookup) else {
return (Vec::new(), 0);
};
let HandleValue::Json(payload) = &record.value else {
return (Vec::new(), 0);
};
let omitted = payload
.get("omitted_messages")
.and_then(serde_json::Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(0);
let messages = payload
.get("messages")
.and_then(serde_json::Value::as_array)
.map(|raw| {
raw.iter()
.filter_map(|value| serde_json::from_value::<Message>(value.clone()).ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
(messages, omitted)
}
pub(crate) fn agent_display_label(app: &App, agent_id: &str) -> String {
app.subagent_cache
.iter()
.find(|agent| agent.agent_id == agent_id)
.and_then(crate::tui::sidebar::dispatched_agent_name)
.map(str::to_string)
.unwrap_or_else(|| crate::tui::agent_details::safe_agent_display_name(app, agent_id))
}
fn cells_for_messages(messages: &[Message], receipts: &[(String, String)]) -> Vec<HistoryCell> {
use crate::models::ContentBlock;
let mut resulted: std::collections::HashSet<&str> = std::collections::HashSet::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
resulted.insert(tool_use_id.as_str());
}
}
}
let mut cells = Vec::new();
for message in messages {
cells.extend(history_cells_from_message(message));
for block in &message.content {
let anchor = match block {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
ContentBlock::ToolUse { id, .. } if !resulted.contains(id.as_str()) => {
Some(id.as_str())
}
_ => None,
};
let Some(anchor) = anchor else { continue };
for (tool_id, text) in receipts {
if tool_id == anchor {
cells.push(HistoryCell::System {
content: text.clone(),
});
}
}
}
}
cells
}
fn child_receipts<'a>(app: &'a App, agent_id: &str) -> &'a [(String, String)] {
app.child_gate_receipts
.get(agent_id)
.map_or(&[], Vec::as_slice)
}
pub(crate) fn focus_agent(app: &mut App, agent_id: &str) {
if app
.agent_focus
.as_ref()
.is_some_and(|focus| focus.is(agent_id))
{
app.needs_redraw = true;
return;
}
let label = agent_display_label(app, agent_id);
let mut focus = AgentFocus::new(agent_id.to_string(), label.clone());
let (messages, omitted) = resolve_agent_transcript_messages(app, agent_id);
let receipts = child_receipts(app, agent_id);
focus.cells = cells_for_messages(&messages, receipts);
focus.receipt_count = receipts.len();
focus.source_message_count = messages.len();
focus.omitted_messages = omitted;
focus.last_refresh = Instant::now();
app.agent_focus = Some(focus);
crate::tui::work_surface::release_focus(app);
app.scroll_to_bottom();
let status = app
.tr(MessageId::AgentFocusOpened)
.replace("{agent}", &label);
app.status_message = Some(status.clone());
app.push_status_toast(status, crate::tui::app::StatusToastLevel::Info, Some(4_000));
app.needs_redraw = true;
}
pub(crate) fn exit_focus(app: &mut App) -> bool {
let Some(focus) = app.agent_focus.take() else {
return false;
};
crate::tui::work_surface::agent_details_closed(app, &focus.agent_id);
app.scroll_to_bottom();
let status = app.tr(MessageId::AgentFocusClosed).into_owned();
app.status_message = Some(status);
app.needs_redraw = true;
true
}
pub(crate) fn refresh_focus(app: &mut App) {
let Some(focus) = app.agent_focus.as_ref() else {
return;
};
if focus.last_refresh.elapsed() < REFRESH_INTERVAL {
return;
}
let agent_id = focus.agent_id.clone();
let (messages, omitted) = resolve_agent_transcript_messages(app, &agent_id);
let receipts = child_receipts(app, &agent_id).to_vec();
let Some(focus) = app.agent_focus.as_mut() else {
return;
};
focus.last_refresh = Instant::now();
if messages.len() == focus.source_message_count
&& omitted == focus.omitted_messages
&& receipts.len() == focus.receipt_count
{
return;
}
let previous_count = focus.source_message_count;
focus.cells = cells_for_messages(&messages, &receipts);
focus.receipt_count = receipts.len();
focus.source_message_count = messages.len();
focus.omitted_messages = omitted;
for message in messages.iter().skip(previous_count) {
if message.role != "user" {
continue;
}
let text = message_plain_text(message);
if let Some(index) = focus.local_cells.iter().position(
|cell| matches!(cell, HistoryCell::User { content } if content.trim() == text.trim()),
) {
focus.local_cells.remove(index);
}
}
app.needs_redraw = true;
}
fn message_plain_text(message: &Message) -> String {
message
.content
.iter()
.filter_map(|block| match block {
crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn echo_user_follow_up(app: &mut App, text: &str) {
if let Some(focus) = app.agent_focus.as_mut() {
focus.local_cells.push(HistoryCell::User {
content: text.to_string(),
});
focus.scroll_top = None;
app.needs_redraw = true;
}
}
pub(crate) fn apply_follow_up_receipt(
app: &mut App,
agent_id: &str,
outcome: &Result<crate::tools::subagent::UserFollowUpOutcome, String>,
) {
let label = agent_display_label(app, agent_id);
let note = match outcome {
Ok(receipt) if receipt.resumed && receipt.target_agent_id != agent_id => {
let target_label = agent_display_label(app, &receipt.target_agent_id);
if app
.agent_focus
.as_ref()
.is_some_and(|focus| focus.is(agent_id))
{
let carried = app
.agent_focus
.as_ref()
.map(|focus| focus.local_cells.clone())
.unwrap_or_default();
app.agent_focus = None;
focus_agent(app, &receipt.target_agent_id);
if let Some(focus) = app.agent_focus.as_mut() {
focus.local_cells = carried;
}
}
app.tr(MessageId::AgentFocusFollowUpContinued)
.replace("{agent}", &label)
.replace("{target}", &target_label)
}
Ok(receipt) if receipt.delivered => app
.tr(MessageId::AgentFocusFollowUpDelivered)
.replace("{agent}", &label),
Ok(receipt) => app
.tr(MessageId::AgentFocusFollowUpFailed)
.replace("{agent}", &label)
.replace("{reason}", &receipt.note),
Err(reason) => app
.tr(MessageId::AgentFocusFollowUpFailed)
.replace("{agent}", &label)
.replace("{reason}", reason),
};
let level = if matches!(outcome, Ok(receipt) if receipt.delivered) {
crate::tui::app::StatusToastLevel::Info
} else {
crate::tui::app::StatusToastLevel::Warning
};
if let Some(focus) = app.agent_focus.as_mut() {
focus.local_cells.push(HistoryCell::System {
content: note.clone(),
});
focus.scroll_top = None;
}
app.status_message = Some(note.clone());
app.push_status_toast(note, level, Some(5_000));
app.needs_redraw = true;
}
pub(crate) fn focused_status(app: &App) -> Option<(char, String)> {
let focus = app.agent_focus.as_ref()?;
let agent = app
.subagent_cache
.iter()
.find(|agent| agent.agent_id == focus.agent_id)?;
Some(match &agent.status {
SubAgentStatus::Running => ('●', "running".to_string()),
SubAgentStatus::Completed => ('✓', "done".to_string()),
SubAgentStatus::Interrupted(_) => ('⏸', "interrupted".to_string()),
SubAgentStatus::Failed(_) => ('✕', "failed".to_string()),
SubAgentStatus::Cancelled => ('✕', "cancelled".to_string()),
SubAgentStatus::BudgetExhausted => ('◆', "budget exhausted".to_string()),
})
}
pub(crate) fn focused_posture(app: &App) -> Option<String> {
let focus = app.agent_focus.as_ref()?;
let agent = app
.subagent_cache
.iter()
.find(|agent| agent.agent_id == focus.agent_id)?;
let permissions = agent.runtime_permissions.as_ref()?;
let write = app.tr(if permissions.write {
MessageId::AgentFocusPostureWrites
} else {
MessageId::AgentFocusPostureReadOnly
});
let network = app.tr(if permissions.network {
MessageId::AgentFocusPostureNetwork
} else {
MessageId::AgentFocusPostureNoNetwork
});
let shell = app.tr(match permissions.shell.as_str() {
"full" => MessageId::AgentFocusPostureShellFull,
"read_only" => MessageId::AgentFocusPostureShellReadOnly,
_ => MessageId::AgentFocusPostureShellNone,
});
Some(
app.tr(MessageId::AgentFocusPosture)
.replace("{role}", agent.agent_type.as_str())
.replace("{write}", &write)
.replace("{network}", &network)
.replace("{shell}", &shell),
)
}
pub(crate) fn agents_exist(app: &App) -> bool {
!app.subagent_cache.is_empty() || !app.agent_progress.is_empty()
}
pub(crate) fn footer_agent_hints(app: &App) -> String {
let ascii = crate::tui::color_compat::ascii_safe_enabled();
let (left, down) = if ascii { ("<-", "v") } else { ("←", "↓") };
format!(
"{left} {} · {down} {}",
app.tr(MessageId::FooterHintForAgents),
app.tr(MessageId::FooterHintToManage)
)
}
pub(crate) fn queued_suffix(app: &App, agent_id: &str) -> Option<String> {
let count = *app.agent_queued_follow_ups.get(agent_id)?;
if count == 0 {
return None;
}
Some(
app.tr(MessageId::AgentRailQueuedCount)
.replace("{count}", &count.to_string()),
)
}
pub(crate) fn composer_chip_text(app: &App) -> Option<String> {
let focus = app.agent_focus.as_ref()?;
Some(
app.tr(MessageId::AgentFocusComposerChip)
.replace("{agent}", &focus.label),
)
}
pub(crate) fn composer_placeholder(app: &App) -> Option<String> {
let focus = app.agent_focus.as_ref()?;
Some(
app.tr(MessageId::AgentFocusPlaceholder)
.replace("{agent}", &focus.label),
)
}
pub(crate) fn render_focus(app: &mut App, area: Rect, buf: &mut Buffer) {
let Some(focus) = app.agent_focus.as_ref() else {
return;
};
let theme = app.ui_theme;
let background = Style::default().bg(theme.surface_bg);
buf.set_style(area, background);
if area.height == 0 || area.width == 0 {
return;
}
let (status_glyph, status_word) = focused_status(app).unwrap_or(('○', "unknown".to_string()));
let banner = app
.tr(MessageId::AgentFocusBanner)
.replace("{agent}", &focus.label)
.replace("{status}", &status_word);
let mut banner_spans = vec![
Span::styled(
format!("{status_glyph} "),
Style::default().fg(theme.accent_action),
),
Span::styled(
banner,
Style::default()
.fg(theme.accent_action)
.add_modifier(Modifier::BOLD),
),
];
if let Some(posture) = focused_posture(app) {
banner_spans.push(Span::styled(
format!(" · {posture}"),
Style::default().fg(theme.text_muted),
));
}
let banner_line = Line::from(banner_spans);
let width = area.width.max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
if focus.omitted_messages > 0 {
lines.push(Line::from(Span::styled(
app.tr(MessageId::AgentFocusOmitted)
.replace("{count}", &focus.omitted_messages.to_string()),
Style::default().fg(theme.text_muted),
)));
}
if focus.cells.is_empty() && focus.local_cells.is_empty() {
lines.push(Line::from(Span::styled(
app.tr(MessageId::AgentFocusNoTranscript)
.replace("{agent}", &focus.label),
Style::default().fg(theme.text_muted),
)));
}
for cell in focus.cells.iter().chain(focus.local_cells.iter()) {
lines.extend(cell.transcript_lines(width));
lines.push(Line::default());
}
let visible = usize::from(area.height.saturating_sub(1)).max(1);
let total = lines.len();
let max_top = total.saturating_sub(visible);
let delta = app.viewport.pending_scroll_delta;
app.viewport.pending_scroll_delta = 0;
let Some(focus) = app.agent_focus.as_mut() else {
return;
};
let current = focus.scroll_top.unwrap_or(max_top);
let next = if delta < 0 {
current.saturating_sub(delta.unsigned_abs() as usize)
} else {
current.saturating_add(delta as usize)
}
.min(max_top);
focus.scroll_top = if next >= max_top { None } else { Some(next) };
focus.last_visible = visible;
focus.last_total = total;
let top = focus.scroll_top.unwrap_or(max_top);
let banner_area = Rect::new(area.x, area.y, area.width, 1);
Paragraph::new(banner_line)
.style(background)
.render(banner_area, buf);
let body_area = Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height.saturating_sub(1),
);
let shown: Vec<Line<'static>> = lines.into_iter().skip(top).take(visible).collect();
Paragraph::new(shown)
.style(background)
.render(body_area, buf);
app.viewport.last_transcript_area = Some(body_area);
app.viewport.last_transcript_visible = visible;
app.viewport.last_transcript_total = total;
app.viewport.last_transcript_top = top;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use serde_json::json;
use std::path::PathBuf;
use tempfile::tempdir;
fn test_app(workspace: PathBuf) -> App {
App::new(
TuiOptions {
model: "test-model".to_string(),
use_mouse_capture: true,
max_subagents: 4,
..crate::test_support::test_tui_options(workspace)
},
&Config::default(),
)
}
fn seed_resident_transcript(app: &mut App, agent_id: &str, messages: serde_json::Value) {
let mut store = app
.runtime_services
.handle_store
.try_lock()
.expect("handle store");
let count = messages.as_array().map(|m| m.len()).unwrap_or(0);
let _ = store.insert_json(
format!("agent:{agent_id}"),
"full_transcript",
json!({ "message_count": count, "messages": messages }),
);
}
fn render(app: &mut App, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
render_focus(app, area, &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn focus_renders_the_childs_full_transcript_and_the_composer_addresses_it() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
seed_resident_transcript(
&mut app,
"agent_alpha",
json!([
{"role": "user", "content": [{"type": "text", "text": "Investigate the flaky test", "cache_control": null}]},
{"role": "assistant", "content": [{"type": "text", "text": "Found the race in the pool", "cache_control": null}]}
]),
);
focus_agent(&mut app, "agent_alpha");
let focus = app.agent_focus.as_ref().expect("focused");
assert_eq!(focus.source_message_count, 2);
assert_eq!(focus.cells.len(), 2);
let screen = render(&mut app, 80, 12);
assert!(screen.contains("Investigate the flaky test"), "{screen}");
assert!(screen.contains("Found the race in the pool"), "{screen}");
assert!(
composer_chip_text(&app)
.expect("chip")
.contains(&focus_label(&app)),
"chip names the focused worker"
);
assert!(composer_placeholder(&app).is_some());
assert!(exit_focus(&mut app));
assert!(app.agent_focus.is_none());
assert!(composer_chip_text(&app).is_none());
assert!(!exit_focus(&mut app));
}
#[test]
fn focus_banner_states_the_workers_effective_posture_from_the_runtime_snapshot() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
seed_resident_transcript(
&mut app,
"agent_scout",
json!([{"role": "user", "content": [{"type": "text", "text": "look around", "cache_control": null}]}]),
);
app.subagent_cache
.push(crate::tools::subagent::SubAgentResult {
name: "agent_scout".to_string(),
agent_id: "agent_scout".to_string(),
context_mode: "fresh".to_string(),
fork_context: false,
workspace: None,
git_branch: None,
agent_type: crate::tools::subagent::FleetRole::Scout,
assignment: crate::tools::subagent::SubAgentAssignment {
objective: "look around".to_string(),
role: Some("scout".to_string()),
},
model: "deepseek-v4-flash".to_string(),
nickname: None,
status: SubAgentStatus::Running,
worker_status: None,
runtime_permissions: Some(codewhale_protocol::fleet::FleetEffectivePermissions {
write: false,
network: true,
shell: "read_only".to_string(),
tool_scope: "inherit".to_string(),
tools: Vec::new(),
background: true,
max_spawn_depth: 1,
profile_id: None,
profile_origin: None,
source: "built_in".to_string(),
}),
parent_run_id: None,
spawn_depth: 1,
child_route: None,
result: None,
steps_taken: 0,
checkpoint: None,
needs_input: None,
duration_ms: 0,
started_at: None,
from_prior_session: false,
});
focus_agent(&mut app, "agent_scout");
let posture = focused_posture(&app).expect("posture line from the snapshot");
assert_eq!(posture, "scout · read-only · network · read-only shell");
let screen = render(&mut app, 100, 8);
assert!(
screen.contains("read-only · network · read-only shell"),
"{screen}"
);
app.subagent_cache[0].runtime_permissions = None;
assert!(focused_posture(&app).is_none());
}
fn focus_label(app: &App) -> String {
app.agent_focus.as_ref().map(|f| f.label.clone()).unwrap()
}
#[test]
fn empty_transcript_explains_instead_of_dead_ending() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
focus_agent(&mut app, "agent_quiet");
let screen = render(&mut app, 120, 8);
let expected = app
.tr(MessageId::AgentFocusNoTranscript)
.replace("{agent}", &focus_label(&app));
let head: String = expected.chars().take(24).collect();
assert!(screen.contains(head.trim_end()), "{screen}");
}
#[test]
fn user_echo_is_replaced_once_the_child_transcript_carries_it() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
seed_resident_transcript(
&mut app,
"agent_echo",
json!([{"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]}]),
);
focus_agent(&mut app, "agent_echo");
echo_user_follow_up(&mut app, "please continue");
assert_eq!(app.agent_focus.as_ref().unwrap().local_cells.len(), 1);
seed_resident_transcript(
&mut app,
"agent_echo",
json!([
{"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]},
{"role": "user", "content": [{"type": "text", "text": "please continue", "cache_control": null}]}
]),
);
app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
refresh_focus(&mut app);
let focus = app.agent_focus.as_ref().unwrap();
assert_eq!(focus.source_message_count, 2);
assert!(focus.local_cells.is_empty(), "echo dropped once carried");
}
#[test]
fn scrolling_pages_through_the_focused_transcript_and_returns_to_tail() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
let messages: Vec<serde_json::Value> = (0..40)
.map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
.collect();
seed_resident_transcript(&mut app, "agent_long", json!(messages));
focus_agent(&mut app, "agent_long");
let tail = render(&mut app, 60, 10);
assert!(tail.contains("line 39"), "{tail}");
app.scroll_up(1000);
let head = render(&mut app, 60, 10);
assert!(head.contains("line 0"), "{head}");
assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
app.scroll_down(100_000);
let back = render(&mut app, 60, 10);
assert!(back.contains("line 39"), "{back}");
assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_none());
}
#[test]
fn continued_fork_moves_focus_to_the_new_agent_and_reports_it() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
focus_agent(&mut app, "agent_done");
echo_user_follow_up(&mut app, "one more thing");
let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
agent_id: "agent_done".to_string(),
target_agent_id: "agent_fork".to_string(),
delivered: true,
resumed: true,
note: "continued".to_string(),
});
apply_follow_up_receipt(&mut app, "agent_done", &outcome);
let focus = app.agent_focus.as_ref().expect("focus follows the fork");
assert_eq!(focus.agent_id, "agent_fork");
assert!(
focus.local_cells.iter().any(
|cell| matches!(cell, HistoryCell::User { content } if content == "one more thing")
),
"echo carried across the fork"
);
assert!(
focus
.local_cells
.iter()
.any(|cell| matches!(cell, HistoryCell::System { .. })),
"receipt shown in the focused view"
);
let failed: Result<crate::tools::subagent::UserFollowUpOutcome, String> =
Err("status is cancelled".to_string());
apply_follow_up_receipt(&mut app, "agent_fork", &failed);
assert!(
app.status_message
.as_deref()
.is_some_and(|status| status.contains("status is cancelled"))
);
}
}