mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
fix(tui): mount subagent panel independently of active cell
This commit is contained in:
@@ -693,6 +693,523 @@ impl ThreadEventChannel {
|
||||
}
|
||||
}
|
||||
|
||||
const SUBAGENT_PROMPT_PREVIEW_BUDGET: usize = 120;
|
||||
const SUBAGENT_UPDATE_PREVIEW_BUDGET: usize = 160;
|
||||
const SUBAGENT_PENDING_EVENT_CAPACITY: usize = 12;
|
||||
const SUBAGENT_ANIMATION_TICK: Duration = Duration::from_millis(100);
|
||||
const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SubagentInfo {
|
||||
ordinal: i32,
|
||||
name: String,
|
||||
prompt_preview: String,
|
||||
spawn_mode: CollabAgentSpawnMode,
|
||||
status: AgentStatus,
|
||||
spawned_at: Instant,
|
||||
started_at: Option<Instant>,
|
||||
latest_summary: String,
|
||||
latest_preview: String,
|
||||
latest_update_at: Instant,
|
||||
inflight_message: String,
|
||||
reasoning_buffer: String,
|
||||
notified_terminal: bool,
|
||||
}
|
||||
|
||||
impl SubagentInfo {
|
||||
fn new(
|
||||
ordinal: i32,
|
||||
name: String,
|
||||
prompt_preview: String,
|
||||
spawn_mode: CollabAgentSpawnMode,
|
||||
) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
ordinal,
|
||||
name,
|
||||
prompt_preview: prompt_preview.clone(),
|
||||
spawn_mode,
|
||||
status: AgentStatus::PendingInit,
|
||||
spawned_at: now,
|
||||
started_at: None,
|
||||
latest_summary: String::new(),
|
||||
latest_preview: prompt_preview,
|
||||
latest_update_at: now,
|
||||
inflight_message: String::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
notified_terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running)
|
||||
}
|
||||
|
||||
fn is_watchdog(&self) -> bool {
|
||||
self.spawn_mode == CollabAgentSpawnMode::Watchdog
|
||||
}
|
||||
|
||||
fn is_visible_in_panel(&self) -> bool {
|
||||
if self.is_watchdog() {
|
||||
return matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running);
|
||||
}
|
||||
self.is_running()
|
||||
}
|
||||
|
||||
fn is_running_for_panel(&self) -> bool {
|
||||
if self.is_watchdog() {
|
||||
return matches!(self.status, AgentStatus::Running);
|
||||
}
|
||||
self.is_running()
|
||||
}
|
||||
|
||||
fn running_started_at(&self) -> Instant {
|
||||
self.started_at.unwrap_or(self.spawned_at)
|
||||
}
|
||||
|
||||
fn update_preview(&mut self, preview: String) {
|
||||
self.latest_preview = preview;
|
||||
self.latest_update_at = Instant::now();
|
||||
}
|
||||
|
||||
fn update_reasoning_summary(&mut self, delta: &str) {
|
||||
self.reasoning_buffer.push_str(delta);
|
||||
if let Some(summary) = extract_first_bold(&self.reasoning_buffer) {
|
||||
self.latest_summary = truncate_text(summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
self.latest_update_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_turn_buffers(&mut self) {
|
||||
self.inflight_message.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.latest_summary.clear();
|
||||
}
|
||||
|
||||
fn should_shimmer(&self, now: Instant) -> bool {
|
||||
if self.is_watchdog() && matches!(self.status, AgentStatus::PendingInit) {
|
||||
return false;
|
||||
}
|
||||
self.is_running()
|
||||
&& now.saturating_duration_since(self.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct SubagentRegistry {
|
||||
root_thread_id: Option<ThreadId>,
|
||||
agents: HashMap<ThreadId, SubagentInfo>,
|
||||
order: Vec<ThreadId>,
|
||||
pending_events: HashMap<ThreadId, Vec<EventMsg>>,
|
||||
pending_history: Vec<Box<dyn HistoryCell>>,
|
||||
panel_state: Option<Arc<StdMutex<SubagentPanelState>>>,
|
||||
panel_cell: Option<Arc<SubagentStatusCell>>,
|
||||
animations_enabled: bool,
|
||||
}
|
||||
|
||||
impl SubagentRegistry {
|
||||
fn new(animations_enabled: bool) -> Self {
|
||||
Self {
|
||||
animations_enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_root_thread(&mut self, thread_id: ThreadId) {
|
||||
self.root_thread_id = Some(thread_id);
|
||||
}
|
||||
|
||||
fn is_root_thread(&self, thread_id: ThreadId) -> bool {
|
||||
self.root_thread_id == Some(thread_id)
|
||||
}
|
||||
|
||||
fn contains(&self, thread_id: ThreadId) -> bool {
|
||||
self.agents.contains_key(&thread_id)
|
||||
}
|
||||
|
||||
fn on_spawn_end(&mut self, event: &CollabAgentSpawnEndEvent) -> Option<Box<dyn HistoryCell>> {
|
||||
let new_thread_id = event.new_thread_id?;
|
||||
if event.spawn_mode == CollabAgentSpawnMode::Watchdog {
|
||||
self.prune_superseded_watchdogs(new_thread_id);
|
||||
}
|
||||
if self.contains(new_thread_id) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ordinal = i32::try_from(self.order.len())
|
||||
.unwrap_or(i32::MAX - 1)
|
||||
.saturating_add(1);
|
||||
let prompt_preview = prompt_preview(&event.prompt);
|
||||
let name = derive_subagent_name(&event.prompt, ordinal);
|
||||
|
||||
let mut info = SubagentInfo::new(ordinal, name.clone(), prompt_preview, event.spawn_mode);
|
||||
info.status = event.status.clone();
|
||||
info.latest_preview = info.prompt_preview.clone();
|
||||
info.latest_update_at = Instant::now();
|
||||
|
||||
self.order.push(new_thread_id);
|
||||
self.agents.insert(new_thread_id, info);
|
||||
|
||||
let early_events = self
|
||||
.pending_events
|
||||
.remove(&new_thread_id)
|
||||
.unwrap_or_default();
|
||||
let mut follow_up = Vec::new();
|
||||
for msg in early_events {
|
||||
follow_up.extend(self.on_agent_event(new_thread_id, &msg));
|
||||
}
|
||||
for cell in follow_up {
|
||||
self.queue_history(cell);
|
||||
}
|
||||
|
||||
let prompt_line = prompt_first_line(&event.prompt);
|
||||
Some(Box::new(history_cell::new_subagent_spawned_cell(
|
||||
&name,
|
||||
&prompt_line,
|
||||
)))
|
||||
}
|
||||
|
||||
fn prune_superseded_watchdogs(&mut self, keep_thread_id: ThreadId) {
|
||||
let superseded: HashSet<ThreadId> = self
|
||||
.agents
|
||||
.iter()
|
||||
.filter_map(|(thread_id, info)| {
|
||||
(info.spawn_mode == CollabAgentSpawnMode::Watchdog && *thread_id != keep_thread_id)
|
||||
.then_some(*thread_id)
|
||||
})
|
||||
.collect();
|
||||
if superseded.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.order
|
||||
.retain(|thread_id| !superseded.contains(thread_id));
|
||||
self.agents
|
||||
.retain(|thread_id, _| !superseded.contains(thread_id));
|
||||
self.pending_events
|
||||
.retain(|thread_id, _| !superseded.contains(thread_id));
|
||||
}
|
||||
|
||||
fn on_close_end(&mut self, event: &CollabCloseEndEvent) -> Option<Box<dyn HistoryCell>> {
|
||||
let receiver_id = event.receiver_thread_id;
|
||||
let info = self.agents.get_mut(&receiver_id)?;
|
||||
info.status = event.status.clone();
|
||||
info.latest_update_at = Instant::now();
|
||||
|
||||
if is_terminal_status(&info.status) && !info.notified_terminal {
|
||||
info.notified_terminal = true;
|
||||
let summary = terminal_summary(&info.status);
|
||||
return Some(Box::new(history_cell::new_subagent_update_cell(
|
||||
&info.name,
|
||||
&info.status,
|
||||
summary.as_str(),
|
||||
)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_wait_end(&mut self, event: &CollabWaitingEndEvent) {
|
||||
for (thread_id, status) in &event.statuses {
|
||||
let Some(info) = self.agents.get_mut(thread_id) else {
|
||||
continue;
|
||||
};
|
||||
info.status = status.clone();
|
||||
info.latest_update_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_agent_event(&mut self, thread_id: ThreadId, msg: &EventMsg) -> Vec<Box<dyn HistoryCell>> {
|
||||
let Some(info) = self.agents.get_mut(&thread_id) else {
|
||||
self.buffer_pending_event(thread_id, msg.clone());
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut history = Vec::new();
|
||||
match msg {
|
||||
EventMsg::TurnStarted(TurnStartedEvent { .. }) => {
|
||||
info.clear_turn_buffers();
|
||||
info.status = AgentStatus::Running;
|
||||
if info.started_at.is_none() {
|
||||
info.started_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
EventMsg::AgentReasoningDelta(ev) => {
|
||||
info.update_reasoning_summary(ev.delta.as_str());
|
||||
}
|
||||
EventMsg::AgentReasoningRawContentDelta(ev) => {
|
||||
info.update_reasoning_summary(ev.delta.as_str());
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(ev) => {
|
||||
info.update_reasoning_summary(ev.text.as_str());
|
||||
info.reasoning_buffer.clear();
|
||||
}
|
||||
EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningSectionBreak(_) => {
|
||||
info.reasoning_buffer.clear();
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
info.inflight_message.push_str(delta);
|
||||
let preview =
|
||||
truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
info.update_preview(preview);
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => {
|
||||
info.inflight_message.clear();
|
||||
let preview = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
info.update_preview(preview);
|
||||
}
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message, ..
|
||||
}) => {
|
||||
info.inflight_message.clear();
|
||||
info.status = AgentStatus::Completed(last_agent_message.clone());
|
||||
if !info.notified_terminal {
|
||||
info.notified_terminal = true;
|
||||
let summary = last_agent_message
|
||||
.as_deref()
|
||||
.map(|message| {
|
||||
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
|
||||
})
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
history.push(Box::new(history_cell::new_subagent_update_cell(
|
||||
&info.name,
|
||||
&info.status,
|
||||
summary.as_str(),
|
||||
)) as Box<dyn HistoryCell>);
|
||||
}
|
||||
}
|
||||
EventMsg::TurnAborted(TurnAbortedEvent { reason, .. }) => {
|
||||
info.inflight_message.clear();
|
||||
let reason_text = format!("{reason:?}").to_lowercase();
|
||||
info.status = AgentStatus::Errored(reason_text.clone());
|
||||
if !info.notified_terminal {
|
||||
info.notified_terminal = true;
|
||||
history.push(Box::new(history_cell::new_subagent_update_cell(
|
||||
&info.name,
|
||||
&info.status,
|
||||
reason_text.as_str(),
|
||||
)) as Box<dyn HistoryCell>);
|
||||
}
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message, .. }) => {
|
||||
info.inflight_message.clear();
|
||||
let summary = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
info.status = AgentStatus::Errored(summary.clone());
|
||||
if !info.notified_terminal {
|
||||
info.notified_terminal = true;
|
||||
history.push(Box::new(history_cell::new_subagent_update_cell(
|
||||
&info.name,
|
||||
&info.status,
|
||||
summary.as_str(),
|
||||
)) as Box<dyn HistoryCell>);
|
||||
}
|
||||
}
|
||||
EventMsg::ShutdownComplete => {
|
||||
info.inflight_message.clear();
|
||||
info.status = AgentStatus::Shutdown;
|
||||
if !info.notified_terminal {
|
||||
info.notified_terminal = true;
|
||||
history.push(Box::new(history_cell::new_subagent_update_cell(
|
||||
&info.name,
|
||||
&info.status,
|
||||
"shutdown",
|
||||
)) as Box<dyn HistoryCell>);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if history.is_empty() && matches!(msg, EventMsg::TurnStarted(_)) {
|
||||
info.latest_update_at = Instant::now();
|
||||
}
|
||||
|
||||
history
|
||||
}
|
||||
|
||||
fn buffer_pending_event(&mut self, thread_id: ThreadId, msg: EventMsg) {
|
||||
if self.is_root_thread(thread_id) {
|
||||
return;
|
||||
}
|
||||
let entry = self.pending_events.entry(thread_id).or_default();
|
||||
entry.push(msg);
|
||||
if entry.len() > SUBAGENT_PENDING_EVENT_CAPACITY {
|
||||
let excess = entry.len() - SUBAGENT_PENDING_EVENT_CAPACITY;
|
||||
entry.drain(0..excess);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||
self.pending_history.push(cell);
|
||||
}
|
||||
|
||||
fn take_pending_history(&mut self) -> Vec<Box<dyn HistoryCell>> {
|
||||
std::mem::take(&mut self.pending_history)
|
||||
}
|
||||
|
||||
fn has_animating_agents(&self) -> bool {
|
||||
let now = Instant::now();
|
||||
self.agents.values().any(|info| info.should_shimmer(now))
|
||||
}
|
||||
|
||||
fn rebuild_panel_state(&mut self) {
|
||||
let mut running_infos: Vec<&SubagentInfo> = self
|
||||
.agents
|
||||
.values()
|
||||
.filter(|info| info.is_visible_in_panel())
|
||||
.collect();
|
||||
running_infos.sort_by_key(|info| info.ordinal);
|
||||
|
||||
if running_infos.is_empty() {
|
||||
self.panel_state = None;
|
||||
self.panel_cell = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let started_at = running_infos
|
||||
.iter()
|
||||
.map(|info| info.running_started_at())
|
||||
.min()
|
||||
.unwrap_or_else(Instant::now);
|
||||
let running_count = i32::try_from(
|
||||
running_infos
|
||||
.iter()
|
||||
.filter(|info| info.is_running_for_panel())
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(i32::MAX);
|
||||
let total_agents = i32::try_from(running_infos.len()).unwrap_or(i32::MAX);
|
||||
let running_agents = running_infos
|
||||
.into_iter()
|
||||
.map(|info| SubagentPanelAgent {
|
||||
ordinal: info.ordinal,
|
||||
name: info.name.clone(),
|
||||
status: info.status.clone(),
|
||||
is_watchdog: info.is_watchdog(),
|
||||
preview: running_preview(info),
|
||||
latest_update_at: info.latest_update_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let state = SubagentPanelState {
|
||||
started_at,
|
||||
total_agents,
|
||||
running_count,
|
||||
running_agents,
|
||||
};
|
||||
|
||||
match &self.panel_state {
|
||||
Some(existing) => {
|
||||
let mut guard = existing
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*guard = state;
|
||||
}
|
||||
None => {
|
||||
self.panel_state = Some(Arc::new(StdMutex::new(state)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(panel_state) = &self.panel_state {
|
||||
self.panel_cell = Some(Arc::new(SubagentStatusCell::new(
|
||||
Arc::clone(panel_state),
|
||||
self.animations_enabled,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_cell(&self) -> Option<Arc<SubagentStatusCell>> {
|
||||
self.panel_cell.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal_status(status: &AgentStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
AgentStatus::Completed(_)
|
||||
| AgentStatus::Errored(_)
|
||||
| AgentStatus::Shutdown
|
||||
| AgentStatus::NotFound
|
||||
)
|
||||
}
|
||||
|
||||
fn terminal_summary(status: &AgentStatus) -> String {
|
||||
match status {
|
||||
AgentStatus::Completed(Some(message)) => {
|
||||
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
|
||||
}
|
||||
AgentStatus::Completed(None) => "completed".to_string(),
|
||||
AgentStatus::Errored(message) => {
|
||||
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
|
||||
}
|
||||
AgentStatus::Shutdown => "shutdown".to_string(),
|
||||
AgentStatus::NotFound => "not found".to_string(),
|
||||
AgentStatus::PendingInit | AgentStatus::Running => "running".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_first_line(prompt: &str) -> String {
|
||||
prompt
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn prompt_preview(prompt: &str) -> String {
|
||||
let first_line = prompt_first_line(prompt);
|
||||
truncate_text(first_line.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET)
|
||||
}
|
||||
|
||||
fn running_preview(info: &SubagentInfo) -> String {
|
||||
if !info.latest_summary.trim().is_empty() {
|
||||
return truncate_text(info.latest_summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
}
|
||||
if !info.inflight_message.trim().is_empty() {
|
||||
return truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
}
|
||||
if !info.latest_preview.trim().is_empty() {
|
||||
return truncate_text(info.latest_preview.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
|
||||
}
|
||||
truncate_text(info.prompt_preview.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET)
|
||||
}
|
||||
|
||||
fn derive_subagent_name(prompt: &str, ordinal: i32) -> String {
|
||||
let first_line = prompt_first_line(prompt);
|
||||
let stripped = first_line
|
||||
.strip_prefix("Task:")
|
||||
.or_else(|| first_line.strip_prefix("task:"))
|
||||
.unwrap_or(&first_line)
|
||||
.trim();
|
||||
|
||||
let stopwords = [
|
||||
"the", "a", "an", "to", "and", "or", "of", "for", "from", "in", "on", "with", "read",
|
||||
"file", "task",
|
||||
];
|
||||
|
||||
let tokens: Vec<String> = stripped
|
||||
.split_whitespace()
|
||||
.map(clean_token)
|
||||
.filter(|token| !token.is_empty())
|
||||
.filter(|token| !stopwords.contains(&token.as_str()))
|
||||
.take(4)
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return format!("agent-{ordinal}");
|
||||
}
|
||||
|
||||
let joined = tokens.join("-");
|
||||
truncate_text(&joined, 40)
|
||||
}
|
||||
|
||||
fn clean_token(token: &str) -> String {
|
||||
token
|
||||
.chars()
|
||||
.map(|ch| ch.to_ascii_lowercase())
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_show_model_migration_prompt(
|
||||
current_model: &str,
|
||||
target_model: &str,
|
||||
@@ -6701,6 +7218,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_subagent_panel_update_mounts_on_fresh_chat_widget_after_thread_switch() {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let root_thread_id = ThreadId::new();
|
||||
let subagent_thread_id = ThreadId::new();
|
||||
|
||||
app.primary_thread_id = Some(root_thread_id);
|
||||
app.active_thread_id = Some(root_thread_id);
|
||||
app.subagents.set_root_thread(root_thread_id);
|
||||
|
||||
let mut info = SubagentInfo::new(
|
||||
1,
|
||||
"watchdog-agent".to_string(),
|
||||
"watchdog idle".to_string(),
|
||||
CollabAgentSpawnMode::Watchdog,
|
||||
);
|
||||
info.status = AgentStatus::PendingInit;
|
||||
info.latest_preview = "watchdog idle".to_string();
|
||||
info.latest_update_at = Instant::now();
|
||||
app.subagents.order.push(subagent_thread_id);
|
||||
app.subagents.agents.insert(subagent_thread_id, info);
|
||||
|
||||
app.sync_subagent_panel_state();
|
||||
|
||||
let queued_panel = match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::UpdateSubagentPanel(panel)) => panel,
|
||||
other => panic!("expected queued subagent panel update, got {other:?}"),
|
||||
};
|
||||
|
||||
let (fresh_chat_widget, _fresh_app_event_tx, _fresh_rx, _fresh_op_rx) =
|
||||
make_chatwidget_manual_with_sender().await;
|
||||
app.chat_widget = fresh_chat_widget;
|
||||
app.chat_widget.set_composer_text(
|
||||
"back on the root thread".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
app.chat_widget.on_subagent_panel_updated(queued_panel);
|
||||
|
||||
let width = 80;
|
||||
let height = app.chat_widget.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(crate::test_backend::VT100Backend::new(width, height))
|
||||
.expect("create terminal");
|
||||
terminal.set_viewport_area(ratatui::prelude::Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| app.chat_widget.render(f.area(), f.buffer_mut()))
|
||||
.expect("render fresh widget with queued subagent panel");
|
||||
let screen = terminal.backend().vt100().screen().contents();
|
||||
|
||||
assert!(
|
||||
screen.contains("Subagents"),
|
||||
"queued subagent panel update should mount on the fresh widget"
|
||||
);
|
||||
assert!(screen.contains("watchdog-agent"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -780,6 +780,7 @@ pub(crate) struct ChatWidget {
|
||||
codex_op_target: CodexOpTarget,
|
||||
bottom_pane: BottomPane,
|
||||
active_cell: Option<Box<dyn HistoryCell>>,
|
||||
subagent_panel: Option<SubagentStatusCell>,
|
||||
/// Monotonic-ish counter used to invalidate transcript overlay caching.
|
||||
///
|
||||
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
|
||||
@@ -4038,6 +4039,35 @@ impl ChatWidget {
|
||||
self.run_commit_tick();
|
||||
}
|
||||
|
||||
pub(crate) fn on_subagent_panel_updated(&mut self, panel: Arc<SubagentStatusCell>) {
|
||||
let state_handle = panel.state_handle();
|
||||
|
||||
if let Some(existing) = self.subagent_panel.as_mut() {
|
||||
if existing.matches_state(&state_handle) {
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
*existing = panel.as_ref().clone();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
self.subagent_panel = Some(panel.as_ref().clone());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_subagent_panel(&mut self) {
|
||||
if self.subagent_panel.take().is_some() {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_subagent_tick(&mut self) {
|
||||
if self.subagent_panel.is_some() {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a regular periodic commit tick.
|
||||
fn run_commit_tick(&mut self) {
|
||||
self.run_commit_tick_with_scope(CommitTickScope::AnyMode);
|
||||
@@ -4514,8 +4544,209 @@ impl ChatWidget {
|
||||
self.had_work_activity = true;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self {
|
||||
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
|
||||
=======
|
||||
pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc<ThreadManager>) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
initial_user_message,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
feedback,
|
||||
is_first_run,
|
||||
feedback_audience,
|
||||
model,
|
||||
startup_tooltip_override,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
session_telemetry,
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
let mut config = config;
|
||||
config.model = model.clone();
|
||||
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
|
||||
|
||||
let model_override = model.as_deref();
|
||||
let model_for_header = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or_else(|| model_for_header.clone());
|
||||
let fallback_default = Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
// Collaboration modes start in Default mode.
|
||||
let current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
animations_enabled: config.animations,
|
||||
skills: None,
|
||||
}),
|
||||
active_cell,
|
||||
subagent_panel: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_telemetry,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
rate_limit_poller: None,
|
||||
adaptive_chunking: AdaptiveChunkingPolicy::default(),
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
last_copyable_output: None,
|
||||
running_commands: HashMap::new(),
|
||||
pending_collab_spawn_requests: HashMap::new(),
|
||||
suppressed_exec_calls: HashSet::new(),
|
||||
last_unified_wait: None,
|
||||
unified_exec_wait_streak: None,
|
||||
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
|
||||
task_complete_pending: false,
|
||||
unified_exec_processes: Vec::new(),
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
connectors_partial_snapshot: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
pending_mcp_output_requests: 0,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status: StatusIndicatorState::working(),
|
||||
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
startup_tooltip_override,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
had_work_activity: false,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
last_plan_progress: None,
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
last_separator_elapsed_secs: None,
|
||||
turn_runtime_metrics: RuntimeMetricsSummary::default(),
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
feedback_audience,
|
||||
current_rollout_path: None,
|
||||
current_cwd,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_replayed_agent_inbox_message: None,
|
||||
last_rendered_user_message_event: None,
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
widget.bottom_pane.set_voice_transcription_enabled(
|
||||
widget.config.features.enabled(Feature::VoiceTranscription),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_collaboration_modes_enabled(/*enabled*/ true);
|
||||
widget.sync_fast_command_enabled();
|
||||
widget.sync_personality_command_enabled();
|
||||
widget.sync_plugins_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
|
||||
#[cfg(target_os = "windows")]
|
||||
widget.bottom_pane.set_windows_degraded_sandbox_active(
|
||||
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& matches!(
|
||||
WindowsSandboxLevel::from_config(&widget.config),
|
||||
WindowsSandboxLevel::RestrictedToken
|
||||
),
|
||||
);
|
||||
widget.update_collaboration_mode_indicator();
|
||||
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.connectors_enabled());
|
||||
|
||||
widget.refresh_terminal_title();
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -4593,6 +4824,7 @@ impl ChatWidget {
|
||||
skills: None,
|
||||
}),
|
||||
active_cell,
|
||||
subagent_panel: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
@@ -4654,7 +4886,199 @@ impl ChatWidget {
|
||||
show_welcome_banner: is_first_run,
|
||||
startup_tooltip_override,
|
||||
suppress_session_configured_redraw: false,
|
||||
<<<<<<< HEAD
|
||||
suppress_initial_user_message_submit: false,
|
||||
=======
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
had_work_activity: false,
|
||||
last_separator_elapsed_secs: None,
|
||||
turn_runtime_metrics: RuntimeMetricsSummary::default(),
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
feedback_audience,
|
||||
current_rollout_path: None,
|
||||
current_cwd,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_replayed_agent_inbox_message: None,
|
||||
last_rendered_user_message_event: None,
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
widget.bottom_pane.set_voice_transcription_enabled(
|
||||
widget.config.features.enabled(Feature::VoiceTranscription),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_collaboration_modes_enabled(/*enabled*/ true);
|
||||
widget.sync_fast_command_enabled();
|
||||
widget.sync_personality_command_enabled();
|
||||
widget.sync_plugins_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.connectors_enabled());
|
||||
widget.refresh_terminal_title();
|
||||
widget.refresh_terminal_title();
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
|
||||
pub(crate) fn new_from_existing(
|
||||
common: ChatWidgetInit,
|
||||
conversation: std::sync::Arc<codex_core::CodexThread>,
|
||||
session_configured: codex_protocol::protocol::SessionConfiguredEvent,
|
||||
) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
initial_user_message,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
feedback,
|
||||
is_first_run: _,
|
||||
feedback_audience,
|
||||
model,
|
||||
startup_tooltip_override: _,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
session_telemetry,
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
|
||||
let model_override = model.as_deref();
|
||||
let header_model = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_configured.model.clone());
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or(header_model);
|
||||
|
||||
let current_cwd = Some(session_configured.cwd.clone());
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
|
||||
let fallback_default = Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
// Collaboration modes start in Default mode.
|
||||
let current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
animations_enabled: config.animations,
|
||||
skills: None,
|
||||
}),
|
||||
active_cell: None,
|
||||
subagent_panel: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_telemetry,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
rate_limit_poller: None,
|
||||
adaptive_chunking: AdaptiveChunkingPolicy::default(),
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
last_copyable_output: None,
|
||||
running_commands: HashMap::new(),
|
||||
pending_collab_spawn_requests: HashMap::new(),
|
||||
suppressed_exec_calls: HashSet::new(),
|
||||
last_unified_wait: None,
|
||||
unified_exec_wait_streak: None,
|
||||
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
|
||||
task_complete_pending: false,
|
||||
unified_exec_processes: Vec::new(),
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
connectors_partial_snapshot: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
pending_mcp_output_requests: 0,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status: StatusIndicatorState::working(),
|
||||
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: false,
|
||||
startup_tooltip_override: None,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
@@ -10724,8 +11148,13 @@ impl ChatWidget {
|
||||
)),
|
||||
None => RenderableItem::Owned(Box::new(())),
|
||||
};
|
||||
let subagent_panel_renderable = match &self.subagent_panel {
|
||||
Some(panel) => RenderableItem::Borrowed(panel).inset(Insets::tlbr(1, 0, 0, 0)),
|
||||
None => RenderableItem::Owned(Box::new(())),
|
||||
};
|
||||
let mut flex = FlexRenderable::new();
|
||||
flex.push(/*flex*/ 1, active_cell_renderable);
|
||||
flex.push(0, subagent_panel_renderable);
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
╭──────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: ~/code/codex-split/…/codex-rs/tui │
|
||||
╰──────────────────────────────────────────────╯
|
||||
|
||||
• Subagents (0s • no subagents running • esc to interrupt)
|
||||
• [#1] [watchdog] watchdog-agent idle — watchdog idle
|
||||
|
||||
|
||||
› show current subagent state
|
||||
|
||||
100% context left
|
||||
@@ -2166,6 +2166,7 @@ async fn make_chatwidget_manual(
|
||||
codex_op_target: super::CodexOpTarget::Direct(op_tx),
|
||||
bottom_pane: bottom,
|
||||
active_cell: None,
|
||||
subagent_panel: None,
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
current_collaboration_mode,
|
||||
@@ -2451,7 +2452,7 @@ async fn subagent_panel_is_not_flushed_into_transcript_history() {
|
||||
latest_update_at: Instant::now(),
|
||||
}],
|
||||
}));
|
||||
chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(state, true)));
|
||||
chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(Arc::clone(&state), true)));
|
||||
|
||||
chat.add_to_history(history_cell::new_error_event("follow-up cell".to_string()));
|
||||
|
||||
@@ -2464,11 +2465,66 @@ async fn subagent_panel_is_not_flushed_into_transcript_history() {
|
||||
let rendered = lines_to_single_string(&inserted[0]);
|
||||
assert!(rendered.contains("follow-up cell"));
|
||||
assert!(!rendered.contains("Subagents"));
|
||||
assert!(
|
||||
chat.subagent_panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.matches_state(&state)),
|
||||
"subagent panel should stay mounted after other history cells are inserted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_panel_mounts_while_placeholder_active_cell_exists_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.active_cell = Some(ChatWidget::placeholder_session_header_cell(
|
||||
chat.config_ref(),
|
||||
));
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"show current subagent state".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let state = Arc::new(StdMutex::new(SubagentPanelState {
|
||||
started_at: Instant::now(),
|
||||
total_agents: 1,
|
||||
running_count: 0,
|
||||
running_agents: vec![SubagentPanelAgent {
|
||||
ordinal: 1,
|
||||
name: "watchdog-agent".to_string(),
|
||||
status: AgentStatus::PendingInit,
|
||||
is_watchdog: true,
|
||||
preview: "watchdog idle".to_string(),
|
||||
latest_update_at: Instant::now(),
|
||||
}],
|
||||
}));
|
||||
chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(Arc::clone(&state), false)));
|
||||
|
||||
assert!(
|
||||
chat.active_cell
|
||||
.as_ref()
|
||||
.is_some_and(|cell| cell.as_any().is::<SubagentStatusCell>()),
|
||||
"subagent panel should stay mounted after other history cells are inserted"
|
||||
.is_some_and(|cell| cell.as_any().is::<history_cell::SessionHeaderHistoryCell>()),
|
||||
"placeholder session header should remain the active cell"
|
||||
);
|
||||
assert!(
|
||||
chat.subagent_panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.matches_state(&state)),
|
||||
"subagent panel should mount even when another active cell already exists"
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("render chat with placeholder header and subagent panel");
|
||||
|
||||
assert_snapshot!(
|
||||
"subagent_panel_mounts_while_placeholder_active_cell_exists",
|
||||
terminal.backend().vt100().screen().contents()
|
||||
);
|
||||
}
|
||||
fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo {
|
||||
|
||||
@@ -566,9 +566,12 @@ impl SubagentStatusCell {
|
||||
|
||||
impl HistoryCell for SubagentStatusCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let state = match self.state.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
let state = {
|
||||
let guard = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
guard.clone()
|
||||
};
|
||||
if state.running_agents.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -618,10 +621,10 @@ impl HistoryCell for SubagentStatusCell {
|
||||
if !self.animations_enabled {
|
||||
return None;
|
||||
}
|
||||
let guard = match self.state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let guard = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let now = Instant::now();
|
||||
if !guard.has_animating_agents(now) {
|
||||
return None;
|
||||
@@ -630,7 +633,27 @@ impl HistoryCell for SubagentStatusCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Renderable for SubagentStatusCell {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.display_lines(area.width);
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
} else {
|
||||
let overflow = paragraph
|
||||
.line_count(area.width)
|
||||
.saturating_sub(usize::from(area.height));
|
||||
u16::try_from(overflow).unwrap_or(u16::MAX)
|
||||
};
|
||||
paragraph.scroll((y, 0)).render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
HistoryCell::desired_height(self, width)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn new_subagent_spawned_cell(name: &str, prompt_preview: &str) -> PlainHistoryCell {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(vec![
|
||||
|
||||
Reference in New Issue
Block a user