返回 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 widgets::{Paragraph, Widget},
27 };
28
29 use crate::tools::subagent::SubAgentStatus;
30 use crate::tui::app::App;
31 use crate::tui::history::{HistoryCell, history_cells_from_message};
32 use codewhale_localization::MessageId;
33 use codewhale_models::Message;
34
35 /// How often the focused transcript re-reads the child's durable artifact.
36 /// The rail's live activity line already ticks per event; the full chat only
37 /// needs to catch up at a human cadence.
38 const REFRESH_INTERVAL: Duration = Duration::from_millis(400);
39
40 /// Focus state for one child.
41 #[derive(Debug, Clone)]
42 pub struct AgentFocus {
43 /// The worker whose fork the composer addresses.
44 pub agent_id: String,
45 /// Stable user-facing name (dispatch name, generated whale, or label).
46 pub label: String,
47 /// The child's transcript rendered as ordinary history cells.
48 pub cells: Vec<HistoryCell>,
49 /// Number of source messages the cells were built from.
50 pub source_message_count: usize,
51 /// Messages omitted from the resident tail (durable artifact absent).
52 pub omitted_messages: usize,
53 /// Local receipts appended after the source transcript: the user's own
54 /// follow-ups echoed immediately and delivery notes.
55 pub local_cells: Vec<HistoryCell>,
56 /// Scroll position from the top in visual lines; `None` follows the tail.
57 pub scroll_top: Option<usize>,
58 /// Last visible-line count, so paging keys move a screen at a time.
59 pub last_visible: usize,
60 /// Last total line count after wrapping (for scrollbar/clamping).
61 pub last_total: usize,
62 /// Number of permission receipts folded into `cells`, so a new receipt
63 /// on an unchanged message count still triggers a rebuild.
64 receipt_count: usize,
65 last_refresh: Instant,
66 }
67
68 impl AgentFocus {
69 fn new(agent_id: String, label: String) -> Self {
70 Self {
71 agent_id,
72 label,
73 cells: Vec::new(),
74 source_message_count: 0,
75 omitted_messages: 0,
76 local_cells: Vec::new(),
77 scroll_top: None,
78 last_visible: 0,
79 last_total: 0,
80 receipt_count: 0,
81 last_refresh: Instant::now() - REFRESH_INTERVAL,
82 }
83 }
84
85 /// Whether the given agent is the focused one.
86 pub fn is(&self, agent_id: &str) -> bool {
87 self.agent_id == agent_id
88 }
89 }
90
91 /// Load a child's transcript messages: the durable on-disk artifact first
92 /// (the whole chat), else the bounded resident handle. Returns the messages and
93 /// how many earlier messages the resident tail omitted.
94 pub(crate) fn resolve_agent_transcript_messages(
95 app: &App,
96 agent_id: &str,
97 ) -> (Vec<Message>, usize) {
98 if let Ok(messages) =
99 crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
100 && !messages.is_empty()
101 {
102 return (messages, 0);
103 }
104 use crate::tools::handle::{HandleValue, VarHandle};
105 let lookup = VarHandle {
106 kind: "var_handle".to_string(),
107 session_id: format!("agent:{agent_id}"),
108 name: "full_transcript".to_string(),
109 type_name: String::new(),
110 length: 0,
111 repr_preview: String::new(),
112 sha256: String::new(),
113 };
114 let Ok(store) = app.runtime_services.handle_store.try_lock() else {
115 return (Vec::new(), 0);
116 };
117 let Some(record) = store.get(&lookup) else {
118 return (Vec::new(), 0);
119 };
120 let HandleValue::Json(payload) = &record.value else {
121 return (Vec::new(), 0);
122 };
123 let omitted = payload
124 .get("omitted_messages")
125 .and_then(serde_json::Value::as_u64)
126 .and_then(|value| usize::try_from(value).ok())
127 .unwrap_or(0);
128 let messages = payload
129 .get("messages")
130 .and_then(serde_json::Value::as_array)
131 .map(|raw| {
132 raw.iter()
133 .filter_map(|value| serde_json::from_value::<Message>(value.clone()).ok())
134 .collect::<Vec<_>>()
135 })
136 .unwrap_or_default();
137 (messages, omitted)
138 }
139
140 /// The same name the rail shows for a worker: its dispatch/session name when
141 /// it has one, else the generated or labelled display name.
142 pub(crate) fn agent_display_label(app: &App, agent_id: &str) -> String {
143 app.subagent_cache
144 .iter()
145 .find(|agent| agent.agent_id == agent_id)
146 .and_then(crate::tui::sidebar::dispatched_agent_name)
147 .map(str::to_string)
148 .unwrap_or_else(|| crate::tui::agent_details::safe_agent_display_name(app, agent_id))
149 }
150
151 /// Render a child's messages as history cells, folding in the permission
152 /// receipts recorded for that child: each receipt lands right after the
153 /// message that carries the tool's result (or, while the call is still
154 /// running, after the tool-use block itself), so a decision reads in place.
155 fn cells_for_messages(messages: &[Message], receipts: &[(String, String)]) -> Vec<HistoryCell> {
156 use codewhale_models::ContentBlock;
157 let mut resulted: std::collections::HashSet<&str> = std::collections::HashSet::new();
158 for message in messages {
159 for block in &message.content {
160 if let ContentBlock::ToolResult { tool_use_id, .. } = block {
161 resulted.insert(tool_use_id.as_str());
162 }
163 }
164 }
165 let mut cells = Vec::new();
166 for message in messages {
167 cells.extend(history_cells_from_message(message));
168 for block in &message.content {
169 let anchor = match block {
170 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
171 ContentBlock::ToolUse { id, .. } if !resulted.contains(id.as_str()) => {
172 Some(id.as_str())
173 }
174 _ => None,
175 };
176 let Some(anchor) = anchor else { continue };
177 for (tool_id, text) in receipts {
178 if tool_id == anchor {
179 cells.push(HistoryCell::System {
180 content: text.clone(),
181 });
182 }
183 }
184 }
185 }
186 cells
187 }
188
189 fn child_receipts<'a>(app: &'a App, agent_id: &str) -> &'a [(String, String)] {
190 app.child_gate_receipts
191 .get(agent_id)
192 .map_or(&[], Vec::as_slice)
193 }
194
195 /// Focus a child: its full transcript owns the main area and the composer
196 /// addresses its fork. Re-focusing the same child is a no-op that keeps the
197 /// scroll position; focusing another child replaces the focus.
198 pub(crate) fn focus_agent(app: &mut App, agent_id: &str) {
199 if app
200 .agent_focus
201 .as_ref()
202 .is_some_and(|focus| focus.is(agent_id))
203 {
204 app.needs_redraw = true;
205 return;
206 }
207 let label = agent_display_label(app, agent_id);
208 let mut focus = AgentFocus::new(agent_id.to_string(), label.clone());
209 let (messages, omitted) = resolve_agent_transcript_messages(app, agent_id);
210 let receipts = child_receipts(app, agent_id);
211 focus.cells = cells_for_messages(&messages, receipts);
212 focus.receipt_count = receipts.len();
213 focus.source_message_count = messages.len();
214 focus.omitted_messages = omitted;
215 focus.last_refresh = Instant::now();
216 app.agent_focus = Some(focus);
217 // The composer is now the natural owner: the next keys address the
218 // worker, and Esc leaves focus rather than the rail.
219 crate::tui::work_surface::release_focus(app);
220 app.scroll_to_bottom();
221 let status = app
222 .tr(MessageId::AgentFocusOpened)
223 .replace("{agent}", &label);
224 app.status_message = Some(status.clone());
225 app.push_status_toast(status, crate::tui::app::StatusToastLevel::Info, Some(4_000));
226 app.needs_redraw = true;
227 }
228
229 /// Return to the main conversation. Returns whether a focus was active.
230 pub(crate) fn exit_focus(app: &mut App) -> bool {
231 let Some(focus) = app.agent_focus.take() else {
232 return false;
233 };
234 // The rail tracked this worker as its opened row while focused.
235 crate::tui::work_surface::agent_details_closed(app, &focus.agent_id);
236 app.scroll_to_bottom();
237 let status = app.tr(MessageId::AgentFocusClosed).into_owned();
238 app.status_message = Some(status);
239 app.needs_redraw = true;
240 true
241 }
242
243 /// Re-read the focused child's transcript at a human cadence so live workers
244 /// stream into the focused view. Local echoes of the user's own follow-ups are
245 /// dropped once the child's own transcript carries them.
246 pub(crate) fn refresh_focus(app: &mut App) {
247 let Some(focus) = app.agent_focus.as_ref() else {
248 return;
249 };
250 if focus.last_refresh.elapsed() < REFRESH_INTERVAL {
251 return;
252 }
253 let agent_id = focus.agent_id.clone();
254 let (messages, omitted) = resolve_agent_transcript_messages(app, &agent_id);
255 let receipts = child_receipts(app, &agent_id).to_vec();
256 let Some(focus) = app.agent_focus.as_mut() else {
257 return;
258 };
259 focus.last_refresh = Instant::now();
260 if messages.len() == focus.source_message_count
261 && omitted == focus.omitted_messages
262 && receipts.len() == focus.receipt_count
263 {
264 return;
265 }
266 let previous_count = focus.source_message_count;
267 focus.cells = cells_for_messages(&messages, &receipts);
268 focus.receipt_count = receipts.len();
269 focus.source_message_count = messages.len();
270 focus.omitted_messages = omitted;
271 // Drop local user echoes that the transcript now carries itself.
272 for message in messages.iter().skip(previous_count) {
273 if message.role != "user" {
274 continue;
275 }
276 let text = message_plain_text(message);
277 if let Some(index) = focus.local_cells.iter().position(
278 |cell| matches!(cell, HistoryCell::User { content } if content.trim() == text.trim()),
279 ) {
280 focus.local_cells.remove(index);
281 }
282 }
283 app.needs_redraw = true;
284 }
285
286 fn message_plain_text(message: &Message) -> String {
287 message
288 .content
289 .iter()
290 .filter_map(|block| match block {
291 codewhale_models::ContentBlock::Text { text, .. } => Some(text.as_str()),
292 _ => None,
293 })
294 .collect::<Vec<_>>()
295 .join("\n")
296 }
297
298 /// Record the user's follow-up in the focused view immediately so the send is
299 /// visible before the child's transcript catches up.
300 pub(crate) fn echo_user_follow_up(app: &mut App, text: &str) {
301 if let Some(focus) = app.agent_focus.as_mut() {
302 focus.local_cells.push(HistoryCell::User {
303 content: text.to_string(),
304 });
305 focus.scroll_top = None;
306 app.needs_redraw = true;
307 }
308 }
309
310 /// Apply a delivery receipt from the engine. When the child was continued on
311 /// a new fork, focus follows the fork so the conversation stays in one place.
312 pub(crate) fn apply_follow_up_receipt(
313 app: &mut App,
314 agent_id: &str,
315 outcome: &Result<crate::tools::subagent::UserFollowUpOutcome, String>,
316 ) {
317 let label = agent_display_label(app, agent_id);
318 let note = match outcome {
319 Ok(receipt) if receipt.resumed && receipt.target_agent_id != agent_id => {
320 let target_label = agent_display_label(app, &receipt.target_agent_id);
321 if app
322 .agent_focus
323 .as_ref()
324 .is_some_and(|focus| focus.is(agent_id))
325 {
326 // Carry the local echoes across so the send stays visible on
327 // the fork until its transcript includes it.
328 let carried = app
329 .agent_focus
330 .as_ref()
331 .map(|focus| focus.local_cells.clone())
332 .unwrap_or_default();
333 app.agent_focus = None;
334 focus_agent(app, &receipt.target_agent_id);
335 if let Some(focus) = app.agent_focus.as_mut() {
336 focus.local_cells = carried;
337 }
338 }
339 app.tr(MessageId::AgentFocusFollowUpContinued)
340 .replace("{agent}", &label)
341 .replace("{target}", &target_label)
342 }
343 Ok(receipt) if receipt.delivered => app
344 .tr(MessageId::AgentFocusFollowUpDelivered)
345 .replace("{agent}", &label),
346 Ok(receipt) => app
347 .tr(MessageId::AgentFocusFollowUpFailed)
348 .replace("{agent}", &label)
349 .replace("{reason}", &receipt.note),
350 Err(reason) => app
351 .tr(MessageId::AgentFocusFollowUpFailed)
352 .replace("{agent}", &label)
353 .replace("{reason}", reason),
354 };
355 let level = if matches!(outcome, Ok(receipt) if receipt.delivered) {
356 crate::tui::app::StatusToastLevel::Info
357 } else {
358 crate::tui::app::StatusToastLevel::Warning
359 };
360 if let Some(focus) = app.agent_focus.as_mut() {
361 focus.local_cells.push(HistoryCell::System {
362 content: note.clone(),
363 });
364 focus.scroll_top = None;
365 }
366 app.status_message = Some(note.clone());
367 app.push_status_toast(note, level, Some(5_000));
368 app.needs_redraw = true;
369 }
370
371 /// Status word for the focused child from the live cache (glyph + word rule:
372 /// the word is always shown; color is secondary).
373 pub(crate) fn focused_status(app: &App) -> Option<(char, String)> {
374 let focus = app.agent_focus.as_ref()?;
375 let agent = app
376 .subagent_cache
377 .iter()
378 .find(|agent| agent.agent_id == focus.agent_id)?;
379 Some(match &agent.status {
380 SubAgentStatus::Running => ('●', "running".to_string()),
381 SubAgentStatus::Completed => ('✓', "done".to_string()),
382 SubAgentStatus::Interrupted(_) => ('⏸', "interrupted".to_string()),
383 SubAgentStatus::Failed(_) => ('✕', "failed".to_string()),
384 SubAgentStatus::Cancelled => ('✕', "cancelled".to_string()),
385 SubAgentStatus::BudgetExhausted => ('◆', "budget exhausted".to_string()),
386 })
387 }
388
389 /// One short line naming the focused worker's effective posture: its role,
390 /// whether it may write the workspace, reach the network, and run shell —
391 /// from the runtime's persisted permission snapshot, never guessed.
392 pub(crate) fn focused_posture(app: &App) -> Option<String> {
393 let focus = app.agent_focus.as_ref()?;
394 let agent = app
395 .subagent_cache
396 .iter()
397 .find(|agent| agent.agent_id == focus.agent_id)?;
398 let permissions = agent.runtime_permissions.as_ref()?;
399 let write = app.tr(if permissions.write {
400 MessageId::AgentFocusPostureWrites
401 } else {
402 MessageId::AgentFocusPostureReadOnly
403 });
404 let network = app.tr(if permissions.network {
405 MessageId::AgentFocusPostureNetwork
406 } else {
407 MessageId::AgentFocusPostureNoNetwork
408 });
409 let shell = app.tr(match permissions.shell.as_str() {
410 "full" => MessageId::AgentFocusPostureShellFull,
411 "read_only" => MessageId::AgentFocusPostureShellReadOnly,
412 _ => MessageId::AgentFocusPostureShellNone,
413 });
414 Some(
415 app.tr(MessageId::AgentFocusPosture)
416 .replace("{role}", agent.agent_type.as_str())
417 .replace("{write}", &write)
418 .replace("{network}", &network)
419 .replace("{shell}", &shell),
420 )
421 }
422
423 /// Whether any worker exists to list, focus, or manage this session.
424 pub(crate) fn agents_exist(app: &App) -> bool {
425 !app.subagent_cache.is_empty() || !app.agent_progress.is_empty()
426 }
427
428 /// Shell action owned by the agent shortcuts advertised above the composer.
429 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
430 pub(crate) enum AgentShellShortcut {
431 FocusAgents,
432 ManageAgents,
433 }
434
435 /// Whether the composer currently owns the two agent shortcuts.
436 ///
437 /// This is deliberately stricter than [`agents_exist`]. A visible worker is
438 /// not enough to advertise a key when a modal, attachment, or focused inline
439 /// surface owns that same arrow. Rendering and dispatch both consume this
440 /// predicate so the footer cannot promise an action that another owner will
441 /// swallow.
442 pub(crate) fn shell_shortcuts_available(app: &App, completion_menu_open: bool) -> bool {
443 agents_exist(app)
444 && !completion_menu_open
445 && app.input.is_empty()
446 && app.view_stack.is_empty()
447 && app.selected_composer_attachment_index().is_none()
448 && !app.work_surface.focused
449 && !app
450 .workflow_panel
451 .as_ref()
452 .is_some_and(|panel| panel.keyboard_focus)
453 }
454
455 /// Resolve a key only while the agent shortcut contract is actually active.
456 pub(crate) fn shell_shortcut(
457 app: &App,
458 key: &KeyEvent,
459 completion_menu_open: bool,
460 ) -> Option<AgentShellShortcut> {
461 if key.modifiers != KeyModifiers::NONE || !shell_shortcuts_available(app, completion_menu_open)
462 {
463 return None;
464 }
465 match key.code {
466 KeyCode::Left => Some(AgentShellShortcut::FocusAgents),
467 KeyCode::Down => Some(AgentShellShortcut::ManageAgents),
468 _ => None,
469 }
470 }
471
472 /// Footer hint chain fragment `← for agents · ↓ to manage` (ASCII-safe:
473 /// `<- for agents · v to manage`). Words are localized; the glyphs follow the
474 /// shell's ASCII-safe switch.
475 pub(crate) fn footer_agent_hints(app: &App) -> String {
476 let ascii = crate::tui::color_compat::ascii_safe_enabled();
477 let (left, down) = if ascii { ("<-", "v") } else { ("←", "↓") };
478 format!(
479 "{left} {} · {down} {}",
480 app.tr(MessageId::FooterHintForAgents),
481 app.tr(MessageId::FooterHintToManage)
482 )
483 }
484
485 /// `· N queued` suffix for a rail row: follow-ups the running child has not
486 /// yet taken at its next round boundary. `None` when nothing is queued.
487 pub(crate) fn queued_suffix(app: &App, agent_id: &str) -> Option<String> {
488 let count = *app.agent_queued_follow_ups.get(agent_id)?;
489 if count == 0 {
490 return None;
491 }
492 Some(
493 app.tr(MessageId::AgentRailQueuedCount)
494 .replace("{count}", &count.to_string()),
495 )
496 }
497
498 /// The composer chip naming the addressed fork.
499 pub(crate) fn composer_chip_text(app: &App) -> Option<String> {
500 let focus = app.agent_focus.as_ref()?;
501 Some(
502 app.tr(MessageId::AgentFocusComposerChip)
503 .replace("{agent}", &focus.label),
504 )
505 }
506
507 /// Empty-composer hint while focused.
508 pub(crate) fn composer_placeholder(app: &App) -> Option<String> {
509 let focus = app.agent_focus.as_ref()?;
510 Some(
511 app.tr(MessageId::AgentFocusPlaceholder)
512 .replace("{agent}", &focus.label),
513 )
514 }
515
516 /// Render the focused child's transcript into the main conversation area.
517 ///
518 /// Consumes the shared `pending_scroll_delta` so PageUp/PageDown, wheel, and
519 /// the jump-to-latest affordance behave exactly as they do on the main
520 /// transcript.
521 pub(crate) fn render_focus(app: &mut App, area: Rect, buf: &mut Buffer) {
522 let Some(focus) = app.agent_focus.as_ref() else {
523 return;
524 };
525 let theme = app.ui_theme;
526 let background = Style::default().bg(theme.surface_bg);
527 buf.set_style(area, background);
528 if area.height == 0 || area.width == 0 {
529 return;
530 }
531 let (status_glyph, status_word) = focused_status(app).unwrap_or(('○', "unknown".to_string()));
532 let banner = app
533 .tr(MessageId::AgentFocusBanner)
534 .replace("{agent}", &focus.label)
535 .replace("{status}", &status_word);
536 let mut banner_spans = vec![
537 Span::styled(
538 format!("{status_glyph} "),
539 Style::default().fg(theme.accent_action),
540 ),
541 Span::styled(
542 banner,
543 Style::default()
544 .fg(theme.accent_action)
545 .add_modifier(Modifier::BOLD),
546 ),
547 ];
548 // The worker's effective posture, in the same dot chain: what it may do
549 // is stated where its conversation is read, not hidden in a role name.
550 if let Some(posture) = focused_posture(app) {
551 banner_spans.push(Span::styled(
552 format!(" · {posture}"),
553 Style::default().fg(theme.text_muted),
554 ));
555 }
556 let banner_line = Line::from(banner_spans);
557 let width = area.width.max(1);
558 let mut lines: Vec<Line<'static>> = Vec::new();
559 if focus.omitted_messages > 0 {
560 lines.push(Line::from(Span::styled(
561 app.tr(MessageId::AgentFocusOmitted)
562 .replace("{count}", &focus.omitted_messages.to_string()),
563 Style::default().fg(theme.text_muted),
564 )));
565 }
566 if focus.cells.is_empty() && focus.local_cells.is_empty() {
567 lines.push(Line::from(Span::styled(
568 app.tr(MessageId::AgentFocusNoTranscript)
569 .replace("{agent}", &focus.label),
570 Style::default().fg(theme.text_muted),
571 )));
572 }
573 for cell in focus.cells.iter().chain(focus.local_cells.iter()) {
574 lines.extend(cell.transcript_lines(width));
575 lines.push(Line::default());
576 }
577 let visible = usize::from(area.height.saturating_sub(1)).max(1);
578 let total = lines.len();
579 let max_top = total.saturating_sub(visible);
580 let delta = app.viewport.pending_scroll_delta;
581 app.viewport.pending_scroll_delta = 0;
582 let Some(focus) = app.agent_focus.as_mut() else {
583 return;
584 };
585 let current = focus.scroll_top.unwrap_or(max_top);
586 let next = if delta < 0 {
587 current.saturating_sub(delta.unsigned_abs() as usize)
588 } else {
589 current.saturating_add(delta as usize)
590 }
591 .min(max_top);
592 focus.scroll_top = if next >= max_top { None } else { Some(next) };
593 focus.last_visible = visible;
594 focus.last_total = total;
595 let top = focus.scroll_top.unwrap_or(max_top);
596
597 let banner_area = Rect::new(area.x, area.y, area.width, 1);
598 Paragraph::new(banner_line)
599 .style(background)
600 .render(banner_area, buf);
601 let body_area = Rect::new(
602 area.x,
603 area.y.saturating_add(1),
604 area.width,
605 area.height.saturating_sub(1),
606 );
607 let shown: Vec<Line<'static>> = lines.into_iter().skip(top).take(visible).collect();
608 Paragraph::new(shown)
609 .style(background)
610 .render(body_area, buf);
611 // The focused view owns the transcript geometry for paging keys.
612 app.viewport.last_transcript_area = Some(body_area);
613 app.viewport.last_transcript_visible = visible;
614 app.viewport.last_transcript_total = total;
615 app.viewport.last_transcript_top = top;
616 }
617
618 #[cfg(test)]
619 mod tests {
620 use super::*;
621 use crate::config::Config;
622 use crate::tui::app::TuiOptions;
623 use serde_json::json;
624 use std::path::PathBuf;
625 use tempfile::tempdir;
626
627 fn test_app(workspace: PathBuf) -> App {
628 App::new(
629 TuiOptions {
630 model: "test-model".to_string(),
631 use_mouse_capture: true,
632 max_subagents: 4,
633 ..crate::test_support::test_tui_options(workspace)
634 },
635 &Config::default(),
636 )
637 }
638
639 #[test]
640 fn agent_shell_shortcuts_only_claim_an_unowned_empty_composer() {
641 let tmp = tempdir().expect("tempdir");
642 let mut app = test_app(tmp.path().to_path_buf());
643 let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
644 let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
645
646 assert_eq!(
647 shell_shortcut(&app, &left, false),
648 None,
649 "no agents: cursor owns Left"
650 );
651 assert_eq!(
652 shell_shortcut(&app, &down, false),
653 None,
654 "no agents: cursor owns Down"
655 );
656
657 app.agent_progress
658 .insert("agent_one".to_string(), "working".to_string());
659 assert_eq!(
660 shell_shortcut(&app, &left, false),
661 Some(AgentShellShortcut::FocusAgents)
662 );
663 assert_eq!(
664 shell_shortcut(&app, &down, false),
665 Some(AgentShellShortcut::ManageAgents)
666 );
667
668 app.input = "draft".to_string();
669 assert_eq!(
670 shell_shortcut(&app, &left, false),
671 None,
672 "text cursor keeps Left"
673 );
674 app.input.clear();
675 app.work_surface.focused = true;
676 assert_eq!(
677 shell_shortcut(&app, &down, false),
678 None,
679 "focused work surface keeps Down for row navigation"
680 );
681 }
682
683 #[test]
684 fn open_completion_menu_keeps_agent_shortcuts_out_of_its_arrows() {
685 let tmp = tempdir().expect("tempdir");
686 let mut app = test_app(tmp.path().to_path_buf());
687 app.agent_progress
688 .insert("agent_one".to_string(), "working".to_string());
689
690 assert_eq!(
691 shell_shortcut(
692 &app,
693 &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
694 true,
695 ),
696 None
697 );
698 assert_eq!(
699 shell_shortcut(
700 &app,
701 &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
702 true,
703 ),
704 None
705 );
706 assert!(!shell_shortcuts_available(&app, true));
707 }
708
709 fn seed_resident_transcript(app: &mut App, agent_id: &str, messages: serde_json::Value) {
710 let mut store = app
711 .runtime_services
712 .handle_store
713 .try_lock()
714 .expect("handle store");
715 let count = messages.as_array().map(|m| m.len()).unwrap_or(0);
716 let _ = store.insert_json(
717 format!("agent:{agent_id}"),
718 "full_transcript",
719 json!({ "message_count": count, "messages": messages }),
720 );
721 }
722
723 fn render(app: &mut App, width: u16, height: u16) -> String {
724 let area = Rect::new(0, 0, width, height);
725 let mut buf = Buffer::empty(area);
726 render_focus(app, area, &mut buf);
727 (0..height)
728 .map(|y| {
729 (0..width)
730 .map(|x| buf[(x, y)].symbol().to_string())
731 .collect::<String>()
732 .trim_end()
733 .to_string()
734 })
735 .collect::<Vec<_>>()
736 .join("\n")
737 }
738
739 #[test]
740 fn focus_renders_the_childs_full_transcript_and_the_composer_addresses_it() {
741 let tmp = tempdir().expect("tempdir");
742 let mut app = test_app(tmp.path().to_path_buf());
743 seed_resident_transcript(
744 &mut app,
745 "agent_alpha",
746 json!([
747 {"role": "user", "content": [{"type": "text", "text": "Investigate the flaky test", "cache_control": null}]},
748 {"role": "assistant", "content": [{"type": "text", "text": "Found the race in the pool", "cache_control": null}]}
749 ]),
750 );
751 focus_agent(&mut app, "agent_alpha");
752 let focus = app.agent_focus.as_ref().expect("focused");
753 assert_eq!(focus.source_message_count, 2);
754 assert_eq!(focus.cells.len(), 2);
755 let screen = render(&mut app, 80, 12);
756 assert!(screen.contains("Investigate the flaky test"), "{screen}");
757 assert!(screen.contains("Found the race in the pool"), "{screen}");
758 assert!(
759 composer_chip_text(&app)
760 .expect("chip")
761 .contains(&focus_label(&app)),
762 "chip names the focused worker"
763 );
764 assert!(composer_placeholder(&app).is_some());
765 assert!(exit_focus(&mut app));
766 assert!(app.agent_focus.is_none());
767 assert!(composer_chip_text(&app).is_none());
768 assert!(!exit_focus(&mut app));
769 }
770
771 #[test]
772 fn focus_banner_states_the_workers_effective_posture_from_the_runtime_snapshot() {
773 let tmp = tempdir().expect("tempdir");
774 let mut app = test_app(tmp.path().to_path_buf());
775 seed_resident_transcript(
776 &mut app,
777 "agent_scout",
778 json!([{"role": "user", "content": [{"type": "text", "text": "look around", "cache_control": null}]}]),
779 );
780 app.subagent_cache
781 .push(crate::tools::subagent::SubAgentResult {
782 usage: None,
783 name: "agent_scout".to_string(),
784 agent_id: "agent_scout".to_string(),
785 context_mode: "fresh".to_string(),
786 fork_context: false,
787 workspace: None,
788 git_branch: None,
789 agent_type: crate::tools::subagent::FleetRole::Scout,
790 assignment: crate::tools::subagent::SubAgentAssignment {
791 objective: "look around".to_string(),
792 role: Some("explore".to_string()),
793 },
794 model: "deepseek-v4-flash".to_string(),
795 nickname: None,
796 status: SubAgentStatus::Running,
797 worker_status: None,
798 runtime_permissions: Some(codewhale_protocol::fleet::FleetEffectivePermissions {
799 write: false,
800 network: true,
801 shell: "read_only".to_string(),
802 tool_scope: "inherit".to_string(),
803 tools: Vec::new(),
804 background: true,
805 max_spawn_depth: 1,
806 profile_id: None,
807 profile_origin: None,
808 source: "built_in".to_string(),
809 }),
810 parent_run_id: None,
811 spawn_depth: 1,
812 child_route: None,
813 result: None,
814 steps_taken: 0,
815 checkpoint: None,
816 needs_input: None,
817 duration_ms: 0,
818 started_at: None,
819 from_prior_session: false,
820 });
821 focus_agent(&mut app, "agent_scout");
822 let posture = focused_posture(&app).expect("posture line from the snapshot");
823 assert_eq!(posture, "explore · read-only · network · read-only shell");
824 let screen = render(&mut app, 100, 8);
825 assert!(
826 screen.contains("read-only · network · read-only shell"),
827 "{screen}"
828 );
829 // No snapshot, no guess.
830 app.subagent_cache[0].runtime_permissions = None;
831 assert!(focused_posture(&app).is_none());
832 }
833
834 fn focus_label(app: &App) -> String {
835 app.agent_focus.as_ref().map(|f| f.label.clone()).unwrap()
836 }
837
838 #[test]
839 fn empty_transcript_explains_instead_of_dead_ending() {
840 let tmp = tempdir().expect("tempdir");
841 let mut app = test_app(tmp.path().to_path_buf());
842 focus_agent(&mut app, "agent_quiet");
843 let screen = render(&mut app, 120, 8);
844 let expected = app
845 .tr(MessageId::AgentFocusNoTranscript)
846 .replace("{agent}", &focus_label(&app));
847 let head: String = expected.chars().take(24).collect();
848 assert!(screen.contains(head.trim_end()), "{screen}");
849 }
850
851 #[test]
852 fn user_echo_is_replaced_once_the_child_transcript_carries_it() {
853 let tmp = tempdir().expect("tempdir");
854 let mut app = test_app(tmp.path().to_path_buf());
855 seed_resident_transcript(
856 &mut app,
857 "agent_echo",
858 json!([{"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]}]),
859 );
860 focus_agent(&mut app, "agent_echo");
861 echo_user_follow_up(&mut app, "please continue");
862 assert_eq!(app.agent_focus.as_ref().unwrap().local_cells.len(), 1);
863 seed_resident_transcript(
864 &mut app,
865 "agent_echo",
866 json!([
867 {"role": "assistant", "content": [{"type": "text", "text": "ready", "cache_control": null}]},
868 {"role": "user", "content": [{"type": "text", "text": "please continue", "cache_control": null}]}
869 ]),
870 );
871 // Force the cadence gate open.
872 app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
873 refresh_focus(&mut app);
874 let focus = app.agent_focus.as_ref().unwrap();
875 assert_eq!(focus.source_message_count, 2);
876 assert!(focus.local_cells.is_empty(), "echo dropped once carried");
877 }
878
879 #[test]
880 fn scrolling_pages_through_the_focused_transcript_and_returns_to_tail() {
881 let tmp = tempdir().expect("tempdir");
882 let mut app = test_app(tmp.path().to_path_buf());
883 let messages: Vec<serde_json::Value> = (0..40)
884 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
885 .collect();
886 seed_resident_transcript(&mut app, "agent_long", json!(messages));
887 focus_agent(&mut app, "agent_long");
888 let tail = render(&mut app, 60, 10);
889 assert!(tail.contains("line 39"), "{tail}");
890 app.scroll_up(1000);
891 let head = render(&mut app, 60, 10);
892 assert!(head.contains("line 0"), "{head}");
893 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
894 app.scroll_down(100_000);
895 let back = render(&mut app, 60, 10);
896 assert!(back.contains("line 39"), "{back}");
897 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_none());
898 }
899
900 #[test]
901 fn continued_fork_moves_focus_to_the_new_agent_and_reports_it() {
902 let tmp = tempdir().expect("tempdir");
903 let mut app = test_app(tmp.path().to_path_buf());
904 focus_agent(&mut app, "agent_done");
905 echo_user_follow_up(&mut app, "one more thing");
906 let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
907 agent_id: "agent_done".to_string(),
908 target_agent_id: "agent_fork".to_string(),
909 delivered: true,
910 resumed: true,
911 note: "continued".to_string(),
912 });
913 apply_follow_up_receipt(&mut app, "agent_done", &outcome);
914 let focus = app.agent_focus.as_ref().expect("focus follows the fork");
915 assert_eq!(focus.agent_id, "agent_fork");
916 assert!(
917 focus.local_cells.iter().any(
918 |cell| matches!(cell, HistoryCell::User { content } if content == "one more thing")
919 ),
920 "echo carried across the fork"
921 );
922 assert!(
923 focus
924 .local_cells
925 .iter()
926 .any(|cell| matches!(cell, HistoryCell::System { .. })),
927 "receipt shown in the focused view"
928 );
929 let failed: Result<crate::tools::subagent::UserFollowUpOutcome, String> =
930 Err("status is cancelled".to_string());
931 apply_follow_up_receipt(&mut app, "agent_fork", &failed);
932 assert!(
933 app.status_message
934 .as_deref()
935 .is_some_and(|status| status.contains("status is cancelled"))
936 );
937 }
938
939 #[test]
940 fn scroll_to_bottom_returns_the_focused_transcript_to_its_tail() {
941 let tmp = tempdir().expect("tempdir");
942 let mut app = test_app(tmp.path().to_path_buf());
943 let messages: Vec<serde_json::Value> = (0..40)
944 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
945 .collect();
946 seed_resident_transcript(&mut app, "agent_tail", json!(messages));
947 focus_agent(&mut app, "agent_tail");
948 let _ = render(&mut app, 60, 10);
949 app.scroll_up(5);
950 let _ = render(&mut app, 60, 10);
951 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
952
953 // The main transcript's jump-to-bottom affordances (Ctrl+End, the
954 // jump-to-latest button) route through `App::scroll_to_bottom`; the
955 // focused pane shares those keys, so it must return to its own tail.
956 app.scroll_to_bottom();
957 let screen = render(&mut app, 60, 10);
958 assert!(
959 app.agent_focus.as_ref().unwrap().scroll_top.is_none(),
960 "jump-to-bottom must release the focused pane's pin"
961 );
962 assert!(screen.contains("line 39"), "{screen}");
963 }
964
965 #[test]
966 fn main_conversation_activity_keeps_a_pinned_focused_transcript_pinned() {
967 let tmp = tempdir().expect("tempdir");
968 let mut app = test_app(tmp.path().to_path_buf());
969 let messages: Vec<serde_json::Value> = (0..40)
970 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
971 .collect();
972 seed_resident_transcript(&mut app, "agent_pin", json!(messages));
973 focus_agent(&mut app, "agent_pin");
974 let _ = render(&mut app, 60, 10);
975 app.scroll_up(10);
976 let _ = render(&mut app, 60, 10);
977 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_some());
978
979 // Turn completion clears the per-turn scroll lock while the focused
980 // pane keeps its pin — the state the auto-follow guard must respect.
981 app.user_scrolled_during_stream = false;
982 app.add_message(HistoryCell::System {
983 content: "worker finished".to_string(),
984 });
985
986 assert!(
987 app.agent_focus.as_ref().unwrap().scroll_top.is_some(),
988 "main-conversation activity must not yank the pinned focused pane to its tail"
989 );
990 }
991
992 #[test]
993 fn focused_transcript_follows_new_child_activity_while_at_tail() {
994 let tmp = tempdir().expect("tempdir");
995 let mut app = test_app(tmp.path().to_path_buf());
996 let seed = |count: usize| {
997 let messages: Vec<serde_json::Value> = (0..count)
998 .map(|i| json!({"role": "assistant", "content": [{"type": "text", "text": format!("line {i}"), "cache_control": null}]}))
999 .collect();
1000 json!(messages)
1001 };
1002 seed_resident_transcript(&mut app, "agent_live", seed(40));
1003 focus_agent(&mut app, "agent_live");
1004 let first = render(&mut app, 60, 10);
1005 assert!(first.contains("line 39"), "{first}");
1006 assert!(app.agent_focus.as_ref().unwrap().scroll_top.is_none());
1007
1008 // The child streams on; while the pane sits at its tail the new
1009 // activity must pull the viewport down with it.
1010 seed_resident_transcript(&mut app, "agent_live", seed(60));
1011 app.agent_focus.as_mut().unwrap().last_refresh = Instant::now() - REFRESH_INTERVAL;
1012 refresh_focus(&mut app);
1013 let second = render(&mut app, 60, 10);
1014 assert!(second.contains("line 59"), "{second}");
1015 assert!(
1016 app.agent_focus.as_ref().unwrap().scroll_top.is_none(),
1017 "following the tail must not flip into a pinned offset"
1018 );
1019 }
1020 }
1021
1021 lines RUST