返回 CodeWhale
agent_focus.rs
根目录 / crates / tui / src / tui / agent_focus.rs
1 //! Agent focus — one child's full conversation takes over the main
2 //! transcript area and the composer addresses that child's fork.
3 //!
4 //! Selecting a worker anywhere it is listed (the Agents rail panel, the
5 //! sub-agent cards, `/agents`) focuses it: the transcript area shows the
6 //! child's complete chat rendered with the same history cells as the main
7 //! conversation and scrolls the same way, the focused rail row carries a
8 //! left-edge marker, and the composer grows a chip naming the worker so it is
9 //! unmistakable that the next message goes to *that* fork. Esc on an empty
10 //! composer returns to the main conversation.
11 //!
12 //! Follow-ups are real runtime work, never a UI illusion: a running child
13 //! receives the text on its live input channel; an interrupted or completed
14 //! child is continued from its checkpoint on a new fork (the terminal record
15 //! is an immutable receipt), and focus follows the fork. Failed and cancelled
16 //! children answer with the exact reason they cannot continue.
17
18 use std::time::{Duration, Instant};
19
20 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
21 use ratatui::{
22 buffer::Buffer,
23 layout::Rect,
24 style::{Modifier, Style},
25 text::{Line, Span},
26 };
27
28 use crate::tools::subagent::SubAgentStatus;
29 use crate::tui::app::App;
30 use crate::tui::history::{HistoryCell, history_cells_from_message};
31 use codewhale_localization::MessageId;
32 use codewhale_models::Message;
33
34 /// How often the focused transcript re-reads the child's durable artifact.
35 /// The rail's live activity line already ticks per event; the full chat only
36 /// needs to catch up at a human cadence.
37 const REFRESH_INTERVAL: Duration = Duration::from_millis(400);
38
39 /// Focus state for one child.
40 #[derive(Debug, Clone)]
41 pub struct AgentFocus {
42 /// The worker whose fork the composer addresses.
43 pub agent_id: String,
44 /// Stable user-facing name (dispatch name, generated whale, or label).
45 pub label: String,
46 /// The child's transcript rendered as ordinary history cells.
47 pub cells: Vec<HistoryCell>,
48 /// Number of source messages the cells were built from.
49 pub source_message_count: usize,
50 /// Messages omitted from the resident tail (durable artifact absent).
51 pub omitted_messages: usize,
52 /// A settled agent's full result (or the reason it stopped), shown after
53 /// the transcript when the transcript does not already end with it, and
54 /// on its own when there is no transcript at all (#6565).
55 /// The text and whether it is a stop reason rather than a result.
56 pub result: Option<(String, bool)>,
57 /// Local receipts appended after the source transcript: the user's own
58 /// follow-ups echoed immediately and delivery notes.
59 pub local_cells: Vec<HistoryCell>,
60 /// Scroll position from the top in visual lines; `None` follows the tail.
61 pub scroll_top: Option<usize>,
62 /// Last visible-line count, so paging keys move a screen at a time.
63 pub last_visible: usize,
64 /// Last total line count after wrapping (for scrollbar/clamping).
65 pub last_total: usize,
66 /// Number of permission receipts folded into `cells`, so a new receipt
67 /// on an unchanged message count still triggers a rebuild.
68 receipt_count: usize,
69 last_refresh: Instant,
70 }
71
72 impl AgentFocus {
73 fn new(agent_id: String, label: String) -> Self {
74 Self {
75 agent_id,
76 label,
77 cells: Vec::new(),
78 source_message_count: 0,
79 omitted_messages: 0,
80 result: None,
81 local_cells: Vec::new(),
82 scroll_top: None,
83 last_visible: 0,
84 last_total: 0,
85 receipt_count: 0,
86 last_refresh: Instant::now() - REFRESH_INTERVAL,
87 }
88 }
89
90 /// Whether the given agent is the focused one.
91 pub fn is(&self, agent_id: &str) -> bool {
92 self.agent_id == agent_id
93 }
94 }
95
96 /// Load a child's transcript messages: the durable on-disk artifact first
97 /// (the whole chat), else the bounded resident handle. Returns the messages and
98 /// how many earlier messages the resident tail omitted.
99 pub(crate) fn resolve_agent_transcript_messages(
100 app: &App,
101 agent_id: &str,
102 ) -> (Vec<Message>, usize) {
103 if let Ok(messages) =
104 crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
105 && !messages.is_empty()
106 {
107 return (messages, 0);
108 }
109 use crate::tools::handle::{HandleValue, VarHandle};
110 let lookup = VarHandle {
111 kind: "var_handle".to_string(),
112 session_id: format!("agent:{agent_id}"),
113 name: "full_transcript".to_string(),
114 type_name: String::new(),
115 length: 0,
116 repr_preview: String::new(),
117 sha256: String::new(),
118 };
119 let Ok(store) = app.runtime_services.handle_store.try_lock() else {
120 return (Vec::new(), 0);
121 };
122 let Some(record) = store.get(&lookup) else {
123 return (Vec::new(), 0);
124 };
125 let HandleValue::Json(payload) = &record.value else {
126 return (Vec::new(), 0);
127 };
128 let omitted = payload
129 .get("omitted_messages")
130 .and_then(serde_json::Value::as_u64)
131 .and_then(|value| usize::try_from(value).ok())
132 .unwrap_or(0);
133 let messages = payload
134 .get("messages")
135 .and_then(serde_json::Value::as_array)
136 .map(|raw| {
137 raw.iter()
138 .filter_map(|value| serde_json::from_value::<Message>(value.clone()).ok())
139 .collect::<Vec<_>>()
140 })
141 .unwrap_or_default();
142 (messages, omitted)
143 }
144
145 /// The same name the rail shows for a worker: its dispatch/session name when
146 /// it has one, else the generated or labelled display name.
147 pub(crate) fn agent_display_label(app: &App, agent_id: &str) -> String {
148 app.agent_given_name(agent_id)
149 .unwrap_or_else(|| crate::tui::agent_details::safe_agent_display_name(app, agent_id))
150 }
151
152 /// Render a child's messages as history cells, folding in the permission
153 /// receipts recorded for that child: each receipt lands right after the
154 /// message that carries the tool's result (or, while the call is still
155 /// running, after the tool-use block itself), so a decision reads in place.
156 fn cells_for_messages(messages: &[Message], receipts: &[(String, String)]) -> Vec<HistoryCell> {
157 use codewhale_models::ContentBlock;
158 let mut calls = std::collections::HashMap::new();
159 let mut results = std::collections::HashMap::new();
160 let mut raw_ids = std::collections::HashMap::<&str, usize>::new();
161 for block in messages.iter().flat_map(|message| &message.content) {
162 let Some(key) = block
163 .tool_call_key()
164 .filter(|key| !key.as_str().trim().is_empty())
165 else {
166 continue;
167 };
168 match block {
169 ContentBlock::ToolUse { id, .. } => {
170 *raw_ids.entry(key.as_str()).or_default() += 1;
171 calls
172 .entry(key)
173 .and_modify(|entry| *entry = None)
174 .or_insert(Some(id.as_str()));
175 }
176 ContentBlock::ToolResult { tool_use_id, .. } => {
177 results
178 .entry(key)
179 .and_modify(|entry| *entry = None)
180 .or_insert(Some(tool_use_id.as_str()));
181 }
182 _ => {}
183 }
184 }
185 let mut cells = Vec::new();
186 for message in messages {
187 cells.extend(history_cells_from_message(message));
188 for block in &message.content {
189 let Some(key) = block.tool_call_key() else {
190 continue;
191 };
192 let Some(Some(provider)) = calls.get(&key) else {
193 continue;
194 };
195 if raw_ids.get(key.as_str()) != Some(&1) {
196 continue;
197 }
198 let anchor = match block {
199 ContentBlock::ToolResult { tool_use_id, .. } => {
200 results.get(&key) == Some(&Some(*provider)) && tool_use_id.as_str() == *provider
201 }
202 ContentBlock::ToolUse { .. } => !results.contains_key(&key),
203 _ => false,
204 };
205 if anchor {
206 for (_, text) in receipts.iter().filter(|(id, _)| id == key.as_str()) {
207 cells.push(HistoryCell::System {
208 content: text.clone(),
209 });
210 }
211 }
212 }
213 }
214 cells
215 }
216
217 fn child_receipts<'a>(app: &'a App, agent_id: &str) -> &'a [(String, String)] {
218 app.child_gate_receipts
219 .get(agent_id)
220 .map_or(&[], Vec::as_slice)
221 }
222
223 /// What a settled agent produced, in full, and whether it is a failure
224 /// reason rather than a result. The durable worker record (the roster) holds
225 /// the complete text; the manager snapshot is the fallback. `None` while the
226 /// agent is live or when it left nothing. Not redacted; see [`settled_result`].
227 pub(crate) fn settled_result_raw(app: &App, agent_id: &str) -> Option<(String, bool)> {
228 let from_roster = app
229 .current_agent_roster()
230 .iter()
231 .find(|row| row.worker_id == agent_id)
232 .and_then(|row| {
233 let failed = matches!(
234 row.state,
235 crate::agent_roster::RosterState::Failed
236 | crate::agent_roster::RosterState::Cancelled
237 );
238 row.outcome.clone().map(|text| (text, failed))
239 });
240 let from_snapshot = || {
241 let agent = app
242 .subagent_cache
243 .iter()
244 .find(|agent| agent.agent_id == agent_id)?;
245 match &agent.status {
246 SubAgentStatus::Running => None,
247 SubAgentStatus::Completed => agent.result.clone().map(|text| (text, false)),
248 SubAgentStatus::Failed(reason) | SubAgentStatus::Interrupted(reason) => {
249 Some((reason.clone(), true))
250 }
251 SubAgentStatus::Cancelled | SubAgentStatus::BudgetExhausted => {
252 agent.result.clone().map(|text| (text, true))
253 }
254 }
255 };
256 from_roster
257 .or_else(from_snapshot)
258 .filter(|(text, _)| !text.trim().is_empty())
259 }
260
261 /// Strip terminal escapes and redact secrets before agent text is shown.
262 fn visible_agent_text(text: &str) -> String {
263 let mut visible = String::with_capacity(text.len());
264 crate::tui::osc8::strip_ansi_into(text, &mut visible);
265 codewhale_config::persistence::redact_secrets(&visible)
266 }
267
268 /// [`settled_result_raw`], safe to render.
269 pub(crate) fn settled_result(app: &App, agent_id: &str) -> Option<(String, bool)> {
270 settled_result_raw(app, agent_id)
271 .map(|(text, failed)| (visible_agent_text(&text).trim().to_string(), failed))
272 .filter(|(text, _)| !text.is_empty())
273 }
274
275 /// One line saying what a settled agent produced (its result headline, or
276 /// why it stopped), bounded for a row with a visible `…` when cut. The full
277 /// text is in the agent's focused view.
278 pub(crate) fn settled_headline(app: &App, agent_id: &str) -> Option<String> {
279 let (text, _) = settled_result_raw(app, agent_id)?;
280 crate::agent_roster::result_headline(&text)
281 .map(|headline| crate::agent_roster::one_line(&visible_agent_text(&headline)))
282 }
283
284 /// The result block for a settled agent, unless the transcript already ends
285 /// with that answer. The completion envelope is machine data and is dropped.
286 fn result_block_for(app: &App, agent_id: &str, messages: &[Message]) -> Option<(String, bool)> {
287 let (text, failed) = settled_result(app, agent_id)?;
288 let text = text
289 .lines()
290 .filter(|line| !line.trim_start().starts_with("<codewhale:"))
291 .collect::<Vec<_>>()
292 .join("\n");
293 let text = text.trim();
294 if text.is_empty() {
295 return None;
296 }
297 let answered = messages
298 .iter()
299 .rev()
300 .find(|message| message.role == "assistant")
301 .map(message_plain_text)
302 .is_some_and(|last| {
303 crate::agent_roster::result_headline(text)
304 .is_some_and(|headline| last.contains(headline.as_str()))
305 });
306 (!answered || failed).then(|| (text.to_string(), failed))
307 }
308
309 /// The heading and body cells for a result block.
310 fn result_cells(app: &App, result: Option<&(String, bool)>) -> Vec<HistoryCell> {
311 let Some((text, failed)) = result else {
312 return Vec::new();
313 };
314 let heading = if *failed {
315 MessageId::AgentFocusStopReason
316 } else {
317 MessageId::AgentFocusResult
318 };
319 vec![
320 HistoryCell::System {
321 content: app.tr(heading).into_owned(),
322 },
323 HistoryCell::Assistant {
324 content: text.clone(),
325 streaming: false,
326 },
327 ]
328 }
329
330 /// Focus a child: its full transcript owns the main area and the composer
331 /// addresses its fork. Re-focusing the same child is a no-op that keeps the
332 /// scroll position; focusing another child replaces the focus.
333 pub(crate) fn focus_agent(app: &mut App, agent_id: &str) {
334 if app
335 .agent_focus
336 .as_ref()
337 .is_some_and(|focus| focus.is(agent_id))
338 {
339 app.needs_redraw = true;
340 return;
341 }
342 let label = agent_display_label(app, agent_id);
343 let mut focus = AgentFocus::new(agent_id.to_string(), label.clone());
344 let (messages, omitted) = resolve_agent_transcript_messages(app, agent_id);
345 let receipts = child_receipts(app, agent_id);
346 focus.cells = cells_for_messages(&messages, receipts);
347 focus.receipt_count = receipts.len();
348 focus.source_message_count = messages.len();
349 focus.omitted_messages = omitted;
350 focus.result = result_block_for(app, agent_id, &messages);
351 focus.last_refresh = Instant::now();
352 app.agent_focus = Some(focus);
353 crate::extension_host::command::bump_epoch();
354 // The composer is now the natural owner: the next keys address the
355 // worker, and Esc leaves focus rather than the rail.
356 crate::tui::work_surface::release_focus(app);
357 app.scroll_to_bottom();
358 let status = app
359 .tr(MessageId::AgentFocusOpened)
360 .replace("{agent}", &label);
361 app.status_message = Some(status.clone());
362 app.push_status_toast(status, crate::tui::app::StatusToastLevel::Info, Some(4_000));
363 app.needs_redraw = true;
364 }
365
366 /// Return to the main conversation. Returns whether a focus was active.
367 pub(crate) fn exit_focus(app: &mut App) -> bool {
368 crate::extension_host::command::bump_epoch();
369 let Some(focus) = app.agent_focus.take() else {
370 return false;
371 };
372 // The rail tracked this worker as its opened row while focused.
373 crate::tui::work_surface::agent_details_closed(app, &focus.agent_id);
374 app.scroll_to_bottom();
375 let status = app.tr(MessageId::AgentFocusClosed).into_owned();
376 app.status_message = Some(status);
377 app.needs_redraw = true;
378 true
379 }
380
381 /// Re-read the focused child's transcript at a human cadence so live workers
382 /// stream into the focused view. Local echoes of the user's own follow-ups are
383 /// dropped once the child's own transcript carries them.
384 pub(crate) fn refresh_focus(app: &mut App) {
385 let Some(focus) = app.agent_focus.as_ref() else {
386 return;
387 };
388 if focus.last_refresh.elapsed() < REFRESH_INTERVAL {
389 return;
390 }
391 let agent_id = focus.agent_id.clone();
392 let (messages, omitted) = resolve_agent_transcript_messages(app, &agent_id);
393 let receipts = child_receipts(app, &agent_id).to_vec();
394 // The result lands when the agent settles, often with no new message.
395 let result = result_block_for(app, &agent_id, &messages);
396 let Some(focus) = app.agent_focus.as_mut() else {
397 return;
398 };
399 focus.last_refresh = Instant::now();
400 if focus.result != result {
401 focus.result = result;
402 app.needs_redraw = true;
403 }
404 let Some(focus) = app.agent_focus.as_mut() else {
405 return;
406 };
407 if messages.len() == focus.source_message_count
408 && omitted == focus.omitted_messages
409 && receipts.len() == focus.receipt_count
410 {
411 return;
412 }
413 let previous_count = focus.source_message_count;
414 focus.cells = cells_for_messages(&messages, &receipts);
415 focus.receipt_count = receipts.len();
416 focus.source_message_count = messages.len();
417 focus.omitted_messages = omitted;
418 // Drop local user echoes that the transcript now carries itself.
419 for message in messages.iter().skip(previous_count) {
420 if message.role != "user" {
421 continue;
422 }
423 let text = message_plain_text(message);
424 if let Some(index) = focus.local_cells.iter().position(
425 |cell| matches!(cell, HistoryCell::User { content } if content.trim() == text.trim()),
426 ) {
427 focus.local_cells.remove(index);
428 }
429 }
430 app.needs_redraw = true;
431 }
432
433 fn message_plain_text(message: &Message) -> String {
434 message
435 .content
436 .iter()
437 .filter_map(|block| match block {
438 codewhale_models::ContentBlock::Text { text, .. } => Some(text.as_str()),
439 _ => None,
440 })
441 .collect::<Vec<_>>()
442 .join("\n")
443 }
444
445 /// Record the user's follow-up in the focused view immediately so the send is
446 /// visible before the child's transcript catches up.
447 pub(crate) fn echo_user_follow_up(app: &mut App, text: &str) {
448 if let Some(focus) = app.agent_focus.as_mut() {
449 focus.local_cells.push(HistoryCell::User {
450 content: text.to_string(),
451 });
452 focus.scroll_top = None;
453 app.needs_redraw = true;
454 }
455 }
456
457 /// Apply a delivery receipt from the engine. When the child was continued on
458 /// a new fork, focus follows the fork so the conversation stays in one place.
459 pub(crate) fn apply_follow_up_receipt(
460 app: &mut App,
461 agent_id: &str,
462 outcome: &Result<crate::tools::subagent::UserFollowUpOutcome, String>,
463 ) {
464 let label = agent_display_label(app, agent_id);
465 let note = match outcome {
466 Ok(receipt) if receipt.resumed && receipt.target_agent_id != agent_id => {
467 let target_label = agent_display_label(app, &receipt.target_agent_id);
468 if app
469 .agent_focus
470 .as_ref()
471 .is_some_and(|focus| focus.is(agent_id))
472 {
473 // Carry the local echoes across so the send stays visible on
474 // the fork until its transcript includes it.
475 let carried = app
476 .agent_focus
477 .as_ref()
478 .map(|focus| focus.local_cells.clone())
479 .unwrap_or_default();
480 app.agent_focus = None;
481 crate::extension_host::command::bump_epoch();
482 focus_agent(app, &receipt.target_agent_id);
483 if let Some(focus) = app.agent_focus.as_mut() {
484 focus.local_cells = carried;
485 }
486 }
487 app.tr(MessageId::AgentFocusFollowUpContinued)
488 .replace("{agent}", &label)
489 .replace("{target}", &target_label)
490 }
491 Ok(receipt) if receipt.delivered => app
492 .tr(MessageId::AgentFocusFollowUpDelivered)
493 .replace("{agent}", &label),
494 Ok(receipt) => app
495 .tr(MessageId::AgentFocusFollowUpFailed)
496 .replace("{agent}", &label)
497 .replace("{reason}", &receipt.note),
498 Err(reason) => app
499 .tr(MessageId::AgentFocusFollowUpFailed)
500 .replace("{agent}", &label)
501 .replace("{reason}", reason),
502 };
503 let level = if matches!(outcome, Ok(receipt) if receipt.delivered) {
504 crate::tui::app::StatusToastLevel::Info
505 } else {
506 crate::tui::app::StatusToastLevel::Warning
507 };
508 // The receipt belongs to the agent it names (or the fork focus followed
509 // above). A delayed receipt for another agent must not land in whichever
510 // transcript happens to be focused now; the toast still reports it.
511 let receipt_target = outcome
512 .as_ref()
513 .ok()
514 .map(|receipt| receipt.target_agent_id.as_str());
515 if let Some(focus) = app
516 .agent_focus
517 .as_mut()
518 .filter(|focus| focus.is(agent_id) || receipt_target.is_some_and(|id| focus.is(id)))
519 {
520 focus.local_cells.push(HistoryCell::System {
521 content: note.clone(),
522 });
523 focus.scroll_top = None;
524 }
525 app.status_message = Some(note.clone());
526 app.push_status_toast(note, level, Some(5_000));
527 app.needs_redraw = true;
528 }
529
530 /// Status word for the focused child from the live cache (glyph + word rule:
531 /// the word is always shown; color is secondary).
532 pub(crate) fn focused_status(app: &App) -> Option<(char, String)> {
533 let focus = app.agent_focus.as_ref()?;
534 let agent = app
535 .subagent_cache
536 .iter()
537 .find(|agent| agent.agent_id == focus.agent_id)?;
538 Some(match &agent.status {
539 SubAgentStatus::Running => ('●', "running".to_string()),
540 SubAgentStatus::Completed => ('✓', "done".to_string()),
541 SubAgentStatus::Interrupted(_) => ('⏸', "interrupted".to_string()),
542 SubAgentStatus::Failed(_) => ('✕', "failed".to_string()),
543 SubAgentStatus::Cancelled => ('✕', "cancelled".to_string()),
544 SubAgentStatus::BudgetExhausted => ('◆', "budget exhausted".to_string()),
545 })
546 }
547
548 /// One short line naming the focused worker's effective posture: its role,
549 /// whether it may write the workspace, reach the network, and run shell —
550 /// from the runtime's persisted permission snapshot, never guessed.
551 pub(crate) fn focused_posture(app: &App) -> Option<String> {
552 let focus = app.agent_focus.as_ref()?;
553 let agent = app
554 .subagent_cache
555 .iter()
556 .find(|agent| agent.agent_id == focus.agent_id)?;
557 let permissions = agent.runtime_permissions.as_ref()?;
558 let write = app.tr(if permissions.write {
559 MessageId::AgentFocusPostureWrites
560 } else {
561 MessageId::AgentFocusPostureReadOnly
562 });
563 let network = app.tr(if permissions.network {
564 MessageId::AgentFocusPostureNetwork
565 } else {
566 MessageId::AgentFocusPostureNoNetwork
567 });
568 let shell = app.tr(match permissions.shell.as_str() {
569 "full" => MessageId::AgentFocusPostureShellFull,
570 "read_only" => MessageId::AgentFocusPostureShellReadOnly,
571 _ => MessageId::AgentFocusPostureShellNone,
572 });
573 Some(
574 app.tr(MessageId::AgentFocusPosture)
575 .replace("{role}", agent.agent_type.as_str())
576 .replace("{write}", &write)
577 .replace("{network}", &network)
578 .replace("{shell}", &shell),
579 )
580 }
581
582 /// Whether any worker exists to list, focus, or manage this session.
583 pub(crate) fn agents_exist(app: &App) -> bool {
584 !app.subagent_cache.is_empty() || !app.agent_progress.is_empty()
585 }
586
587 /// Shell action owned by the agent shortcuts advertised above the composer.
588 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
589 pub(crate) enum AgentShellShortcut {
590 FocusAgents,
591 ManageAgents,
592 }
593
594 /// Whether the composer currently owns the two agent shortcuts.
595 ///
596 /// This is deliberately stricter than [`agents_exist`]. A visible worker is
597 /// not enough to advertise a key when a modal, attachment, or focused inline
598 /// surface owns that same arrow. Rendering and dispatch both consume this
599 /// predicate so the footer cannot promise an action that another owner will
600 /// swallow.
601 ///
602 /// A workflow run on the workbar counts as agents to manage: `↓` then opens
603 /// `/workflows` instead of the agent register.
604 pub(crate) fn shell_shortcuts_available(app: &App, completion_menu_open: bool) -> bool {
605 (agents_exist(app) || !app.workflow_runs.is_empty())
606 && !completion_menu_open
607 && app.input.is_empty()
608 && app.view_stack.is_empty()
609 && app.selected_composer_attachment_index().is_none()
610 && !app.work_surface.focused
611 }
612
613 /// Resolve a key only while the agent shortcut contract is actually active.
614 pub(crate) fn shell_shortcut(
615 app: &App,
616 key: &KeyEvent,
617 completion_menu_open: bool,
618 ) -> Option<AgentShellShortcut> {
619 if key.modifiers != KeyModifiers::NONE || !shell_shortcuts_available(app, completion_menu_open)
620 {
621 return None;
622 }
623 match key.code {
624 KeyCode::Left => Some(AgentShellShortcut::FocusAgents),
625 KeyCode::Down => Some(AgentShellShortcut::ManageAgents),
626 _ => None,
627 }
628 }
629
630 /// Footer hint chain fragment `← for agents · ↓ to manage` (ASCII-safe:
631 /// `<- for agents · v to manage`). Words are localized; the glyphs follow the
632 /// shell's ASCII-safe switch.
633 pub(crate) fn footer_agent_hints(app: &App) -> String {
634 let ascii = crate::tui::color_compat::ascii_safe_enabled();
635 let (left, down) = if ascii { ("<-", "v") } else { ("←", "↓") };
636 format!(
637 "{left} {} · {down} {}",
638 app.tr(MessageId::FooterHintForAgents),
639 app.tr(MessageId::FooterHintToManage)
640 )
641 }
642
643 /// `· N queued` suffix for a rail row: follow-ups the running child has not
644 /// yet taken at its next round boundary. `None` when nothing is queued.
645 pub(crate) fn queued_suffix(app: &App, agent_id: &str) -> Option<String> {
646 let count = *app.agent_queued_follow_ups.get(agent_id)?;
647 if count == 0 {
648 return None;
649 }
650 Some(
651 app.tr(MessageId::AgentRailQueuedCount)
652 .replace("{count}", &count.to_string()),
653 )
654 }
655
656 /// The composer chip naming the addressed fork.
657 pub(crate) fn composer_chip_text(app: &App) -> Option<String> {
658 let focus = app.agent_focus.as_ref()?;
659 Some(
660 app.tr(MessageId::AgentFocusComposerChip)
661 .replace("{agent}", &focus.label),
662 )
663 }
664
665 /// Empty-composer hint while focused.
666 pub(crate) fn composer_placeholder(app: &App) -> Option<String> {
667 let focus = app.agent_focus.as_ref()?;
668 Some(
669 app.tr(MessageId::AgentFocusPlaceholder)
670 .replace("{agent}", &focus.label),
671 )
672 }
673
674 /// Render the focused child's transcript into the main conversation area.
675 ///
676 /// Consumes the shared `pending_scroll_delta` so PageUp/PageDown, wheel, and
677 /// the jump-to-latest affordance behave exactly as they do on the main
678 /// transcript.
679 pub(crate) fn render_focus(app: &mut App, area: Rect, buf: &mut Buffer) {
680 let area = area.intersection(buf.area);
681 let Some(focus) = app.agent_focus.as_ref() else {
682 return;
683 };
684 let theme = app.ui_theme;
685 let background = Style::default().bg(theme.surface_bg);
686 buf.set_style(area, background);
687 if area.height == 0 || area.width == 0 {
688 return;
689 }
690 let (status_glyph, status_word) = focused_status(app).unwrap_or(('○', "unknown".to_string()));
691 let banner = app
692 .tr(MessageId::AgentFocusBanner)
693 .replace("{agent}", &focus.label)
694 .replace("{status}", &status_word);
695 let mut banner_spans = vec![
696 Span::styled(
697 format!("{status_glyph} "),
698 Style::default().fg(theme.accent_action),
699 ),
700 Span::styled(
701 banner,
702 Style::default()
703 .fg(theme.accent_action)
704 .add_modifier(Modifier::BOLD),
705 ),
706 ];
707 // The worker's effective posture, in the same dot chain: what it may do
708 // is stated where its conversation is read, not hidden in a role name.
709 if let Some(posture) = focused_posture(app) {
710 banner_spans.push(Span::styled(
711 format!(" · {posture}"),
712 Style::default().fg(theme.text_muted),
713 ));
714 }
715 let banner_line = Line::from(banner_spans);
716 let width = area.width.max(1);
717 let mut lines: Vec<Line<'static>> = Vec::new();
718 if focus.omitted_messages > 0 {
719 lines.push(Line::from(Span::styled(
720 app.tr(MessageId::AgentFocusOmitted)
721 .replace("{count}", &focus.omitted_messages.to_string()),
722 Style::default().fg(theme.text_muted),
723 )));
724 }
725 let result_cells = result_cells(app, focus.result.as_ref());
726 if focus.cells.is_empty() && result_cells.is_empty() && focus.local_cells.is_empty() {
727 lines.push(Line::from(Span::styled(
728 app.tr(MessageId::AgentFocusNoTranscript)
729 .replace("{agent}", &focus.label),
730 Style::default().fg(theme.text_muted),
731 )));
732 }
733 for cell in focus
734 .cells
735 .iter()
736 .chain(result_cells.iter())
737 .chain(focus.local_cells.iter())
738 {
739 lines.extend(cell.transcript_lines(width));
740 lines.push(Line::default());
741 }
742 let visible = usize::from(area.height.saturating_sub(1)).max(1);
743 let total = lines.len();
744 let max_top = total.saturating_sub(visible);
745 let delta = app.viewport.pending_scroll_delta;
746 app.viewport.pending_scroll_delta = 0;
747 let Some(focus) = app.agent_focus.as_mut() else {
748 return;
749 };
750 let current = focus.scroll_top.unwrap_or(max_top);
751 let next = if delta < 0 {
752 current.saturating_sub(delta.unsigned_abs() as usize)
753 } else {
754 current.saturating_add(delta as usize)
755 }
756 .min(max_top);
757 focus.scroll_top = if next >= max_top { None } else { Some(next) };
758 focus.last_visible = visible;
759 focus.last_total = total;
760 let top = focus.scroll_top.unwrap_or(max_top);
761
762 lines.insert(0, banner_line);
763 let mut viewport = codewhale_ratatui::TranscriptViewport::new(&lines);
764 viewport.offset = top;
765 viewport.pinned_rows = 1;
766 viewport.style = background;
767 let plan = viewport.render(area, buf);
768 app.viewport.ocean_semantic_surfaces = codewhale_ratatui::ocean::ocean_semantic_surfaces(
769 plan.display_rows(),
770 plan.area,
771 crate::tui::ui_text::grapheme_display_width,
772 );
773 // The focused view owns the transcript geometry for paging keys.
774 app.viewport.last_transcript_area = Some(plan.body_area);
775 app.viewport.last_transcript_visible = visible;
776 app.viewport.last_transcript_total = total;
777 app.viewport.last_transcript_top = top;
778 }
779
780 #[cfg(test)]
781 mod tests {
782 use super::*;
783 use crate::config::Config;
784 use crate::tui::app::TuiOptions;
785 use serde_json::json;
786 use std::path::PathBuf;
787 use tempfile::tempdir;
788
789 fn test_app(workspace: PathBuf) -> App {
790 App::new(
791 TuiOptions {
792 model: "test-model".to_string(),
793 use_mouse_capture: true,
794 max_subagents: 4,
795 ..crate::test_support::test_tui_options(workspace)
796 },
797 &Config::default(),
798 )
799 }
800
801 #[test]
802 fn child_receipts_anchor_only_to_unique_execution_pairs() {
803 let messages: Vec<Message> = serde_json::from_value(json!([
804 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"read_file","input":{}}]},
805 {"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"one"}]},
806 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"read_file","input":{}}]},
807 {"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"two"}]}
808 ])).unwrap();
809 let receipts = vec![
810 ("first".into(), "receipt one".into()),
811 ("second".into(), "receipt two".into()),
812 ];
813 let rendered = cells_for_messages(&messages, &receipts);
814 let anchored: Vec<_> = rendered
815 .iter()
816 .filter_map(|cell| match cell {
817 HistoryCell::System { content } if content.starts_with("receipt ") => {
818 Some(content.as_str())
819 }
820 _ => None,
821 })
822 .collect();
823 assert_eq!(anchored, ["receipt one", "receipt two"]);
824
825 for duplicate in [messages[0].clone(), serde_json::from_value(json!({
826 "role":"assistant","content":[{"type":"tool_use","id":"first","name":"read_file","input":{}}]
827 })).unwrap()] {
828 let mut ambiguous = messages.clone();
829 ambiguous.push(duplicate);
830 let rendered = cells_for_messages(&ambiguous, &receipts);
831 assert!(!rendered.iter().any(|cell| matches!(cell, HistoryCell::System { content } if content == "receipt one")));
832 assert_eq!(rendered.iter().filter(|cell| matches!(cell, HistoryCell::System { content } if content == "receipt two")).count(), 1);
833 }
834 }
835
836 #[test]
837 fn agent_shell_shortcuts_only_claim_an_unowned_empty_composer() {
838 let tmp = tempdir().expect("tempdir");
839 let mut app = test_app(tmp.path().to_path_buf());
840 let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
841 let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
842
843 assert_eq!(
844 shell_shortcut(&app, &left, false),
845 None,
846 "no agents: cursor owns Left"
847 );
848 assert_eq!(
849 shell_shortcut(&app, &down, false),
850 None,
851 "no agents: cursor owns Down"
852 );
853
854 app.agent_progress
855 .insert("agent_one".to_string(), "working".to_string());
856 assert_eq!(
857 shell_shortcut(&app, &left, false),
858 Some(AgentShellShortcut::FocusAgents)
859 );
860 assert_eq!(
861 shell_shortcut(&app, &down, false),
862 Some(AgentShellShortcut::ManageAgents)
863 );
864
865 app.input = "draft".to_string();
866 assert_eq!(
867 shell_shortcut(&app, &left, false),
868 None,
869 "text cursor keeps Left"
870 );
871 app.input.clear();
872 app.work_surface.focused = true;
873 assert_eq!(
874 shell_shortcut(&app, &down, false),
875 None,
876 "focused work surface keeps Down for row navigation"
877 );
878 }
879
880 #[test]
881 fn open_completion_menu_keeps_agent_shortcuts_out_of_its_arrows() {
882 let tmp = tempdir().expect("tempdir");
883 let mut app = test_app(tmp.path().to_path_buf());
884 app.agent_progress
885 .insert("agent_one".to_string(), "working".to_string());
886
887 assert_eq!(
888 shell_shortcut(
889 &app,
890 &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
891 true,
892 ),
893 None
894 );
895 assert_eq!(
896 shell_shortcut(
897 &app,
898 &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
899 true,
900 ),
901 None
902 );
903 assert!(!shell_shortcuts_available(&app, true));
904 }
905
906 fn seed_resident_transcript(app: &mut App, agent_id: &str, messages: serde_json::Value) {
907 let mut store = app
908 .runtime_services
909 .handle_store
910 .try_lock()
911 .expect("handle store");
912 let count = messages.as_array().map(|m| m.len()).unwrap_or(0);
913 let _ = store.insert_json(
914 format!("agent:{agent_id}"),
915 "full_transcript",
916 json!({ "message_count": count, "messages": messages }),
917 );
918 }
919
920 fn render(app: &mut App, width: u16, height: u16) -> String {
921 let area = Rect::new(0, 0, width, height);
922 let mut buf = Buffer::empty(area);
923 render_focus(app, area, &mut buf);
924 (0..height)
925 .map(|y| {
926 (0..width)
927 .map(|x| buf[(x, y)].symbol().to_string())
928 .collect::<String>()
929 .trim_end()
930 .to_string()
931 })
932 .collect::<Vec<_>>()
933 .join("\n")
934 }
935
936 #[test]
937 fn focus_renders_the_childs_full_transcript_and_the_composer_addresses_it() {
938 let tmp = tempdir().expect("tempdir");
939 let mut app = test_app(tmp.path().to_path_buf());
940 seed_resident_transcript(
941 &mut app,
942 "agent_alpha",
943 json!([
944 {"role": "user", "content": [{"type": "text", "text": "Investigate the flaky test", "cache_control": null}]},
945 {"role": "assistant", "content": [{"type": "text", "text": "Found the race in the pool", "cache_control": null}]}
946 ]),
947 );
948 focus_agent(&mut app, "agent_alpha");
949 let focus = app.agent_focus.as_ref().expect("focused");
950 assert_eq!(focus.source_message_count, 2);
951 assert_eq!(focus.cells.len(), 2);
952 let screen = render(&mut app, 80, 12);
953 assert!(screen.contains("Investigate the flaky test"), "{screen}");
954 assert!(screen.contains("Found the race in the pool"), "{screen}");
955 assert!(
956 composer_chip_text(&app)
957 .expect("chip")
958 .contains(&focus_label(&app)),
959 "chip names the focused worker"
960 );
961 assert!(composer_placeholder(&app).is_some());
962 assert!(exit_focus(&mut app));
963 assert!(app.agent_focus.is_none());
964 assert!(composer_chip_text(&app).is_none());
965 assert!(!exit_focus(&mut app));
966 }
967
968 #[test]
969 fn focus_banner_states_the_workers_effective_posture_from_the_runtime_snapshot() {
970 let tmp = tempdir().expect("tempdir");
971 let mut app = test_app(tmp.path().to_path_buf());
972 seed_resident_transcript(
973 &mut app,
974 "agent_scout",
975 json!([{"role": "user", "content": [{"type": "text", "text": "look around", "cache_control": null}]}]),
976 );
977 app.subagent_cache
978 .push(crate::tools::subagent::SubAgentResult {
979 usage: None,
980 name: "agent_scout".to_string(),
981 agent_id: "agent_scout".to_string(),
982 context_mode: "fresh".to_string(),
983 fork_context: false,
984 workspace: None,
985 git_branch: None,
986 agent_type: crate::tools::subagent::FleetRole::Scout,
987 assignment: crate::tools::subagent::SubAgentAssignment {
988 native_preset: None,
989 objective: "look around".to_string(),
990 role: Some("explore".to_string()),
991 },
992 model: "deepseek-v4-flash".to_string(),
993 nickname: None,
994 status: SubAgentStatus::Running,
995 worker_status: None,
996 runtime_permissions: Some(codewhale_protocol::fleet::FleetEffectivePermissions {
997 write: false,
998 network: true,
999 shell: "read_only".to_string(),
1000 tool_scope: "inherit".to_string(),
1001 tools: Vec::new(),
1002 background: true,
1003 max_spawn_depth: 1,
1004 profile_id: None,
1005 profile_origin: None,
1006 source: "built_in".to_string(),
1007 }),
1008 parent_run_id: None,
1009 spawn_depth: 1,
1010 child_route: None,
1011 result: None,
1012 steps_taken: 0,
1013 checkpoint: None,
1014 needs_input: None,
1015 duration_ms: 0,
1016 started_at: None,
1017 from_prior_session: false,
1018 idle_ms: None,
1019 heartbeat_timeout_ms: None,
1020 });
1021 focus_agent(&mut app, "agent_scout");
1022 let posture = focused_posture(&app).expect("posture line from the snapshot");
1023 assert_eq!(posture, "explore · read-only · network · read-only shell");
1024 let screen = render(&mut app, 100, 8);
1025 assert!(
1026 screen.contains("read-only · network · read-only shell"),
1027 "{screen}"
1028 );
1029 // No snapshot, no guess.
1030 app.subagent_cache[0].runtime_permissions = None;
1031 assert!(focused_posture(&app).is_none());
1032 }
1033
1034 fn focus_label(app: &App) -> String {
1035 app.agent_focus.as_ref().map(|f| f.label.clone()).unwrap()
1036 }
1037
1038 #[test]
1039 fn empty_transcript_explains_instead_of_dead_ending() {
1040 let tmp = tempdir().expect("tempdir");
1041 let mut app = test_app(tmp.path().to_path_buf());
1042 focus_agent(&mut app, "agent_quiet");
1043 let screen = render(&mut app, 120, 8);
1044 let expected = app
1045 .tr(MessageId::AgentFocusNoTranscript)
1046 .replace("{agent}", &focus_label(&app));
1047 let head: String = expected.chars().take(24).collect();
1048 assert!(screen.contains(head.trim_end()), "{screen}");
1049 }
1050
1051 fn settled_agent(
1052 agent_id: &str,
1053 status: SubAgentStatus,
1054 result: Option<&str>,
1055 ) -> crate::tools::subagent::SubAgentResult {
1056 crate::tools::subagent::SubAgentResult {
1057 usage: None,
1058 name: agent_id.to_string(),
1059 agent_id: agent_id.to_string(),
1060 context_mode: "fresh".to_string(),
1061 fork_context: false,
1062 workspace: None,
1063 git_branch: None,
1064 agent_type: crate::tools::subagent::FleetRole::Scout,
1065 assignment: crate::tools::subagent::SubAgentAssignment {
1066 native_preset: None,
1067 objective: "audit the docs".to_string(),
1068 role: Some("explore".to_string()),
1069 },
1070 model: "deepseek-v4-flash".to_string(),
1071 nickname: None,
1072 status,
1073 worker_status: None,
1074 runtime_permissions: None,
1075 parent_run_id: None,
1076 spawn_depth: 1,
1077 child_route: None,
1078 result: result.map(str::to_string),
1079 steps_taken: 3,
1080 checkpoint: None,
1081 needs_input: None,
1082 duration_ms: 1_000,
1083 started_at: None,
1084 from_prior_session: false,
1085 idle_ms: None,
1086 heartbeat_timeout_ms: None,
1087 }
1088 }
1089
1090 #[test]
1091 fn a_finished_agent_without_a_transcript_shows_its_full_result() {
1092 // #6565: with no live capture the focused view explained the missing
1093 // capture instead of showing the answer the engine had kept.
1094 let tmp = tempdir().expect("tempdir");
1095 let mut app = test_app(tmp.path().to_path_buf());
1096 let body = (0..80)
1097 .map(|n| format!("Finding {n} is documented in section {n}."))
1098 .collect::<Vec<_>>()
1099 .join(" ");
1100 assert!(body.len() > 2_500);
1101 let result = format!("## Summary\n\n{body}\n\nEND-OF-RESULT");
1102 app.subagent_cache.push(settled_agent(
1103 "agent_done",
1104 SubAgentStatus::Completed,
1105 Some(&result),
1106 ));
1107 focus_agent(&mut app, "agent_done");
1108 let (text, failed) = app
1109 .agent_focus
1110 .as_ref()
1111 .and_then(|focus| focus.result.clone())
1112 .expect("the stored result is the focused view");
1113 assert!(!failed);
1114 assert_eq!(text, result);
1115 let screen = render(&mut app, 100, 200);
1116 assert!(screen.contains("Finding 0 is documented"), "{screen}");
1117 assert!(screen.contains("Finding 79 is documented"), "{screen}");
1118 assert!(screen.contains("END-OF-RESULT"), "{screen}");
1119 let heading = app.tr(MessageId::AgentFocusResult).into_owned();
1120 assert!(screen.contains(&heading), "{screen}");
1121 let missing = app
1122 .tr(MessageId::AgentFocusNoTranscript)
1123 .replace("{agent}", &focus_label(&app));
1124 let head: String = missing.chars().take(12).collect();
1125 assert!(!screen.contains(head.trim_end()), "{screen}");
1126 }
1127
1128 #[test]
1129 fn a_failed_agent_shows_why_it_stopped_in_full() {
1130 let tmp = tempdir().expect("tempdir");
1131 let mut app = test_app(tmp.path().to_path_buf());
1132 let reason = format!(
1133 "provider returned 429 after 5 retries; {} last-line",
1134 "context ".repeat(80)
1135 );
1136 app.subagent_cache.push(settled_agent(
1137 "agent_failed",
1138 SubAgentStatus::Failed(reason.clone()),
1139 None,
1140 ));
1141 focus_agent(&mut app, "agent_failed");
1142 assert_eq!(
1143 app.agent_focus
1144 .as_ref()
1145 .and_then(|focus| focus.result.clone()),
1146 Some((reason.trim().to_string(), true))
1147 );
1148 let screen = render(&mut app, 100, 60);
1149 assert!(screen.contains("last-line"), "{screen}");
1150 let heading = app.tr(MessageId::AgentFocusStopReason).into_owned();
1151 assert!(screen.contains(&heading), "{screen}");
1152 }
1153
1154 #[test]
1155 fn a_transcript_that_already_ends_with_the_answer_gets_no_result_block() {
1156 let tmp = tempdir().expect("tempdir");
1157 let mut app = test_app(tmp.path().to_path_buf());
1158 seed_resident_transcript(
1159 &mut app,
1160 "agent_told",
1161 json!([
1162 {"role": "user", "content": [{"type": "text", "text": "audit", "cache_control": null}]},
1163 {"role": "assistant", "content": [{"type": "text", "text": "## Summary\n\nThree links are stale. Details follow.", "cache_control": null}]}
1164 ]),
1165 );
1166 app.subagent_cache.push(settled_agent(
1167 "agent_told",
1168 SubAgentStatus::Completed,
1169 Some("## Summary\n\nThree links are stale. Details follow."),
1170 ));
1171 focus_agent(&mut app, "agent_told");
1172 assert_eq!(app.agent_focus.as_ref().unwrap().result, None);
1173
1174 // A capture that stops before the answer gets the answer appended,
1175 // and the refresh picks it up when the agent settles later.
1176 seed_resident_transcript(
1177 &mut app,
1178 "agent_partial",
1179 json!([{"role": "user", "content": [{"type": "text", "text": "audit", "cache_control": null}]}]),
1180 );
1181 app.subagent_cache.push(settled_agent(
1182 "agent_partial",
1183 SubAgentStatus::Running,
1184 None,
1185 ));
1186 focus_agent(&mut app, "agent_partial");
1187 assert_eq!(app.agent_focus.as_ref().unwrap().result, None);
1188 let agent = app
1189 .subagent_cache
1190 .iter_mut()
1191 .find(|agent| agent.agent_id == "agent_partial")
1192 .unwrap();
1193 agent.status = SubAgentStatus::Completed;
1194 agent.result = Some("Two links are stale.".to_string());
1195 app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
1196 refresh_focus(&mut app);
1197 assert_eq!(
1198 app.agent_focus.as_ref().unwrap().result,
1199 Some(("Two links are stale.".to_string(), false))
1200 );
1201 }
1202
1203 #[test]
1204 fn user_echo_is_replaced_once_the_child_transcript_carries_it() {
1205 let tmp = tempdir().expect("tempdir");
1206 let mut app = test_app(tmp.path().to_path_buf());
1207 seed_resident_transcript(
1208 &mut app,
1209 "agent_echo",
1210 json!([{"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]}]),
1211 );
1212 focus_agent(&mut app, "agent_echo");
1213 echo_user_follow_up(&mut app, "please continue");
1214 assert_eq!(app.agent_focus.as_ref().unwrap().local_cells.len(), 1);
1215 seed_resident_transcript(
1216 &mut app,
1217 "agent_echo",
1218 json!([
1219 {"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]},
1220 {"role": "user", "content": [{"type": "text", "text": "please continue", "cache_control": null}]}
1221 ]),
1222 );
1223 // Force the cadence gate open.
1224 app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
1225 refresh_focus(&mut app);
1226 let focus = app.agent_focus.as_ref().unwrap();
1227 assert_eq!(focus.source_message_count, 2);
1228 assert!(focus.local_cells.is_empty(), "echo dropped once carried");
1229 }
1230
1231 #[test]
1232 fn scrolling_pages_through_the_focused_transcript_and_returns_to_tail() {
1233 let tmp = tempdir().expect("tempdir");
1234 let mut app = test_app(tmp.path().to_path_buf());
1235 let messages: Vec<serde_json::Value> = (0..40)
1236 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
1237 .collect();
1238 seed_resident_transcript(&mut app, "agent_long", json!(messages));
1239 focus_agent(&mut app, "agent_long");
1240 let tail = render(&mut app, 60, 10);
1241 assert!(tail.contains("line 39"), "{tail}");
1242 app.scroll_up(1000);
1243 let head = render(&mut app, 60, 10);
1244 assert!(head.contains("line 0"), "{head}");
1245 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
1246 app.scroll_down(100_000);
1247 let back = render(&mut app, 60, 10);
1248 assert!(back.contains("line 39"), "{back}");
1249 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_none());
1250 }
1251
1252 #[test]
1253 fn continued_fork_moves_focus_to_the_new_agent_and_reports_it() {
1254 let tmp = tempdir().expect("tempdir");
1255 let mut app = test_app(tmp.path().to_path_buf());
1256 focus_agent(&mut app, "agent_done");
1257 echo_user_follow_up(&mut app, "one more thing");
1258 let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
1259 agent_id: "agent_done".to_string(),
1260 target_agent_id: "agent_fork".to_string(),
1261 delivered: true,
1262 resumed: true,
1263 note: "continued".to_string(),
1264 });
1265 apply_follow_up_receipt(&mut app, "agent_done", &outcome);
1266 let focus = app.agent_focus.as_ref().expect("focus follows the fork");
1267 assert_eq!(focus.agent_id, "agent_fork");
1268 assert!(
1269 focus.local_cells.iter().any(
1270 |cell| matches!(cell, HistoryCell::User { content } if content == "one more thing")
1271 ),
1272 "echo carried across the fork"
1273 );
1274 assert!(
1275 focus
1276 .local_cells
1277 .iter()
1278 .any(|cell| matches!(cell, HistoryCell::System { .. })),
1279 "receipt shown in the focused view"
1280 );
1281 let failed: Result<crate::tools::subagent::UserFollowUpOutcome, String> =
1282 Err("status is cancelled".to_string());
1283 apply_follow_up_receipt(&mut app, "agent_fork", &failed);
1284 assert!(
1285 app.status_message
1286 .as_deref()
1287 .is_some_and(|status| status.contains("status is cancelled"))
1288 );
1289 }
1290
1291 /// U05-05: a delayed follow-up receipt for another agent must not be
1292 /// appended to whichever transcript happens to be focused when it lands.
1293 #[test]
1294 fn delayed_follow_up_receipt_stays_out_of_another_agents_focus() {
1295 let tmp = tempdir().expect("tempdir");
1296 let mut app = test_app(tmp.path().to_path_buf());
1297 focus_agent(&mut app, "agent_b");
1298 let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
1299 agent_id: "agent_a".to_string(),
1300 target_agent_id: "agent_a".to_string(),
1301 delivered: true,
1302 resumed: false,
1303 note: "delivered".to_string(),
1304 });
1305 apply_follow_up_receipt(&mut app, "agent_a", &outcome);
1306 let focus = app.agent_focus.as_ref().expect("focus kept");
1307 assert_eq!(focus.agent_id, "agent_b");
1308 assert!(
1309 focus.local_cells.is_empty(),
1310 "another agent's receipt leaked into this transcript: {:?}",
1311 focus.local_cells
1312 );
1313 assert!(app.status_message.is_some(), "the toast still reports it");
1314 }
1315
1316 #[test]
1317 fn scroll_to_bottom_returns_the_focused_transcript_to_its_tail() {
1318 let tmp = tempdir().expect("tempdir");
1319 let mut app = test_app(tmp.path().to_path_buf());
1320 let messages: Vec<serde_json::Value> = (0..40)
1321 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
1322 .collect();
1323 seed_resident_transcript(&mut app, "agent_tail", json!(messages));
1324 focus_agent(&mut app, "agent_tail");
1325 let _ = render(&mut app, 60, 10);
1326 app.scroll_up(5);
1327 let _ = render(&mut app, 60, 10);
1328 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
1329
1330 // The main transcript's jump-to-bottom affordances (Ctrl+End, the
1331 // jump-to-latest button) route through `App::scroll_to_bottom`; the
1332 // focused pane shares those keys, so it must return to its own tail.
1333 app.scroll_to_bottom();
1334 let screen = render(&mut app, 60, 10);
1335 assert!(
1336 app.agent_focus.as_ref().unwrap().scroll_top.is_none(),
1337 "jump-to-bottom must release the focused pane's pin"
1338 );
1339 assert!(screen.contains("line 39"), "{screen}");
1340 }
1341
1342 #[test]
1343 fn main_conversation_activity_keeps_a_pinned_focused_transcript_pinned() {
1344 let tmp = tempdir().expect("tempdir");
1345 let mut app = test_app(tmp.path().to_path_buf());
1346 let messages: Vec<serde_json::Value> = (0..40)
1347 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
1348 .collect();
1349 seed_resident_transcript(&mut app, "agent_pin", json!(messages));
1350 focus_agent(&mut app, "agent_pin");
1351 let _ = render(&mut app, 60, 10);
1352 app.scroll_up(10);
1353 let _ = render(&mut app, 60, 10);
1354 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
1355
1356 // Turn completion clears the per-turn scroll lock while the focused
1357 // pane keeps its pin — the state the auto-follow guard must respect.
1358 app.user_scrolled_during_stream = false;
1359 app.add_message(HistoryCell::System {
1360 content: "worker finished".to_string(),
1361 });
1362
1363 assert!(
1364 app.agent_focus.as_ref().unwrap().scroll_top.is_some(),
1365 "main-conversation activity must not yank the pinned focused pane to its tail"
1366 );
1367 }
1368
1369 #[test]
1370 fn focused_transcript_follows_new_child_activity_while_at_tail() {
1371 let tmp = tempdir().expect("tempdir");
1372 let mut app = test_app(tmp.path().to_path_buf());
1373 let seed = |count: usize| {
1374 let messages: Vec<serde_json::Value> = (0..count)
1375 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
1376 .collect();
1377 json!(messages)
1378 };
1379 seed_resident_transcript(&mut app, "agent_live", seed(40));
1380 focus_agent(&mut app, "agent_live");
1381 let first = render(&mut app, 60, 10);
1382 assert!(first.contains("line 39"), "{first}");
1383 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_none());
1384
1385 // The child streams on; while the pane sits at its tail the new
1386 // activity must pull the viewport down with it.
1387 seed_resident_transcript(&mut app, "agent_live", seed(60));
1388 app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
1389 refresh_focus(&mut app);
1390 let second = render(&mut app, 60, 10);
1391 assert!(second.contains("line 59"), "{second}");
1392 assert!(
1393 app.agent_focus.as_ref().unwrap().scroll_top.is_none(),
1394 "following the tail must not flip into a pinned offset"
1395 );
1396 }
1397 }
1398
1399 #[cfg(test)]
1400 #[path = "agent_focus/transcript_legacy.rs"]
1401 mod transcript_legacy;
1402
1403 #[cfg(test)]
1404 mod mounted_transcript_acceptance {
1405 use super::*;
1406 use crate::config::Config;
1407 use codewhale_localization::Locale;
1408 use std::path::PathBuf;
1409
1410 fn fixture(locale: Locale, offset: Option<usize>) -> App {
1411 let mut app = App::new(
1412 crate::test_support::test_tui_options(PathBuf::from(".")),
1413 &Config::default(),
1414 );
1415 app.launch.visible = false;
1416 app.ui_locale = locale;
1417 app.low_motion = true;
1418 let mut focus = AgentFocus::new("worker".into(), "Builder 中".into());
1419 focus.omitted_messages = 2;
1420 focus.cells = vec![
1421 HistoryCell::User { content: "Review the changed source".into() },
1422 HistoryCell::Assistant { content: "A child receipt with 鲸鱼 cafe\u{0301} and wrapped source.\n\n```rust\nlet x = 1;\n```".into(), streaming: true },
1423 ];
1424 focus.local_cells.push(HistoryCell::User {
1425 content: "Keep this local follow-up".into(),
1426 });
1427 focus.scroll_top = offset;
1428 app.agent_focus = Some(focus);
1429 app
1430 }
1431 #[test]
1432 fn mounted_transcript_focused_child_banner_and_scroll_match_frozen_painter() {
1433 for &locale in Locale::shipped() {
1434 for offset in [None, Some(0), Some(2)] {
1435 let (mut actual_app, mut old_app) =
1436 (fixture(locale, offset), fixture(locale, offset));
1437 for width in [20, 40, 80, 120] {
1438 for height in [1, 3, 6, 12] {
1439 let area = Rect::new(7, 5, width, height);
1440 let mut actual = Buffer::empty(Rect::new(2, 3, width + 12, height + 8));
1441 for cell in &mut actual.content {
1442 cell.set_symbol("~");
1443 }
1444 let mut expected = actual.clone();
1445 transcript_legacy::render_focus(&mut old_app, area, &mut expected);
1446 render_focus(&mut actual_app, area, &mut actual);
1447 assert_eq!(
1448 actual, expected,
1449 "locale={locale:?} offset={offset:?} area={area:?}"
1450 );
1451 assert_eq!(
1452 actual_app.viewport.last_transcript_area,
1453 old_app.viewport.last_transcript_area
1454 );
1455 assert_eq!(
1456 actual_app.viewport.last_transcript_top,
1457 old_app.viewport.last_transcript_top
1458 );
1459 assert_eq!(
1460 actual_app.viewport.last_transcript_total,
1461 old_app.viewport.last_transcript_total
1462 );
1463 assert_eq!(
1464 actual_app.viewport.last_transcript_visible,
1465 old_app.viewport.last_transcript_visible
1466 );
1467 assert_eq!(
1468 actual_app.agent_focus.as_ref().unwrap().scroll_top,
1469 old_app.agent_focus.as_ref().unwrap().scroll_top
1470 );
1471 }
1472 }
1473 }
1474 }
1475 }
1476 }
1477
1477 lines RUST